v0.3.0 — playtest fixes, two new rules, and §7 enforced

This commit is contained in:
Jesse
2026-08-12 21:08:28 -04:00
parent 6ead39c530
commit 1bf1e95058
38 changed files with 8514 additions and 5989 deletions
+173 -5
View File
@@ -7,12 +7,13 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, check, areaOf, facilityCarTypes, reduce } from '../src/engine/apply.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS, OPENING_OTHER, OPENING_TRACK } 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';
import { cardDescription, snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
@@ -118,7 +119,9 @@ describe('Local Operations: drawing (§6.2)', () => {
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);
// The opening deal is 3 track + 3 other, so a draw takes the hand to seven — and §6.2's limit
// then has to be played down to three before the turn can end.
assert.equal(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1);
});
it('takes the TOP card from a Department pile, never one buried under it', () => {
@@ -238,8 +241,17 @@ describe('Local Operations: drawing (§6.2)', () => {
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(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1);
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
// And the way down is play or discard — there is no per-turn cap on either, which is what makes
// the six-card opening hand playable in one turn rather than a limit that cannot be met.
const hand = () => s.decks.hands.get(0)!;
while (hand().length > HAND_LIMIT) {
const r = applyIntent(s, 0, { type: 'card.discard', cardId: hand()[0]!, toSlot: 0 });
assert.ok(r.ok, 'a discard was refused while over the hand limit');
}
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit');
});
it('discards face up ON TOP of a chosen Department, burying what was there', () => {
@@ -254,6 +266,10 @@ describe('Local Operations: drawing (§6.2)', () => {
const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 });
assert.ok(r.ok);
assert.deepEqual(s.decks.departments[0], [under, spare], 'the discard goes on top, and nothing is lost');
// The opening hand is six, so one discard does not yet clear §6.2's limit — shed the rest.
while (s.decks.hands.get(0)!.length > HAND_LIMIT) {
applyIntent(s, 0, { type: 'card.discard', cardId: s.decks.hands.get(0)![0]!, toSlot: 1 });
}
assert.equal(check(s, 0, { type: 'draw.end' }), null);
});
@@ -548,8 +564,8 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
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.menAtWork![0], null);
assert.notEqual(f.menAtWork![1], null);
assert.equal(f.usedThisStage.laborers, 1);
});
@@ -798,6 +814,158 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
);
}
});
it('refuses a second Modifier of the same kind in one Office Area', () => {
// REPORTED from playtesting: two Ice Houses in one district. Industries have been barred from
// doubling up since Q4, but a Modifier is a different card kind and had no such check at all.
const s = game();
const area = areaOf(s, 0);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const copies = [...s.cards.entries()]
.filter(([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea')
.map(([id]) => id);
assert.ok(copies.length >= 2, 'the deck should hold more than one Waiting Area');
s.decks.hands.set(0, [copies[0]!]);
const first = legalActions(s, 0).find(
(i) => i.type === 'card.play' && i.cardId === copies[0] && i.placement !== undefined,
) as { type: 'card.play'; cardId: string; placement: { row: number; col: number } };
assert.ok(first, 'no legal square for the first Waiting Area');
assert.ok(applyIntent(s, 0, first).ok, 'the first Waiting Area should be playable');
// A different copy, on a different square, is still a second Waiting Area in this district.
s.decks.hands.set(0, [copies[1]!]);
const elsewhere = { row: first.placement.row, col: first.placement.col + 1 };
assert.equal(
check(s, 0, { type: 'card.play', cardId: copies[1]!, placement: elsewhere }),
'FACILITY_LOCKED',
'a district accepted two Waiting Areas',
);
assert.equal(
legalActions(s, 0).filter((i) => i.type === 'card.play' && i.cardId === copies[1] && i.placement).length,
0,
'the second copy is still being offered squares it cannot legally take',
);
});
});
// ---------------------------------------------------------------------------
describe("a Modifier grants only what its host's flow can use", () => {
const withFacility = (kind: string, out: boolean, into: boolean) => ({
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: out, inbound: into },
outboundBox: [], inboundBox: [],
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
menAtWork: [null, null, null],
industryTrack: { length: 1, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
});
/** Plays an Ice House next to the facility at `at`, and returns that facility. */
const iceHouseBeside = (kind: string, out: boolean, into: boolean) => {
const s = game();
const area = areaOf(s, 0);
const at = { row: -1, col: 4 };
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const ice = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'iceHouse',
);
assert.ok(ice, 'no Ice House in the deck');
s.decks.hands.set(0, [ice![0]]);
const r = applyIntent(s, 0, {
type: 'card.play', cardId: ice![0], placement: { row: at.row, col: at.col + 1 },
});
assert.ok(r.ok, `the Ice House should be playable beside a ${kind}`);
return area.grid.get(coordKey(at))!.facility!;
};
it('gives a Packing Sheds the outbound slot its card prints', () => {
// The host ships, so every printed grant lands. This is the control for the case below.
const f = iceHouseBeside('packingSheds', true, false);
assert.equal(f.capacity.outbound, 2, 'the outbound slot did not arrive');
assert.equal(f.laborers, 2, 'the laborer did not arrive');
assert.equal(f.industryTrack.length, 2, 'the siding should lengthen with the slot it serves');
});
it("drops the outbound slot on a Grocer's Warehouse, which only receives", () => {
// REPORTED as "the Ice House added the laborer but not the outbound slot" — and it is not a bug.
// A Grocer's is flow:'inbound', so `allows.outbound` is false and the capacity had nowhere to
// go: it was raised anyway, on a direction that can never be drawn or stocked.
const f = iceHouseBeside('grocersWarehouse', false, true);
assert.equal(f.laborers, 2, 'the laborer should still arrive — Laborers have no direction');
assert.equal(f.capacity.outbound, 0, 'outbound capacity was credited to a facility that cannot ship');
assert.equal(f.capacity.inbound, 1, 'the modifier must not silently move the grant to the other direction');
assert.equal(f.industryTrack.length, 1, 'the siding lengthened for a slot that does not exist');
});
it('does not give a Passenger Facility a siding by way of a Modifier', () => {
// FOUND by rendering 12,000 frames of real games rather than a fixture: a Waiting Area beside the
// Office lengthened `industryTrack`, and the board then drew the Depot a siding square to spot a
// car on. The same phantom siding the renderer had just been fixed for, arriving by another door.
// A Waiting Area's "+1" is a passenger slot; passengers board off the platform.
const s = game();
const area = areaOf(s, 0);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
// Forced open so this tests the freight/passenger distinction rather than the `allows` gating —
// the Office starts as a Whistle Post, which is not a Passenger Facility and takes nothing.
const office = area.grid.get(coordKey(area.officeCoord))!.facility!;
office.allows = { outbound: true, inbound: true };
const before = office.industryTrack.length;
const waiting = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
)!;
s.decks.hands.set(0, [waiting[0]]);
const spot = legalActions(s, 0).find(
(i) => i.type === 'card.play' && i.cardId === waiting[0] && i.placement !== undefined,
);
assert.ok(spot, 'a Waiting Area has nowhere legal beside the Office');
assert.ok(applyIntent(s, 0, spot!).ok);
assert.equal(office.capacity.outbound, 1, 'the passenger slot should still be granted');
assert.equal(office.porters, 1, 'the porter should still be granted');
assert.equal(
office.industryTrack.length, before,
'a Modifier gave a Passenger Facility an industry track to spot cars on',
);
});
it('says which bonus was dropped, and why', () => {
// The number genuinely does not move; what was missing was the reason.
const s = game();
const area = areaOf(s, 0);
const at = { row: -1, col: 4 };
area.grid.set(coordKey(at), withFacility('grocersWarehouse', false, true) as never);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const ice = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'iceHouse',
)!;
// Before it is played, the card in hand warns which of its two printed hosts cannot use it.
const what = cardDescription(s, ice[0]);
assert.match(what, /only receives/, 'the hand card does not warn about the dead half');
s.decks.hands.set(0, [ice[0]]);
assert.ok(applyIntent(s, 0, {
type: 'card.play', cardId: ice[0], placement: { row: at.row, col: at.col + 1 },
}).ok);
const fv = snapshot(s, [], null).facilities.find((v) => v.name.includes('Grocer'));
assert.ok(fv, "the Grocer's Warehouse is missing from the panel");
assert.equal(fv!.suppressed.length, 1, 'the dropped bonus is not reported');
assert.match(fv!.suppressed[0]!, /Ice House/);
// And the modifier is now recorded against the host it served, which never used to happen.
assert.deepEqual(fv!.modifiers, ['Ice House'], 'the modifier is not listed against its host');
});
});
// ---------------------------------------------------------------------------