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.
201 lines
7.5 KiB
TypeScript
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);
|
|
});
|