380 lines
15 KiB
TypeScript
380 lines
15 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 {
|
||
createDiscoveryStorm,
|
||
createStormSource,
|
||
discoveryStormDistance,
|
||
discoveryStormOffer,
|
||
discoveryStormSignature,
|
||
} from '../game/discovery-storm';
|
||
import { fighterCount } from '../game/advisors';
|
||
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, []);
|
||
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 and legacy unknown-source storms continue without inventing entitlement or changing distance', () => {
|
||
for (const mode of ['empty', '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 === '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);
|
||
});
|
||
|
||
const stationKey = 'ecological-testing-station:0';
|
||
/** A second distinct non-advisor occupant beside the actual entrant. */
|
||
function contest(g: Game, second = 'g'): Game {
|
||
const player = g.players.find((candidate) => candidate.id === second)!;
|
||
player.reserves -= 2;
|
||
player.forces[stationKey] = (player.forces[stationKey] ?? 0) + 2;
|
||
return g;
|
||
}
|
||
const occupants = (g: Game) =>
|
||
g.order.filter((id) => {
|
||
const player = g.players.find((candidate) => candidate.id === id)!;
|
||
return fighterCount(player, 'ecological-testing-station') > 0;
|
||
});
|
||
const control = (g: Game, id: string, delta: number) => {
|
||
const action = discoveryStormActions(viewGame(g, id)).find(
|
||
(candidate) => candidate.delta === delta,
|
||
);
|
||
assert.ok(action, `${id} must receive a legal ${delta} station control.`);
|
||
return action;
|
||
};
|
||
|
||
void test('two original non-advisor occupants each choose once in original storm order with one cumulative traversal', () => {
|
||
let g = confirmDiscoveryStorm(contest(discoveryStormFixture()));
|
||
const before = reload(g);
|
||
const order = occupants(g);
|
||
assert.equal(order.length, 2);
|
||
assert.notEqual(order[0], order[1]);
|
||
assert.equal(g.decision?.kind, 'ecologicalStorm');
|
||
assert.equal(g.decision?.player, order[0]);
|
||
assert.equal(g.ecologicalStorm?.owner, order[0]);
|
||
assert.deepEqual(g.ecologicalStorm?.queue, { order, choices: [] });
|
||
assert.equal(g.storm, 6);
|
||
const event = g.decision!.event;
|
||
assert.equal(viewGame(g, order[0]).ecologicalStorm?.base, 2);
|
||
assert.equal(viewGame(g, order[1]).ecologicalStorm, null);
|
||
assert.deepEqual(discoveryStormActions(viewGame(g, order[1])), []);
|
||
reject(g, order[1], control(g, order[0], 1));
|
||
reject(g, order[0], { ...control(g, order[0], 1), event: `${event}:1` });
|
||
reject(g, order[0], { ...control(g, order[0], 1), distance: 1 });
|
||
for (const delta of [-2, 2, 0.5, NaN])
|
||
reject(g, order[0], { ...control(g, order[0], 0), delta });
|
||
assert.deepEqual(g, before);
|
||
g = applyAction(reload(g), order[0], control(g, order[0], 1));
|
||
assert.equal(g.ecologicalStorm?.stage, 'choose');
|
||
assert.equal(g.ecologicalStorm?.owner, order[1]);
|
||
assert.equal(g.decision?.player, order[1]);
|
||
assert.ok(g.decision?.kind === 'ecologicalStorm');
|
||
assert.equal(g.decision?.event, `${event}:1`);
|
||
assert.equal(discoveryStormDistance(g.ecologicalStorm!), 3);
|
||
assert.equal(g.stormPending, 3);
|
||
assert.equal(g.storm, 6);
|
||
assert.equal(g.phase, 0);
|
||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||
reject(g, order[0], control(before, order[0], 1));
|
||
reject(g, order[1], { ...control(g, order[1], 0), event });
|
||
const second = viewGame(g, order[1]).ecologicalStorm!;
|
||
assert.deepEqual(second.options.map((option) => option.distance), [2, 3, 4]);
|
||
assert.equal(viewGame(g, order[0]).ecologicalStorm, null);
|
||
g = applyAction(reload(g), order[1], control(g, order[1], -1));
|
||
assert.equal(g.ecologicalStorm?.stage, 'complete');
|
||
assert.deepEqual(g.ecologicalStorm?.queue?.choices, [
|
||
{ owner: order[0], delta: 1 },
|
||
{ owner: order[1], delta: -1 },
|
||
]);
|
||
assert.equal(g.storm, 8);
|
||
assert.equal(g.phase, 1);
|
||
assert.equal(g.stormMovementSource, undefined);
|
||
assert.equal(g.stormResolution, null);
|
||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||
reject(g, order[1], { type: 'decision', event: `${event}:1`, delta: 0 });
|
||
reject(g, order[0], { type: 'decision', event, delta: 1 });
|
||
});
|
||
|
||
void test('a contested station shares one cumulative bound: two decreases reach zero and opposite choices cancel', () => {
|
||
let g = contest(discoveryStormFixture());
|
||
g.stormPending = 3;
|
||
g.stormMovementSource = createStormSource(g.turn, 'dials', 3);
|
||
g = confirmDiscoveryStorm(g);
|
||
const order = occupants(g);
|
||
let floored = applyAction(reload(g), order[0], control(g, order[0], -1));
|
||
assert.equal(discoveryStormDistance(floored.ecologicalStorm!), 2);
|
||
assert.deepEqual(
|
||
discoveryStormOffer(floored, floored.ecologicalStorm!)!.options.map(
|
||
(option) => option.distance,
|
||
),
|
||
[1, 2, 3],
|
||
);
|
||
floored = applyAction(reload(floored), order[1], control(floored, order[1], -1));
|
||
assert.equal(discoveryStormDistance(floored.ecologicalStorm!), 1);
|
||
const single = contest(discoveryStormFixture());
|
||
single.stormPending = 1;
|
||
single.stormMovementSource = createStormSource(single.turn, 'dials', 1);
|
||
let one = confirmDiscoveryStorm(single);
|
||
const oneOrder = occupants(one);
|
||
one = applyAction(reload(one), oneOrder[0], control(one, oneOrder[0], -1));
|
||
assert.deepEqual(
|
||
discoveryStormOffer(one, one.ecologicalStorm!)!.options.map(
|
||
(option) => option.delta,
|
||
),
|
||
[0, 1],
|
||
);
|
||
reject(one, oneOrder[1], {
|
||
type: 'decision',
|
||
event: one.ecologicalStorm!.event,
|
||
delta: -1,
|
||
});
|
||
one = applyAction(reload(one), oneOrder[1], control(one, oneOrder[1], 0));
|
||
assert.equal(discoveryStormDistance(one.ecologicalStorm!), 0);
|
||
assert.equal(one.storm, 6);
|
||
assert.equal(one.phase, 1);
|
||
const ceiling = contest(discoveryStormFixture());
|
||
ceiling.storm = 6;
|
||
ceiling.stormPending = 40;
|
||
ceiling.stormMovementSource = createStormSource(ceiling.turn, 'dials', 40);
|
||
let high = confirmDiscoveryStorm(ceiling);
|
||
const highOrder = occupants(high);
|
||
assert.deepEqual(
|
||
discoveryStormOffer(high, high.ecologicalStorm!)!.options.map(
|
||
(option) => option.delta,
|
||
),
|
||
[-1, 0],
|
||
);
|
||
reject(high, highOrder[0], {
|
||
type: 'decision',
|
||
event: high.ecologicalStorm!.event,
|
||
delta: 1,
|
||
});
|
||
high = applyAction(reload(high), highOrder[0], control(high, highOrder[0], -1));
|
||
assert.equal(discoveryStormDistance(high.ecologicalStorm!), 39);
|
||
high = applyAction(reload(high), highOrder[1], control(high, highOrder[1], 1));
|
||
assert.equal(discoveryStormDistance(high.ecologicalStorm!), 40);
|
||
assert.equal(high.storm, ((6 - 1 + 40) % 18) + 1);
|
||
});
|
||
|
||
void test('a lone Bene Gesserit advisor group is not a station occupant and never receives a contested choice', () => {
|
||
const g = contest(discoveryStormFixture());
|
||
const advisor = g.players[1];
|
||
advisor.faction = 'beneGesserit';
|
||
advisor.advisors = { 'ecological-testing-station': {} };
|
||
assert.equal(occupants(g).includes(advisor.id), false);
|
||
assert.deepEqual(occupants(g), [g.players[0].id]);
|
||
const frame = createDiscoveryStorm(g, 'advisor-exclusion');
|
||
assert.equal(frame.queue, undefined);
|
||
assert.equal(frame.owner, g.players[0].id);
|
||
assert.equal(discoveryStormOffer(g, frame)!.owner, g.players[0].id);
|
||
});
|
||
|
||
void test('Weather Control denies every occupant of a contested station', () => {
|
||
for (const advanced of [false, true]) {
|
||
let g = contest(discoveryStormFixture(advanced));
|
||
const card = holdStormCard(g, 'a', 'weather');
|
||
g = applyAction(g, 'g', { type: 'ready' });
|
||
g = applyAction(g, 'a', { type: 'card', card: card.id, amount: 2 });
|
||
assert.equal(g.stormMovementSource?.kind, 'weather');
|
||
assert.equal(discoveryStormActions(viewGame(g, occupants(g)[0])).length, 0);
|
||
g = confirmDiscoveryStorm(reload(g));
|
||
assert.equal(g.ecologicalStorm, undefined);
|
||
assert.equal(g.storm, 8);
|
||
}
|
||
});
|
||
|
||
void test('every AI level submits the second occupant’s remaining cumulative choice', () => {
|
||
const g = confirmDiscoveryStorm(contest(discoveryStormFixture(true)));
|
||
const order = occupants(g);
|
||
const committed = applyAction(
|
||
reload(g),
|
||
order[0],
|
||
control(g, order[0], 0),
|
||
);
|
||
for (const level of DIFFICULTIES) {
|
||
const view = viewGame(committed, order[1]);
|
||
view.players.find((player) => player.id === order[1])!.bot = level;
|
||
const actions = botActions(view);
|
||
assert.ok(actions.length);
|
||
for (const action of actions)
|
||
assert.doesNotThrow(() => applyAction(reload(committed), order[1], action));
|
||
}
|
||
assert.equal(committed.decision?.player, order[1]);
|
||
assert.equal(discoveryStormDistance(committed.ecologicalStorm!), 2);
|
||
});
|