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:
+313
-8
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user