fixed train# specific rules. cleaned up display topline and train display on division map
This commit is contained in:
+20
-43
@@ -277,64 +277,41 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* A TRAIN, DRAWN AS A TRAIN — the same vocabulary the Office Area card uses.
|
||||
* ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars.
|
||||
*
|
||||
* The chip was a name and a figure, and the figure was Stages left to cross this card: read
|
||||
* once as the car count, and once the unit was spelled out, read as redundant beside the
|
||||
* position the card already draws. What it never said is the thing worth knowing at a glance —
|
||||
* what the train is carrying, whether those cars are loaded, and which end the engine is on.
|
||||
* It was drawn as a full consist here, matching the Office Area card, and reported as too large
|
||||
* and hard to read: a Mainline cell is 152px and a Running Track cell 78px, against a district
|
||||
* card's 166px, and a four-car train plus its engine has to squeeze into a fifth of that. The
|
||||
* Office card is where the consist is worth drawing, because that is where the switching
|
||||
* decisions are made and where there is room to read it.
|
||||
*
|
||||
* WEST ON THE LEFT here too. `cars` is nose first, so an east-facing train is drawn reversed and
|
||||
* its engine lands at the east end, exactly as on the district card.
|
||||
* So this keeps the two things the Division map is actually for — where a train is and which way
|
||||
* it is going — and leaves the cars to the tooltip and to the district.
|
||||
*/
|
||||
c.trains.forEach((t, k) => {
|
||||
const kindOf = (car: string): string =>
|
||||
/caboose/.test(car) ? 'cab' : /hopper/.test(car) ? 'hop' : /boxcar/.test(car) ? 'box'
|
||||
: /reefer/.test(car) ? 'rfr' : /tank/.test(car) ? 'tnk' : /coach/.test(car) ? 'cch' : '?';
|
||||
const cars = t.cars ?? [];
|
||||
const arrow = t.facing === 'w' ? '◀' : t.facing === 'e' ? '▶' : t.facing === 'n' ? '▲' : '▼';
|
||||
const items: { label: string; kind: string }[] = cars.map((car) => ({
|
||||
label: kindOf(car),
|
||||
kind: /^loaded/.test(car) || /caboose/.test(car) ? 'ld' : 'mt',
|
||||
}));
|
||||
items.splice(Math.max(0, Math.min(items.length, t.engineAt ?? 0)), 0, { label: arrow, kind: 'eng' });
|
||||
const laid = t.facing === 'e' ? [...items].reverse() : items;
|
||||
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
|
||||
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
||||
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
||||
|
||||
const nameW = t.label.length * 6.5 + 6;
|
||||
const w = Math.min(c.w - 8, nameW + laid.length * 13 + 6);
|
||||
/**
|
||||
* NARROW THE CARS RATHER THAN DROP THEM.
|
||||
*
|
||||
* A Running Track cell is 78 wide and a four-car train plus its engine wants more than that.
|
||||
* Squeezing is honest; leaving cars out is not — a chip showing three of five cars is a chip
|
||||
* that lies about the train. Below 11px the three-letter type will not fit, so the blocks
|
||||
* carry the load state alone and the tooltip carries the words.
|
||||
*/
|
||||
const CARW = Math.max(5, Math.min(13, (w - nameW - 6) / Math.max(1, laid.length)));
|
||||
const w = Math.min(c.w - 8, label.length * 6.6 + 12);
|
||||
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
||||
const inRegion = c.regions > 1 && typeof t.region === 'number';
|
||||
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
|
||||
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
|
||||
const tx = slotX + spread;
|
||||
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
|
||||
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||||
const stages =
|
||||
typeof t.stagesLeft === 'number'
|
||||
? ` · ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
|
||||
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
|
||||
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
|
||||
' train takes over them depends on the card speed and the train)'
|
||||
: '';
|
||||
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(cars.join(', ') || 'no cars')}, nose first${
|
||||
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
|
||||
}${esc(stages)}">` +
|
||||
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
|
||||
cars.length ? ` (${loaded} loaded)` : ''
|
||||
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''}${esc(stages)}">` +
|
||||
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${tx - w / 2 + 4}" y="${c.y + 35}">${esc(t.label)}</text>`;
|
||||
laid.forEach((it, n) => {
|
||||
const bx = tx - w / 2 + nameW + n * CARW;
|
||||
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${c.y + 25}" width="${Math.max(3, CARW - 2)}" height="13" rx="2"/>`;
|
||||
if (CARW >= 11) {
|
||||
out += `<text class="bs-tcarlab" x="${bx + (CARW - 2) / 2}" y="${c.y + 35}" text-anchor="middle">${esc(it.label)}</text>`;
|
||||
}
|
||||
});
|
||||
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(label)}</text>`;
|
||||
out += '</g>';
|
||||
});
|
||||
out += '</g>';
|
||||
@@ -716,8 +693,8 @@ export function officeSvg(
|
||||
t.facing === 'e' ? 'east' : t.facing === 'w' ? 'west' : t.facing === 'n' ? 'north' : 'south';
|
||||
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
|
||||
out += `<g class="bs-crew" data-tip="${esc(
|
||||
`${t.label} \u2014 engine pointing ${facingWord}, carrying ${consistWords} (nose first). Drawn as it stands: west is on the left.`,
|
||||
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
||||
`${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''),
|
||||
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
||||
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
|
||||
laid.forEach((it, n) => {
|
||||
const bx = tx + 28 + n * cw;
|
||||
|
||||
+9
-4
@@ -1472,10 +1472,15 @@ function followThrough(
|
||||
* §6.3's unjam is for a load stranded on MEN|AT|WORK that cannot advance. A green box holding
|
||||
* a load waiting for a car is not jammed, it is waiting.
|
||||
*/
|
||||
// The option has no "end" action, so something must be done. Order by what it costs to lose:
|
||||
// a load stranded on MEN|AT|WORK is already blocking the pipeline, a red box holds a load
|
||||
// whose Revenue is banked, and the green box is the only one where discarding destroys work
|
||||
// that has not been paid for yet. It is last deliberately.
|
||||
/**
|
||||
* NOTHING WORTH DOING — SO DO NOTHING.
|
||||
*
|
||||
* The option used to have no "end" action, so this fell through to unjamming whichever box
|
||||
* was least costly to wreck. §6.3 requires no action at all, and it now says so: a wasted
|
||||
* Stage is strictly better than destroying a load that cost a whole action to stock.
|
||||
*/
|
||||
const idle = options.find((i) => i.type === 'freightAgent.end');
|
||||
if (idle) return because('nothing productive at any facility — leave the Freight Agent idle rather than wreck a working box', idle);
|
||||
const byCost = (['menAtWork', 'inbound', 'outbound'] as const).map((from) =>
|
||||
options.find((i) => i.type === 'freightAgent.unjam' && i.from === from),
|
||||
);
|
||||
|
||||
@@ -231,6 +231,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'good',
|
||||
text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`,
|
||||
};
|
||||
case 'trainStoodStill':
|
||||
return {
|
||||
tone: 'good',
|
||||
text:
|
||||
`Train ${e.trainNumber} stood still for a whole Stage at ${e.where} and earned a point — ` +
|
||||
'its card pays for the stop, not for the run (circus set-up)',
|
||||
};
|
||||
case 'trainHeld':
|
||||
return {
|
||||
tone: 'bad',
|
||||
@@ -525,6 +532,46 @@ export function impediments(s: GameState, player = 0): Impediment[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THE PASSENGERS ARE NOT GETTING OFF.
|
||||
*
|
||||
* Reported: "I can't figure out how to have a train with three passenger coaches unload all three
|
||||
* at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one coach a
|
||||
* Stage and then needs a Freight Agent action to clear the box before the next. A Station does two
|
||||
* and a Terminal three. That is the Office ladder doing its job, and nothing on screen said so.
|
||||
*
|
||||
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which is a
|
||||
* requirement the engine only started enforcing recently and which no panel mentions at all.
|
||||
*/
|
||||
const office = area.grid.get(coordKey(area.officeCoord));
|
||||
const pf = office?.facility;
|
||||
if (pf && pf.kind === 'passenger') {
|
||||
const waiting = area.adOccupancy
|
||||
.map((id) => s.trays.get(id))
|
||||
.filter((t) => t?.consist.some((c) => c.type === 'coach' && c.loaded));
|
||||
if (waiting.length > 0) {
|
||||
const reasons: string[] = [];
|
||||
if (portersLeft(pf) < 1) {
|
||||
reasons.push(
|
||||
`all ${pf.porters} Porter${pf.porters === 1 ? '' : 's'} used this Stage — one works one coach, ` +
|
||||
'and the Office tier is the Porter count (Depot 1, Station 2, Terminal 3)',
|
||||
);
|
||||
}
|
||||
if (pf.inboundBox.length >= pf.capacity.inbound) {
|
||||
reasons.push(
|
||||
`the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` +
|
||||
'a Freight Agent action clears it, which costs a whole Local Operations turn',
|
||||
);
|
||||
}
|
||||
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) {
|
||||
reasons.push('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
||||
}
|
||||
for (const why of reasons) {
|
||||
out.push({ where: 'passengers waiting to de-train', why, severity: 'waiting' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
|
||||
const due = s.timetable[s.clock.stage - 1];
|
||||
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
|
||||
|
||||
+61
-1
@@ -66,7 +66,20 @@ export type CellView = {
|
||||
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
|
||||
* `facing` is the port it points at on this card.
|
||||
*/
|
||||
train: { label: string; cars: string[]; engineAt: number; facing: string } | null;
|
||||
train: {
|
||||
label: string;
|
||||
cars: string[];
|
||||
engineAt: number;
|
||||
facing: string;
|
||||
/**
|
||||
* WHAT THIS PARTICULAR TRAIN'S CARD SAYS.
|
||||
*
|
||||
* Reported from a playtest: the Circus Train arrived and there was no way to find out what made
|
||||
* it a Circus Train. A special train is special only if the player can read the rule while it is
|
||||
* standing in front of them — the card is face down in a box somewhere by then.
|
||||
*/
|
||||
what: string;
|
||||
} | null;
|
||||
/**
|
||||
* Office card only: A/D tracks taken and how many the tier has.
|
||||
*
|
||||
@@ -424,6 +437,7 @@ function trainOnCard(s: GameState, key: string): CellView['train'] {
|
||||
cars: t.consist.map(carLabel),
|
||||
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
||||
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
|
||||
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
||||
};
|
||||
void id;
|
||||
}
|
||||
@@ -657,6 +671,8 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
return 'End Local Operations';
|
||||
case 'switch.end':
|
||||
return 'End Local Operations';
|
||||
case 'freightAgent.end':
|
||||
return 'End Local Operations — leave the Freight Agent idle';
|
||||
case 'loadUnload.end':
|
||||
return 'End my Cargo phase';
|
||||
case 'newTrain.passCar':
|
||||
@@ -1049,6 +1065,50 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EVERYTHING THIS TRAIN'S CARD PRINTS, in one line.
|
||||
*
|
||||
* The name, its class, what its consist should be, and — the part that prompted this — whatever
|
||||
* special rule the card carries. Nine of the twelve rule flags are declared on the profiles and read
|
||||
* by nothing in the engine, so those are marked as not yet implemented rather than quietly listed:
|
||||
* telling a player a rule applies when it does not is worse than saying nothing.
|
||||
*/
|
||||
export function trainRules(t: {
|
||||
trainNumber: number | null;
|
||||
trainIsExtra: boolean;
|
||||
}): string {
|
||||
const p = trainProfile(t.trainNumber ?? 0, t.trainIsExtra);
|
||||
if (!p) return '';
|
||||
const parts: string[] = [`${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}” · ${p.speed}`];
|
||||
|
||||
const consist: string[] = [];
|
||||
if (p.consist.freight > 0) {
|
||||
consist.push(`${p.consist.freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||||
}
|
||||
if (p.consist.coach > 0) consist.push(`${p.consist.coach} coach${p.consist.coach > 1 ? 'es' : ''}`);
|
||||
if (p.consist.caboose > 0) consist.push(`${p.consist.caboose} caboose`);
|
||||
parts.push(`its card calls for ${consist.join(' + ') || 'no cars'}`);
|
||||
|
||||
if (p.rules.note) parts.push(p.rules.note);
|
||||
|
||||
// Only `expedite` and the consist restrictions are actually enforced today (§7, §8.2), plus the
|
||||
// Circus Train's point for standing still. The rest are printed on the profile and read nowhere.
|
||||
const unbuilt: string[] = [];
|
||||
if (p.rules.noSwitching) unbuilt.push('no switching');
|
||||
if (p.rules.terminalsOnly) unbuilt.push('terminals only');
|
||||
if (p.rules.coachStaysOnStationTrack) unbuilt.push('the coach stays on the station track');
|
||||
if (p.rules.oneFreightPerLocation) unbuilt.push('one freight car per location');
|
||||
if (p.rules.noPassengerWork) unbuilt.push('no passenger work');
|
||||
if (p.rules.dropOnly) unbuilt.push('may drop but not pick up');
|
||||
if (p.rules.pickUpEmptiesOnly) unbuilt.push('may pick up empties only');
|
||||
if (p.rules.stopThenExpedite) unbuilt.push('stops, then runs expedited');
|
||||
if (unbuilt.length > 0) parts.push(`NOT YET ENFORCED BY THE ENGINE: ${unbuilt.join('; ')}`);
|
||||
|
||||
if (p.rules.expedite) parts.push('EXPEDITED — it departs in the same Stage it arrives (§7)');
|
||||
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** The train riding a Crew Tray, for anything that has to talk about it. */
|
||||
export function trainName(s: GameState, trayId: string): string {
|
||||
const tray = s.trays.get(trayId);
|
||||
|
||||
Reference in New Issue
Block a user