Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track
This commit is contained in:
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY, lengthProfile } from '../src/engine/content.ts';
|
||||
import { STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
@@ -523,7 +523,7 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
|
||||
});
|
||||
|
||||
it('conserves rolling stock across a whole game', () => {
|
||||
// Nothing may be created or destroyed: 62 pieces, wherever they sit.
|
||||
// Nothing may be created or destroyed: every piece in the roster, wherever it sits.
|
||||
const s = game(88);
|
||||
playToCompletion(s, 88);
|
||||
|
||||
@@ -539,6 +539,6 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(count, 62, 'rolling stock leaked or was duplicated');
|
||||
assert.equal(count, TOTAL_ROLLING_STOCK, 'rolling stock leaked or was duplicated');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -325,6 +326,7 @@ describe('Freight Agent operations (§6.3)', () => {
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
return coord;
|
||||
}
|
||||
@@ -421,6 +423,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
s.clock.phase = 'loadUnload';
|
||||
return coord;
|
||||
@@ -561,6 +564,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 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') 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Mainline modifiers and Maneuver cards (implications.md §7).
|
||||
*
|
||||
* The 14 cards left after the Enhancements. These are NOT multiplayer-only: Brakeman, Airbrakes,
|
||||
* Helpers and Realignment all change your own crossing times, and Red Flags prevents a rear-ender,
|
||||
* which happens in solitaire too.
|
||||
*
|
||||
* Poling is deliberately absent — the recovered sheet records its effect as "TBD in the source", so
|
||||
* there is nothing to implement and nothing to test.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import {
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MANEUVER_CARDS,
|
||||
REALIGNMENTS,
|
||||
crossingStages,
|
||||
mainlineModifierRule,
|
||||
} 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 });
|
||||
|
||||
/** Puts a card of `kind`/`key` in hand and returns its id. */
|
||||
function hand(s: GameState, kind: string, key: string): string {
|
||||
for (const [id, card] of s.cards) {
|
||||
const k = card.kind as { kind: string; key?: string };
|
||||
if (k.kind === kind && k.key === key) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no ${kind} card: ${key}`);
|
||||
}
|
||||
|
||||
/** A Mainline node pinned to a known terrain, so deck composition cannot decide an outcome. */
|
||||
function pinned(s: GameState, index: number, card: string) {
|
||||
const node = s.division.nodes[index];
|
||||
if (node?.kind !== 'mainline') throw new Error(`node ${index} is not a Mainline card`);
|
||||
node.card = card as never;
|
||||
node.transits = [];
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Lets the player actually play a card this turn. */
|
||||
function drawTurn(s: GameState): void {
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
s.turn.option = 'draw';
|
||||
s.turn.drawnThisTurn = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('grade modifiers change crossing time', () => {
|
||||
it('a Heavy Grade takes two Stages bare', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
|
||||
});
|
||||
|
||||
it('Brakeman speeds the descent but not the climb', () => {
|
||||
// ASSUMPTION (§10 Q11): grades climb eastward, so westbound is downhill.
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east'), 2);
|
||||
});
|
||||
|
||||
it('Helpers speed the climb but not the descent', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west'), 2);
|
||||
});
|
||||
|
||||
it('Airbrakes stack with Brakeman on a slow train', () => {
|
||||
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west'), 3);
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west'), 2);
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west'), 1);
|
||||
});
|
||||
|
||||
it('never lets a train cross in no time', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west'), 1);
|
||||
});
|
||||
|
||||
it('leaves non-grade cards alone', () => {
|
||||
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
|
||||
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west'), 1);
|
||||
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east'), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placing a Mainline modifier', () => {
|
||||
it('accepts Brakeman on a grade', () => {
|
||||
const s = game();
|
||||
pinned(s, 1, 'heavyGrade');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
|
||||
});
|
||||
|
||||
it('rejects Brakeman on anything but a grade', () => {
|
||||
const s = game();
|
||||
pinned(s, 1, 'plains');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_A_GRADE');
|
||||
});
|
||||
|
||||
it('requires Brakeman before Airbrakes', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'heavyGrade');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'airbrakes');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_CONNECTED');
|
||||
|
||||
node.modifiers = ['brakeman'];
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
|
||||
});
|
||||
|
||||
it('will not modify a card with a train on it', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'heavyGrade');
|
||||
node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' });
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
|
||||
});
|
||||
|
||||
it('rejects a Mainline node that is not a Mainline card', () => {
|
||||
const s = game();
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
// Node 0 is the western Division Point.
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 0 }), 'NO_PLACEMENT');
|
||||
});
|
||||
|
||||
it('lays the modifier and spends the card', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'heavyGrade');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
|
||||
|
||||
assert.ok(r.ok, 'placement should be accepted');
|
||||
assert.deepEqual(node.modifiers, ['brakeman']);
|
||||
assert.ok(!(s.decks.hands.get(0) ?? []).includes(cardId), 'card should leave the hand');
|
||||
assert.ok(s.decks.salvageYard.includes(cardId), 'card should reach the Salvage Yard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Realignment converts one Mainline type to another', () => {
|
||||
it('converts according to the table', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'curves');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'realignment');
|
||||
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
|
||||
|
||||
assert.ok(r.ok);
|
||||
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
|
||||
// The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
|
||||
assert.equal(crossingStages(node.card, 'fast', false), 1);
|
||||
});
|
||||
|
||||
it('refuses a card with no conversion listed', () => {
|
||||
const s = game();
|
||||
// Tunnel is not a `from` in REALIGNMENTS.
|
||||
assert.ok(!REALIGNMENTS.some((r) => r.from === 'tunnel'));
|
||||
pinned(s, 1, 'tunnel');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'realignment');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NO_PLACEMENT');
|
||||
});
|
||||
|
||||
it('applies to any Mainline card, not only grades', () => {
|
||||
assert.equal(mainlineModifierRule('realignment')?.gradeOnly, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Red Flags protect a stopped train', () => {
|
||||
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
||||
function rearEnder(s: GameState) {
|
||||
const node = pinned(s, 1, 'plains');
|
||||
node.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,
|
||||
});
|
||||
const dp = s.division.nodes[0];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push('behind');
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return node;
|
||||
}
|
||||
|
||||
it('holds the approaching train instead of letting it close', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
node.redFlagged = ['ahead'];
|
||||
|
||||
advance(s);
|
||||
assert.deepEqual(
|
||||
s.trays.get('behind')!.position,
|
||||
{ at: 'divisionPoint', side: 'west' },
|
||||
'the flagged train must not be approached',
|
||||
);
|
||||
});
|
||||
|
||||
it('comes in when the protected train rolls', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
node.redFlagged = ['ahead'];
|
||||
// Bring the protected train to the end of its crossing so it leaves the card.
|
||||
node.transits[0]!.stagesRemaining = 1;
|
||||
|
||||
for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s);
|
||||
assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off');
|
||||
});
|
||||
|
||||
it('only protects a train out on the Mainline', () => {
|
||||
const s = game();
|
||||
rearEnder(s);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }),
|
||||
'NO_PLACEMENT',
|
||||
'a train sitting at a Division Point cannot be rear-ended',
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null);
|
||||
});
|
||||
|
||||
it('will not double-flag the same train', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||
node.redFlagged = ['ahead'];
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }),
|
||||
'OPTION_ALREADY_CHOSEN',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flying Switch rolls a cut into an industry', () => {
|
||||
/** A crew on a straight with a facility next door, mid-switch. */
|
||||
function setup(s: GameState) {
|
||||
const area = areaOf(s, 0);
|
||||
const spur: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
area.grid.set(coordKey(at(-1, 0)), spur);
|
||||
const industry: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
laborers: 1, porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
area.grid.set(coordKey(at(-2, 0)), industry);
|
||||
|
||||
s.trays.set('crew', {
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
|
||||
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
||||
direction: 'east', position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
s.turn.option = 'switch';
|
||||
s.turn.movesRemaining = 6;
|
||||
return industry;
|
||||
}
|
||||
|
||||
it('drops the cut onto the industry track without moving the engine', () => {
|
||||
const s = game();
|
||||
const industry = setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 0),
|
||||
});
|
||||
assert.ok(r.ok, 'the flying switch should be accepted');
|
||||
|
||||
assert.equal(industry.facility!.industryTrack.cars.length, 1, 'car reaches the industry track');
|
||||
assert.equal(industry.facility!.industryTrack.cars[0]!.type, 'boxcar', '§A.3 — the back car');
|
||||
assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
|
||||
assert.deepEqual(
|
||||
s.trays.get('crew')!.position,
|
||||
{ at: 'grid', owner: 0, coord: at(-1, 0) },
|
||||
'the engine never enters the industry',
|
||||
);
|
||||
assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move');
|
||||
});
|
||||
|
||||
it('only reaches an adjacent card', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-4, 0) }),
|
||||
'NOT_CONNECTED',
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls into an industry, not onto plain track', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
// (0,0) is the Office, adjacent to the crew but not a freight industry.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
||||
'CANNOT_DROP_HERE',
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot cut more cars than it is hauling', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-2, 0) }),
|
||||
'CONSIST_EMPTY',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('card coverage', () => {
|
||||
it('every Mainline modifier is either implemented or a known duplicate', () => {
|
||||
for (const card of MAINLINE_MODIFIER_CARDS) {
|
||||
const known =
|
||||
mainlineModifierRule(card.key) !== null || card.key === 'facingPointLocksMainline';
|
||||
assert.ok(known, `${card.key} has no rule and is not the Facing Point Locks duplicate`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Poling remains unimplemented, deliberately', () => {
|
||||
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
|
||||
// source says otherwise, a silent no-op would be worse than a rejection.
|
||||
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
|
||||
assert.ok(poling, 'Poling should still be in the deck');
|
||||
assert.match(poling!.effect, /TBD/i);
|
||||
});
|
||||
});
|
||||
+18
-14
@@ -49,9 +49,10 @@ const newSolitaireGame = (seed = 1234) =>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('card catalogue (component 1)', () => {
|
||||
it('composes the 115-card deck from the design', () => {
|
||||
// Transcribed from docs/Deck cards2.xlsx; the sheet's own total is 115.
|
||||
assert.equal(DECK_SIZE, 115);
|
||||
it('composes the 133-card deck from the design', () => {
|
||||
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus the 18 extra industry
|
||||
// cards added for Gap 12, taking industries from 9 to 27 (see INDUSTRY_PROFILES).
|
||||
assert.equal(DECK_SIZE, 133);
|
||||
assert.equal(buildDeck().length, DECK_SIZE);
|
||||
});
|
||||
|
||||
@@ -59,7 +60,8 @@ describe('card catalogue (component 1)', () => {
|
||||
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
||||
assert.deepEqual(byCategory, {
|
||||
office: 7,
|
||||
industry: 9,
|
||||
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
|
||||
industry: 27,
|
||||
modifier: 23,
|
||||
train: 22,
|
||||
spaceUse: 12,
|
||||
@@ -72,13 +74,13 @@ describe('card catalogue (component 1)', () => {
|
||||
|
||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
||||
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
||||
// game they would be 22 of 115 draws (19%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 93);
|
||||
// game they would be 22 of 133 draws (17%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 111);
|
||||
const solo = buildDeck('solitaire');
|
||||
assert.equal(solo.length, 93);
|
||||
assert.equal(solo.length, 111);
|
||||
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
||||
// A competitive deck keeps them.
|
||||
assert.equal(buildDeck('competitive').length, 115);
|
||||
assert.equal(buildDeck('competitive').length, 133);
|
||||
});
|
||||
|
||||
it('keeps track OUT of the deck, as a per-player supply', () => {
|
||||
@@ -190,9 +192,11 @@ describe('card catalogue (component 1)', () => {
|
||||
assert.equal(nextOfficeTier('terminal'), null);
|
||||
});
|
||||
|
||||
it('supplies 62 rolling stock pieces', () => {
|
||||
assert.equal(TOTAL_ROLLING_STOCK, 62);
|
||||
assert.equal(buildRollingStock().length, 62);
|
||||
it('supplies the full rolling stock roster', () => {
|
||||
// 80 pieces after the Gap 12 supply scale-up. Asserted against the constant rather than a
|
||||
// literal so the invariant is "the yard holds exactly the roster", not a number to re-edit.
|
||||
assert.equal(TOTAL_ROLLING_STOCK, 80);
|
||||
assert.equal(buildRollingStock().length, TOTAL_ROLLING_STOCK);
|
||||
});
|
||||
|
||||
it('never demands more cars than the supply can furnish', () => {
|
||||
@@ -340,7 +344,7 @@ describe('game setup (component 2)', () => {
|
||||
...g.decks.salvageYard,
|
||||
...[...g.decks.hands.values()].flat(),
|
||||
];
|
||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 93, not 115.
|
||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 111, not 133.
|
||||
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
|
||||
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
||||
});
|
||||
@@ -357,9 +361,9 @@ describe('game setup (component 2)', () => {
|
||||
assert.equal(newSolitaireGame().freeTrays.length, 4);
|
||||
});
|
||||
|
||||
it('puts all 62 rolling stock pieces in the Division Yard', () => {
|
||||
it('puts every rolling stock piece in the Division Yard', () => {
|
||||
const g = newSolitaireGame();
|
||||
assert.equal(g.yards.divisionYard.length, 62);
|
||||
assert.equal(g.yards.divisionYard.length, TOTAL_ROLLING_STOCK);
|
||||
assert.equal(g.yards.classificationYard.length, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
|
||||
@@ -45,6 +46,7 @@ const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
@@ -55,6 +57,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const officeCard = (): TrackCard => ({
|
||||
@@ -63,6 +66,7 @@ const officeCard = (): TrackCard => ({
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const lockedFacility = (): TrackCard => ({
|
||||
@@ -84,6 +88,7 @@ const lockedFacility = (): TrackCard => ({
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
||||
@@ -100,6 +105,9 @@ function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): Off
|
||||
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
|
||||
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
|
||||
adOccupancy: [],
|
||||
heldAtLimits: [],
|
||||
dispatchUsedToday: [],
|
||||
trackSupply: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,6 +167,7 @@ describe('ports and geometry', () => {
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
assert.deepEqual(exitsFrom(r, 's'), ['w']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user