v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+46
-14
@@ -525,6 +525,13 @@ export function officeSvg(
|
||||
|
||||
let bx = 7;
|
||||
const by = H - 42;
|
||||
/**
|
||||
* The MEN | AT | WORK sign is a FREIGHT fitting, so a Passenger Facility has none — `maw` comes
|
||||
* back empty for one. The arrows below point into and out of that sign, so they go with it:
|
||||
* without the sign they were chevrons aimed at nothing. A Depot keeps its green and red boxes,
|
||||
* because passengers really do wait in one and arrive into the other.
|
||||
*/
|
||||
const hasSign = f.maw.length > 0;
|
||||
// Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows
|
||||
// point opposite ways. An industry that only receives has no green boxes at all.
|
||||
if (f.allowsOut) {
|
||||
@@ -532,16 +539,20 @@ export function officeSvg(
|
||||
out += box(bx, by, i < f.green.length, 'bs-green');
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||
bx += 9;
|
||||
if (hasSign) {
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||
bx += 9;
|
||||
}
|
||||
}
|
||||
if (f.allowsIn) {
|
||||
for (let i = 0; i < Math.max(1, f.redCap); i++) {
|
||||
out += box(bx, by, i < f.red.length, 'bs-red');
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
|
||||
bx += 9;
|
||||
if (hasSign) {
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
|
||||
bx += 9;
|
||||
}
|
||||
}
|
||||
// MEN | AT | WORK — one physical sign, worked left to right loading and right to left
|
||||
// unloading. The spotted car sits past WORK, on the siding drawn below.
|
||||
@@ -549,7 +560,9 @@ export function officeSvg(
|
||||
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
|
||||
if (hasSign) {
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY THIS INDUSTRY RUNS, IN WORDS.
|
||||
@@ -559,14 +572,21 @@ export function officeSvg(
|
||||
* playtesting whether the two were supposed to look the same: they were not, and effectively
|
||||
* they did. A word costs almost nothing and cannot be misread.
|
||||
*/
|
||||
const flow = f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
||||
const flowCls = f.allowsOut && f.allowsIn ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
|
||||
// A Passenger Facility is not an industry and must not borrow an industry's word for it: a
|
||||
// Depot read "SHIPS + RECEIVES", which says freight. Passengers board and alight instead.
|
||||
const passenger = f.kind === 'passenger';
|
||||
const flow = passenger
|
||||
? 'BOARDS + ALIGHTS'
|
||||
: f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
||||
const flowCls = passenger || (f.allowsOut && f.allowsIn) ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
|
||||
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
|
||||
f.allowsOut && f.allowsIn
|
||||
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
|
||||
: f.allowsOut
|
||||
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
|
||||
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
|
||||
passenger
|
||||
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
|
||||
: f.allowsOut && f.allowsIn
|
||||
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
|
||||
: f.allowsOut
|
||||
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
|
||||
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
|
||||
)}">${esc(flow)}</text>`;
|
||||
|
||||
/**
|
||||
@@ -596,7 +616,15 @@ export function officeSvg(
|
||||
}
|
||||
|
||||
// Standing room, drawn as the printed squares: filled means occupied.
|
||||
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
|
||||
//
|
||||
// `Math.max(1, …)` applies only to an industry, whose track is at least one car long. A Passenger
|
||||
// Facility has NO industry track — passengers board straight off the platform — so a Depot was
|
||||
// being drawn a siding slot it cannot spot a car on.
|
||||
const spots = cell.facility
|
||||
? cell.facility.kind === 'passenger'
|
||||
? cell.facility.trackCap
|
||||
: Math.max(1, cell.facility.trackCap)
|
||||
: cell.cars.length;
|
||||
for (let i = 0; i < Math.min(spots, 4); i++) {
|
||||
const filled = i < cell.cars.length;
|
||||
const carTip = filled
|
||||
@@ -652,7 +680,11 @@ export function officeSvg(
|
||||
// ABOVE the rail. An `onCard` enhancement can land on a facility, so it has to clear both the
|
||||
// load pipeline below and the through track — and with the rail now down the middle of the
|
||||
// card, the old y = H-46 baseline printed the label straight along the rail itself.
|
||||
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
// Hover text per enhancement, from the card catalogue — an Interlocking used to be a bare word
|
||||
// on the card with nothing to say what it did, or that it does not do it yet.
|
||||
out += `<text class="bs-enh" x="6" y="26" data-tip="${esc(
|
||||
(cell.enhancementsWhat ?? []).join(' · '),
|
||||
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
}
|
||||
if (cell.train) {
|
||||
/**
|
||||
|
||||
+4
-3
@@ -502,7 +502,7 @@ function canStockProductively(s: GameState, player: PlayerIndex): boolean {
|
||||
function hasStuckLoad(s: GameState, player: PlayerIndex): boolean {
|
||||
for (const card of areaOf(s, player).grid.values()) {
|
||||
const f = card.facility;
|
||||
if (!f || laborersLeft(f) < 1) continue;
|
||||
if (!f?.menAtWork || laborersLeft(f) < 1) continue;
|
||||
for (let box = 0; box < f.menAtWork.length; box++) {
|
||||
if (f.menAtWork[box] && !canAdvanceLoad(f, box)) return true;
|
||||
}
|
||||
@@ -834,12 +834,13 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
};
|
||||
|
||||
/**
|
||||
* A curve of the RIGHT HAND to hang under this turnout, somewhere the player can still reach.
|
||||
* A curve of the MATCHING hand to hang under this turnout, somewhere the player can still reach.
|
||||
*
|
||||
* A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so
|
||||
* cutting one with nothing to hang there is pure loss. Handedness is the slope, so a left-hand
|
||||
* turnout's leg can only be continued by a left-hand curve and a hand full of right-hand ones is
|
||||
* no help to it.
|
||||
* no help to it. That is a match on hands, not on a particular hand, so it was unaffected when
|
||||
* the two were found to be inverted and swapped.
|
||||
*
|
||||
* Track is drawn from the deck now rather than taken from a private supply, so "have I got one?"
|
||||
* means the HAND — with 20 of each hand among 104 track cards in a 243-card deck, the next one is
|
||||
|
||||
+2
-2
@@ -460,8 +460,8 @@ export function impediments(s: GameState, player = 0): Impediment[] {
|
||||
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
|
||||
// it also strips the industry track of Operational Rail status (§9.3), so no car can be
|
||||
// brought in to rescue it.
|
||||
for (let box = 0; box < f.menAtWork.length; box++) {
|
||||
const load = f.menAtWork[box];
|
||||
for (let box = 0; box < (f.menAtWork?.length ?? 0); box++) {
|
||||
const load = f.menAtWork![box];
|
||||
if (!load || canAdvanceLoad(f, box)) continue;
|
||||
const reason =
|
||||
laborersLeft(f) < 1
|
||||
|
||||
+18
-6
@@ -204,7 +204,7 @@ export function compress(frames: Frame[]): Packed {
|
||||
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||
// `train` rides whole rather than being interned: it changes almost every frame, so a table
|
||||
// of them would be as long as the frames are and buy nothing.
|
||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad];
|
||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat];
|
||||
});
|
||||
return { ...f, cells } as unknown as Frame;
|
||||
});
|
||||
@@ -238,7 +238,7 @@ export function rehydrateCells(
|
||||
facs: unknown[],
|
||||
): unknown[] {
|
||||
return packed.map((row) => {
|
||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown];
|
||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[]];
|
||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||
return {
|
||||
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
||||
@@ -246,6 +246,9 @@ export function rehydrateCells(
|
||||
facility: p[5] < 0 ? null : facs[p[5]],
|
||||
train: p[6] ?? null,
|
||||
ad: p[7] ?? null,
|
||||
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
||||
// cannot reach the card catalogue that produced the text.
|
||||
enhancementsWhat: p[8] ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -293,7 +296,7 @@ margin-top:5px;font-size:11px;line-height:1.35}
|
||||
.fac{border:1px solid var(--line);border-radius:5px;padding:8px;margin-bottom:8px;background:#20252c}
|
||||
.boxes{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;align-items:center}
|
||||
.box{border-radius:3px;padding:3px 7px;font-size:11px;border:1px solid var(--line)}
|
||||
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
|
||||
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}.box.s{background:#3a4450}
|
||||
.box.empty{background:transparent;color:var(--dim)}
|
||||
.log{max-height:230px;overflow:auto}
|
||||
/* Auto-hide, the same as the playable page. Folded leaves a summary rather than nothing, because a
|
||||
@@ -519,10 +522,19 @@ function render() {
|
||||
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
|
||||
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
||||
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
||||
'<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>' +
|
||||
// No sign and no siding on a Passenger Facility: maw is empty for one, but the LABEL was
|
||||
// printed before the loop, so a Depot showed the caption over nothing. The mini card above
|
||||
// gates on trackCap and was already right; this copy was not.
|
||||
// (No backticks in here — this whole block is inside a template literal.)
|
||||
(x.maw.length > 0
|
||||
? '<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>'
|
||||
: '') +
|
||||
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
|
||||
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
|
||||
// 's', not 'g': the siding is a place, not a direction, and green is reserved for outbound.
|
||||
(x.trackCap > 0 || x.track.length > 0
|
||||
? '<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 's') + '</div>'
|
||||
: '') +
|
||||
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
|
||||
(x.jammed
|
||||
? 'JAMMED — a load is on WORK with no car spotted to receive it; the industry track is locked'
|
||||
|
||||
+141
-24
@@ -24,10 +24,13 @@ import {
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MAINLINE_PROFILES,
|
||||
MANEUVER_CARDS,
|
||||
MODIFIER_PROFILES,
|
||||
REALIGNMENTS,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
enhancementRule,
|
||||
enhancementText,
|
||||
industryProfile,
|
||||
mainlineProfile,
|
||||
modifierProfile,
|
||||
@@ -55,6 +58,13 @@ export type CellView = {
|
||||
* the district works left no trace you could see.
|
||||
*/
|
||||
enhancements: string[];
|
||||
/**
|
||||
* What each of those enhancements does, in the same order — and whether it does it yet.
|
||||
*
|
||||
* Carried on the view model because `board-svg.ts` imports nothing (the replay embeds it via
|
||||
* `toString()`), so it cannot reach the card catalogue itself.
|
||||
*/
|
||||
enhancementsWhat: string[];
|
||||
tray: string | null;
|
||||
/**
|
||||
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
|
||||
@@ -108,6 +118,14 @@ export type CellView = {
|
||||
};
|
||||
|
||||
export type FacilityView = {
|
||||
/**
|
||||
* Freight or passengers — the one thing the renderers could not previously ask.
|
||||
*
|
||||
* Without it they guessed from side effects (`trackCap > 0`, `laborers` starting "0/0"), and one
|
||||
* of the three simply did not guess at all, so a Depot drew three MEN | AT | WORK boxes it has no
|
||||
* Laborer to work. Reported from playtesting.
|
||||
*/
|
||||
kind: 'freight' | 'passenger';
|
||||
name: string;
|
||||
commodity: string;
|
||||
flow: string;
|
||||
@@ -129,6 +147,15 @@ export type FacilityView = {
|
||||
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
|
||||
*/
|
||||
base: { out: number; in: number; laborers: number; porters: number };
|
||||
/**
|
||||
* Printed grants this host's flow throws away, phrased for a tooltip.
|
||||
*
|
||||
* An industry's flow is absolute, so an Ice House beside a Grocer's Warehouse gives its Laborer and
|
||||
* nothing else — the "+1 out" has no direction to go in. Left unsaid that reads as a bug: reported
|
||||
* from playtesting as "the Ice House added the laborer but not the outbound slot". Naming it turns
|
||||
* a number that failed to move into a rule the player can see.
|
||||
*/
|
||||
suppressed: string[];
|
||||
/**
|
||||
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
|
||||
*
|
||||
@@ -373,6 +400,7 @@ function facilityView(
|
||||
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
|
||||
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
|
||||
return {
|
||||
kind: f.kind,
|
||||
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? prettyKey(key)),
|
||||
commodity: facilityCarType(f) ?? '?',
|
||||
flow: f.kind === 'passenger'
|
||||
@@ -380,7 +408,9 @@ function facilityView(
|
||||
: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
|
||||
green: f.outboundBox.map(carLabel),
|
||||
greenCap: f.capacity.outbound,
|
||||
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||
// Empty on a Passenger Facility, so a renderer that loops it draws nothing without needing a
|
||||
// guard of its own — which is the whole point of the field being nullable in the engine.
|
||||
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||
red: f.inboundBox.map(carLabel),
|
||||
redCap: f.capacity.inbound,
|
||||
track: f.industryTrack.cars.map(carLabel),
|
||||
@@ -388,14 +418,37 @@ function facilityView(
|
||||
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
||||
porters: `${portersLeft(f)}/${f.porters}`,
|
||||
canFinish: canFinishHere(f),
|
||||
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
||||
jammed: !!f.menAtWork?.some((l) => l !== null) && !canFinishHere(f),
|
||||
allowsOut: f.allows.outbound,
|
||||
allowsIn: f.allows.inbound,
|
||||
base: baseOf(card, officeName),
|
||||
suppressed: suppressedGrants(card.modifiers ?? [], f),
|
||||
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The half of each Modifier beside this facility that its printed flow discards.
|
||||
*
|
||||
* Mirrors `usableGrant` in the engine — the engine decides, this only reports. A Grocer's Warehouse
|
||||
* is `flow: 'inbound'`, so an Ice House's "+1 out" lands nowhere and the panel would otherwise show
|
||||
* a Modifier that visibly did half of what its card says.
|
||||
*/
|
||||
function suppressedGrants(modifiers: string[], f: Facility): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of modifiers) {
|
||||
const m = MODIFIER_PROFILES.find((p) => p.kind === key);
|
||||
if (!m) continue;
|
||||
if (m.addOut > 0 && !f.allows.outbound) {
|
||||
out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`);
|
||||
}
|
||||
if (m.addIn > 0 && !f.allows.inbound) {
|
||||
out.push(`${m.name}: +${m.addIn} inbound has no effect here — this facility only ships`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the card itself prints, before any Modifier beside it.
|
||||
*
|
||||
@@ -446,7 +499,7 @@ function trainOnCard(s: GameState, key: string): CellView['train'] {
|
||||
|
||||
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
|
||||
function canFinishHere(f: Facility): boolean {
|
||||
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
|
||||
const pending = f.menAtWork?.find((l) => l !== null) ?? f.outboundBox[0];
|
||||
if (!pending) return false;
|
||||
return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
|
||||
}
|
||||
@@ -517,7 +570,21 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
i.placement && kind?.kind === 'track'
|
||||
? variantLabel(kind.geometry, i.variant, kind.hand)
|
||||
: '';
|
||||
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`;
|
||||
/**
|
||||
* "Play a turnout at (0,1)" and "upgrade the straight at (0,1) to a turnout" are different
|
||||
* moves — the second lifts a card already down — and calling both "play" hid the fact that the
|
||||
* square was not empty. The Limits sign is excluded: laying track there is ordinary growth.
|
||||
*/
|
||||
const actor = s.clock.currentActor;
|
||||
const over =
|
||||
i.placement && actor !== null
|
||||
? s.officeAreas.get(actor)?.grid.get(`${i.placement.row},${i.placement.col}`)
|
||||
: undefined;
|
||||
const upgrade = over?.geometry.kind === 'track';
|
||||
return (
|
||||
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
|
||||
`${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`
|
||||
);
|
||||
}
|
||||
case 'card.discard': {
|
||||
/**
|
||||
@@ -745,6 +812,7 @@ export function snapshot(
|
||||
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
|
||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||
enhancements: card.enhancements.map(prettyKey),
|
||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
||||
tray: trayAt.get(key) ?? null,
|
||||
train: trainOnCard(s, key),
|
||||
ad:
|
||||
@@ -895,8 +963,17 @@ export function snapshot(
|
||||
division,
|
||||
cells,
|
||||
facilities,
|
||||
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
|
||||
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
|
||||
/**
|
||||
* NEWEST FIRST, matching the play page (`actionMenu`).
|
||||
*
|
||||
* The engine pushes a drawn card onto the END of the hand, which put the card just turned over
|
||||
* at the far end of a wrapping row. Reversed here rather than in the engine so the bot's hand
|
||||
* iteration — and every revenue measurement taken with it — is left alone.
|
||||
*
|
||||
* Both lines must reverse together or the descriptions come apart from the names.
|
||||
*/
|
||||
hand: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||
handWhat: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((pile) => {
|
||||
const top = pile[pile.length - 1];
|
||||
@@ -1028,7 +1105,24 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
if (m.addIn) adds.push(`+${m.addIn} in`);
|
||||
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
|
||||
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
|
||||
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
|
||||
/**
|
||||
* WARN BEFORE IT IS PLAYED, not only after.
|
||||
*
|
||||
* An industry's printed flow is absolute, so a Modifier granting capacity in the other
|
||||
* direction gives that host nothing — an Ice House lists both Packing Sheds and a Grocer's
|
||||
* Warehouse, and only the Packing Sheds can use its "+1 out". Which host you choose is the
|
||||
* whole decision, so it has to be answerable while the card is still in hand. Computed from
|
||||
* the two catalogues, so no facility need be on the board yet.
|
||||
*/
|
||||
const caveats = m.hosts
|
||||
.filter((h) => h !== 'office')
|
||||
.map((h) => ({ host: h, flow: industryProfile(h as never).flow }))
|
||||
.filter(({ flow }) => (m.addOut > 0 && flow === 'inbound') || (m.addIn > 0 && flow === 'outbound'))
|
||||
.map(({ host, flow }) => `${facilityLabel(host)} only ${flow === 'inbound' ? 'receives' : 'ships'}`);
|
||||
const warn = caveats.length
|
||||
? ` · ${caveats.join(' and ')}, so the ${m.addOut > 0 ? 'outbound' : 'inbound'} slot does nothing there`
|
||||
: '';
|
||||
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}${warn}`;
|
||||
}
|
||||
case 'track': {
|
||||
// Track is the largest category in the deck, so a player holds it constantly — and what it
|
||||
@@ -1038,7 +1132,7 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
if (k.geometry === 'straight') {
|
||||
return `east-west through track · lay it anywhere the rail continues${cost}`;
|
||||
}
|
||||
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
|
||||
const diagonal = k.hand === 'right' ? 'north–east / south–west' : 'north–west / south–east';
|
||||
const ways = variantsFor(k.geometry, k.hand)
|
||||
.map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : ''))
|
||||
.join(', or turned about, ');
|
||||
@@ -1060,7 +1154,20 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
default: {
|
||||
// The recovered categories carry their own prose — effect plus where it may be played.
|
||||
const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
|
||||
return card ? `${card.effect} · played on ${card.placement}` : '';
|
||||
if (!card) return '';
|
||||
/**
|
||||
* An Enhancement also says whether its effect is wired up. Four of the ten are read by nothing
|
||||
* at all, and a card that describes a power it does not have is worse than one that says
|
||||
* nothing — the player cannot tell a misread from a bug.
|
||||
*/
|
||||
const rule = k.kind === 'enhancement' ? enhancementRule(card.key) : null;
|
||||
const note =
|
||||
rule?.effect === 'unbuilt'
|
||||
? ' · NOT YET IMPLEMENTED — no effect in play'
|
||||
: rule?.effect === 'dormantSolo'
|
||||
? ' · never fires in solitaire — it answers an opponent card the solo deck omits'
|
||||
: '';
|
||||
return `${card.effect} · played on ${card.placement}${note}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1091,18 +1198,28 @@ export function trainRules(t: {
|
||||
|
||||
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('; ')}`);
|
||||
/**
|
||||
* §7's operating rules, ALL of which the engine now enforces.
|
||||
*
|
||||
* These used to be listed under "NOT YET ENFORCED BY THE ENGINE", which was honest at the time and
|
||||
* is not any more — every one below is checked in `apply.ts` or `advance.ts`. Saying what a rule
|
||||
* DOES rather than that it exists, because the restriction is the whole character of the card: a
|
||||
* Military train that cannot be worked by Porters plays nothing like a Local.
|
||||
*/
|
||||
if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt');
|
||||
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
|
||||
if (p.rules.coachStaysOnStationTrack) {
|
||||
parts.push('THE COACH STAYS AT THE STATION — a cut carrying it may only be set out at the Office');
|
||||
}
|
||||
if (p.rules.oneFreightPerLocation) {
|
||||
parts.push('ONE FREIGHT CAR PER LOCATION — dropped or picked up, one each square per turn');
|
||||
}
|
||||
if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it');
|
||||
if (p.rules.dropOnly) parts.push('MAY DROP BUT NOT PICK UP — it cannot couple anything');
|
||||
if (p.rules.pickUpEmptiesOnly) parts.push('EMPTIES ONLY — it may not couple a loaded car');
|
||||
if (p.rules.stopThenExpedite) {
|
||||
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -1179,11 +1296,11 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
if (!f) return 'a facility';
|
||||
if (f.kind === 'passenger') return 'passengers board and detrain here';
|
||||
// Say where the work has actually got to — the squares on the card show it, this names it.
|
||||
const inWork = f.menAtWork.findIndex((l) => l !== null);
|
||||
const inWork = (f.menAtWork ?? []).findIndex((l) => l !== null);
|
||||
const progress =
|
||||
inWork >= 0
|
||||
? ` · a load is on ${['MEN', 'AT', 'WORK'][inWork]}, ${
|
||||
inWork === f.menAtWork.length - 1
|
||||
inWork === (f.menAtWork?.length ?? 0) - 1
|
||||
? 'one more Laborer action and it goes onto a spotted car'
|
||||
: 'each Laborer action moves it one square right'
|
||||
}`
|
||||
@@ -1205,7 +1322,7 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
return 'through track, east–west';
|
||||
case 'curved':
|
||||
case 'sharpCurved': {
|
||||
const arc = g.arc ?? (g.hand === 'right' ? 'se' : 'sw');
|
||||
const arc = g.arc ?? (g.hand === 'right' ? 'sw' : 'se');
|
||||
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
|
||||
return `curve — ${curvePhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user