import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, } from '../game/engine'; import { baseDeck, spiceDeck } from '../game/cards'; import { territory, location } from '../game/board'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; const cards = baseDeck(); const karamas = cards.filter((c) => c.effect === 'karama'); const t = 'the_great_flat'; const key = location(t, territory(t).sectors[0]); const lands = spiceDeck().filter((c) => 'territory' in c); function fixture() { let g = createGame('SUMMON22', newPlayer('f', 'Fremen', 'fremen')); joinGame(g, newPlayer('e', 'Emperor', 'emperor')); joinGame(g, newPlayer('g', 'Guild', 'guild')); g.players.forEach((p) => (p.ready = true)); g = applyAction(g, 'f', { type: 'start' }); for (const p of g.players) if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0], }); g = applyAction(g, 'f', { type: 'fremenSetup', placements: { sietch_tabr: 10 }, }); g.advanced = true; g.phase = 1; g.turn = 2; g.storm = 18; g.spiceDeck = [...lands]; g.spiceDiscard = [[], []]; g.players.forEach((p) => { p.forces = {}; p.reserves = 20; p.hand = []; }); g.players[0].hand = [karamas[0]]; g.players[0].forces = { [key]: 4 }; g.players[0].reserves = 16; g.players[0].elites = { reserves: 2, tanks: 0, forces: { [key]: 1 }, revived: 0, }; g.players[1].forces = { [key]: 5 }; g.players[1].reserves = 15; g.spice[key] = 8; return g; } const summon = (g: Game, target = t) => applyAction(g, 'f', { type: 'card', mode: 'special', card: karamas[0].id, territory: target, }); function allow(state: Game) { let g = state; while (g.response) g = applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); return g; } function ready(state: Game) { let g = state; for (const p of state.players) if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' }); return g; } function finish(state: Game) { let g = allow(state); g = ready(g); while (g.decision?.kind === 'wormRide') g = applyAction(g, 'f', { type: 'decision', accept: false }); return g; } void test('special worm devours forces and spice, preserves Fremen elites and pauses before changing either spice pile', () => { const before = fixture(); before.players[2].hand = [karamas[1]]; let g = summon(before); assert.equal(g.response?.kind, 'wormSurvival'); assert.equal(g.players[0].specialKaramaUsed, true); assert.equal(g.players[1].tanks, 0); g = allow(g); assert.equal(g.players[1].tanks, 5); assert.equal(g.players[0].forces[key], 4); assert.equal(g.players[0].elites?.forces[key], 1); assert.equal(g.spice[key], undefined); assert.equal(g.nexus, false); assert.equal(g.summonedBeforeBlow, true); assert.deepEqual(g.spiceDeck, before.spiceDeck); assert.deepEqual(g.spiceDiscard, before.spiceDiscard); assert.deepEqual(g.wormRides, [t]); assert.equal(g.discard.filter((c) => c.id === karamas[0].id).length, 1); assert.deepEqual(before.players[1].forces, { [key]: 5 }); }); void test('before-draw summon defers Nexus until the first blow is accepted, then rides precede the second blow', () => { const before = fixture(); before.ready = ['e']; let g = allow(summon(before)); assert.equal(g.summonedWorm, null); assert.equal(g.nexus, false); assert.deepEqual(g.ready, []); assert.throws( () => applyAction(g, 'f', { type: 'alliance', target: 'e' }), /Nexus/, ); g = ready(g); assert.ok(g.spiceWindow?.event); assert.deepEqual(g.spiceWindow, { ...lands[0], event:g.spiceWindow.event, harvested: false }); assert.equal(g.nexus, true); g = ready(g); assert.equal(g.spiceWindow, null); assert.equal(g.decision, null); g = ready(g); assert.equal(g.decision?.kind, 'wormRide'); g = applyAction(g, 'f', { type: 'decision', accept: true, territory: 'polar_sink', sector: 0, forces: { [key]: 3 }, eliteForces: { [key]: 1 }, }); assert.equal(g.players[0].forces['polar_sink:0'], 3); assert.equal(g.players[0].elites?.forces['polar_sink:0'], 1); assert.ok(g.spiceWindow?.event); assert.deepEqual(g.spiceWindow, { ...lands[1], event:g.spiceWindow.event, harvested: false }); g = ready(g); assert.equal(g.phase, 2); }); void test('normal Karama can cancel Fremen survival without canceling the special summon or Nexus', () => { const before = fixture(); before.players[2].hand = [karamas[1]]; let g = summon(before); g = applyAction(g, 'g', { type: 'card', mode: 'cancel', card: karamas[1].id, }); assert.equal(g.players[0].tanks, 4); assert.equal(g.players[0].elites?.tanks, 1); assert.equal(g.players[1].tanks, 5); assert.equal(g.nexus, false); assert.equal(g.summonedBeforeBlow, true); assert.deepEqual(g.wormRides, []); g = ready(g); assert.equal(g.summonedWorm, null); assert.equal(g.phase, 1); }); void test('ally protection and Fremen survival retain separate cancellation windows', () => { const before = fixture(); before.players[2].hand = [karamas[1]]; before.players[0].ally = 'e'; before.players[1].ally = 'f'; let g = summon(before); assert.equal(g.decision?.kind, 'wormProtection'); g = applyAction(g, 'f', { type: 'decision', accept: true }); assert.equal(g.response?.kind, 'wormAllyProtection'); const protectedGame = allow(g); assert.equal(protectedGame.players[1].forces[key], 5); g = applyAction(g, 'g', { type: 'card', mode: 'cancel', card: karamas[1].id, }); assert.equal(g.response, null); // No cancellation card remains for the separate survival power. g = allow(g); assert.equal(g.players[1].tanks, 5); assert.equal(g.players[0].tanks, 0); }); void test('summoning during an open Nexus merges its ride and clears old confirmations without creating another spice draw', () => { const before = fixture(); before.nexus = true; before.ready = ['e']; before.spiceSequence = { pile: 1, skipped: [] }; before.wormRides = ['red_chasm']; const g = allow(summon(before)); assert.equal(g.summonedWorm, null); assert.equal(g.nexus, true); assert.deepEqual(g.ready, []); assert.deepEqual(g.wormRides, ['red_chasm', t]); assert.deepEqual(g.spiceSequence, before.spiceSequence); assert.deepEqual(g.spiceDeck, before.spiceDeck); }); void test('an interrupted fresh blow and its pile position persist, but devoured spice cannot be harvested afterward', () => { const before = fixture(); const blow = lands.find((c) => c.territory === t)!; before.spiceWindow = { ...blow, harvested: false }; before.spiceSequence = { pile: 1, skipped: [] }; before.players[1].hand = [cards.find((c) => c.effect === 'harvester')!]; const g = allow(JSON.parse(JSON.stringify(summon(before)))); assert.equal(g.spiceSequence?.pile, 1); assert.equal(g.spiceWindow?.harvested, true); assert.equal(g.spiceWindow?.territory, t); assert.throws( () => applyAction(g, 'e', { type: 'card', card: g.players[1].hand[0].id }), /immediately after a spice blow/, ); }); void test('suspended responses preserve passes and private fields without projection leakage', () => { const before = fixture(); before.players[2].hand = [karamas[1]]; before.response = { kind: 'wormSurvival', owner: 'f', location: 'red_chasm', passed: ['e'], }; before.spiceSequence = { pile: 0, skipped: [] }; before.spiceResolution = { skipped: [] }; before.nexus = true; const pending = summon(before); const view = viewGame(pending, 'e'); assert.deepEqual(view.summonedWorm, { territory: t }); assert.equal('resume' in view.summonedWorm!, false); let g = pending; while (g.summonedWorm) g = applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); assert.deepEqual(g.response, before.response); assert.deepEqual(g.spiceResolution, before.spiceResolution); assert.deepEqual(g.spiceSequence, before.spiceSequence); assert.equal(g.nexus, true); }); void test('the called worm can interrupt a pending additional-worm choice without consuming a draw or its placement allowance', () => { const before = fixture(); before.decision = { kind: 'wormPlacement', player: 'f' }; before.spiceResolution = { skipped: [] }; before.spiceSequence = { pile: 1, skipped: [] }; before.nexus = true; before.wormPlacementCanceledTurn = before.turn; const g = allow(summon(before)); assert.deepEqual(g.decision, before.decision); assert.deepEqual(g.spiceDeck, before.spiceDeck); assert.equal(g.wormPlacementCanceledTurn, before.turn); }); void test('invalid territory, phase, basic mode and repeated special use are atomic', () => { const before = fixture(); for (const target of ['arrakeen', 'polar_sink', 'unknown']) assert.throws(() => summon(before, target), /sand territory/); const wrong = structuredClone(before); wrong.phase = 5; assert.throws(() => summon(wrong), /Spice Blow/); wrong.phase = 1; wrong.advanced = false; assert.throws(() => summon(wrong), /advanced game/); assert.equal(before.players[0].hand[0].id, karamas[0].id); const g = finish(summon(before)); g.turn++; g.players[0].hand = [karamas[0]]; assert.throws(() => summon(g), /already been used/); }); void test('first-turn special summons are not discarded as first-turn drawn worm cards', () => { const before = fixture(); before.turn = 1; const g = allow(summon(before)); assert.equal(g.players[1].tanks, 5); assert.equal(g.summonedBeforeBlow, true); assert.deepEqual(g.spiceDeck, before.spiceDeck); }); void test('all AI difficulties use public sand-territory threats and can continue the summoned-worm sequence', () => { for (const difficulty of DIFFICULTIES) { const before = fixture(); before.players[0].bot = difficulty; const action = botActions(viewGame(before, 'f'))[0]; assert.equal(action.mode, 'special'); assert.equal(action.territory, t); const g = applyAction(before, 'f', action); assert.equal(g.response, null); assert.equal(g.summonedWorm, null); assert.equal(g.players[1].tanks, 5); assert.equal(g.players[0].tanks, 0); const other = structuredClone(before); other.players[1].hand = cards.slice(0, 4); other.spiceDeck.reverse(); assert.deepEqual(botActions(viewGame(other, 'f'))[0], action); } }); void test('a summon while a ride is awaiting input reopens Nexus before preserving both ride opportunities', () => { const before = fixture(); before.nexus = true; before.spiceSequence = { pile: 1, skipped: [] }; before.decision = { kind: 'wormRide', player: 'f', territory: t }; before.ready = before.players.map((p) => p.id); let g = allow(summon(before)); assert.equal(g.decision, null); assert.equal(g.nexus, true); assert.deepEqual(g.ready, []); assert.deepEqual(g.wormRides, [t, t]); g = ready(g); g = applyAction(g, 'f', { type: 'decision', accept: true, territory: 'polar_sink', sector: 0, forces: { [key]: 2 }, eliteForces: { [key]: 1 }, }); assert.equal(g.decision?.kind, 'wormRide'); assert.equal(g.players[0].forces[key], 2); g = applyAction(g, 'f', { type: 'decision', accept: false }); assert.equal(g.phase, 2); }); void test('summoning during a ride intrusion resolves that decision before reopening Nexus and offering the new ride', () => { const before = fixture(); const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit'); bg.forces = { 'carthag:11': 1 }; before.players[0].forces['carthag:11'] = 1; before.players[0].reserves--; bg.reserves = 19; before.players.push(bg); before.order.push('b'); before.nexus = true; before.spiceSequence = { pile: 1, skipped: [] }; before.decision = { kind: 'intrusion', player: 'b', territory: 'carthag', wormRide: true, }; let g = allow(summon(before)); assert.equal(g.decision?.kind, 'intrusion'); assert.equal(g.summonedNexusBeforeRides, true); g = applyAction(g, 'b', { type: 'decision', accept: false }); assert.equal(g.decision, null); assert.equal(g.nexus, true); assert.equal(g.summonedNexusBeforeRides, false); assert.deepEqual(g.ready, []); g = ready(g); assert.equal(g.decision?.kind, 'wormRide'); }); void test('a suspended Worthless conversion and its original worm response are restored together and remain private', () => { const before = fixture(); before.players[2].hand = [karamas[1]]; const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit'); const worthless = cards.find((c) => c.kind === 'worthless')!; bg.hand = [worthless]; before.players.push(bg); before.order.push('b'); before.response = { kind: 'wormSurvival', owner: 'f', location: 'red_chasm', passed: ['e'], }; before.spiceResolution = { skipped: [] }; before.spiceSequence = { pile: 0, skipped: [] }; before.nexus = true; const converting = applyAction(before, 'b', { type: 'card', mode: 'cancel', card: worthless.id, }); let g = summon(converting); assert.equal(g.pendingKarama, null); assert.equal('resume' in viewGame(g, 'e').summonedWorm!, false); while (g.summonedWorm) g = applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); assert.deepEqual(g.response, converting.response); assert.deepEqual(g.pendingKarama, converting.pendingKarama); assert.equal('pendingKarama' in viewGame(g, 'e'), false); });