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) => 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'); } });