v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+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