363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
/**
|
|
* Enhancement cards (implications.md §7).
|
|
*
|
|
* These are the 18 cards that make up the largest block of the "third of the deck that does
|
|
* nothing". Several change core loops — Small Yard makes the facing-point switching puzzle
|
|
* solvable at all, and ABS Signals amends Gap 2's unconditional collisions.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { advance } from '../src/engine/advance.ts';
|
|
import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts';
|
|
import { ENHANCEMENT_RULES, enhancementRule } from '../src/engine/content.ts';
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import type { 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 = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
|
const at = (row: number, col: number): GridCoord => ({ row, col });
|
|
|
|
const straight = (): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
|
areaOf(s, 0).grid.set(coordKey(coord), card);
|
|
}
|
|
|
|
/** Puts an enhancement card in hand and returns its id. */
|
|
function handEnhancement(s: GameState, key: string): string {
|
|
for (const [id, card] of s.cards) {
|
|
if (card.kind.kind === 'enhancement' && card.kind.key === key) {
|
|
s.decks.hands.set(0, [id]);
|
|
return id;
|
|
}
|
|
}
|
|
throw new Error(`no enhancement card: ${key}`);
|
|
}
|
|
|
|
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
|
|
const id = s.freeTrays.pop()!;
|
|
s.trays.set(id, {
|
|
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
|
|
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('enhancement placement', () => {
|
|
it('defines a rule for every enhancement card', () => {
|
|
assert.equal(ENHANCEMENT_RULES.length, 10);
|
|
for (const r of ENHANCEMENT_RULES) assert.ok(enhancementRule(r.key), r.key);
|
|
});
|
|
|
|
it('puts Interlocking on a Running Track straight, not a Secondary one', () => {
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
addCard(s, at(-1, 0), straight());
|
|
const id = handEnhancement(s, 'interlocking');
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), null);
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), 'NOT_CONNECTED');
|
|
});
|
|
|
|
it('puts Small Yard on a Secondary Track straight, not the Running Track', () => {
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
addCard(s, at(-1, 0), straight());
|
|
const id = handEnhancement(s, 'smallYard');
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), null);
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), 'NOT_CONNECTED');
|
|
});
|
|
|
|
it('stacks Telephone on Telegraph and Radio on Telephone, never bare', () => {
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
|
|
const phone = handEnhancement(s, 'telephone');
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: phone, placement: at(0, 2) }), 'NOT_CONNECTED');
|
|
|
|
const tel = handEnhancement(s, 'telegraph');
|
|
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: tel, placement: at(0, 2) }).ok);
|
|
|
|
const phone2 = handEnhancement(s, 'telephone');
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: phone2, placement: at(0, 2) }), null);
|
|
});
|
|
|
|
it('requires an Interlocking in the district before Facing Point Locks', () => {
|
|
const s = game();
|
|
addCard(s, at(0, 2), straight());
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
|
const fpl = handEnhancement(s, 'facingPointLocks');
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: fpl, placement: at(0, 2) }), 'NOT_CONNECTED');
|
|
|
|
const lock = handEnhancement(s, 'interlocking');
|
|
applyIntent(s, 0, { type: 'card.play', cardId: lock, placement: at(0, 2) });
|
|
const fpl2 = handEnhancement(s, 'facingPointLocks');
|
|
assert.equal(check(s, 0, { type: 'card.play', cardId: fpl2, placement: at(0, 2) }), null);
|
|
assert.ok(isProtectedFromDerail(areaOf(s, 0)) === false, 'not protected until actually played');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Small Yard — the card that makes switching solvable', () => {
|
|
function yardGame() {
|
|
const s = game();
|
|
const yard = at(-1, 0);
|
|
const card = straight();
|
|
card.enhancements.push('smallYard');
|
|
addCard(s, yard, card);
|
|
const tray = placeTray(s, yard, [
|
|
{ type: 'boxcar', loaded: false },
|
|
{ type: 'hopper', loaded: true },
|
|
{ type: 'coach', loaded: false },
|
|
]);
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
|
return { s, tray, yard };
|
|
}
|
|
|
|
it('re-orders a consist, including bringing a buried car to the end', () => {
|
|
// §A.3 forces cars off in seated order, so without this the middle car can never be spotted.
|
|
const { s, tray } = yardGame();
|
|
const r = applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [0, 2, 1] });
|
|
assert.ok(r.ok);
|
|
assert.deepEqual(
|
|
s.trays.get(tray)!.consist.map((c) => c.type),
|
|
['boxcar', 'coach', 'hopper'],
|
|
'the hopper is now on the droppable end',
|
|
);
|
|
});
|
|
|
|
it('costs one Move — "spends one move in the yard"', () => {
|
|
const { s, tray } = yardGame();
|
|
const before = s.turn.movesRemaining;
|
|
applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [2, 1, 0] });
|
|
assert.equal(s.turn.movesRemaining, before - 1);
|
|
});
|
|
|
|
it('is refused anywhere without a Small Yard', () => {
|
|
const s = game();
|
|
addCard(s, at(-1, 0), straight());
|
|
const tray = placeTray(s, at(-1, 0), [
|
|
{ type: 'boxcar', loaded: false },
|
|
{ type: 'hopper', loaded: true },
|
|
]);
|
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
|
assert.equal(
|
|
check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [1, 0] }),
|
|
'NOT_CONNECTED',
|
|
);
|
|
});
|
|
|
|
it('refuses an order that is not a permutation of the consist', () => {
|
|
const { s, tray } = yardGame();
|
|
for (const bad of [[0, 1], [0, 1, 1], [0, 1, 5]]) {
|
|
assert.equal(
|
|
check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: bad }),
|
|
'CONSIST_ORDER',
|
|
`accepted ${JSON.stringify(bad)}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Interlocking and Yard Office relieve the Office', () => {
|
|
function inbound(s: GameState, consist: TrackCard['standing']) {
|
|
const id = 'inbound';
|
|
s.trays.set(id, {
|
|
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
|
|
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') {
|
|
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
|
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
|
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
|
ml.card = 'plains';
|
|
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
|
}
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
return id;
|
|
}
|
|
|
|
it('holds a train at the Limits instead of colliding when the Office is full', () => {
|
|
// Gap 2d made a full Office an automatic collision. Interlocking is the designed answer.
|
|
const s = game();
|
|
const card = straight();
|
|
card.enhancements.push('interlocking');
|
|
addCard(s, at(0, 2), card);
|
|
areaOf(s, 0).adOccupancy = ['blocker'];
|
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
|
|
|
advance(s);
|
|
assert.equal(s.players[0]!.revenue, 0, 'no collision penalty');
|
|
assert.ok(areaOf(s, 0).heldAtLimits.includes(id), 'train is held at the Limits');
|
|
});
|
|
|
|
it('still collides without an Interlocking', () => {
|
|
const s = game();
|
|
areaOf(s, 0).adOccupancy = ['blocker'];
|
|
inbound(s, [{ type: 'hopper', loaded: true }]);
|
|
advance(s);
|
|
assert.equal(s.players[0]!.revenue, -5);
|
|
});
|
|
|
|
it('diverts a coachless train to the Yard Office', () => {
|
|
const s = game();
|
|
const card = straight();
|
|
card.enhancements.push('yardOffice');
|
|
addCard(s, at(-1, 0), card);
|
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
|
|
|
advance(s);
|
|
const pos = s.trays.get(id)!.position;
|
|
assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office');
|
|
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track');
|
|
});
|
|
|
|
it('does not divert a train carrying coaches', () => {
|
|
// "An inbound train with NO COACHES" — passengers must reach the Train Order Office.
|
|
const s = game();
|
|
const card = straight();
|
|
card.enhancements.push('yardOffice');
|
|
addCard(s, at(-1, 0), card);
|
|
const id = inbound(s, [{ type: 'coach', loaded: true }]);
|
|
|
|
advance(s);
|
|
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'a coach train must use the Office');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ABS Signals amend the collision rule', () => {
|
|
it('turns a following-train judgment into a plain hold', () => {
|
|
// "Trains on this card will not rear-end each other. They will stop short of a collision."
|
|
const s = game();
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') {
|
|
// Pin the terrain — see the meet tests below. Double Track and Uncontrolled Siding let trains
|
|
// pass, so the following train would never be held and there would be nothing for ABS to do.
|
|
ml.card = 'plains';
|
|
ml.absSignals = true;
|
|
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
|
}
|
|
s.trays.set('ahead', {
|
|
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
s.trays.set('behind', {
|
|
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
|
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
|
|
const r = advance(s);
|
|
assert.equal(r.needsInput, false, 'no clearance decision is needed with signals');
|
|
assert.equal(s.clock.pendingDecision, null);
|
|
assert.deepEqual(s.trays.get('behind')!.position, { at: 'divisionPoint', side: 'west' });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
|
function meet(s: GameState, device?: string) {
|
|
if (device) {
|
|
const card = straight();
|
|
card.enhancements.push(device);
|
|
addCard(s, at(0, 2), card);
|
|
}
|
|
// An oncoming senior train — §8.1 makes this an absolute bar without a device.
|
|
s.trays.set('oncoming', {
|
|
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineFront: true,
|
|
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
const ml = s.division.nodes[1];
|
|
if (ml?.kind === 'mainline') {
|
|
// Pin the terrain. Mainline types are drawn from the shuffled deck, so deck composition would
|
|
// otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
|
// `trainsMayPass`, which legitimately removes the §8.1 bar this test is about.
|
|
ml.card = 'plains';
|
|
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
|
}
|
|
s.trays.set('mine', {
|
|
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'mainline';
|
|
s.movedThisPhase = new Set();
|
|
}
|
|
|
|
it('holds a facing train with no device — §8.1 stands', () => {
|
|
const s = game();
|
|
meet(s);
|
|
advance(s);
|
|
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
|
|
});
|
|
|
|
it('lets a junior train win the meet with a Telegraph', () => {
|
|
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
|
|
const s = game();
|
|
meet(s, 'radio');
|
|
advance(s);
|
|
assert.equal(s.trays.get('mine')!.position.at, 'mainline', 'the meet was dispatched');
|
|
assert.ok(areaOf(s, 0).dispatchUsedToday.includes('radio'));
|
|
});
|
|
|
|
it('spends each device only once a Day', () => {
|
|
const s = game();
|
|
meet(s, 'radio');
|
|
advance(s);
|
|
assert.deepEqual(areaOf(s, 0).dispatchUsedToday, ['radio']);
|
|
// Reset happens at the Day boundary.
|
|
s.clock.stage = 12;
|
|
s.clock.phase = 'shiftChange';
|
|
advance(s);
|
|
assert.deepEqual(areaOf(s, 0).dispatchUsedToday, [], 'devices reset each Day');
|
|
});
|
|
|
|
it('gives Radio the largest bonus and Telegraph the smallest', () => {
|
|
assert.equal(enhancementRule('telegraph')!.dispatchBonus, 4);
|
|
assert.equal(enhancementRule('telephone')!.dispatchBonus, 8);
|
|
assert.equal(enhancementRule('radio')!.dispatchBonus, 12);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('defensive enhancements', () => {
|
|
it('reports district protection only once actually built', () => {
|
|
// These guard against opponent-directed cards, which a solitaire deck omits entirely (Q6).
|
|
const s = game();
|
|
assert.equal(isProtectedFromDerail(areaOf(s, 0)), false);
|
|
const card = straight();
|
|
card.enhancements.push('facingPointLocks');
|
|
addCard(s, at(0, 2), card);
|
|
assert.equal(isProtectedFromDerail(areaOf(s, 0)), true);
|
|
assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false);
|
|
});
|
|
});
|