v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+257
-2
@@ -11,10 +11,10 @@ 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 { ENHANCEMENT_RULES, enhancementRule, trainProfile } 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';
|
||||
import { coordKey, subdivisions } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
@@ -49,6 +49,18 @@ function handEnhancement(s: GameState, key: string): string {
|
||||
throw new Error(`no enhancement card: ${key}`);
|
||||
}
|
||||
|
||||
/** Puts a specific track card in hand and returns its id. */
|
||||
function trackInHand(s: GameState, geometry: string, hand: string): string {
|
||||
for (const [id, card] of s.cards) {
|
||||
const k = card.kind as { kind: string; geometry?: string; hand?: string };
|
||||
if (k.kind === 'track' && k.geometry === geometry && k.hand === hand) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no track card: ${geometry}/${hand}`);
|
||||
}
|
||||
|
||||
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
@@ -101,6 +113,43 @@ describe('enhancement placement', () => {
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: phone2, placement: at(0, 2) }), null);
|
||||
});
|
||||
|
||||
it('will not stack two straight-placed enhancements on one card', () => {
|
||||
// A straight-placed Enhancement REPLACES the straight — its printed placement is "any Running
|
||||
// Track Straight", so the card goes down in place of it and what stands there afterwards is an
|
||||
// Interlocking, not a straight carrying one. A second such card has no straight left to take.
|
||||
// This was unchecked, because an enhancement only pushes a string onto `enhancements[]` and
|
||||
// leaves the geometry alone: one straight could hold Interlocking, Telegraph and a Water Column
|
||||
// at once.
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
addCard(s, at(0, 3), straight());
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const lock = handEnhancement(s, 'interlocking');
|
||||
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: lock, placement: at(0, 2) }).ok);
|
||||
|
||||
for (const key of ['telegraph', 'waterColumn'] as const) {
|
||||
const id = handEnhancement(s, key);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }),
|
||||
'NOT_CONNECTED',
|
||||
`${key} was stacked on top of an Interlocking`,
|
||||
);
|
||||
// The bare straight next door is still a legal home for it.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 3) }), null,
|
||||
`${key} should still be playable on a straight that carries nothing`,
|
||||
);
|
||||
}
|
||||
|
||||
// And a turnout cannot upgrade it either — the straight it would replace is no longer there.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at(0, 2), variant: 0 }),
|
||||
'UPGRADE_ENHANCED',
|
||||
'a turnout was laid over a built Interlocking',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires an Interlocking in the district before Facing Point Locks', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
@@ -283,6 +332,212 @@ describe('ABS Signals amend the collision rule', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('one Revenue for every train that clears your section', () => {
|
||||
/** A train sitting on the A/D track, made up and ready to highball. */
|
||||
const readyToLeave = (s: GameState, direction: 'east' | 'west' = 'east'): string => {
|
||||
const id = 'leaving';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction, position: { at: 'grid', owner: 0, coord: areaOf(s, 0).officeCoord },
|
||||
movesUsed: 0,
|
||||
});
|
||||
areaOf(s, 0).adOccupancy.push(id);
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return id;
|
||||
};
|
||||
|
||||
it('pays the owner when a train departs onto the Mainline', () => {
|
||||
const s = game();
|
||||
const before = s.players[0]!.revenue;
|
||||
readyToLeave(s, 'east');
|
||||
|
||||
const r = advance(s);
|
||||
|
||||
assert.equal(s.players[0]!.revenue, before + 1, 'clearing a train earned nothing');
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train cleared your section'),
|
||||
'no revenue event was emitted for the departure',
|
||||
);
|
||||
|
||||
// Paid ONCE. Running off the end of the Division later is not a second departure — that far
|
||||
// Division Point belongs to whoever is seated at it.
|
||||
const after = s.players[0]!.revenue;
|
||||
for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
|
||||
assert.equal(s.players[0]!.revenue, after, 'the same train was paid for a second time on its way out');
|
||||
});
|
||||
|
||||
it('pays nothing for a local crew, which is switching rather than departing', () => {
|
||||
const s = game();
|
||||
const before = s.players[0]!.revenue;
|
||||
const id = readyToLeave(s, 'east');
|
||||
s.trays.get(id)!.trainNumber = null;
|
||||
|
||||
advance(s);
|
||||
|
||||
assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had cleared the section');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§8.1 — a train that just arrived from the mainline stops for the Stage', () => {
|
||||
/**
|
||||
* "The train did not just initially arrive from a mainline card at the Office this Stage."
|
||||
*
|
||||
* This holds today, but only as a SIDE EFFECT: the Mainline Phase visits each train once per Stage
|
||||
* in numeric order, so a train that spends its visit arriving has no visit left to depart with.
|
||||
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
|
||||
* would break in silence. These pin it.
|
||||
*
|
||||
* It is also what gives the printed **Expedite** rule something to do — an Expedited train is
|
||||
* granted a second `moveTrain` in the same phase precisely because the general rule forbids it.
|
||||
*/
|
||||
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
|
||||
const id = 'arriving';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber, trainIsExtra: isExtra, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') {
|
||||
ml.card = 'plains';
|
||||
// One Stage left to run, so this Stage it rolls off the card and into the Office.
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return id;
|
||||
};
|
||||
|
||||
it('does not depart the Stage it rolls in', () => {
|
||||
const s = game();
|
||||
const id = arriving(s, 9);
|
||||
assert.notEqual(trainProfile(9, false)?.rules.expedite, true, 'train 9 must NOT be an Expedite train');
|
||||
|
||||
advance(s);
|
||||
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'the train did not arrive at the Office at all');
|
||||
assert.equal(
|
||||
s.trays.get(id)!.position.at, 'grid',
|
||||
'the train departed again in the same Stage it arrived from a mainline card',
|
||||
);
|
||||
});
|
||||
|
||||
it('unless its card prints Expedite, which exists to override exactly this', () => {
|
||||
// Q3. Train 3 prints `expedite`, so it arrives and departs in one Stage — still subject to the
|
||||
// rest of §8.1, which is why the clearance path is re-entered rather than skipped.
|
||||
const s = game();
|
||||
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
|
||||
const id = arriving(s, 3);
|
||||
|
||||
advance(s);
|
||||
|
||||
assert.equal(
|
||||
s.trays.get(id)!.position.at, 'mainline',
|
||||
'an Expedite train should leave again the Stage it arrives',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§8.1 asks about the next Subdivision, not the next card', () => {
|
||||
/**
|
||||
* "If there is a train in the next Subdivision moving towards the considered train, the considered
|
||||
* train will not depart." This used to inspect only the card being entered, so a train ran headlong
|
||||
* into a Subdivision an opposing train was two cards deep in and was stopped only when they met.
|
||||
*
|
||||
* At game start every Office is a Whistle Post — not a Control Point — so the whole railroad is ONE
|
||||
* Subdivision: nodes [1, 2, 3] between the two Division Points. That is what makes early traffic so
|
||||
* constrained, and each upgrade to a Control Point splits it and buys capacity back.
|
||||
*/
|
||||
const facingTrainOn = (s: GameState, index: number): void => {
|
||||
// EVERY Mainline card is pinned, not just the one the facing train stands on: Double Track and
|
||||
// Uncontrolled Siding print "trains may pass", so terrain anywhere in the Subdivision can clear
|
||||
// the departure. Terrain is dealt from the same RNG stream as the cards, so leaving any of it to
|
||||
// the seed makes this luck — which is exactly how it broke when the deck composition changed.
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
const ml = s.division.nodes[index];
|
||||
if (ml?.kind === 'mainline') {
|
||||
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
s.trays.set('facing', {
|
||||
id: 'facing', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'west', position: { at: 'mainline', index }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('considered', {
|
||||
id: 'considered', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
};
|
||||
|
||||
it('holds a train out of a Subdivision an opposing train is deep inside', () => {
|
||||
const s = game();
|
||||
assert.deepEqual(subdivisions(s), [[1, 2, 3]], 'the fixture assumes one Subdivision of three nodes');
|
||||
|
||||
// The facing train is on node 3 — NOT the card being entered, which is node 1.
|
||||
facingTrainOn(s, 3);
|
||||
const r = advance(s);
|
||||
|
||||
assert.equal(r.needsInput, false, 'an opposing train is an absolute bar, never a judgment call');
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
assert.deepEqual(
|
||||
s.trays.get('considered')!.position, { at: 'divisionPoint', side: 'west' },
|
||||
'the train departed into a Subdivision already occupied by a train coming the other way',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a train standing clear on Secondary Track in a Whistle Post district', () => {
|
||||
// The last §8.1 condition: "if the Subdivision contains a Whistle Post yet a train is occupying
|
||||
// Secondary Track there (i.e. clear of the Running Track), the Superintendent MUST Highball the
|
||||
// train." The Office node sits inside this Subdivision, so a scan that counted everything in the
|
||||
// group would have blocked on it. Only Mainline occupancy counts.
|
||||
const s = game();
|
||||
assert.deepEqual(subdivisions(s), [[1, 2, 3]], 'node 2 is the Office, inside the Subdivision');
|
||||
|
||||
addCard(s, at(-1, 0), straight());
|
||||
placeTray(s, at(-1, 0));
|
||||
|
||||
s.trays.set('considered', {
|
||||
id: 'considered', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.card = 'plains';
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
|
||||
advance(s);
|
||||
|
||||
assert.deepEqual(
|
||||
s.trays.get('considered')!.position, { at: 'mainline', index: 1 },
|
||||
'a train standing clear on Secondary Track held up the whole Subdivision',
|
||||
);
|
||||
});
|
||||
|
||||
it('still lets it go once a Control Point splits that Subdivision away', () => {
|
||||
// The same position, with the Office upgraded to a Depot: node 2 becomes a boundary, so the
|
||||
// facing train on node 3 is in a DIFFERENT Subdivision and no longer bars the departure.
|
||||
const s = game();
|
||||
areaOf(s, 0).tier = 'depot';
|
||||
assert.deepEqual(subdivisions(s), [[1], [3]], 'a Control Point should split the Division in two');
|
||||
|
||||
facingTrainOn(s, 3);
|
||||
advance(s);
|
||||
|
||||
assert.deepEqual(
|
||||
s.trays.get('considered')!.position, { at: 'mainline', index: 1 },
|
||||
'a train in another Subdivision must not hold this one at the Division Point',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
||||
function meet(s: GameState, device?: string) {
|
||||
if (device) {
|
||||
|
||||
Reference in New Issue
Block a user