v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+173
-5
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user