Files
Dune/tests/discovery-storm.test.ts
T
Quintine f0d3d9c464 Prototype Jacurutu income, Testing Station and Shrine effects
Connect automatic unambiguous Jacurutu physical-force income, a sole-occupant
Testing Station storm choice and Shrine reciprocal physical card roles to
engine actions, player controls, legal AI and saved continuation. Preserve
Weather Control exclusion, exact card/force custody, private hand boundaries
and committed effects after restart. Shared role checks cover existing ordinary
and special Karama paths; reserved cards cannot enter another pending use.

Keep Orgiz, mixed-force Jacurutu allocation and contested Cistern/Testing
Station benefits explicitly pending. These are bounded prototypes; complete
Discovery/Advanced/expansion and publication gates remain closed.

Validation: types, lint and 4,345 offline tests passed (32 added), production
build passed, and 40 HTTP tests passed. Independent cross-reviews repaired
reserved-card deadlock, physical-card selection, alternate-role permission,
storm source/token binding and public-entitlement bot ordering. The first broad
run found five failures; focused repairs preceded the stable repeated check.

Browser A5EJQ2W4 used genuine Discovery setup/reveal/free entry with targeted QA
positioning. A human increased storm movement from two to three sectors,
moving sector 6 to 9. Refresh restored station forces and the private Snooper.
This was targeted control/recovery evidence, not an unmodified full game.
All 187 opening saved rooms remained unchanged; 31 QA/HTTP rooms were added
(218 total). Reused the running server; no restart or save reset.

Source-bound report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-location-effects/verification-final/report.json
Source fingerprint: beecb85ab7ec39319115389c115434d161591e8549802c542a6343a0fc19bea6
Durations: check 140540ms; build 6741ms; integration 24956ms.
Aggregate goal counter 63,116,444 to 64,295,091 at final verification:
1,178,647 tokens before final Git/push overhead for three bounded functions.
Standing authorization: Push all from now on.
2026-09-13 14:50:09 +10:00

201 lines
7.5 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryStormActions } from '../game/discovery-storm-options';
import {
createStormSource,
discoveryStormOffer,
discoveryStormSignature,
} from '../game/discovery-storm';
import {
discoveryStormFixture,
confirmDiscoveryStorm,
holdStormCard,
} from './fixture-discovery-storm';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function reject(g: Game, owner: string, action: Action) {
const before = reload(g);
assert.throws(() => applyAction(g, owner, action));
assert.deepEqual(g, before);
}
for (const advanced of [false, true])
for (const delta of [-1, 0, 1]) {
void test(`Ecological Testing Station changes genuine ${advanced ? 'Advanced card' : 'Basic dial'} movement by ${delta} after the storm window`, () => {
let g = discoveryStormFixture(advanced);
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.stormMovementSource?.kind, advanced ? 'card' : 'dials');
g = confirmDiscoveryStorm(g);
assert.equal(g.decision?.kind, 'ecologicalStorm');
assert.equal(g.storm, 6);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
const action = discoveryStormActions(viewGame(g, 'a')).find(
(a) => a.delta === delta,
)!;
const before = reload(g);
g = applyAction(reload(g), 'a', action);
assert.deepEqual(
before.players.map((p) => p.hand),
g.players.map((p) => p.hand),
);
assert.equal(g.storm, 8 + delta);
assert.equal(g.phase, 1);
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.equal(g.stormMovementSource, undefined);
assert.equal(g.stormResolution, null);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
reject(g, 'a', action);
});
}
void test('station choice is private to its current sole fighter occupant; wrong owner, stale event and injected distance are immutable rejections', () => {
const g = confirmDiscoveryStorm(discoveryStormFixture()),
before = reload(g);
assert.equal(viewGame(g, 'g').ecologicalStorm, null);
assert.deepEqual(discoveryStormActions(viewGame(g, 'g')), []);
const v = viewGame(g, 'a'),
action = discoveryStormActions(v)[0];
v.ecologicalStorm!.options[0].distance = 999;
assert.deepEqual(g, before);
reject(g, 'g', action);
reject(g, 'a', { ...action, event: 'stale' });
for (const delta of [-2, 2, 0.5, NaN]) reject(g, 'a', { ...action, delta });
reject(g, 'a', { ...action, distance: 10 });
reject(g, 'a', { type: 'ready' });
});
void test('all four AI levels select a legal owned station choice', () => {
const g = confirmDiscoveryStorm(discoveryStormFixture(true));
for (const level of DIFFICULTIES) {
const v = viewGame(g, 'a');
v.players[0].bot = level;
const actions = botActions(v);
assert.ok(actions.length);
for (const action of actions)
assert.doesNotThrow(() => applyAction(g, 'a', action));
}
});
void test('Weather Control replaces natural provenance and bypasses the station, including zero movement', () => {
for (const advanced of [false, true])
for (const amount of [0, 3]) {
let g = discoveryStormFixture(advanced);
const card = holdStormCard(g, 'a', 'weather');
g = applyAction(g, 'g', { type: 'ready' });
g = applyAction(g, 'a', { type: 'card', card: card.id, amount });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.deepEqual(g.ready, []);
assert.match(discoveryStormOffer(g)!.blocked!, /Weather Control/);
g = confirmDiscoveryStorm(reload(g));
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 6 + amount);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
}
});
void test('unoccupied, contested and legacy unknown-source storms continue without inventing entitlement or changing distance', () => {
for (const mode of ['empty', 'contested', 'legacy']) {
let g = discoveryStormFixture();
if (mode === 'empty') {
g.players[0].reserves +=
g.players[0].forces['ecological-testing-station:0'];
delete g.players[0].forces['ecological-testing-station:0'];
}
if (mode === 'contested') {
g.players[1].reserves--;
g.players[1].forces['ecological-testing-station:0'] = 1;
}
if (mode === 'legacy') delete g.stormMovementSource;
g = confirmDiscoveryStorm(g);
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 8);
}
});
void test('station can reduce a one-sector natural storm to zero and never offers a negative distance', () => {
let g = discoveryStormFixture(true);
g.stormPending = 1;
g.stormMovementSource = createStormSource(g.turn, 'card', 1);
g = confirmDiscoveryStorm(g);
g = applyAction(g, 'a', {
type: 'decision',
event: g.ecologicalStorm!.event,
delta: -1,
});
assert.equal(g.storm, 6);
assert.equal(g.phase, 1);
const source = discoveryStormFixture();
source.stormPending = 0;
source.stormMovementSource = createStormSource(source.turn, 'dials', 0);
assert.deepEqual(
discoveryStormOffer(source)!.options.map((o) => o.delta),
[0, 1],
);
});
void test('orphaned station choices, rewritten Weather sources and mismatched traversal receipts fail closed on reads and normalization', () => {
const original = confirmDiscoveryStorm(discoveryStormFixture());
for (const mutate of [
(g: Game) => {
delete g.ecologicalStorm;
},
(g: Game) => {
g.decision = null;
},
(g: Game) => {
g.ecologicalStorm!.owner = 'g';
g.ecologicalStorm!.signature = discoveryStormSignature(
g.ecologicalStorm!,
);
},
(g: Game) => {
g.stormMovementSource = createStormSource(g.turn, 'weather', 2);
},
(g: Game) => {
g.stormPending = 3;
},
]) {
const g = reload(original);
mutate(g);
const before = reload(g);
assert.throws(() => viewGame(g, 'a'));
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, before);
}
});
void test('adjusted Advanced storm retains its receipt through Fremen protection and typed loss continuation', () => {
let g = discoveryStormFixture(true);
const f = g.players[2];
f.reserves -= 3;
f.forces['red_chasm:7'] = 3;
f.elites!.reserves--;
f.elites!.forces['red_chasm:7'] = 1;
holdStormCard(g, 'g', 'karama');
g = confirmDiscoveryStorm(g);
g = applyAction(g, 'a', {
type: 'decision',
event: g.ecologicalStorm!.event,
delta: 1,
});
assert.equal(g.response?.kind, 'stormProtection');
assert.equal(g.ecologicalStorm!.stage, 'traversal');
assert.equal(g.stormResolution!.discoveryStorm, g.ecologicalStorm!.signature);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
const corrupt = reload(g);
delete corrupt.stormResolution!.discoveryStorm;
assert.throws(() => viewGame(corrupt, 'f'));
for (let step = 0; g.response?.kind === 'stormProtection'; step++) {
assert.ok(step < 10);
const owner = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = applyAction(reload(g), owner, { type: 'passResponse' });
}
assert.equal(g.decision?.kind, 'stormLosses');
g = applyAction(reload(g), 'f', { type: 'decision', elite: 1 });
assert.equal(g.players[2].forces['red_chasm:7'], 1);
assert.equal(g.players[2].elites!.forces['red_chasm:7'] ?? 0, 0);
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.equal(g.storm, 9);
});