import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import { baseDeck } from '../game/cards'; import { createTerrorState, placeTerror, type TerrorKind, } from '../game/moritani-terror'; import type { FactionId } from '../game/catalog'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); const decision = (g: Game, id: string, extra: Omit) => applyAction(g, id, { type: 'decision', ...extra }); function fixture(kind: TerrorKind = 'robbery', entrant: FactionId = 'emperor') { const g = createGame('TERROR03', newPlayer('m', 'Moritani', 'moritani')); g.players.push( newPlayer('e', 'Entrant', entrant), newPlayer('a', 'Atreides', 'atreides'), newPlayer('f', 'Fremen', 'fremen'), ); g.status = 'playing'; g.phase = 5; g.turn = 2; g.storm = 18; g.active = 'e'; g.order = ['e', 'm', 'a', 'f']; g.movementRemaining = [...g.order]; g.deck = baseDeck(); 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( (candidate) => candidate.kind === kind, )!; g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1); return g; } const enter = (g = fixture()) => applyAction(g, 'e', { type: 'ship', amount: 2, territory: 'arrakeen', sector: 10, }); const offer = (g = enter()) => decision(g, 'm', { alliance: true }); function allow(state: Game) { let g = state; for (let i = 0; g.response && i < 20; i++) { const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id; g = applyAction(g, id, { type: 'passResponse' }); } assert.equal(g.response, null); return g; } function rejected(g: Game, id: string, action: Action) { const before = structuredClone(g); assert.throws(() => applyAction(g, id, action)); assert.deepEqual(g, before); } function hold(g: Game, id: string, effect: string) { const index = g.deck.findIndex((card) => card.effect === effect); assert.ok(index >= 0); const card = g.deck.splice(index, 1)[0]; player(g, id).hand.push(card); return card; } void test('Enemy of My Enemy opens a public Karama opportunity before allowing only the entrant to reply', () => { const initial = fixture(); hold(initial, 'a', 'karama'); const entered = enter(initial); assert.equal(viewGame(entered, 'm').terrorEntry!.canOfferAlliance, true); assert.equal( 'canOfferAlliance' in viewGame(entered, 'e').terrorEntry!, false, ); rejected(entered, 'e', { type: 'decision', alliance: true }); const declared = offer(entered); assert.equal(declared.response?.kind, 'moritaniAlliance'); assert.equal(declared.response?.owner, 'm'); assert.deepEqual(declared.players, entered.players); assert.deepEqual(declared.moritaniTerror, entered.moritaniTerror); rejected(declared, 'e', { type: 'decision', accept: true }); const reply = allow(reload(declared)); assert.equal(reply.pendingTerrorEntry?.stage, 'allianceReply'); assert.deepEqual(reply.decision, { kind: 'moritaniTerror', player: 'e', entrant: 'e', territory: 'arrakeen', }); rejected(reply, 'm', { type: 'decision', accept: true }); rejected(reply, 'a', { type: 'decision', accept: false }); rejected(reply, 'e', { type: 'decision', decline: true }); rejected(reply, 'e', { type: 'decision', accept: 'true' }); assert.equal('kind' in viewGame(reply, 'e').terrorEntry!, false); }); void test('acceptance outside Nexus joins both factions and returns the token hidden without repeating shipment', () => { const initial = fixture(); player(initial, 'm').forces = { 'arrakeen:10': 1 }; player(initial, 'm').reserves = 19; const entered = enter(initial); const before = structuredClone(entered.players); const done = decision(reload(allow(offer(entered))), 'e', { accept: true }); assert.equal(done.nexus, false); assert.equal(player(done, 'm').ally, 'e'); assert.equal(player(done, 'e').ally, 'm'); for (const viewer of done.players) { const projection = viewGame(done, viewer.id); for (const member of ['m', 'e']) assert.equal( projection.players.find((p) => p.id === member)!.allySinceTurn, done.turn, ); } assert.equal(player(done, 'e').forces['arrakeen:10'], 2); assert.equal(player(done, 'm').forces['arrakeen:10'], 1); assert.equal(player(done, 'e').spice, 18); assert.equal(player(done, 'e').reserves, 18); assert.equal(player(done, 'e').shipped, true); assert.equal(done.active, 'e'); for (const old of before) { const current = player(done, old.id); assert.deepEqual( current, ['m', 'e'].includes(old.id) ? { ...old, ally: old.id === 'm' ? 'e' : 'm', allySinceTurn: done.turn } : old, ); } assert.equal(done.pendingTerrorEntry, null); assert.equal(done.decision, null); assert.equal(done.response, null); assert.ok( done.moritaniTerror!.tokens.every( (token) => token.status === 'available' && token.location === null, ), ); assert.ok( done.moritaniTerror!.tokens.every( (token) => !entered.moritaniTerror!.tokens.some((old) => old.id === token.id), ), ); assert.deepEqual(viewGame(done, 'a').moritaniTerror!.tokens, []); assert.equal(done.moritaniTerror!.placementTurn, 1); rejected(done, 'e', { type: 'decision', accept: true }); rejected(done, 'e', { type: 'ship', amount: 2, territory: 'carthag', sector: 11, }); }); void test('acceptance detaches both previous partners and refunds broken-pair escrow exactly once while preserving unrelated funding', () => { let g = fixture(); g.players.push( newPlayer('x', 'Ixians', 'ixians'), newPlayer('t', 'Tleilaxu', 'tleilaxu'), ); g.order.push('x', 't'); for (const id of ['x', 't']) player(g, id).spice = 20; for (const [a, b] of [ ['m', 'a'], ['e', 'f'], ['x', 't'], ]) { player(g, a).ally = b; player(g, b).ally = a; } for (const [id, amount] of [ ['m', 3], ['a', 4], ['e', 5], ['f', 6], ['x', 7], ] as const) g = applyAction(g, id, { type: 'pledgeAid', amount }); g.allianceOffers = { m: 'a', a: 'm', e: 'f', f: 'e', x: 't', t: 'x' }; const entered = enter(g); const pending = allow(offer(entered)); assert.equal(player(pending, 'm').ally, 'a'); assert.deepEqual(pending.aid, entered.aid); const done = decision(reload(pending), 'e', { accept: true }); assert.deepEqual( done.players.map((p) => [p.id, p.ally]), [ ['m', 'e'], ['e', 'm'], ['a', null], ['f', null], ['x', 't'], ['t', 'x'], ], ); assert.deepEqual(done.aid, { x: { recipient: 't', amount: 7 } }); assert.deepEqual(done.allianceOffers, { x: 't', t: 'x' }); for (const id of ['m', 'a', 'f', 't']) assert.equal(player(done, id).spice, 20); assert.equal(player(done, 'e').spice, 18); assert.equal(player(done, 'x').spice, 13); rejected(reload(done), 'e', { type: 'decision', accept: true }); }); void test('refusing an offered alliance immediately reveals Robbery and does not restore the optional decline', () => { const pending = allow(offer()); const refused = decision(reload(pending), 'e', { accept: false }); assert.equal(refused.pendingTerrorEntry?.stage, 'robbery'); assert.equal(refused.decision?.player, 'm'); assert.equal( refused.moritaniTerror!.tokens.find((token) => token.kind === 'robbery')! .status, 'removed', ); assert.equal(player(refused, 'e').ally, null); assert.equal(player(refused, 'm').ally, null); rejected(refused, 'm', { type: 'decision', decline: true }); const done = decision(refused, 'm', { choice: 'spice' }); assert.equal(player(done, 'm').spice, 29); assert.equal(player(done, 'e').spice, 9); assert.equal(done.pendingTerrorEntry, null); }); void test('a refused Sabotage offer discards once and retains only its optional gift decision after reload', () => { const initial = fixture('sabotage'); player(initial, 'e').hand.push(...initial.deck.splice(0, 2)); player(initial, 'm').hand.push(initial.deck.shift()!); const pending = allow(offer(enter(initial))); const refused = decision(reload(pending), 'e', { accept: false }); assert.equal(player(refused, 'e').hand.length, 1); assert.equal(refused.discard.length, 1); assert.equal(refused.pendingTerrorEntry?.stage, 'gift'); rejected(reload(refused), 'e', { type: 'decision', accept: false }); const done = decision(reload(refused), 'm', { decline: true }); assert.deepEqual(done.discard, refused.discard); assert.equal(done.pendingTerrorEntry, null); }); void test('Karama cancels the alliance offer while preserving reveal or decline and blocking repeated offers', () => { const initial = fixture(); const karama = hold(initial, 'a', 'karama'); const entered = enter(initial); const pending = offer(entered); const canceled = applyAction(reload(pending), 'a', { type: 'card', card: karama.id, mode: 'cancel', }); assert.equal(canceled.response, null); assert.equal(canceled.pendingTerrorEntry?.stage, 'offer'); assert.equal(canceled.decision?.player, 'm'); assert.equal(viewGame(canceled, 'm').terrorEntry!.canOfferAlliance, false); assert.ok(viewGame(canceled, 'm').terrorEntry!.allianceBlockedReason); assert.deepEqual(canceled.moritaniTerror, entered.moritaniTerror); assert.equal( canceled.discard.filter((card) => card.id === karama.id).length, 1, ); rejected(canceled, 'm', { type: 'decision', alliance: true }); const declined = decision(reload(canceled), 'm', { decline: true }); assert.equal(declined.pendingTerrorEntry, null); assert.deepEqual(declined.moritaniTerror, canceled.moritaniTerror); const revealed = decision(reload(canceled), 'm', { reveal: true }); assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery'); }); void test('Ecaz entrants cannot be offered Enemy of My Enemy', () => { const g = enter(fixture('robbery', 'ecaz')); assert.equal(viewGame(g, 'm').terrorEntry!.canOfferAlliance, false); rejected(g, 'm', { type: 'decision', alliance: true }); assert.equal(decision(g, 'm', { decline: true }).pendingTerrorEntry, null); }); void test('Extortion refusal reserves the award while acceptance returns the token without collecting it', () => { const entered = enter(fixture('extortion')); assert.equal(viewGame(entered, 'm').terrorEntry!.canOfferAlliance, true); const declined = decision(reload(allow(offer(entered))), 'e', { accept: false }); assert.equal(declined.moritaniExtortion?.bank.reserved, 5); assert.equal(player(declined, 'm').spice, player(entered, 'm').spice); assert.equal(declined.moritaniTerror!.tokens.find(token => token.kind === 'extortion')?.status, 'extortion'); const accepted = decision(reload(allow(offer(entered))), 'e', { accept: true }); assert.equal(accepted.moritaniExtortion, undefined); assert.equal(player(accepted, 'm').spice, player(entered, 'm').spice); assert.equal(accepted.moritaniTerror!.tokens.find(token => token.kind === 'extortion')?.status, 'available'); assert.equal(viewGame(accepted, 'e').extortion.deferred, 0); }); void test('alliance declarations and replies do not expose different hidden Terror faces', () => { const initial = fixture(); const alternative = structuredClone(initial); const robbery = alternative.moritaniTerror!.tokens.find( (token) => token.kind === 'robbery', )!; const sabotage = alternative.moritaniTerror!.tokens.find( (token) => token.kind === 'sabotage', )!; [robbery.kind, sabotage.kind] = [sabotage.kind, robbery.kind]; const first = offer(enter(initial)); const second = offer(enter(alternative)); assert.deepEqual(viewGame(first, 'e'), viewGame(second, 'e')); assert.deepEqual(viewGame(allow(first), 'a'), viewGame(allow(second), 'a')); }); void test('alliance acceptance resumes a suspended worm ride queue once after the token returns', () => { let g = fixture(); player(g, 'f').forces = { 'imperial_basin:10': 2, 'hagga_basin:11': 1 }; player(g, 'f').reserves = 17; g.phase = 1; g.active = null; g.decision = { kind: 'wormRide', player: 'f', territory: 'imperial_basin' }; g.wormRides = ['hagga_basin']; g = applyAction(g, 'f', { type: 'decision', accept: true, territory: 'arrakeen', sector: 10, forces: { 'imperial_basin:10': 2 }, }); const pending = allow(offer(g)); assert.deepEqual(pending.wormRides, ['hagga_basin']); const done = decision(reload(pending), 'f', { accept: true }); assert.equal(player(done, 'f').ally, 'm'); assert.deepEqual(done.wormRides, []); assert.deepEqual(done.decision, { kind: 'wormRide', player: 'f', territory: 'hagga_basin', }); assert.equal(player(done, 'f').forces['arrakeen:10'], 2); assert.equal(done.pendingTerrorEntry, null); }); void test('stale alliance response metadata rejects settlement without partial alliances or token return', () => { const initial = fixture(); hold(initial, 'a', 'karama'); const waiting = offer(enter(initial)); for (const field of ['turn', 'phase'] as const) { const stale = reload(waiting); stale.pendingTerrorEntry![field]--; stale.response!.passed = stale.players.slice(1).map((p) => p.id); rejected(stale, stale.players[0].id, { type: 'passResponse' }); const reply = allow(waiting); reply.pendingTerrorEntry![field]--; rejected(reply, 'e', { type: 'decision', accept: true }); rejected(reply, 'e', { type: 'decision', accept: false }); } }); void test('nested Worthless-as-Karama cancellation restores the original alliance window without premature acceptance', () => { const initial = fixture('robbery', 'beneGesserit'); initial.advanced = true; const worthlessIndex = initial.deck.findIndex( (card) => card.kind === 'worthless', ); const worthless = initial.deck.splice(worthlessIndex, 1)[0]; player(initial, 'e').hand.push(worthless); const karama = hold(initial, 'a', 'karama'); const pending = offer(enter(initial)); const converted = applyAction(pending, 'e', { type: 'card', card: worthless.id, mode: 'cancel', }); assert.equal(converted.response?.kind, 'worthlessKarama'); assert.equal(converted.pendingTerrorEntry?.stage, 'allianceResponse'); assert.deepEqual(converted.moritaniTerror, pending.moritaniTerror); assert.equal(player(converted, 'm').ally, null); const restored = applyAction(reload(converted), 'a', { type: 'card', card: karama.id, mode: 'cancel', }); assert.equal( restored.response, null, 'spent cancellation cards leave no response choice', ); assert.equal(restored.pendingTerrorEntry?.stage, 'allianceReply'); assert.equal(restored.pendingKarama, null); assert.deepEqual(restored.moritaniTerror, pending.moritaniTerror); assert.equal( restored.discard.filter((card) => card.id === worthless.id).length, 1, ); assert.equal( restored.discard.filter((card) => card.id === karama.id).length, 1, ); const accepted = decision(allow(reload(restored)), 'e', { accept: true }); assert.equal(player(accepted, 'm').ally, 'e'); assert.equal(accepted.pendingTerrorEntry, null); const allowedConversion = allow(reload(converted)); assert.equal(allowedConversion.pendingTerrorEntry?.stage, 'offer'); assert.equal( viewGame(allowedConversion, 'm').terrorEntry!.canOfferAlliance, false, ); assert.deepEqual(allowedConversion.moritaniTerror, pending.moritaniTerror); }); void test('new allies survive their current movement finish and must separate on the following turn', () => { const initial = fixture(); player(initial, 'm').forces = { 'arrakeen:10': 1 }; player(initial, 'm').reserves = 19; player(initial, 'm').shipped = true; player(initial, 'm').moved = 1; initial.movementRemaining = ['e', 'a', 'f']; const accepted = decision(allow(offer(enter(initial))), 'e', { accept: true, }); assert.equal(player(accepted, 'm').allySinceTurn, 2); assert.equal(player(accepted, 'e').allySinceTurn, 2); const currentTurn = applyAction(reload(accepted), 'e', { type: 'endMovement', }); assert.equal(player(currentTurn, 'e').forces['arrakeen:10'], 2); assert.equal(player(currentTurn, 'e').tanks, 0); assert.equal(player(currentTurn, 'm').forces['arrakeen:10'], 1); const next = reload(accepted); next.turn++; next.active = 'e'; next.movementRemaining = ['e', 'a', 'f']; const separated = applyAction(next, 'e', { type: 'endMovement' }); assert.equal(player(separated, 'e').forces['arrakeen:10'] ?? 0, 0); assert.equal(player(separated, 'e').tanks, 2); assert.equal(player(separated, 'm').forces['arrakeen:10'], 1); }); void test('ordinary Nexus formation stamps both allies with the same creation turn', () => { let g = fixture(); g.phase = 1; g.nexus = true; g = applyAction(g, 'm', { type: 'alliance', target: 'e' }); g = applyAction(g, 'e', { type: 'alliance', target: 'm' }); assert.equal(player(g, 'm').ally, 'e'); assert.equal(player(g, 'e').ally, 'm'); assert.equal(player(g, 'm').allySinceTurn, g.turn); assert.equal(player(g, 'e').allySinceTurn, g.turn); }); void test('returning an alliance token preserves other placed and removed token identities', () => { const initial = fixture(); const placed = initial.moritaniTerror!.tokens.find( (token) => token.kind === 'sabotage', )!; initial.moritaniTerror = placeTerror( initial.moritaniTerror!, placed.id, 'carthag', 2, ); const removed = initial.moritaniTerror!.tokens.find( (token) => token.kind === 'atomics', )!; removed.status = 'removed'; const unchanged = initial.moritaniTerror!.tokens.filter( (token) => token.id === placed.id || token.id === removed.id, ); const done = decision(allow(offer(enter(initial))), 'e', { accept: true }); assert.deepEqual( done.moritaniTerror!.tokens.filter( (token) => token.id === placed.id || token.id === removed.id, ), unchanged, ); assert.equal( done.moritaniTerror!.tokens.filter((token) => token.status === 'available') .length, 4, ); assert.equal(done.moritaniTerror!.placementTurn, 2); assert.equal('supplyEpoch' in viewGame(done, 'a').moritaniTerror!, false); }); void test('all four AI profiles own only their alliance decisions and finish every supported alliance continuation from private views', () => { const ownerOffer = enter(); const response = offer(ownerOffer); const entrantReply = allow(response); const refused = decision(entrantReply, 'e', { accept: false }); const cancelInitial = fixture(); const karama = hold(cancelInitial, 'a', 'karama'); const canceled = applyAction(offer(enter(cancelInitial)), 'a', { type: 'card', card: karama.id, mode: 'cancel', }); for (const difficulty of DIFFICULTIES) { for (const state of [ ownerOffer, response, entrantReply, refused, canceled, ]) { let g = reload(state); for (const p of g.players) p.bot = difficulty; for (let step = 0; g.pendingTerrorEntry && step < 40; step++) { const choices = g.players.map((p) => ({ id: p.id, actions: botActions(viewGame(g, p.id)), })); if (g.decision) { for (const choice of choices) { if (choice.id === g.decision.player) assert.ok( choice.actions.length > 0, difficulty + ':' + g.pendingTerrorEntry.stage, ); else assert.deepEqual( choice.actions, [], 'Only the pending decision owner may act.', ); } } for (const choice of choices) for (const action of choice.actions) assert.doesNotThrow(() => applyAction(g, choice.id, action)); const next = choices.find((choice) => choice.actions.length > 0); assert.ok( next, difficulty + ':' + g.pendingTerrorEntry.stage + ' cannot stall', ); g = reload(applyAction(g, next.id, next.actions[0])); } assert.equal( g.pendingTerrorEntry, null, difficulty + ':' + state.pendingTerrorEntry!.stage, ); assert.equal(g.decision, null); assert.equal(g.response, null); assert.equal(player(g, 'e').forces['arrakeen:10'], 2); assert.equal(player(g, 'e').reserves, 18); } } });