Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button

This commit is contained in:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+313 -8
View File
@@ -6,7 +6,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, check, areaOf, facilityCarTypes } from '../src/engine/apply.ts';
import { applyIntent, check, areaOf, facilityCarTypes, reduce } from '../src/engine/apply.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
@@ -121,17 +121,109 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.equal(s.decks.hands.get(0)!.length, 4);
});
it('takes the face-up card from a Department slot', () => {
it('takes the TOP card from a Department pile, never one buried under it', () => {
const s = game();
const target = s.decks.departments[1]!;
const buried = s.decks.departments[1]![0]!;
// Bury it: a card discarded onto this Department goes on top and puts the other out of reach.
const spare = s.decks.hands.get(0)![0]!;
s.decks.departments[1]!.push(spare);
s.decks.hands.set(0, s.decks.hands.get(0)!.filter((c) => c !== spare));
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
assert.ok(r.ok);
assert.ok(s.decks.hands.get(0)!.includes(spare), 'the top card should be the one taken');
assert.ok(!s.decks.hands.get(0)!.includes(buried), 'a buried card must not be reachable');
assert.deepEqual(s.decks.departments[1], [buried], 'the pile should be one shorter, not refilled');
});
it('refills a Department only when taking its last card empties it', () => {
const s = game();
const target = s.decks.departments[1]![0]!;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
assert.ok(r.ok);
assert.ok(s.decks.hands.get(0)!.includes(target));
// §6.2 — an emptied Department slot is refilled from the Home Office deck immediately, so the
// §6.2 — an emptied Department is refilled from the Home Office deck immediately, so the
// face-up market never disappears.
assert.notEqual(s.decks.departments[1], null, 'Department slot was not refilled');
assert.notEqual(s.decks.departments[1], target, 'refilled with the same card');
assert.equal(s.decks.departments[1]!.length, 1, 'an emptied Department was not refilled');
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
});
it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => {
// §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards
// from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office
// deck and Department slots."
//
// This had a `deckReshuffled` event declared in `events.ts` and a line of narration written for
// it, and was never emitted or reduced anywhere. The game looked like it had a reshuffle.
const s = game();
const all = [...s.decks.homeOffice];
s.decks.salvageYard = all.slice(0, 40);
s.decks.homeOffice = all.slice(40, 41);
const loose = (): number =>
s.decks.homeOffice.length +
s.decks.departments.reduce((n, p) => n + p.length, 0) +
s.decks.salvageYard.length +
[...s.decks.hands.values()].reduce((n, h) => n + h.length, 0);
const before = loose();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept');
assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established');
assert.ok(
s.decks.departments.every((p) => p.length === 1),
'the Departments must be turned face up again, one card each',
);
assert.equal(loose(), before, 'the reshuffle created or destroyed cards');
const ids = [
...s.decks.homeOffice,
...s.decks.departments.flat(),
...s.decks.salvageYard,
...[...s.decks.hands.values()].flat(),
];
assert.equal(new Set(ids).size, ids.length, 'a card ended up in two places');
});
it('does not reshuffle while the deck still has cards', () => {
const s = game();
s.decks.salvageYard = [...s.decks.homeOffice].slice(0, 10);
s.decks.homeOffice = [...s.decks.homeOffice].slice(10);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.ok(!r.events.some((e) => e.type === 'deckReshuffled'), 'reshuffled with cards still in the deck');
});
it('leaves a genuinely exhausted game exhausted', () => {
// Cards played onto the board are on the table, not in the Salvage Yard, so a game CAN run out
// for real. Reshuffling an empty sweep would loop forever pretending otherwise.
const s = game();
s.decks.homeOffice = [];
s.decks.salvageYard = [];
for (const pile of s.decks.departments) pile.length = 0;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'DECK_EMPTY');
});
it('replays the reshuffle identically from the same seed', () => {
// The shuffled order rides the event, and `rngState` with it, so a save — a seed plus intents —
// reconstructs the same deck. A reshuffle that re-rolled would fork every replay after it.
const build = () => {
const s = game();
const all = [...s.decks.homeOffice];
s.decks.salvageYard = all.slice(0, 40);
s.decks.homeOffice = all.slice(40, 41);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
return s;
};
assert.deepEqual(build().decks.homeOffice, build().decks.homeOffice);
assert.deepEqual(build().decks.departments, build().decks.departments);
});
it('allows only one draw per Stage', () => {
@@ -150,17 +242,38 @@ describe('Local Operations: drawing (§6.2)', () => {
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
});
it('discards face up onto a Department slot, restoring the limit', () => {
it('discards face up ON TOP of a chosen Department, burying what was there', () => {
// The choice of WHICH Department is the strategy: a card put on an empty-ish pile is an offer, a
// card put on top of one a rival wants takes that card out of reach. Overwriting the slot — what
// the old single-slot model did — destroyed the buried card outright and threw the choice away.
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
const under = s.decks.departments[0]![s.decks.departments[0]!.length - 1]!;
const spare = s.decks.hands.get(0)![0]!;
const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 });
assert.ok(r.ok);
assert.equal(s.decks.departments[0], spare, 'discards go face up onto a Department');
assert.deepEqual(s.decks.departments[0], [under, spare], 'the discard goes on top, and nothing is lost');
assert.equal(check(s, 0, { type: 'draw.end' }), null);
});
it('lets the discarding player pick which Department to use', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
const spare = s.decks.hands.get(0)![0]!;
for (const slot of [0, 1, 2]) {
assert.equal(check(s, 0, { type: 'card.discard', cardId: spare, toSlot: slot }), null, `Department ${slot}`);
}
const depths = s.decks.departments.map((p) => p.length);
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 2 }).ok);
assert.deepEqual(
s.decks.departments.map((p) => p.length),
[depths[0]!, depths[1]!, depths[2]! + 1],
'only the chosen Department should grow',
);
});
it('refuses to play a card that is not in hand', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
@@ -686,3 +799,195 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
}
});
});
// ---------------------------------------------------------------------------
describe('Industry cards go on a stub, and lock each other out', () => {
/**
* A district with a siding hanging off the Running Track, which is the only place an industry may
* go. Returns the siding square east of the curve.
*
* row 0: [lim] [office] [turnout, leg south] … <- Running Track
* row -1: [curve ne] [siding square]
*/
function withSiding(s: GameState): GridCoord {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
const col = area.limitsEast.col;
area.grid.set(coordKey(at(area.runningRow, col)), plain({
kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left',
}));
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
}));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
return at(area.runningRow - 1, col + 1);
}
/** Puts an industry card of `kind` in hand and returns its id. */
function industryInHand(s: GameState, kind: string): string {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'freightFacility' && card.kind.facility === kind) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no industry card: ${kind}`);
}
/** Puts an industry straight onto the board, bypassing the hand. */
function build(s: GameState, kind: string, coord: GridCoord): void {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: kind as never },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
}
it('refuses an industry on the Running Track', () => {
// The sheet's "Placed" column, identical for all six: "Straight, Stub (not on Running Track)".
// An industry has to hang off a siding — which is what makes building one worth the cards.
const s = game();
withSiding(s);
const area = areaOf(s, 0);
const cardId = industryInHand(s, 'mineTipple');
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, area.limitsEast.col) }),
'ON_RUNNING_TRACK',
);
});
it('accepts the same industry on a stub off the Running Track', () => {
const s = game();
const siding = withSiding(s);
const cardId = industryInHand(s, 'mineTipple');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), null);
});
it('refuses a second industry of the same kind', () => {
// The sheet states this in the Freight House row, which lists Freight House among its own
// lockouts. It is a general rule and applies to every kind.
const s = game();
const siding = withSiding(s);
build(s, 'mineTipple', at(areaOf(s, 0).runningRow - 1, siding.col + 1));
const cardId = industryInHand(s, 'mineTipple');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), 'FACILITY_LOCKED');
});
it('refuses a producer once its consumer is built, and the reverse', () => {
// Every locked pair is a producer and the consumer of the same commodity: Mine Tipple makes the
// coal a Power Plant burns, the Refinery makes the oil it also burns, Packing Sheds fill the
// reefers a Grocer's Warehouse empties. One end of a chain or the other, never both.
const pairs: [string, string][] = [
['mineTipple', 'powerPlant'],
['refinery', 'powerPlant'],
['packingSheds', 'grocersWarehouse'],
['freightHouse', 'grocersWarehouse'],
];
for (const [a, b] of pairs) {
for (const [built, wanted] of [[a, b], [b, a]] as [string, string][]) {
const s = game();
const siding = withSiding(s);
build(s, built, at(areaOf(s, 0).runningRow - 1, siding.col + 1));
const cardId = industryInHand(s, wanted);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: siding }),
'FACILITY_LOCKED',
`${wanted} should be locked out by ${built}`,
);
}
}
});
it('allows industries that share no commodity', () => {
// Mine Tipple ships coal and Packing Sheds ship produce; neither consumes what the other makes,
// so both may stand in one district. The lockouts must not be a blanket ban.
const s = game();
const siding = withSiding(s);
build(s, 'mineTipple', at(areaOf(s, 0).runningRow - 1, siding.col + 1));
const cardId = industryInHand(s, 'packingSheds');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: siding }), null);
});
it('matches the lockouts on the sheet exactly', () => {
// Transcribed from the "Lockouts" column, so a change to the catalogue has to be deliberate.
const expected: Record<string, string[]> = {
freightHouse: ['grocersWarehouse'],
mineTipple: ['powerPlant'],
refinery: ['powerPlant'],
powerPlant: ['mineTipple', 'refinery'],
packingSheds: ['grocersWarehouse'],
grocersWarehouse: ['packingSheds', 'freightHouse'],
};
for (const p of INDUSTRY_PROFILES) {
assert.deepEqual([...p.lockouts].sort(), [...expected[p.kind]!].sort(), p.kind);
}
});
});
// ---------------------------------------------------------------------------
describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendix A)', () => {
const car = (type: string, loaded = false): { type: string; loaded: boolean } => ({ type, loaded });
it('holds an engine and up to four cars', () => {
// "Up to five tokens: an engine, and up to four cars, one of which could be a caboose." The
// engine is not one of the consist entries — §8.2 counts the consist as Rolling Stock and the
// four-car limit is a limit on cars, not on the locomotive hauling them.
const s = game();
const id = placeTray(s, at(0, 0), [car('boxcar'), car('hopper'), car('tank'), car('caboose')] as never);
assert.equal(s.trays.get(id)!.consist.length, 4);
});
it('couples to the nose going forward and to the tail backing up', () => {
// Appendix A: "Engines also have couplers on the front end... a train can pick up two cars and
// add them to the Crew Tray in order that they were in, pushing them into the Facility." Which
// end they land on is the whole point of a run-around: it decides which car comes off next.
const build = (toNose: boolean): string[] => {
const s = game();
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
return s.trays.get(id)!.consist.map((c) => c.type);
};
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
assert.deepEqual(build(false), ['boxcar', 'hopper'], 'backing up couples them behind');
});
it('moves the engine back when cars are taken onto the nose, and forward when they are set out', () => {
// Cars taken on the nose go AHEAD of the engine, so it is no longer leading. That is what §8.2
// then refuses to let out of the Office, and setting the nose cars out is the way back.
const s = game();
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
const tray = s.trays.get(id)!;
tray.engineAt = 0;
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
});
it('will not cut a train in the middle', () => {
// A drop takes a cut off an OUTER end. Lifting cars from beside the engine would leave the far
// end of the train coupled to nothing — a cut no coupler could make.
const s = game();
// On a Limits card, not the Office — Rolling Stock may never be left at the Office (§A.4).
const id = placeTray(s, at(0, 1), [car('boxcar'), car('hopper')] as never);
s.trays.get(id)!.engineAt = 1;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
// One car ahead of the engine and one behind: at most one may come off either end.
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }), null);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }), null);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2, fromNose: true }), 'CONSIST_EMPTY');
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }), 'CONSIST_EMPTY');
});
});