239 lines
11 KiB
TypeScript
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);
|
|
});
|