Files
Dune/tests/moritani-force-leader.test.ts
Quintine 660b0a0c25 Open native advisor preparation before the first shipment
Allow Advanced BG preparation in phases2–5 at quiet boundaries, preserving
original phase/auction/readiness and native eligibility. Close a current-turn
receipt only on committed shipments, including marker and Sneak Attack entry.
Retain the legacy phase5 queue and qualify the per-territory prevention policy.
Connect persistent controls, minimal bot support and focused native evidence.
Complete modes and browser/deployed acceptance remain open.
2026-10-24 18:36:55 +11:00

350 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { type FactionId } from '../game/catalog';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { createTechTokens } from '../game/tech-tokens';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const decide = (g: Game, extra: Omit<Action, 'type'>) =>
applyAction(g, 'm', { type: 'decision', ...extra });
function fixture(
kind: 'assassination' | 'sneakAttack' = 'assassination',
entrant: FactionId = 'emperor',
) {
const g = createGame('TERROR02', newPlayer('m', 'Moritani', 'moritani'));
g.players.push(
newPlayer('e', 'Entrant', entrant),
newPlayer('a', 'Atreides', 'atreides'),
);
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.storm = 18;
g.active = 'e';
g.order = ['e', 'm', 'a'];
g.movementRemaining = [...g.order];
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.spice = 20;
p.hand = [];
}
g.moritaniTerror = createTerrorState(() => 0);
const token = g.moritaniTerror.tokens.find((t) => t.kind === kind)!;
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
return g;
}
const enter = (g: Game) =>
applyAction(g, 'e', {
type: 'ship',
amount: 2,
territory: 'arrakeen',
sector: 10,
});
const offered = (kind: 'assassination' | 'sneakAttack' = 'assassination') =>
enter(fixture(kind));
function rejected(g: Game, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'm', action));
assert.deepEqual(g, before);
}
const totalForces = (g: Game, id: string) => {
const p = player(g, id);
return (
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0)
);
};
void test('Assassination selects exactly one live ordinary leader, spends its token and leaves the client no victim choice', () => {
const g = offered();
const original = structuredClone(player(g, 'e').leaders);
assert.equal(original.length, 5);
const done = decide(g, {
reveal: true,
leader: 'not-a-leader-or-a-valid-choice',
});
const victims = player(done, 'e').leaders.filter((leader) => leader.dead);
assert.equal(victims.length, 1);
assert.ok(original.some((leader) => leader.id === victims[0].id));
assert.equal(victims[0].deaths, 1);
assert.equal(player(done, 'm').spice, 20 + victims[0].strength);
assert.equal(done.pendingTerrorEntry, null);
assert.equal(done.decision, null);
assert.equal(
done.moritaniTerror!.tokens.find((t) => t.kind === 'assassination')!.status,
'removed',
);
assert.deepEqual(player(g, 'e').leaders, original);
for (const leader of player(done, 'e').leaders.filter(
(leader) => !leader.dead,
))
assert.deepEqual(
leader,
original.find((l) => l.id === leader.id),
);
rejected(reload(done), { type: 'decision', reveal: true });
});
void test('each native leader is eligible when alone alive; death count and battle-use marker settle exactly once', () => {
for (let index = 0; index < 5; index++) {
const initial = fixture();
player(initial, 'e').leaders.forEach((leader, i) => {
leader.dead = i !== index;
leader.deaths = i === index ? 2 : 1;
leader.usedAt = 'carthag';
});
const g = enter(initial);
const before = structuredClone(player(g, 'e').leaders);
const done = decide(reload(g), { reveal: true });
const victim = player(done, 'e').leaders[index];
assert.equal(victim.dead, true);
assert.equal(victim.deaths, 3);
assert.equal(victim.usedAt, undefined);
assert.equal(player(done, 'm').spice, 20 + before[index].strength);
for (let other = 0; other < 5; other++)
if (other !== index)
assert.deepEqual(player(done, 'e').leaders[other], before[other]);
rejected(reload(done), { type: 'decision', reveal: true });
}
});
void test('Assassination pays the printed three for Zoal', () => {
const initial = fixture('assassination', 'tleilaxu');
const zoal = player(initial, 'e').leaders.find(
(leader) => leader.name === 'Zoal',
)!;
assert.ok(zoal);
for (const leader of player(initial, 'e').leaders)
leader.dead = leader.id !== zoal.id;
const done = decide(enter(initial), { reveal: true });
assert.equal(player(done, 'm').spice, 23);
assert.equal(
player(done, 'e').leaders.find((leader) => leader.id === zoal.id)!.dead,
true,
);
});
void test('malformed capture and Ghola assignments reject atomically without disclosing rival token faces', () => {
for (const field of ['capturedBy', 'gholaBy'] as const) {
for (const foreign of [false, true]) {
const initial = fixture();
if (foreign) player(initial, 'a').leaders[0][field] = 'e';
else player(initial, 'e').leaders[0][field] = 'a';
const g = enter(initial);
assert.equal('canReveal' in viewGame(g, 'e').terrorEntry!, false);
assert.equal('kind' in viewGame(g, 'a').terrorEntry!, false);
rejected(g, { type: 'decision', reveal: true });
const declined = decide(g, { decline: true });
assert.equal(declined.pendingTerrorEntry, null);
assert.deepEqual(declined.players, g.players);
}
}
});
void test('Assassination is inspectable only by its owner before revelation', () => {
const g = offered();
assert.equal(viewGame(g, 'm').terrorEntry!.kind, 'assassination');
assert.equal(viewGame(g, 'm').terrorEntry!.canReveal, true);
for (const id of ['e', 'a']) {
assert.equal('kind' in viewGame(g, id).terrorEntry!, false);
assert.equal('canReveal' in viewGame(g, id).terrorEntry!, false);
assert.ok(
viewGame(g, id).moritaniTerror!.tokens.every(
(token) => !('kind' in token),
),
);
}
});
void test('Sneak Attack deploys up to five reserves for free and does not consume ordinary shipment or movement', () => {
const g = offered('sneakAttack');
const owner = player(g, 'm');
owner.shipped = true;
owner.moved = 1;
const revealed = decide(g, { reveal: true });
assert.equal(revealed.pendingTerrorEntry?.stage, 'sneakAttack');
assert.equal(
revealed.moritaniTerror!.tokens.find(
(token) => token.kind === 'sneakAttack',
)!.status,
'removed',
);
assert.equal(player(revealed, 'm').reserves, 20);
const done = decide(reload(revealed), { amount: 5 });
assert.equal(player(done, 'm').forces['arrakeen:10'], 5);
assert.equal(player(done, 'm').reserves, 15);
assert.equal(player(done, 'm').spice, 20);
assert.equal(player(done, 'm').shipped, true);
assert.equal(player(done, 'm').moved, 1);
assert.equal(totalForces(done, 'm'), totalForces(g, 'm'));
assert.equal(done.pendingTerrorEntry, null);
assert.equal(done.decision, null);
assert.deepEqual(player(done, 'e'), player(g, 'e'));
rejected(reload(done), { type: 'decision', amount: 5 });
});
void test('Sneak Attack rejects invalid amounts without spending forces and zero completes without a new arrival', () => {
const revealed = decide(offered('sneakAttack'), { reveal: true });
for (const amount of [-1, 6, 1.5, '2', null, undefined, Infinity, NaN])
rejected(revealed, { type: 'decision', amount });
const limited = reload(revealed);
player(limited, 'm').reserves = 2;
rejected(limited, { type: 'decision', amount: 3 });
const deployed = decide(limited, { amount: 2 });
assert.equal(player(deployed, 'm').reserves, 0);
assert.equal(player(deployed, 'm').forces['arrakeen:10'], 2);
const none = decide(reload(revealed), { amount: 0 });
assert.deepEqual(none.players, revealed.players);
assert.equal(none.pendingTerrorEntry, null);
assert.equal(none.decision, null);
const empty = reload(revealed);
player(empty, 'm').reserves = 0;
rejected(empty, { type: 'decision', amount: 1 });
assert.equal(decide(empty, { amount: 0 }).pendingTerrorEntry, null);
});
void test('Sneak Attack respects storm, printed sector, and two-enemy stronghold occupancy', () => {
const revealed = decide(offered('sneakAttack'), { reveal: true });
const storm = reload(revealed);
storm.storm = 10;
rejected(storm, { type: 'decision', amount: 1 });
assert.equal(decide(storm, { amount: 0 }).pendingTerrorEntry, null);
const invalid = reload(revealed);
invalid.pendingTerrorEntry!.sector = 9;
rejected(invalid, { type: 'decision', amount: 1 });
const initial = fixture('sneakAttack');
player(initial, 'a').forces = { 'arrakeen:10': 1 };
player(initial, 'a').reserves = 19;
const crowded = decide(enter(initial), { reveal: true });
rejected(crowded, { type: 'decision', amount: 1 });
assert.equal(decide(crowded, { amount: 0 }).pendingTerrorEntry, null);
});
void test('Sneak Attack reinforces a stronghold already occupied by Moritani without duplicating its prior forces', () => {
const initial = fixture('sneakAttack');
player(initial, 'm').forces = { 'arrakeen:10': 2 };
player(initial, 'm').reserves = 18;
const g = decide(enter(initial), { reveal: true });
const done = decide(reload(g), { amount: 3 });
assert.equal(player(done, 'm').forces['arrakeen:10'], 5);
assert.equal(player(done, 'm').reserves, 15);
assert.equal(totalForces(done, 'm'), 20);
});
void test('unknown native leader identities cannot bypass Assassination; newly verified rosters are eligible', () => {
const unknown = fixture();
player(unknown, 'e').leaders[0].id = 'not-a-catalog-leader';
const offeredUnknown = enter(unknown);
rejected(offeredUnknown, { type: 'decision', reveal: true });
for (const faction of ['choam', 'richese'] as const) {
const known = enter(fixture('assassination', faction));
assert.equal(viewGame(known, 'm').terrorEntry!.canReveal, true);
const malformed = fixture('assassination', faction);
player(malformed, 'e').leaders[0].id = 'unverified-expansion-leader';
const missing = enter(malformed);
rejected(missing, { type: 'decision', reveal: true });
}
});
void test('Sneak Attack rejects an altered client sector and accrues Heighliners once for a positive entry while allowing zero', () => {
const revealed = decide(offered('sneakAttack'), { reveal: true });
rejected(revealed, { type: 'decision', amount: 1, sector: 9 });
const valid = decide(revealed, { amount: 1, sector: 10 });
assert.equal(player(valid, 'm').forces['arrakeen:10'], 1);
const technology = reload(revealed);
technology.techTokens = createTechTokens();
technology.techTokens.heighliners.owner = 'a';
technology.techTokens.heighliners.spice = 2;
const accrued = decide(technology, { amount: 1, sector: 10 });
assert.equal(player(accrued, 'm').forces['arrakeen:10'], 1);
assert.equal(accrued.techTokens!.heighliners.triggeredTurn, accrued.turn);
assert.equal(accrued.techTokens!.heighliners.spice, 1);
const passed = decide(technology, { amount: 0 });
assert.deepEqual(passed.techTokens, technology.techTokens);
assert.deepEqual(passed.players, technology.players);
assert.equal(passed.pendingTerrorEntry, null);
});
void test('Sneak Attack resolves the committed entry and then offers the Bene Gesserit arrival reactions', () => {
const initial = fixture('sneakAttack', 'beneGesserit');
initial.advanced = true;
player(initial, 'e').forces = { 'arrakeen:10': 1 };
player(initial, 'e').reserves = 19;
const revealed = decide(enter(initial), { reveal: true });
const done = decide(revealed, { amount: 1 });
assert.equal(player(done, 'm').forces['arrakeen:10'], 1);
assert.equal(player(done, 'm').reserves, 19);
assert.equal(done.pendingTerrorEntry, null);
assert.equal(done.decision?.kind, 'intrusion');
assert.equal(done.decision?.player, 'e');
const zero = decide(revealed, { amount: 0 });
assert.deepEqual(zero.players, revealed.players);
assert.equal(zero.pendingTerrorEntry, null);
assert.equal(zero.decision, null);
});
void test('declining the Bene Gesserit intrusion still offers the free advisor shipment and completes the entry', () => {
const initial = fixture('sneakAttack', 'beneGesserit');
initial.advanced = true;
player(initial, 'e').forces = { 'arrakeen:10': 1 };
player(initial, 'e').reserves = 19;
const revealed = decide(enter(initial), { reveal: true });
const sneaked = decide(revealed, { amount: 2 });
assert.equal(sneaked.decision?.kind, 'intrusion');
const declined = applyAction(sneaked, 'e', { type: 'decision', accept: false });
assert.equal(declined.decision?.kind, 'advisor');
assert.equal(declined.decision?.player, 'e');
const offered = applyAction(declined, 'e', {
type: 'decision',
accept: true,
amount: 1,
});
assert.equal(offered.response ?? null, null);
assert.equal(offered.pendingTerrorEntry, null);
assert.equal(player(offered, 'm').forces['arrakeen:10'], 2);
assert.equal(
player(offered, 'e').forces['polar_sink:0'] ?? 0,
1,
'The declined intrusion still lets the Bene Gesserit send one free reserve force.',
);
});
void test('only a positive Sneak Attack after ground entry closes native advisor preparation', () => {
// Controlled native entry board; the separate advisor fixture owns public
// setup chronology. No shipment occurs before the actual ground/Terror path.
for (const amount of [0, 1]) {
const g = fixture('sneakAttack');
g.advanced = true;
g.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
g.order.push('b');
g.movementRemaining!.push('b');
g.advisorPreparation = { turn: g.turn, firstShipment: null, deniedTerritories: [] };
player(g, 'e').forces = { 'imperial_basin:10': 1 };
player(g, 'e').reserves = 19;
const entered = applyAction(g, 'e', { type: 'move', from: 'imperial_basin:10',
territory: 'arrakeen', sector: 10, amount: 1 });
assert.equal(entered.advisorPreparation?.firstShipment, null);
assert.equal(player(entered, 'e').shipped, true, 'Ground movement spends the allowance without shipping');
const revealed = decide(entered, { reveal: true });
const done = decide(revealed, { amount });
assert.deepEqual(done.advisorPreparation?.firstShipment,
amount ? { player: 'm', producer: 'sneakAttack' } : null);
assert.equal(player(done, 'm').reserves, 20 - amount);
assert.equal(player(done, 'm').forces['arrakeen:10'] ?? 0, amount);
assert.equal(totalForces(done, 'm'), 20);
if (amount) assert.equal(done.decision?.kind, 'advisor', 'The clock closes before accompaniment');
}
});