Housekeeping before v0.8.0: the answer to "anything else that should be looked at first". One real hole, one stale document, and my own leavings. #102 — `npm test` passed green on a type error. `pretest` ran `scripts/build-web.ts`, which invokes `tsc --ignoreConfig` against three web entry points, so it saw only what those three transitively import and under a WEAKER configuration than tsconfig.json — no `noUncheckedIndexedAccess`, no `exactOptionalPropertyTypes`, `--types ''`. It never saw `src/server/` or a single file under `test/`. Demonstrated rather than argued: a planted `const DELIBERATE_TYPE_ERROR: number = 'not a number';` in src/server/session.ts gives `npm run typecheck` a TS2322 and `npm test` a clean `# fail 0`. `pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same error exits 1 with the tests never running. This mattered THIS week rather than generally: v0.8.0 is steps 2-7 of the common board — display stream, credentials, persistence, Chromium supervisor — which is almost entirely src/server/, exactly the half the test command could not see. #103 — the plan had drifted from the code it is the source for. docs/plans/jitsi-common-board.md was written 2026-08-27, still said "No implementation has been performed", and is what steps 2-7 get built from. Step 1 shipped across four releases since, so every "current code finding" under it described a fault that is now fixed — a document reading as present tense and nine days stale sends the next reader to fix things twice. Measured: its PublicFrame sketch lists four properties never built (protocolVersion, config, scoring, deckCounts) and omits 28 that exist, and the shape is the real difference — the implementation is FLAT where the plan grouped things into objects, so a renderer written from the sketch would not compile. The plan now says so at the top and at step 1, names src/sim/view.ts and the redaction allow-list as the authority, keeps the original sketch for its reasoning, and calls out `protocolVersion` as unbuilt rather than dropping it quietly — step 2 is the reconnecting display stream and is the first thing that would want one. One step-1 item is STRUCK OFF rather than built: "add the Red Flag holder to the public player projection". The premise does not hold here. `decks.redFlags` is written once, in setup.ts, from `optionalRules.emergencyToolbox`, and never again — `redFlag.play` emits `phaseEnded` and does not spend it — so every player holds one or none does, decided before the deal. A per-player `redFlagHeld` would be one already-public option copied N times, while telling every reader of the common board that it varies by player and might change mid-game. Worse than the absence. Pinned by test so it is not re-raised from the plan. Four dead imports removed, all mine: `HAND_LIMIT` left unused in apply.ts, view.ts and web/game.ts when 0.7.9.6 consolidated the three copies of the §6.2 test, and `actingPlayer` in web/game.ts, dead since 0.7.9.5 made `currentActor` delegate. Finding them re-measured #46: `tsc --noUnusedLocals` now reports 40, up from 29 on 2026-08-30. That entry's "without the flag this list simply regrows" is a measurement rather than a forecast now. The other 36 and the flag stay open. 946 tests pass, up from 943. No behaviour changes: three new tests pin an invariant, and the rest is a build command, dead imports and a document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
438 lines
22 KiB
TypeScript
438 lines
22 KiB
TypeScript
/**
|
|
* WHAT THE ENGINE KNOWS AND THE SCREEN DOES NOT — the 2026-09-07 sweep.
|
|
*
|
|
* Gitea#21, Gitea#22, #94 and #96 were four instances of one fault in a row: the engine gained a
|
|
* thing that changes what a train may do, and nothing drew it. Each was found by a player hitting
|
|
* it. So rather than wait for the fifth, every field of `GameState` and its nested types was
|
|
* enumerated and checked for a reader in `sim/view.ts`, `src/web/` and `sim/narrate.ts`, and the
|
|
* survivors were then verified by running the engine rather than by trusting the grep.
|
|
*
|
|
* Four fields had no reader anywhere. `movedThisPhase` is set and cleared inside one `advance` call
|
|
* and is genuinely nobody's business. The other three are these tests. The bookkeeping fields whose
|
|
* EFFECT is already visible as legality — `freightWorked`, `drawnThisTurn`, `freightAgentUsed`,
|
|
* `movesUsed` (its complement `movesRemaining` is on the Frame), `switchedSince` — are deliberately
|
|
* not here: a field is not a display gap merely because no one renders it.
|
|
*
|
|
* The method is worth more than the three fixes, and is written down in TODO Reference · #98.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import { crewTrayCount, enhancementText } from '../src/engine/content.ts';
|
|
import { areaOf } from '../src/engine/apply.ts';
|
|
import { impediments } from '../src/sim/narrate.ts';
|
|
import { projectDistrict, projectSharedTable, publicSnapshot, trainRules } from '../src/sim/view.ts';
|
|
import type { CrewTray, GameConfig, GameState, PlayerIndex, SeatIndex } from '../src/engine/state.ts';
|
|
import { seatOf } from '../src/engine/state.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'competitive',
|
|
days: 3,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
|
};
|
|
|
|
const game = (): GameState => {
|
|
const s = createGame({ id: 'g', seed: 4242, config, playerNames: ['Ann', 'Bob'] });
|
|
s.status = 'active';
|
|
return s;
|
|
};
|
|
|
|
/**
|
|
* THE CREW TRAY POOL IS A MECHANIC, AND IT WAS INVISIBLE (#98).
|
|
*
|
|
* `state.ts` calls tray scarcity "an explicit mechanic": there are fewer trays than there are trains
|
|
* wanting one, and which trains get held is the whole of §7. The engine knows three things about it
|
|
* — how many trays are free, which Extras are queued for one, and which second sections are — and
|
|
* the view read none of them.
|
|
*
|
|
* The panel that answers "why is nothing moving?" had exactly one tray rule, keyed off the train due
|
|
* out THIS Stage (`s.timetable[stage - 1]`). So a player who spent a card on an Extra, or ordered a
|
|
* second section, got a blocked panel that was completely EMPTY while their train sat behind an
|
|
* exhausted pool — and both had been announced once in the log, in a line that promised a future
|
|
* event ("as soon as a Crew Tray frees up") which nothing then confirmed.
|
|
*/
|
|
describe('the Crew Tray pool is a mechanic the player can see (#98)', () => {
|
|
/** A table whose trays are all out, with one Extra and one second section queued behind them. */
|
|
const jammed = (): GameState => {
|
|
const s = game();
|
|
s.freeTrays = [];
|
|
s.pendingExtras.push({ trainNumber: 17, player: 0 as PlayerIndex });
|
|
s.pendingSecondSections.push(8);
|
|
return s;
|
|
};
|
|
|
|
it('reports how many Crew Trays are free, and how many there are', () => {
|
|
const s = game();
|
|
const total = crewTrayCount(2);
|
|
assert.equal(s.freeTrays.length, total, 'the premise is gone: trays were already out at setup');
|
|
assert.deepEqual(projectSharedTable(s).crewTrays, { free: total, total });
|
|
|
|
s.freeTrays = s.freeTrays.slice(0, 1);
|
|
assert.deepEqual(
|
|
projectSharedTable(s).crewTrays,
|
|
{ free: 1, total },
|
|
'the pool emptied and the shared table did not notice',
|
|
);
|
|
});
|
|
|
|
it('names the trains queued for a tray, so a promise made in the log is kept on the board', () => {
|
|
const q = projectSharedTable(jammed()).queued;
|
|
assert.deepEqual(q.extras, [{ trainNumber: 17, player: 0 }], 'a played Extra is waiting nowhere visible');
|
|
assert.deepEqual(q.secondSections, [8], 'an ordered second section is waiting nowhere visible');
|
|
});
|
|
|
|
it('tells the player who played the Extra that it is held for want of a crew', () => {
|
|
const blocked = impediments(jammed(), 0 as PlayerIndex);
|
|
const extra = blocked.find((b) => /X17/.test(b.where));
|
|
assert.ok(extra, 'the blocked panel said nothing about an Extra held for want of a Crew Tray');
|
|
assert.match(extra.why, /Crew Tray/, 'it was listed without naming the thing it is waiting for');
|
|
assert.equal(extra.severity, 'stuck');
|
|
// The POOL SIZE, not a second derivation of it. Written as `trays.size + freeTrays.length`
|
|
// first, which reads 0 of 0 for any state where a tray is neither free nor carrying a train.
|
|
assert.match(
|
|
extra.why,
|
|
new RegExp(`0 of ${crewTrayCount(2)} Crew Trays free`),
|
|
'the panel reported the wrong pool size',
|
|
);
|
|
});
|
|
|
|
it('says the same for a second section, which waits on the identical pool', () => {
|
|
const blocked = impediments(jammed(), 0 as PlayerIndex);
|
|
const second = blocked.find((b) => /Train 8\b/.test(b.where) && /second section/i.test(b.why));
|
|
assert.ok(second, 'an ordered second section was queued invisibly');
|
|
assert.match(second.why, /Crew Tray/);
|
|
});
|
|
|
|
it('says none of it once a tray is free, because then nothing is being waited on', () => {
|
|
const s = jammed();
|
|
s.freeTrays = ['tray0'];
|
|
const blocked = impediments(s, 0 as PlayerIndex);
|
|
assert.equal(
|
|
blocked.filter((b) => /Crew Tray/.test(b.why)).length,
|
|
0,
|
|
'a free tray still reported trains held for want of one',
|
|
);
|
|
});
|
|
|
|
it('is on the common board too — the pool is on the table, not in a hand', () => {
|
|
const pub = publicSnapshot(jammed()) as unknown as Record<string, unknown>;
|
|
assert.ok('crewTrays' in pub, 'a spectator cannot see the scarcity everyone at the table can');
|
|
assert.ok('queued' in pub);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A TRAIN HELD AT THE LIMITS IS STILL ON THE BOARD (#99).
|
|
*
|
|
* The Interlocking enhancement is the designed answer to a full Office: instead of the automatic
|
|
* collision of Gap 2d, "may stop an inbound train on the Limit Track" — the train is held inside the
|
|
* player's Limits, and takes the first A/D track that frees, ahead of any newcomer.
|
|
*
|
|
* It was drawn NOWHERE. `arriveAtOffice` removes the tray from the Mainline node's `transits`
|
|
* (advance.ts) and the Interlocking branch pushes it onto `area.heldAtLimits` without assigning
|
|
* `tray.position` — so the map, which draws mainline nodes from `transits` and district squares from
|
|
* `position.at === 'grid'`, has nothing to draw it from in either place. The train vanished off the
|
|
* board on arrival and reappeared in the Office some Stages later.
|
|
*
|
|
* Measured before fixing: with the tray in `transits` the Interchange node carries its chip; with
|
|
* the tray moved to `heldAtLimits` exactly as the engine moves it, the node's `trains` is empty and
|
|
* no grid square has gained it.
|
|
*
|
|
* The fix is in the VIEW, not the engine. The engine's state is right — a held train is inside the
|
|
* Limits and not on an A/D track, which is what `heldAtLimits` says — and `position` is left alone
|
|
* deliberately, so nothing may treat the train as standing on a square it could be switched from.
|
|
*/
|
|
describe('a train held at the Limits is drawn at the Limits (#99)', () => {
|
|
const held = (direction: 'east' | 'west') => {
|
|
const s = game();
|
|
const seat = seatOf(s, 0 as PlayerIndex);
|
|
const area = areaOf(s, 0 as PlayerIndex);
|
|
const trayId = s.freeTrays.pop()!;
|
|
const tray: CrewTray = {
|
|
id: trayId,
|
|
trainNumber: 5,
|
|
trainIsExtra: false,
|
|
engineAt: 0,
|
|
consist: [],
|
|
direction,
|
|
movesUsed: 0,
|
|
position: { at: 'mainline', index: 1 },
|
|
} as CrewTray;
|
|
s.trays.set(trayId, tray);
|
|
area.heldAtLimits.push(trayId);
|
|
return { s, seat, trayId, area };
|
|
};
|
|
|
|
it('draws it on the Limits square it is standing at, not nowhere', () => {
|
|
const { s, seat, trayId, area } = held('east');
|
|
const { cells } = projectDistrict(s, seat as SeatIndex);
|
|
const on = cells.filter((c) => c.trains.some((t) => t.trayId === trayId));
|
|
assert.equal(on.length, 1, 'a train held at the Limits is drawn on no square at all');
|
|
assert.equal(on[0]!.row, area.limitsWest.row, 'drawn at the wrong Limits');
|
|
assert.equal(on[0]!.col, area.limitsWest.col);
|
|
});
|
|
|
|
it('holds an EASTBOUND train at the western Limits, because that is the end it came in by', () => {
|
|
const { s, seat, area } = held('east');
|
|
const { cells } = projectDistrict(s, seat as SeatIndex);
|
|
const on = cells.find((c) => c.trains.length > 0)!;
|
|
assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsWest.row, col: area.limitsWest.col });
|
|
});
|
|
|
|
it('and a WESTBOUND train at the eastern Limits', () => {
|
|
const { s, seat, area } = held('west');
|
|
const { cells } = projectDistrict(s, seat as SeatIndex);
|
|
const on = cells.find((c) => c.trains.length > 0)!;
|
|
assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsEast.row, col: area.limitsEast.col });
|
|
});
|
|
|
|
it('says on the chip that it is HELD, so it is not read as a train free to switch', () => {
|
|
const { s, seat, trayId } = held('east');
|
|
const { cells } = projectDistrict(s, seat as SeatIndex);
|
|
const chip = cells.flatMap((c) => c.trains).find((t) => t.trayId === trayId)!;
|
|
assert.equal(chip.heldAtLimits, true, 'a held train looked exactly like one standing on the square');
|
|
assert.match(chip.what, /Interlocking|held/i, 'the chip does not say why it is standing there');
|
|
});
|
|
|
|
it('tells the district owner it is waiting, and what for', () => {
|
|
const { s } = held('east');
|
|
const blocked = impediments(s, 0 as PlayerIndex);
|
|
const b = blocked.find((x) => /Train 5/.test(x.where) && /Limits/i.test(x.why));
|
|
assert.ok(b, 'the blocked panel said nothing about a train held at the Limits');
|
|
});
|
|
|
|
it('does not invent a train on a square when nothing is held', () => {
|
|
const s = game();
|
|
const { cells } = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex);
|
|
assert.equal(cells.flatMap((c) => c.trains).length, 0, 'a district with no trains drew one');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* THE CAMPAIGN TRAIN'S SPEECHES CHANGE ITS RULES, AND THE CARD HAS TO SAY WHICH HALF IT IS IN (#100).
|
|
*
|
|
* X17 is "one turn at station (speeches) then expedite". Its first Office arrival is an ordinary
|
|
* stop; every arrival after that runs EXPEDITED, which means that if it is not back on the Office
|
|
* square when the next Mainline Phase begins it is a Station Master fault costing 1 Revenue.
|
|
*
|
|
* `trainRules()` took `{ trainNumber, trainIsExtra }` — it could not see `speechMade` even though
|
|
* both of its tray-side callers pass a whole `CrewTray` that has it. So the chip read identically
|
|
* before and after, and worse: the "EXPEDITED — must be kept ready to highball… costs 1 Revenue"
|
|
* warning is printed only under `rules.expedite`, so X17 became subject to a fault whose warning the
|
|
* game shows to other trains and never to it.
|
|
*/
|
|
describe('the Campaign Train says whether its speeches are made (#100)', () => {
|
|
const X17 = { trainNumber: 17, trainIsExtra: true } as const;
|
|
|
|
it('before the speeches, says they are still to come and does not claim it is expedited yet', () => {
|
|
const t = trainRules({ ...X17 });
|
|
assert.match(t, /SPEECHES/i);
|
|
assert.doesNotMatch(t, /costs 1 Revenue/, 'it warned of a fault the train is not yet subject to');
|
|
});
|
|
|
|
it('after the speeches, says it is expedited NOW and carries the fault it is now subject to', () => {
|
|
const t = trainRules({ ...X17, speechMade: true });
|
|
assert.match(t, /EXPEDITED/, 'a train that is now expedited did not say so');
|
|
assert.match(t, /costs 1 Revenue/, 'the fault warning is shown to other trains and not to this one');
|
|
});
|
|
|
|
it('reads differently before and after — the whole of the bug was that it did not', () => {
|
|
assert.notEqual(trainRules({ ...X17 }), trainRules({ ...X17, speechMade: true }));
|
|
});
|
|
|
|
it('still warns a permanently expedited train, which must not regress', () => {
|
|
const fast = trainRules({ trainNumber: 5, trainIsExtra: false });
|
|
assert.match(fast, /EXPEDITED/);
|
|
assert.match(fast, /costs 1 Revenue/);
|
|
});
|
|
|
|
it('says nothing about speeches for a train that has no such rule', () => {
|
|
assert.doesNotMatch(trainRules({ trainNumber: 5, trainIsExtra: false }), /SPEECHES/i);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A DISPATCH DEVICE SAYS WHETHER IT IS STILL AVAILABLE TODAY (#101).
|
|
*
|
|
* Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains,
|
|
* add +N to the other train's number". The device is drawn on its card and its effect text is in the
|
|
* tooltip — but `enhancementText(key)` takes only the KEY, so it could not vary with anything, and
|
|
* the Radio read "once a day, add +12…" all Day after it had been spent. That is `trainRules` before
|
|
* #100, in a different corner of the same view.
|
|
*
|
|
* THE SECOND HALF IS THE ONE THAT SURPRISES. `spendDispatchBonus` reads
|
|
* `areaOf(s, s.clock.superintendent)` — the SUPERINTENDENT's own devices, not the train owner's —
|
|
* and the Fedora moves every `STAGES_PER_SHIFT` Stages, four times a Day. So a player's Radio does
|
|
* nothing at all for three-quarters of the Day, and is spent automatically, without being asked,
|
|
* during the quarter it is theirs to use. The board said neither half.
|
|
*
|
|
* Shown on EVERY district (Jesse, 2026-09-07), not only the viewer's: it is public, and a rival's
|
|
* spent Radio is exactly what you want to know before forcing a meet. `projectDistrict` serves both
|
|
* the player's own cells and the common board's `districts`, so one change covers both.
|
|
*/
|
|
describe('a dispatch device says whether it is still available today (#101)', () => {
|
|
/** Puts `key` on the first card of seat 0's district and returns the pieces to assert on. */
|
|
const withDevice = (key: string, opts: { spent?: boolean; fedora?: boolean } = {}) => {
|
|
const s = game();
|
|
const area = areaOf(s, 0 as PlayerIndex);
|
|
const card = [...area.grid.values()][0]!;
|
|
card.enhancements.push(key);
|
|
if (opts.spent) area.dispatchUsedToday.push(key);
|
|
// The Fedora is a PLAYER; give it to somebody whose seat is not this district's.
|
|
s.clock.superintendent = (opts.fedora ? 0 : 1) as PlayerIndex;
|
|
const seat = seatOf(s, 0 as PlayerIndex);
|
|
const cells = projectDistrict(s, seat as SeatIndex).cells;
|
|
const cell = cells.find((c) => c.enhancements.length > 0)!;
|
|
const i = cell.enhancementsWhat.findIndex((w) => new RegExp(key, 'i').test(w));
|
|
return { s, area, cell, what: cell.enhancementsWhat[i] ?? '', spent: cell.enhancementsSpent[i] };
|
|
};
|
|
|
|
it('reads as available before it is used, and says what it is worth', () => {
|
|
const { what, spent } = withDevice('radio', { fedora: true });
|
|
assert.equal(spent, false, 'an unused device reported itself spent');
|
|
assert.match(what, /\+12/, 'the device no longer says what it is worth');
|
|
assert.doesNotMatch(what, /spent/i, 'an unused device claimed it had been spent');
|
|
});
|
|
|
|
it('says so once it has been spent, and says when it comes back', () => {
|
|
const { what, spent } = withDevice('radio', { spent: true, fedora: true });
|
|
assert.equal(spent, true, 'a spent device still reported itself available');
|
|
assert.match(what, /spent/i, 'a spent device read exactly as it did before it was spent');
|
|
assert.match(what, /next Day|tomorrow/i, 'it does not say the device comes back');
|
|
});
|
|
|
|
it('reads differently spent and unspent — the whole of the bug was that it did not', () => {
|
|
assert.notEqual(
|
|
withDevice('telegraph', { fedora: true }).what,
|
|
withDevice('telegraph', { spent: true, fedora: true }).what,
|
|
);
|
|
});
|
|
|
|
it('says a device is idle while somebody else holds the Fedora', () => {
|
|
const { what } = withDevice('telephone');
|
|
assert.match(what, /Fedora|Superintendent/i, 'nothing said the device is only used while dispatching');
|
|
});
|
|
|
|
it('does not say that when this district IS the Superintendent', () => {
|
|
const { what } = withDevice('telephone', { fedora: true });
|
|
assert.doesNotMatch(what, /while .*holds? the Fedora/i);
|
|
});
|
|
|
|
/**
|
|
* EXACT equality, not "does not mention the Fedora". The first draft asserted the absence of
|
|
* /spent|Fedora/i with the Fedora held, and a mutation that removed the `dispatchBonus` guard
|
|
* altogether PASSED it — because the leaked text in that case reads "Available today, and this
|
|
* district is dispatching", which contains neither word. A test for a field being left alone has
|
|
* to compare it to what it should be.
|
|
*/
|
|
it('leaves an enhancement that is not a dispatch device exactly as it was', () => {
|
|
for (const fedora of [true, false]) {
|
|
const { what, spent } = withDevice('interlocking', { fedora });
|
|
assert.equal(spent, false, 'a non-dispatch enhancement was marked spendable');
|
|
assert.equal(what, enhancementText('interlocking'), 'an Interlocking was given a dispatch caveat');
|
|
}
|
|
});
|
|
|
|
it('comes back when the Day turns, which is what clears the record', () => {
|
|
const { s, area } = withDevice('radio', { spent: true, fedora: true });
|
|
area.dispatchUsedToday = [];
|
|
const cell = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex).cells.find(
|
|
(c) => c.enhancements.length > 0,
|
|
)!;
|
|
assert.equal(cell.enhancementsSpent.some((x) => x), false, 'a new Day did not restore the device');
|
|
});
|
|
|
|
/**
|
|
* SEAT IS NOT PLAYER INDEX, and this is the one place the two are joined: the Fedora is held by a
|
|
* PLAYER and the devices sit in an Office Area keyed by SEAT. `advance.ts` carried a comment
|
|
* warning that indexing one with the other was safe only while seating was the identity map — it
|
|
* read as a live bug and was not one, because `areaOf` resolves through `seatOf`. The comment is
|
|
* corrected; this is the guard, because the next reader deserves better than a claim.
|
|
*
|
|
* §4.4's D12 makes seating a real permutation, so a two-player game where player 1 sits in seat 0
|
|
* is ordinary rather than contrived.
|
|
*/
|
|
it("marks the SUPERINTENDENT's own district as dispatching under non-identity seating", () => {
|
|
const s = game();
|
|
s.seating = [1, 0] as PlayerIndex[];
|
|
assert.notEqual(seatOf(s, 0 as PlayerIndex), 0, 'the premise is gone: seating is still identity');
|
|
s.clock.superintendent = 0 as PlayerIndex;
|
|
|
|
const mine = areaOf(s, 0 as PlayerIndex);
|
|
[...mine.grid.values()][0]!.enhancements.push('radio');
|
|
const other = areaOf(s, 1 as PlayerIndex);
|
|
[...other.grid.values()][0]!.enhancements.push('radio');
|
|
|
|
const whatAt = (player: PlayerIndex): string => {
|
|
const cells = projectDistrict(s, seatOf(s, player) as SeatIndex).cells;
|
|
return cells.find((c) => c.enhancements.length > 0)!.enhancementsWhat.join(' ');
|
|
};
|
|
// The Fedora is player 0's, whatever seat that is.
|
|
assert.doesNotMatch(whatAt(0 as PlayerIndex), /IDLE/, "the Superintendent's own device read as idle");
|
|
assert.match(whatAt(1 as PlayerIndex), /IDLE/, "somebody else's device read as dispatching");
|
|
});
|
|
|
|
it('is on every district of the common board, not only the viewer own', () => {
|
|
const { s } = withDevice('radio', { spent: true, fedora: true });
|
|
const pub = publicSnapshot(s);
|
|
const all = pub.districts.flatMap((d) => d.cells);
|
|
assert.ok(
|
|
all.some((c) => c.enhancementsSpent.some((x) => x)),
|
|
"a spectator cannot see which devices are spent in a player's district",
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* THE RED FLAG HOLDER IS NOT A PER-PLAYER FACT, so it does not belong on the public player view.
|
|
*
|
|
* `docs/plans/jitsi-common-board.md` step 1 asks for one: "Add the Red Flag holder to the public
|
|
* player projection. It is public game state but is currently absent from `Frame`", and its
|
|
* `PublicPlayerView` carries `redFlagHeld: boolean`. Every other step-1 item shipped across v0.7.9.2
|
|
* to v0.7.9.5; this one is STRUCK OFF instead, because the premise does not hold in this codebase.
|
|
*
|
|
* `decks.redFlags` is written in exactly one place — `setup.ts`, from
|
|
* `config.optionalRules.emergencyToolbox` — and never again. There is no `.set` anywhere else, and
|
|
* `redFlag.play` (the intent gated on holding one) emits a `phaseEnded` event and does not spend it.
|
|
* So every player holds one or none of them does, decided before the first card is dealt.
|
|
*
|
|
* A `redFlagHeld` on each player would therefore be `optionalRules.emergencyToolbox` copied N times
|
|
* — already on the public projection — while implying to every reader of the common board that it
|
|
* varies by player and might change during a game. That is worse than the absence.
|
|
*
|
|
* This test exists so the plan item is not re-raised from the plan text: if the rule ever DOES
|
|
* become per-player, this fails and the projection is the right place to look.
|
|
*/
|
|
describe('the Red Flag holding is the Emergency Toolbox option, not a per-player fact', () => {
|
|
const dealt = (emergencyToolbox: boolean): GameState =>
|
|
createGame({
|
|
id: 'g',
|
|
seed: 99,
|
|
config: { ...config, optionalRules: { ...config.optionalRules, emergencyToolbox } },
|
|
playerNames: ['Ann', 'Bob', 'Cy'],
|
|
});
|
|
|
|
for (const toolbox of [true, false]) {
|
|
it(`gives every player the same answer with the toolbox ${toolbox ? 'on' : 'off'}`, () => {
|
|
const s = dealt(toolbox);
|
|
const held = s.players.map((p) => s.decks.redFlags.get(p.index) === true);
|
|
assert.deepEqual(held, [toolbox, toolbox, toolbox], 'the Red Flag has become a per-player fact');
|
|
});
|
|
}
|
|
|
|
it('is already public, through the option it comes from', () => {
|
|
const pub = publicSnapshot(dealt(true));
|
|
assert.equal(
|
|
pub.optionalRules.emergencyToolbox,
|
|
true,
|
|
'a spectator cannot tell whether the hand limit is three or four',
|
|
);
|
|
});
|
|
});
|