Initial commit
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* Build order step 3 — "Individual actions apply correctly; illegal ones rejected."
|
||||
* See architecture/components.md §4.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, check, areaOf } from '../src/engine/apply.ts';
|
||||
import { HAND_LIMIT, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
const game = (seed = 77): GameState =>
|
||||
createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });
|
||||
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
|
||||
/** Puts a tray on the player's grid so switching intents have something to act on. */
|
||||
function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist'] = []): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id,
|
||||
trainNumber: null,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
consist,
|
||||
direction: 'east',
|
||||
position: { at: 'grid', owner: 0, coord },
|
||||
movesUsed: 0,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||||
}
|
||||
|
||||
const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('turn and phase gating', () => {
|
||||
it('rejects an intent from a player who is not the actor', () => {
|
||||
const s = game();
|
||||
s.clock.currentActor = 1;
|
||||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'NOT_YOUR_TURN');
|
||||
});
|
||||
|
||||
it('rejects an intent belonging to another phase', () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'loadUnload';
|
||||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
|
||||
});
|
||||
|
||||
it('rejects everything once the game is finished', () => {
|
||||
const s = game();
|
||||
s.status = 'finished';
|
||||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Local Operations: the three-way exclusive choice (§6)', () => {
|
||||
it('accepts a first choice and records it', () => {
|
||||
const s = game();
|
||||
const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.turn.option, 'draw');
|
||||
});
|
||||
|
||||
it('forecloses the other two options for the Stage', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'localOps.choose', option: 'switch' }),
|
||||
'OPTION_ALREADY_CHOSEN',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses sub-intents of an option that was not chosen', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(check(s, 0, { type: 'switch.end' }), 'OPTION_NOT_CHOSEN');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Local Operations: drawing (§6.2)', () => {
|
||||
it('draws from the Home Office deck into the hand', () => {
|
||||
const s = game();
|
||||
const before = s.decks.homeOffice.length;
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.decks.homeOffice.length, before - 1);
|
||||
assert.equal(s.decks.hands.get(0)!.length, 4);
|
||||
});
|
||||
|
||||
it('takes the face-up card from a Department slot', () => {
|
||||
const s = game();
|
||||
const target = s.decks.departments[1]!;
|
||||
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));
|
||||
assert.equal(s.decks.departments[1], null);
|
||||
});
|
||||
|
||||
it('allows only one draw per Stage', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'OPTION_ALREADY_CHOSEN');
|
||||
});
|
||||
|
||||
it('will not end the phase over the hand limit', () => {
|
||||
// §6.2 — "must reduce his hand to no more than three cards".
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT + 1);
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
|
||||
});
|
||||
|
||||
it('discards face up onto a Department slot, restoring the limit', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
|
||||
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.equal(check(s, 0, { type: 'draw.end' }), null);
|
||||
});
|
||||
|
||||
it('refuses to play a card that is not in hand', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: 'nonsense' }),
|
||||
'CARD_NOT_IN_HAND',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Office upgrades (Gap 3b, Gap 8)', () => {
|
||||
function handCardOfTier(s: GameState, tier: 'depot' | 'station') {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind === 'office' && card.kind.tier === tier) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error('no such office card');
|
||||
}
|
||||
|
||||
it('upgrades a Whistle Post to a Depot', () => {
|
||||
const s = game();
|
||||
const id = handCardOfTier(s, 'depot');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(areaOf(s, 0).tier, 'depot');
|
||||
});
|
||||
|
||||
it('refuses to skip a tier', () => {
|
||||
const s = game();
|
||||
const id = handCardOfTier(s, 'station');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: id }), 'NOT_UPGRADEABLE');
|
||||
});
|
||||
|
||||
it('preserves every grid connection through an upgrade', () => {
|
||||
// Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan
|
||||
// Secondary Track hanging off the Office.
|
||||
const s = game();
|
||||
addCard(s, at(1, 0), straight());
|
||||
const gridBefore = new Map(areaOf(s, 0).grid);
|
||||
const id = handCardOfTier(s, 'depot');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||||
|
||||
const gridAfter = areaOf(s, 0).grid;
|
||||
assert.equal(gridAfter.size, gridBefore.size, 'no card added or lost');
|
||||
for (const key of gridBefore.keys()) assert.ok(gridAfter.has(key), `lost ${key}`);
|
||||
});
|
||||
|
||||
it('grants the Depot its Porters and slots on upgrade', () => {
|
||||
const s = game();
|
||||
const id = handCardOfTier(s, 'depot');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||||
const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!;
|
||||
assert.equal(office.facility!.porters, 1);
|
||||
assert.equal(office.facility!.capacity.inbound, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Local Operations: switching (§6.1, Appendix A)', () => {
|
||||
it('moves a tray and spends a Move', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.turn.movesRemaining, MOVES_PER_LOCAL_OPS - 1);
|
||||
});
|
||||
|
||||
it('couples standing cars automatically and mandatorily', () => {
|
||||
// §A.4 — "you MUST pick them up".
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight([{ type: 'boxcar', loaded: false }]));
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'carsCoupled'), 'coupling must be an event');
|
||||
assert.equal(s.trays.get(tray)!.consist.length, 1);
|
||||
});
|
||||
|
||||
it('refuses a move to an unreachable card', () => {
|
||||
const s = game();
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId: tray, to: at(9, 9), reverse: false }),
|
||||
'ILLEGAL_MOVE',
|
||||
);
|
||||
});
|
||||
|
||||
it('runs out of Moves after six', () => {
|
||||
const s = game();
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
s.turn.movesRemaining = 0;
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
|
||||
'NO_MOVES_REMAINING',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops cars from the seated end, in order', () => {
|
||||
// §A.3 — cars must come off in the order they are seated in the Crew Tray.
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
const tray = placeTray(s, at(0, 2), [
|
||||
{ type: 'boxcar', loaded: false },
|
||||
{ type: 'hopper', loaded: true },
|
||||
]);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
const r = applyIntent(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 });
|
||||
assert.ok(r.ok);
|
||||
const left = areaOf(s, 0).grid.get(coordKey(at(0, 2)))!.standing;
|
||||
assert.equal(left.length, 1);
|
||||
assert.equal(left[0]!.type, 'hopper', 'the last-seated car comes off first');
|
||||
assert.equal(s.trays.get(tray)!.consist.length, 1);
|
||||
});
|
||||
|
||||
it('refuses to drop cars at the Office', () => {
|
||||
// §A.4 — a passenger platform is no place to switch Rolling Stock.
|
||||
const s = game();
|
||||
const office = areaOf(s, 0).officeCoord;
|
||||
const tray = placeTray(s, office, [{ type: 'boxcar', loaded: false }]);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }),
|
||||
'CANNOT_DROP_HERE',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Freight Agent operations (§6.3)', () => {
|
||||
function withFacility(s: GameState): GridCoord {
|
||||
const coord = at(1, 0);
|
||||
addCard(s, coord, {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 4, cars: [] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
});
|
||||
return coord;
|
||||
}
|
||||
|
||||
it('stocks the green Outbound box from the Division Yard', () => {
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
const before = s.yards.divisionYard.length;
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.yards.divisionYard.length, before - 1);
|
||||
assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1);
|
||||
});
|
||||
|
||||
it('allows only one Freight Agent operation per Stage', () => {
|
||||
// This is the bottleneck the whole economy rests on (card-reference.md §7).
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||||
'OPTION_ALREADY_CHOSEN',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to overfill the Outbound box', () => {
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.outboundBox = [
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'hopper', loaded: true },
|
||||
];
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||||
'BOX_FULL',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to load a facility that only unloads', () => {
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.allows = { outbound: false, inbound: true };
|
||||
// Give it something to clear, so the Freight Agent option itself remains available.
|
||||
f.inboundBox = [{ type: 'hopper', loaded: true }];
|
||||
const chose = applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
assert.ok(chose.ok);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||||
'NO_SUCH_FACILITY',
|
||||
);
|
||||
});
|
||||
|
||||
it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => {
|
||||
// A player with no Facility cannot choose an option that has no possible follow-up.
|
||||
const s = game();
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'localOps.choose', option: 'freightAgent' }),
|
||||
'NO_SUCH_FACILITY',
|
||||
);
|
||||
});
|
||||
|
||||
it('makes the switch option unavailable with no train to switch', () => {
|
||||
const s = game();
|
||||
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'switch' }), 'NO_SUCH_TRAY');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
function facilityWithLoad(s: GameState): GridCoord {
|
||||
const coord = at(1, 0);
|
||||
addCard(s, coord, {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
|
||||
industryTrack: { length: 4, cars: [{ type: 'hopper', loaded: false }] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
});
|
||||
s.clock.phase = 'loadUnload';
|
||||
return coord;
|
||||
}
|
||||
|
||||
it('advances a load one box per Laborer action', () => {
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 });
|
||||
assert.ok(r.ok);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
assert.equal(f.menAtWork[0], null);
|
||||
assert.notEqual(f.menAtWork[1], null);
|
||||
assert.equal(f.usedThisStage.laborers, 1);
|
||||
});
|
||||
|
||||
it('spends each Laborer only once per Stage', () => {
|
||||
// §9.1 — Laborers and Porters may be used once each in a Stage.
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.usedThisStage.laborers = f.laborers;
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 }),
|
||||
'RESOURCE_SPENT',
|
||||
);
|
||||
});
|
||||
|
||||
it('scores exactly one Revenue when a load comes off WORK', () => {
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
|
||||
|
||||
assert.equal(s.players[0]!.revenue, 0);
|
||||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.players[0]!.revenue, 1, 'one point per completed load');
|
||||
assert.ok(r.events.some((e) => e.type === 'loadCompleted'));
|
||||
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the spotted car is now loaded');
|
||||
});
|
||||
|
||||
it('will not complete a load with no empty car spotted', () => {
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
|
||||
f.industryTrack.cars = [];
|
||||
assert.equal(check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }), 'BOX_EMPTY');
|
||||
});
|
||||
|
||||
it('takes four Laborer actions in total for one point', () => {
|
||||
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.laborers = 99; // isolate the pipeline from the per-Stage cap
|
||||
|
||||
let actions = 0;
|
||||
for (let box = 0; box < 3; box++) {
|
||||
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
|
||||
assert.ok(r.ok, `advance from box ${box}`);
|
||||
actions++;
|
||||
}
|
||||
assert.equal(actions, 3, 'Green->MEN, MEN->AT, AT->WORK');
|
||||
assert.equal(s.players[0]!.revenue, 1, 'the third advance came off WORK and scored');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the Superintendent clearance ruling (§8.1)', () => {
|
||||
it('is refused when no decision is pending', () => {
|
||||
const s = game();
|
||||
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NO_PENDING_DECISION');
|
||||
});
|
||||
|
||||
it('is accepted out of turn order, because it interrupts an automatic phase', () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'mainline';
|
||||
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
|
||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
||||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
});
|
||||
|
||||
it('is refused to a player who is not the Superintendent', () => {
|
||||
const s = game();
|
||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
||||
s.clock.superintendent = 1;
|
||||
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('legalActions shares its rules with apply (component 6)', () => {
|
||||
it('offers only intents that apply would accept', () => {
|
||||
// The whole discipline of legal.ts in one assertion.
|
||||
const s = game();
|
||||
for (const i of legalActions(s, 0)) {
|
||||
assert.equal(check(s, 0, i), null, `offered an illegal intent: ${i.type}`);
|
||||
}
|
||||
});
|
||||
|
||||
const chooseOptions = (s: GameState) =>
|
||||
legalActions(s, 0)
|
||||
.filter((i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose')
|
||||
.map((i) => i.option)
|
||||
.sort();
|
||||
|
||||
it('offers only "draw" on the opening Stage', () => {
|
||||
// §6 — the other two options have no possible follow-up yet: a player opens with no Facility
|
||||
// and no train. This is why the very first Stages are forced development.
|
||||
assert.deepEqual(chooseOptions(game()), ['draw']);
|
||||
});
|
||||
|
||||
it('offers all three once the player has a facility and a train', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
placeTray(s, at(0, 2));
|
||||
addCard(s, at(1, 0), {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 4, cars: [] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
});
|
||||
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
|
||||
});
|
||||
|
||||
it('narrows to one option once a choice is made', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const kinds = new Set(legalActions(s, 0).map((i) => i.type));
|
||||
assert.ok(!kinds.has('localOps.choose'), 'the choice is spent');
|
||||
assert.ok(kinds.has('draw.fromHomeOffice'), 'draw sub-intents are now available');
|
||||
assert.ok(!kinds.has('switch.end'), 'switch sub-intents are not');
|
||||
});
|
||||
|
||||
it('offers reachable destinations for a placed tray', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
const tray = placeTray(s, at(0, 1));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
const moves = legalActions(s, 0).filter((i) => i.type === 'switch.move');
|
||||
assert.ok(moves.length > 0, 'a tray with open track should have somewhere to go');
|
||||
for (const m of moves) assert.equal(check(s, 0, m), null);
|
||||
assert.ok(moves.some((m) => m.type === 'switch.move' && m.trayId === tray));
|
||||
});
|
||||
|
||||
it('offers nothing illegal after the game ends', () => {
|
||||
const s = game();
|
||||
s.status = 'finished';
|
||||
assert.equal(legalActions(s, 0).length, 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user