Files
station-master/test/extended-play.test.ts
T
Jesse.MarkowitzandClaude Opus 5 d5445badcc v0.7.9.5 — two answers to one question, and the copy nobody read
Both faults are in what 0.7.9.4 had just built, and both are the same
shape: a second copy of an answer that agreed with the first until it
didn't.

#96 — the §3.3 vote has no actor, and the screen named one anyway. The
vote is PARALLEL: every un-voted seat may vote at any moment, in any
order, one refusal ends it, and `apply.ts` says where it accepts one that
there is no actor to be. The turn chart named the last seat to move
before the timetable ran out — no more claim on the vote than anybody
else — directly above a tally correctly showing three seats outstanding.

The cause is worth more than the symptom. `currentActor(game)`
(`web/game.ts`) guarded on `status !== 'active'`; `currentActorOfState`
(`sim/view.ts`), added the same day in #95 and the one the frame calls,
did not, so it handed back whatever `clock.currentActor` was left
holding. The view now carries the guard and `currentActor` delegates to
it. That matters more than the tidiness: `currentActor` is what REFUSES
an intent, so a screen answering differently tells the table to wait on a
player the server would turn away.

The fourth of this class after Gitea#21, #22 and #94 — but the first
found by asking a view helper its question in a state the game is not
`active` in, which is the generalisation and is cheaper than finding the
fifth the same way.

#97 — narration reaches a seat once, by one path. `Frame.lines` carried
the whole log on every push to every seat, and nothing read it:
`RemoteSession` accumulates from `push.lines` alone and its `lines()`
returns that accumulator, so the log was serialised into every frame,
grew all game, and was discarded on arrival while `linesSince` sent the
same text correctly beside it.

The duplicate was masking a bug rather than merely wasting bandwidth.
`connect()` cleared `lastFrame` but not `sentLines`, so a reconnecting
seat was told "nothing new since your last push" while the browser it
answered had just reloaded from an EMPTY accumulator — the history panel
came back blank, mid-game, with the server holding the whole log. So the
two halves are one change, and the plan's instruction taken alone ("stop
passing the full game log into `frameFor()`") would have deleted a real
behaviour rather than a duplicate.

Every remaining reader of `Frame.lines` was checked before the field was
emptied: all of them are the solitaire and replay path, which builds
Frames through `snapshot()` directly and never goes near a session.

One test was wrong before the code was. The first draft of the reconnect
test connected inside its own fixture, so both sides of the comparison
were the empty array and it passed against the broken server. Each test
now asserts its premise is non-empty before comparing.

Also: `docs/plans/jitsi-common-board.md` is committed. It was never added
— not ignored, just missed — while TODO.md cites it twice as the plan for
all of v0.8.0 and the last two releases were built from it, so a clone
got a TODO pointing at a file that did not exist.

917 tests pass, up from 909.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
2026-09-07 20:35:19 -04:00

325 lines
16 KiB
TypeScript

/**
* §3.3, EXTENDED PLAY — Gitea#11, "when game ends allow players to continue playing if they wish".
*
* The rule as Jesse specified it (2026-08-28), which is what these tests are written against:
*
* - the OFFICIAL result is decided at the original game length and never changes. "In a five-day
* game, even if it's extended to eight or nine days, the winner and the official answer is the
* winner at the end of five days";
* - extending grants exactly ONE Day, and the question is put again at the end of it;
* - solitaire: the player decides alone. Multiplayer: unanimous, and one refusal ends it there;
* - only days-based endings offer it. A §3.4 collision breach is final, during an extended Day
* just as during the regular game.
*
* The persistence half matters as much as the rules half: a save is `{ seed, config, history }`
* replayed through the engine, so an extension that is not an INTENT does not survive a reload, an
* Undo, or a server restart. `replays the extension` below is the test that pins that.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, check } from '../src/engine/apply.ts';
import { STAGES_PER_DAY } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import { currentActorOfState, publicSnapshot, snapshot } from '../src/sim/view.ts';
import { currentActor } from '../src/web/game.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
days: 3,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
...over,
});
const game = (over: Partial<GameConfig> = {}, names = ['Jesse']): GameState =>
createGame({ id: 'g', seed: 1, config: baseConfig(over), playerNames: names });
/** Parks a game on the last Stage of its final Day, so one `advance` runs the clock off the end. */
function atTheEnd(s: GameState): GameState {
s.clock.day = s.config.days + s.extraDays + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
return s;
}
describe('§3.3 extended play — the ending pauses rather than stopping (Gitea#11)', () => {
it('offers another Day on a days-based ending, and records the result anyway', () => {
const s = atTheEnd(game());
advance(s);
assert.equal(s.status, 'awaitingExtension', 'a days-based ending did not offer another Day');
assert.ok(s.outcome, 'the result was not decided');
assert.ok(s.official, 'the official result was not frozen');
assert.equal(s.official!.day, s.config.days, 'the official Day is not the original game length');
});
it('does NOT offer another Day after a collision breach — §3.4 is final', () => {
const s = game({ mode: 'competitive', maxCollisionsPerDay: 2 });
s.collisionsToday = 2;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished', 'a railroad declared unsafe offered to carry on');
assert.equal(s.outcome!.reason, 'collisionFloor');
});
it('refuses every ordinary intent while the extension question is open', () => {
const s = atTheEnd(game());
advance(s);
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'WRONG_PHASE');
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), null, 'the vote itself was refused');
});
it('offers only the two votes, and only to a seat that has not voted', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
advance(s);
assert.deepEqual(
legalActions(s, 0).map((i) => i.type),
['game.extend', 'game.extend'],
'something other than the vote is legal while the game is stopped',
);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.equal(legalActions(s, 0).length, 0, 'a seat was offered a second vote');
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'ALREADY_VOTED');
assert.equal(legalActions(s, 1).length, 2, 'the seat still to vote was not offered the vote');
});
it('refuses the vote when no extension is pending', () => {
const s = game();
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'NOT_AWAITING_EXTENSION');
});
});
describe('§3.3 extended play — one Day at a time (Gitea#11)', () => {
it('grants exactly one Day in solitaire, then asks again at the end of it', () => {
const s = atTheEnd(game());
advance(s);
const official = { ...s.official!.outcome };
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.equal(s.status, 'active', 'agreeing did not resume play');
assert.equal(s.extraDays, 1, 'more or less than one Day was granted');
assert.deepEqual(s.extensionVotes, [null], 'the votes were not cleared for the next question');
// Run the extra Day off the end: the question comes round again.
atTheEnd(s);
advance(s);
assert.equal(s.status, 'awaitingExtension', 'the second ending did not ask again');
assert.equal(s.extraDays, 1, 'a second Day was granted without being asked for');
assert.deepEqual(s.official!.outcome, official, 'the official result was rewritten');
assert.equal(s.official!.day, s.config.days, 'the official Day moved with the extension');
});
it('ends the moment one seat declines, without waiting for the rest', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
advance(s);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.equal(s.status, 'awaitingExtension', 'one yes ended the vote');
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
assert.equal(s.status, 'finished', 'a refusal did not end the game immediately');
assert.equal(s.extraDays, 0, 'a Day was granted despite a refusal');
assert.equal(check(s, 2, { type: 'game.extend', player: 2, agree: true }), 'NOT_AWAITING_EXTENSION',
'the seat that never voted is still being waited on');
});
it('needs every seat before it grants the Day', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
advance(s);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
assert.equal(s.status, 'awaitingExtension', 'two of three votes granted the Day');
applyIntent(s, 2, { type: 'game.extend', player: 2, agree: true });
assert.equal(s.status, 'active', 'a unanimous table was not given its Day');
assert.equal(s.extraDays, 1);
});
it('keeps the official result when a collision ends an EXTENDED Day', () => {
// The case the freeze exists for: a railroad declared unsafe on the extra Day does not retract
// who won on the last scheduled one.
const s = atTheEnd(game({ mode: 'competitive', maxCollisionsPerDay: 2 }, ['A', 'B']));
s.players[0]!.revenue = 9;
s.players[1]!.revenue = 2;
advance(s);
const official = { ...s.official!.outcome };
assert.equal(official.result, 'win');
assert.equal(official.winner, 0);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
assert.equal(s.status, 'active');
s.collisionsToday = 2;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.reason, 'collisionFloor', 'the current evaluation was not updated');
assert.deepEqual(s.official!.outcome, official, 'a late collision rewrote a recorded win');
});
it('decides the winner at the ORIGINAL game length, whoever leads afterwards', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
s.players[0]!.revenue = 9;
s.players[1]!.revenue = 2;
advance(s);
assert.equal(s.official!.outcome.winner, 0);
assert.deepEqual(s.official!.revenues, [9, 2], 'the official standings were not frozen');
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
// B overtakes A during the extra Day, and it changes nothing official.
s.players[1]!.revenue = 40;
atTheEnd(s);
advance(s);
assert.equal(s.official!.outcome.winner, 0, 'overtaking after the timetable took the win');
assert.deepEqual(s.official!.revenues, [9, 2], 'the frozen standings moved');
assert.equal(s.outcome!.winner, 1, 'the informational evaluation did not follow the new leader');
});
});
describe('§3.3 extended play — it survives being replayed (Gitea#11)', () => {
it('reproduces an extended game from seed and intents alone', () => {
// The reason the vote is an intent at all. A save is a replay, so a decision that is not in the
// history did not happen — an extended game would evaporate on the next reload.
const play = (): GameState => {
const s = atTheEnd(game());
advance(s);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
return s;
};
const a = play();
const b = play();
assert.equal(a.extraDays, b.extraDays);
assert.equal(a.status, b.status);
assert.deepEqual(a.official!.outcome, b.official!.outcome);
});
it('narrates the vote, so a table can see who called time', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
advance(s);
const yes = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.ok(yes.ok);
assert.deepEqual(yes.events.map((e) => e.type), ['extensionVoted']);
const no = applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
assert.ok(no.ok);
assert.deepEqual(no.events.map((e) => e.type), ['extensionVoted', 'playConcluded']);
});
it('announces the granted Day', () => {
const s = atTheEnd(game());
advance(s);
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.ok(r.ok);
assert.deepEqual(r.events.map((e) => e.type), ['extensionVoted', 'dayExtended']);
const extended = r.events.find((e) => e.type === 'dayExtended');
assert.equal(extended && 'day' in extended ? extended.day : null, s.config.days + 1);
});
});
describe('§3.3 extended play — bots play the timetable they were dealt (Gitea#11)', () => {
it('REGRESSION: a simulated game terminates whatever the policy would vote', () => {
/**
* `playGame` declines on the driver's own account rather than leaving it to the policy, and this
* is why. `randomBot` picks uniformly among its legal options, so it takes another Day about
* half the time — and since a table may go on granting Days for ever, the game then runs to
* `maxTurns`. It did: `test/sim.test.ts` went from under a second to an unbounded hang, every
* seeded game in the harness playing fifty thousand turns instead of a couple of hundred.
*
* `randomBot` is the policy that exposes it, but the guarantee has to hold for any policy,
* including ones not written yet — hence the fix living in the driver and the test living here.
*/
const s = createGame({ id: 'g', seed: 9, config: baseConfig({ days: 1 }), playerNames: ['Jesse'] });
const out = playGame(s, randomBot(7), pump, 4_000);
assert.equal(s.status, 'finished', 'a simulated game did not finish');
assert.equal(s.extraDays, 0, 'the harness played Days the game was not dealt');
assert.ok(out.turns < 4_000, `ran to the turn cap (${out.turns}) instead of ending`);
});
it('developerBot declines, so a bot-only game ends on schedule', () => {
// "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28). The server votes
// yes on a bot's behalf once every human has already agreed; this policy is what is left when
// there are no humans to follow.
const s = atTheEnd(game());
advance(s);
const choice = developerBot.choose(s, 0, legalActions(s, 0));
assert.equal(choice.type, 'game.extend');
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
});
});
/**
* §3.3 — WHO THE SCREEN SAYS THE TABLE IS WAITING ON, while the vote is open.
*
* The vote is PARALLEL, and `apply.ts` says so where it accepts one: "open to every seat at once:
* it is a table decision rather than a ruling, so THERE IS NO ACTOR TO BE". Any seat that has not
* voted may vote at any moment, in any order, and one refusal ends it. So the honest answer to "who
* are we waiting on" is every un-voted seat — which is exactly what the vote tally beside the chart
* already draws — and the honest answer to "whose turn is it" is nobody.
*
* The engine gave that answer and the screen did not. `currentActor(game)` guards on
* `status !== 'active'` and returned null; `actingPlayer(state)` has no such guard and returned
* `clock.currentActor`, which still holds whoever moved last before the timetable ran out. The
* frame took the second, so the turn chart named one arbitrary seat — the last to act, who has no
* more claim on the vote than anybody else — while the tally underneath correctly showed three
* seats outstanding.
*
* The fourth of these in a row after Gitea#21, #22 and #94, and the first found by asking the
* question of a state the game is not ACTIVE in. See TODO #96.
*/
describe('§3.3 extended play — the vote has no actor (#96)', () => {
const table = (): GameState => {
const s = atTheEnd(game({ mode: 'competitive' }, ['Ann', 'Bob', 'Cy']));
advance(s);
assert.equal(s.status, 'awaitingExtension', 'the table is not being asked');
return s;
};
it('reports nobody acting while the vote is open, to a player and to a spectator alike', () => {
const s = table();
assert.notEqual(s.clock.currentActor, null, 'the premise is gone: nothing was left on the clock');
assert.equal(currentActorOfState(s), null, 'the engine named an actor during a parallel vote');
assert.equal(
snapshot(s, [], null, null, null, false, 0).actor,
null,
'the turn chart named a seat while the whole table was voting',
);
assert.equal(publicSnapshot(s).actor, null, 'the common board named a seat during the vote');
});
it('agrees with the session, which is the half that decides what is legal', () => {
// The disagreement is the bug, not either answer on its own: `currentActor` is what refuses an
// intent, so a screen that names somebody it would refuse is telling the table to wait on a
// player who cannot act.
const s = table();
const g = { state: s, log: [] } as unknown as Parameters<typeof currentActor>[0];
assert.equal(currentActorOfState(s), currentActor(g), 'the screen and the session disagree');
});
it('still names the actor during ordinary play, which is the case that must not regress', () => {
const s = game({ mode: 'competitive' }, ['Ann', 'Bob', 'Cy']);
pump(s);
assert.equal(s.status, 'active', 'the premise is gone: the game is not running');
assert.equal(currentActorOfState(s), s.clock.currentActor, 'an active game lost its actor');
assert.equal(snapshot(s, [], null, null, null, false, 0).actor, s.clock.currentActor);
});
it('reports nobody once the table has declined and the game is finished', () => {
const s = table();
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
assert.equal(s.status, 'finished', 'a refusal did not end it');
assert.equal(currentActorOfState(s), null, 'a finished game still had somebody to move');
assert.equal(publicSnapshot(s).actor, null, 'the common board named a seat after the game ended');
});
});