Files
Dune/tests/testing-station-contest-runtime.test.ts

239 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { discoveryStormActions } from '../game/discovery-storm-options';
import { discoveryStormDistance, discoveryStormOffer } from '../game/discovery-storm';
import { fighterCount } from '../game/advisors';
import { stormSectorAfter } from '../game/disaster-rules';
import {
advanceTestingStationContest,
createTestingStationContestFixture,
holdTestingStationStormCard,
testingStationOccupants,
testingStationStormOrder,
} from './fixture-testing-station-contest';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Normalization must accept the signed queue and stay stable afterwards. */
function normalized(game: Game): Game {
const once = normalizeAutomaticGame(reload(game));
assert.deepEqual(normalizeAutomaticGame(reload(once)), once);
return once;
}
const controls = (game: Game, actor: string) =>
discoveryStormActions(viewGame(game, actor));
const chosen = (game: Game, actor: string, delta: number) => {
const action = controls(game, actor).find((candidate) => candidate.delta === delta);
assert.ok(action, `${actor} must receive the real ${delta} station control.`);
return action;
};
const at = (game: Game, actor: string) =>
game.players.find((player) => player.id === actor)!;
void test('two real occupants of the revealed station each choose once in original storm order through the ordinary controls', () => {
const fixture = createTestingStationContestFixture();
const g = fixture.game,
source = g.stormMovementSource!;
assert.equal(g.status, 'playing');
assert.equal(g.turn, 3);
assert.equal(g.phase, 0);
assert.notEqual(source.kind, 'weather');
assert.equal(source.turn, 3);
assert.equal(source.distance, g.stormPending);
assert.deepEqual(fixture.source, {
turn: source.turn,
kind: source.kind,
distance: source.distance,
});
const token = g.discoveries!.tokens.find(
(candidate) => candidate.id === fixture.token,
)!;
assert.equal(token.face, 'ecological-testing-station');
assert.equal(token.status, 'placed');
assert.equal(token.revealedTurn, 1);
assert.deepEqual([token.territory, token.sector], [fixture.parent, fixture.parentSector]);
assert.deepEqual(testingStationOccupants(g), fixture.order);
assert.deepEqual(fixture.order, testingStationStormOrder(g, fixture.order));
assert.equal(fixture.order.length, 2);
for (const id of fixture.order)
assert.ok(fighterCount(at(g, id), 'ecological-testing-station') > 0);
assert.equal(g.decision!.player, fixture.owner);
assert.equal(g.ecologicalStorm!.queue!.order.join(','), fixture.order.join(','));
assert.deepEqual(g.ecologicalStorm!.queue!.choices, []);
// Privacy: only the current occupant receives the projected offer.
assert.equal(viewGame(g, fixture.owner).ecologicalStorm!.owner, fixture.owner);
assert.equal(viewGame(g, fixture.second).ecologicalStorm, null);
assert.deepEqual(controls(g, fixture.second), []);
for (const player of g.players)
if (!fixture.order.includes(player.id))
assert.equal(viewGame(g, player.id).ecologicalStorm, null);
const pending = normalized(g);
assert.deepEqual(pending.ecologicalStorm?.queue?.order, fixture.order);
assert.equal(pending.decision?.player, fixture.owner);
const afterFirst = applyAction(reload(g), fixture.owner, chosen(g, fixture.owner, 1));
assert.equal(afterFirst.ecologicalStorm!.stage, 'choose');
assert.equal(afterFirst.decision!.player, fixture.second);
assert.ok(afterFirst.decision?.kind === 'ecologicalStorm');
assert.equal(afterFirst.decision!.event, `${fixture.event}:1`);
assert.equal(afterFirst.ecologicalStorm!.owner, fixture.second);
assert.deepEqual(afterFirst.ecologicalStorm!.queue!.choices, [
{ owner: fixture.owner, delta: 1 },
]);
assert.equal(discoveryStormDistance(afterFirst.ecologicalStorm!), source.distance + 1);
assert.equal(afterFirst.stormPending, source.distance + 1);
assert.equal(afterFirst.storm, g.storm);
assert.equal(afterFirst.phase, 0);
assert.equal(afterFirst.stormResolution, null);
const queued = normalized(afterFirst);
assert.equal(queued.ecologicalStorm?.owner, fixture.second);
assert.equal(queued.stormPending, source.distance + 1);
const done = applyAction(reload(afterFirst), fixture.second, chosen(afterFirst, fixture.second, -1));
assert.equal(done.ecologicalStorm!.stage, 'complete');
assert.deepEqual(done.ecologicalStorm!.queue!.choices, [
{ owner: fixture.owner, delta: 1 },
{ owner: fixture.second, delta: -1 },
]);
assert.equal(discoveryStormDistance(done.ecologicalStorm!), source.distance);
assert.equal(done.storm, stormSectorAfter(g.storm, source.distance));
assert.equal(done.phase, 1);
assert.equal(done.stormMovementSource, undefined);
assert.equal(done.stormResolution, null);
const settled = normalized(done);
assert.equal(settled.ecologicalStorm?.stage, 'complete');
assert.equal(settled.storm, stormSectorAfter(g.storm, source.distance));
// The committed queue cannot be replayed by either original occupant.
reject(done, fixture.owner, chosen(g, fixture.owner, 1));
reject(done, fixture.second, chosen(afterFirst, fixture.second, -1));
});
void test('the same native two-occupant proof holds for the Advanced revealed Storm Card source', () => {
const fixture = createTestingStationContestFixture({ advanced: true });
const g = fixture.game;
assert.equal(g.stormMovementSource?.kind, 'card');
assert.equal(fixture.source.kind, 'card');
assert.equal(fixture.source.turn, 3);
assert.equal(g.stormPending, fixture.source.distance);
assert.deepEqual(testingStationOccupants(g), fixture.order);
assert.deepEqual(fixture.order, testingStationStormOrder(g, fixture.order));
assert.equal(g.ecologicalStorm!.queue!.order.join(','), fixture.order.join(','));
assert.equal(g.ecologicalStorm!.base, fixture.source.distance);
assert.equal(viewGame(g, fixture.second).ecologicalStorm, null);
const first = applyAction(reload(g), fixture.owner, chosen(g, fixture.owner, 1));
assert.equal(first.storm, g.storm);
assert.equal(first.phase, 0);
assert.equal(first.decision!.player, fixture.second);
assert.equal(first.stormPending, fixture.source.distance + 1);
const done = applyAction(reload(first), fixture.second, chosen(first, fixture.second, -1));
assert.equal(done.ecologicalStorm!.stage, 'complete');
assert.equal(done.storm, stormSectorAfter(g.storm, fixture.source.distance));
assert.equal(done.phase, 1);
});
void test('one shared cumulative bound: two real decreases reach zero while opposite choices cancel to the ordinary distance', () => {
const fixture = createTestingStationContestFixture();
const base = fixture.game,
[first, second] = fixture.order,
pending = base.stormPending!;
const decrease = applyAction(reload(base), first, chosen(base, first, -1));
assert.equal(discoveryStormDistance(decrease.ecologicalStorm!), pending - 1);
const remaining = controls(decrease, second)
.map((action) => action.delta as number)
.sort((a, b) => a - b);
assert.deepEqual(remaining, pending - 1 > 0 ? [-1, 0, 1] : [0, 1]);
const floor = applyAction(
reload(decrease),
second,
chosen(decrease, second, pending - 1 > 0 ? -1 : 0),
);
assert.equal(
discoveryStormDistance(floor.ecologicalStorm!),
Math.max(0, pending - 2),
);
assert.equal(floor.storm, stormSectorAfter(base.storm, Math.max(0, pending - 2)));
assert.equal(floor.phase, 1);
if (pending - 1 === 0) {
reject(decrease, second, { type: 'decision', event: decrease.ecologicalStorm!.event, delta: -1 });
assert.deepEqual(
discoveryStormOffer(decrease, decrease.ecologicalStorm!)!.options.map(
(option) => option.distance,
),
[0, 1],
);
}
const up = applyAction(reload(base), first, chosen(base, first, 1));
const cancel = applyAction(
reload(up),
second,
chosen(up, second, -1),
);
assert.equal(discoveryStormDistance(cancel.ecologicalStorm!), pending);
assert.equal(cancel.storm, stormSectorAfter(base.storm, pending));
});
void test('wrong owner, stale event, injected fields and replayed choices fail closed on the signed queue', () => {
const fixture = createTestingStationContestFixture();
const base = fixture.game,
[first, second] = fixture.order;
const firstAction = chosen(base, first, 1);
reject(base, second, firstAction);
reject(base, first, { ...firstAction, event: `${fixture.event}:1` });
reject(base, first, { ...firstAction, distance: 1 });
for (const delta of [-2, 2, 0.5, NaN]) reject(base, first, { ...firstAction, delta });
const committed = applyAction(reload(base), first, firstAction);
reject(committed, first, firstAction);
reject(committed, second, firstAction);
const secondAction = chosen(committed, second, 0);
reject(committed, first, secondAction);
const done = applyAction(reload(committed), second, secondAction);
assert.equal(done.ecologicalStorm!.stage, 'complete');
reject(done, second, secondAction);
});
void test('a real Weather Control play replaces the ordinary source and denies both occupants any station change', () => {
const fixture = createTestingStationContestFixture();
const [first] = fixture.order,
before = fixture.beforeStorm;
assert.equal(before.stormPending, null);
let g = structuredClone(before);
const holder = g.players.find((player) => player.id !== first)!.id;
const card = holdTestingStationStormCard(g, first, 'weather');
g = advanceTestingStationContest(
g,
(state) =>
state.phase === 0 &&
state.stormPending !== null &&
state.stormMovementSource?.kind === 'dials',
);
// One ordinary ready opens the storm-card window; only the real card play follows.
g = applyAction(g, holder, { type: 'ready' });
g = applyAction(g, first, { type: 'card', card: card.id, amount: 2 });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.equal(g.stormPending, 2);
assert.equal(
viewGame(g, first).ecologicalStorm,
null,
'Weather Control must deny the current occupant its station choice.',
);
assert.equal(viewGame(g,fixture.second).ecologicalStorm,null);
g = advanceTestingStationContest(g, (state) => state.phase === 1);
assert.equal(g.storm, stormSectorAfter(before.storm, 2));
assert.equal(g.decision, null);
assert.equal(fighterCount(at(g, fixture.second), 'ecological-testing-station'),
fighterCount(at(before, fixture.second), 'ecological-testing-station'));
assert.equal(g.discard.filter((candidate) => candidate.id === card.id).length, 1);
});