Files
Dune/tests/moritani-force-leader.test.ts
T
Quintine 3bebc68cd5 Compose the Sneak Attack arrival with the Bene Gesserit reactions
A positive Moritani Sneak Attack now resolves its committed Terror entry first —
placing the reserve forces and accruing the Heighliners token — and then offers
the Bene Gesserit reactions under the same adopted shipment classification used
for ordinary off-planet arrivals: an eligible Advanced Intrusion choice when
Bene Gesserit fighters stand in the destination, carrying the free
spiritual-advisor follow-up, and the advisor shipment itself when the intrusion
is declined, with its existing territory, storm and occupancy validation. A
zero-force resolution still opens none of them, and the last "Sneak Attack
combined with Bene Gesserit arrival reactions" guard is removed rather than left
as a duplicate check.

This selected order is an implementation interpretation, not a located ruling;
the source note, the pending-interpretation registry, the component inventory,
the player reference and the status log record it. Evidence:
tests/moritani-force-leader.test.ts asserts the post-entry Intrusion offer and
the full decline -> advisor decision -> placement chain with nothing left
pending; the former gated-interaction pin was replaced by the composed
behaviour. Offline suite 7,798/7,798 with typecheck and lint clean, plus the
reference guide tests 10/10. No game, seat, saved state or deployment changes.
2026-10-07 18:53:19 +11:00

443 lines
17 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 { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
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 three for Zoal but leaves unaudited empty live-leader pools unchanged', () => {
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,
);
const empty = fixture();
player(empty, 'e').leaders.forEach((leader) => {
leader.dead = true;
leader.deaths = 2;
});
const entered = enter(empty);
assert.equal(viewGame(entered, 'm').terrorEntry!.canReveal, false);
rejected(entered, { type: 'decision', reveal: true });
assert.equal(
entered.moritaniTerror!.tokens.find(
(token) => token.kind === 'assassination',
)!.status,
'placed',
);
const declined = decide(entered, { decline: true });
assert.deepEqual(declined.players, entered.players);
assert.equal(declined.pendingTerrorEntry, null);
});
void test('unresolved native and foreign custody disables Assassination only in the owner projection and rejects atomically', () => {
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(viewGame(g, 'm').terrorEntry!.canReveal, false);
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('all four AI profiles offer legal Assassination and Sneak Attack decisions', () => {
for (const difficulty of DIFFICULTIES) {
for (const state of [
offered(),
offered('sneakAttack'),
decide(offered('sneakAttack'), { reveal: true }),
]) {
player(state, 'm').bot = difficulty;
const actions = botActions(viewGame(state, 'm'));
assert.ok(
actions.length > 0,
difficulty + ':' + state.pendingTerrorEntry?.stage,
);
for (const action of actions)
assert.doesNotThrow(() => applyAction(state, 'm', action));
}
}
});
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);
assert.equal(viewGame(offeredUnknown, 'm').terrorEntry!.canReveal, false);
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);
assert.equal(viewGame(missing, 'm').terrorEntry!.canReveal, false);
rejected(missing, { type: 'decision', reveal: true });
}
});
void test('advanced Harkonnen tables uniformly disable Assassination without exposing hidden capture changes', () => {
const initial = fixture();
initial.advanced = true;
const harkonnen = newPlayer('h', 'Harkonnen', 'harkonnen');
initial.players.push(harkonnen);
initial.order.push('h');
const empty = enter(initial);
assert.equal(viewGame(empty, 'm').terrorEntry!.canReveal, false);
rejected(empty, { type: 'decision', reveal: true });
const captured = structuredClone(initial);
player(captured, 'e').leaders[0].capturedBy = 'h';
const held = enter(captured);
assert.deepEqual(
viewGame(held, 'm').terrorEntry,
viewGame(empty, 'm').terrorEntry,
);
rejected(held, { 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);
assert.equal(
accrued.log.some((line) =>
line.text.includes('Heighliners accrued 1 spice for Atreides'),
),
true,
);
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('all four AI profiles complete blocked effects by declining instead of retrying illegal choices', () => {
const noLeaders = fixture();
player(noLeaders, 'e').leaders.forEach((leader) => {
leader.dead = true;
});
const blockedAssassination = enter(noLeaders);
const blockedSneak = decide(offered('sneakAttack'), { reveal: true });
blockedSneak.storm = 10;
const blockedOffer = offered('sneakAttack');
blockedOffer.storm = 10;
const emptyOffer = offered('sneakAttack');
player(emptyOffer, 'm').reserves = 0;
assert.ok(viewGame(blockedOffer, 'm').terrorEntry!.sneakAttack?.blocked);
assert.equal(viewGame(blockedOffer, 'm').terrorEntry!.stage, 'offer');
assert.equal(viewGame(emptyOffer, 'm').terrorEntry!.sneakAttack?.maximum, 0);
for (const state of [blockedOffer, emptyOffer]) {
for (const id of ['e', 'a']) {
assert.equal('sneakAttack' in viewGame(state, id).terrorEntry!, false);
assert.equal('kind' in viewGame(state, id).terrorEntry!, false);
}
}
for (const difficulty of DIFFICULTIES) {
for (const state of [
blockedAssassination,
blockedSneak,
blockedOffer,
emptyOffer,
]) {
const g = reload(state);
player(g, 'm').bot = difficulty;
const actions = botActions(viewGame(g, 'm'));
assert.ok(actions.length > 0);
for (const action of actions) {
const done = applyAction(g, 'm', action);
assert.equal(done.pendingTerrorEntry, null);
assert.equal(done.decision, null);
assert.deepEqual(done.players, g.players);
if (state === blockedOffer || state === emptyOffer) {
assert.equal(action.decline, true);
assert.deepEqual(done.moritaniTerror, g.moritaniTerror);
assert.equal(
done.moritaniTerror!.tokens.find(
(token) => token.kind === 'sneakAttack',
)!.status,
'placed',
);
}
}
}
}
assert.ok(viewGame(blockedSneak, 'm').terrorEntry!.sneakAttack?.blocked);
assert.equal(
'sneakAttack' in viewGame(blockedSneak, 'e').terrorEntry!,
false,
);
});