v0.3.0 — playtest fixes, two new rules, and §7 enforced

This commit is contained in:
Jesse
2026-08-12 21:08:28 -04:00
parent 6ead39c530
commit 1bf1e95058
38 changed files with 8514 additions and 5989 deletions
+141 -24
View File
@@ -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)}`;
}