import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, RuleError, type Game, type Action, } from '../game/engine'; import { baseDeck } from '../game/cards'; import { TERRITORIES, gameDistance, splitLocation } from '../game/board'; import { forceRevivalQuote, newRevivalRules } from '../game/revival'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { richeseCards } from '../game/richese-cards'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; function fixture(advanced = false) { const g = createGame('CWORTH22', newPlayer('c', 'CHOAM', 'choam'), advanced, [ 'choam', ]); g.players.push( newPlayer('e', 'Emperor', 'emperor'), newPlayer('b', 'Bene Gesserit', 'beneGesserit'), ); g.status = 'playing'; g.phase = 4; g.active = 'c'; g.order = ['c', 'e', 'b']; g.storm = 18; g.deck = baseDeck(); g.revivalRules = newRevivalRules(); for (const p of g.players) { p.spice = 10; p.tanks = 6; p.reserves = 14; p.traitorChoices = []; } hold(g, 'c', 'Kulon'); hold(g, 'c', 'La La La'); return g; } const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const send = (g: Game, id: string, a: Action) => applyAction(g, id, a); function hold(g: Game, id: string, name: string) { const i = g.deck.findIndex((c) => c.name === name); assert.ok(i >= 0); const c = g.deck.splice(i, 1)[0]; player(g, id).hand.push(c); return c.id; } const named = (g: Game, name: string) => player(g, 'c').hand.find((c) => c.name === name)!.id; const play = (g: Game, name: string, target?: string) => send(g, 'c', { type: 'card', mode: 'choam', card: named(g, name), target }); function contest(g: Game, id = 'e') { hold(g, id, 'Karama'); return g; } function allow(state: Game) { let g = state; for (let n = 0; g.response && n < 30; n++) g = send( g, g.players.find( (p) => !viewGame(g, p.id).responseControls?.hasPassed && !!viewGame(g, p.id).responseControls?.cancelCards.length, )!.id, { type: 'passResponse', }, ); assert.equal(g.response, null); return g; } function movement(ornithopters = false) { const g = fixture(); g.phase = 5; player(g, 'c').forces = { 'red_chasm:7': 3 }; player(g, 'c').reserves = 11; if (ornithopters) { player(g, 'c').forces['arrakeen:10'] = 1; player(g, 'c').reserves--; } return g; } function destination(g: Game, range: number) { const target = TERRITORIES.flatMap((t) => t.sectors.map((s) => `${t.id}:${s}`), ).find( (key) => splitLocation(key).sector !== g.storm && gameDistance( g, 'red_chasm:7', key, (k) => splitLocation(k).sector === g.storm, ) === range, ); assert.ok(target, `range ${range}`); return splitLocation(target); } const move = (g: Game, range: number) => { const to = destination(g, range); return send(g, 'c', { type: 'move', forces: { 'red_chasm:7': 2 }, territory: to.territory, sector: to.sector, }); }; void test('Kulon adds one range in basic and advanced play only after its Karama response', () => { for (const advanced of [false, true]) { let g = contest(movement()); g.advanced = advanced; assert.throws(() => move(g, 2), /more than 1/); const card = named(g, 'Kulon'); g = play(g, 'Kulon'); assert.equal(g.response?.kind, 'choamWorthless'); assert.ok(player(g, 'c').hand.some((c) => c.id === card)); g = allow(g); assert.equal(g.choamMovement?.bonus, 1); assert.ok(g.discard.some((c) => c.id === card)); assert.throws(() => move(g, 3), /more than 2/); g = move(g, 2); assert.equal(player(g, 'c').moved, 1); assert.throws(() => move(g, 1), /movement is not available/); } }); void test('clean Kulon discard offers Semuta before the one earned movement bonus', () => { const initial = movement(); initial.players[1] = newPlayer('e', 'Richese', 'richese'); initial.richeseCache = richeseCards(); initial.semutaPreview = true; const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(index >= 0); const semuta = initial.richeseCache.splice(index, 1)[0]; player(initial, 'e').hand.push(semuta); contest(initial, 'b'); const card = named(initial, 'Kulon'); const physical = (state: Game) => [ ...state.deck, ...state.discard, ...state.richeseCache!, ...state.players.flatMap(p => p.hand), ].map(c => c.id).sort(); const original = physical(initial); const absent = structuredClone(initial); player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]]; absent.richeseCache!.push(semuta); const offered = (state: Game) => allow(play(state, 'Kulon')); const pending = offered(initial), neutral = offered(absent); const event = pending.pendingTreacheryDiscard!.batch.event; assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamKulonDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(pending.choamMovement, undefined); assert.equal(pending.discard.filter(c => c.id === card).length, 1); assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c')); assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b')); assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true); assert.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit'); assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending); assert.throws(() => move(pending, 2)); for (const change of [ (state: Game) => { const c = state.pendingTreacheryDiscard!.continuation; if (c.kind === 'choamKulonDiscard') c.before = 3; }, (state: Game) => { state.choamMovement = { turn: state.turn, bonus: 1 }; }, (state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; }, ]) { const corrupt: Game = JSON.parse(JSON.stringify(pending)); change(corrupt); const before = JSON.stringify(corrupt); assert.throws(() => applyAction(corrupt, 'e', { type: 'semutaCommit', event })); assert.equal(JSON.stringify(corrupt), before); } let declined: Game = JSON.parse(JSON.stringify(pending)); for (const id of ['c', 'b', 'e']) declined = applyAction(declined, id, { type: 'semutaPass', event }); const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e', { type: 'semutaCommit', event }); for (const state of [declined, claimed]) { assert.equal(state.pendingTreacheryDiscard, null); assert.deepEqual(state.choamMovement, { turn: state.turn, bonus: 1 }); assert.deepEqual(physical(state), original); assert.equal(player(move(state, 2), 'c').moved, 1); assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event })); } assert.equal(declined.discard.filter(c => c.id === card).length, 1); assert.equal(claimed.players[1].hand.filter(c => c.id === card).length, 1); assert.equal(claimed.discard.filter(c => c.id === semuta.id).length, 1); }); void test('Kulon adds to ornithopters and keeps the usual storm and alliance entry restrictions', () => { let g = movement(true); assert.throws(() => move(g, 4), /more than 3/); g = allow(play(g, 'Kulon')); assert.equal(player(move(g, 4), 'c').moved, 1); const storm = TERRITORIES.find( (t) => t.type === 'sand' && t.sectors.includes(18), )!; assert.throws( () => send(g, 'c', { type: 'move', forces: { 'red_chasm:7': 1 }, territory: storm.id, sector: 18, }), RuleError, ); const to = destination(g, 2); player(g, 'c').ally = 'e'; player(g, 'e').ally = 'c'; player(g, 'e').forces[`${to.territory}:${to.sector}`] = 1; assert.throws(() => move(g, 2), RuleError); }); void test('Kulon applies to available Hajr movements without granting a third movement and expires next turn', () => { let g = movement(); const hajr = hold(g, 'c', 'Hajr'); g = send(g, 'c', { type: 'card', card: hajr }); g = allow(play(g, 'Kulon')); g = move(g, 2); g = send(g, 'c', { type: 'move', forces: { 'red_chasm:7': 1 }, ...(() => { const to = destination(g, 2); return { territory: to.territory, sector: to.sector }; })(), }); assert.equal(player(g, 'c').moved, 2); assert.equal(viewGame(g, 'c').players[0].movesAllowed, 2); g.turn++; player(g, 'c').moved = 0; player(g, 'c').forces = { 'red_chasm:7': 3 }; assert.equal(viewGame(g, 'c').choamMovementBonus, 0); assert.throws(() => move(g, 2), /more than 1/); }); void test('Kulon rejects wrong owner, phase, active seat, spent movement and blocked windows atomically', () => { const initial = movement(); const snapshot = structuredClone(initial); assert.throws( () => send(initial, 'e', { type: 'card', mode: 'choam', card: named(initial, 'Kulon'), }), RuleError, ); assert.deepEqual(initial, snapshot); for (const change of [ (g: Game) => (g.phase = 4), (g: Game) => (g.active = 'e'), (g: Game) => (player(g, 'c').moved = 1), (g: Game) => (g.response = { kind: 'emperorIncome', owner: 'e', amount: 1, passed: [], }), ]) { const g = structuredClone(initial); change(g); assert.throws(() => play(g, 'Kulon'), RuleError); } }); void test('Karama preserves a Worthless card but prevents retrying its effect during that phase', () => { const initial = movement(); const cancel = hold(initial, 'e', 'Karama'); const card = named(initial, 'Kulon'); let g = play(initial, 'Kulon'); g = send(g, 'e', { type: 'card', card: cancel, mode: 'cancel' }); assert.equal(g.choamMovement, undefined); assert.ok(player(g, 'c').hand.some((c) => c.id === card)); assert.throws(() => play(g, 'Kulon'), RuleError); assert.ok(!viewGame(g, 'c').choamWorthless!.cards.some((c) => c.id === card)); g.turn++; g = allow(play(g, 'Kulon')); assert.equal(g.choamMovement?.bonus, 1); }); void test('La La La proactively removes only remaining ordinary free revivals; paid revival remains legal', () => { let g = fixture(); g = send(g, 'e', { type: 'revive', amount: 1 }); g = send(g, 'c', { type: 'decision', decline: true }); assert.equal(player(g, 'e').revived, 1); g = allow(play(g, 'La La La', 'e')); assert.equal(viewGame(g, 'e').revival.freeRemaining, 0); assert.equal(forceRevivalQuote(g, player(g, 'e'), 2).cost, 4); g = send(g, 'e', { type: 'revive', amount: 2 }); assert.equal(player(g, 'e').spice, 6); assert.equal(player(g, 'e').revived, 3); }); void test('proactive La La La discard offers Semuta before one free-revival block', () => { const initial = fixture(); initial.players[1] = newPlayer('e', 'Richese', 'richese'); player(initial, 'e').spice = 10; player(initial, 'e').tanks = 6; player(initial, 'e').reserves = 14; initial.richeseCache = richeseCards(); initial.semutaPreview = true; const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(index >= 0); const semuta = initial.richeseCache.splice(index, 1)[0]; player(initial, 'e').hand.push(semuta); contest(initial, 'b'); const card = named(initial, 'La La La'); const physical = (state: Game) => [ ...state.deck, ...state.discard, ...state.richeseCache!, ...state.players.flatMap(p => p.hand), ].map(c => c.id).sort(); const original = physical(initial); const absent = structuredClone(initial); player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]]; absent.richeseCache!.push(semuta); const offered = (state: Game) => allow(play(state, 'La La La', 'e')); const pending = offered(initial), neutral = offered(absent); const event = pending.pendingTreacheryDiscard!.batch.event; assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamLaLaLaDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.ok(!pending.revivalRules?.freeBlocked?.includes('e')); assert.equal(pending.discard.filter(c => c.id === card).length, 1); assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c')); assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b')); assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true); assert.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit'); assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending); for (const change of [ (state: Game) => { const c = state.pendingTreacheryDiscard!.continuation; if (c.kind === 'choamLaLaLaDiscard') c.target = 'b'; }, (state: Game) => { state.revivalRules!.freeBlocked = ['e']; }, (state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; }, ]) { const corrupt: Game = JSON.parse(JSON.stringify(pending)); change(corrupt); const before = JSON.stringify(corrupt); assert.throws(() => applyAction(corrupt, 'e', { type: 'semutaCommit', event })); assert.equal(JSON.stringify(corrupt), before); } let declined: Game = JSON.parse(JSON.stringify(pending)); for (const id of ['c', 'b', 'e']) declined = applyAction(declined, id, { type: 'semutaPass', event }); const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e', { type: 'semutaCommit', event }); for (const state of [declined, claimed]) { assert.equal(state.pendingTreacheryDiscard, null); assert.deepEqual(state.revivalRules?.freeBlocked, ['e']); assert.equal(viewGame(state, 'e').revival.freeRemaining, 0); assert.equal(forceRevivalQuote(state, player(state, 'e'), 1).cost, 2); assert.deepEqual(physical(state), original); assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event })); } assert.equal(declined.discard.filter(c => c.id === card).length, 1); assert.equal(claimed.players[1].hand.filter(c => c.id === card).length, 1); assert.equal(claimed.discard.filter(c => c.id === semuta.id).length, 1); }); void test('every CHOAM table offers the same public free-revival response regardless of its private hand', () => { const a = fixture(), b = structuredClone(a); player(b, 'c').hand = []; const ga = send(a, 'e', { type: 'revive', amount: 2 }), gb = send(b, 'e', { type: 'revive', amount: 2 }); assert.equal(ga.decision?.kind, 'choamFreeRevival'); assert.deepEqual(viewGame(ga, 'e').decision, viewGame(gb, 'e').decision); assert.equal(player(ga, 'e').tanks, 6); assert.equal(player(ga, 'e').spice, 10); assert.throws( () => send(ga, 'e', { type: 'decision', decline: true }), RuleError, ); }); void test('reactive La La La stops an entire mixed free/paid request without charging or consuming quota', () => { let g = send(fixture(), 'e', { type: 'revive', amount: 3 }); g = play(g, 'La La La'); assert.equal(g.response, null); // No eligible cancel card: the denial has settled. g = allow(g); assert.equal(g.pendingRevival, null); assert.equal(player(g, 'e').spice, 10); assert.equal(player(g, 'e').tanks, 6); assert.equal(player(g, 'e').revived, 0); g = send(g, 'e', { type: 'revive', amount: 3 }); assert.equal(player(g, 'e').spice, 4); assert.equal(player(g, 'e').revived, 3); }); void test('canceling reactive La La La resumes the original quote exactly once and keeps the card', () => { const initial = fixture(); const card = hold(initial, 'e', 'Karama'); let g = send(initial, 'e', { type: 'revive', amount: 3 }); g = play(g, 'La La La'); g = send(g, 'e', { type: 'card', card, mode: 'cancel' }); assert.equal(player(g, 'e').spice, 6); assert.equal(player(g, 'e').revived, 3); assert.equal(g.pendingRevival, null); assert.equal(viewGame(g, 'e').revival.freeBlocked, false); assert.ok(player(g, 'c').hand.some((c) => c.name === 'La La La')); }); void test('a cash-in consuming the pending Worthless card aborts its effect and resumes the original revival', () => { const initial = fixture(true); contest(initial); const karama = hold(initial, 'c', 'Karama'); let g = send(initial, 'e', { type: 'revive', amount: 2 }); const card = named(g, 'La La La'); g = play(g, 'La La La'); g = send(g, 'c', { type: 'card', mode: 'special', card: karama, cards: [card], }); g = allow(g); assert.equal(player(g, 'e').revived, 2); assert.equal(player(g, 'e').spice, 8); assert.equal(viewGame(g, 'e').revival.freeBlocked, false); assert.equal(player(g, 'c').spice, 13); }); void test('La La La denial overrides a later Fremen free-revival grant but does not block Ghola', () => { const initial = fixture(); initial.players.push(newPlayer('f', 'Fremen', 'fremen')); player(initial, 'f').ally = 'e'; player(initial, 'e').ally = 'f'; const ghola = hold(initial, 'e', 'Tleilaxu Ghola'); let g = allow(play(initial, 'La La La', 'e')); g = send(g, 'f', { type: 'grantRevival' }); assert.equal(forceRevivalQuote(g, player(g, 'e'), 3).cost, 6); g = send(g, 'e', { type: 'card', card: ghola, amount: 3 }); assert.equal(player(g, 'e').spice, 10); assert.equal(player(g, 'e').tanks, 3); assert.equal(player(g, 'e').revived, 0); }); void test('the free-revival reaction precedes Tleilaxu special prevention and cancellation restores that continuation', () => { const initial = fixture(true); initial.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu')); const karama = hold(initial, 'e', 'Karama'); let g = send(initial, 'e', { type: 'revive', amount: 2 }); assert.equal(g.decision?.kind, 'choamFreeRevival'); g = play(g, 'La La La'); g = send(g, 'e', { type: 'card', card: karama, mode: 'cancel' }); assert.equal(g.decision?.kind, 'revivalStop'); assert.equal(player(g, 'e').revived, 0); g = send(g, 't', { type: 'decision', decline: true }); g = allow(g); assert.equal(player(g, 'e').revived, 2); assert.equal(player(g, 'e').spice, 8); }); void test('free denial resets on entering a later Revival phase', () => { let g = allow(play(fixture(), 'La La La', 'e')); g.turn++; g.phase = 2; g.expansions = []; g.deck = []; g.discard = []; for (const p of g.players) { p.hand = []; p.spice = 0; } for (const p of g.players) g = send(g, p.id, { type: 'ready' }); while (g.decision?.kind === 'choamMarket') g = send(g, 'c', { type: 'decision', done: true }); assert.equal(g.phase, 4); assert.equal(viewGame(g, 'e').revival.freeBlocked, false); assert.equal(forceRevivalQuote(g, player(g, 'e'), 1).cost, 0); }); void test('pending Worthless effects and hidden card choices survive JSON reconnects', () => { for (const g of [ play( send(contest(fixture()), 'e', { type: 'revive', amount: 2 }), 'La La La', ), play(contest(movement()), 'Kulon'), ]) { assert.equal(g.response?.kind, 'choamWorthless'); const copy = JSON.parse(JSON.stringify(g)); assert.deepEqual(viewGame(copy, 'c'), viewGame(g, 'c')); assert.equal(viewGame(g, 'e').choamWorthless, null); assert.deepEqual(allow(copy), allow(g)); } }); void test('AI policies use Kulon, choose whether to deny free revival and budget correctly after denial', () => { for (const difficulty of DIFFICULTIES) { let g = movement(); player(g, 'c').bot = difficulty; assert.equal(botActions(viewGame(g, 'c'))[0].mode, 'choam'); g = fixture(); player(g, 'c').bot = difficulty; g = send(g, 'e', { type: 'revive', amount: 2 }); const action = botActions(viewGame(g, 'c'))[0]; assert.equal(action.type, difficulty === 'Easy' ? 'decision' : 'card'); g = send(g, 'c', action); g = allow(g); if (difficulty !== 'Easy') { player(g, 'e').bot = difficulty; player(g, 'e').spice = 0; assert.ok(!botActions(viewGame(g, 'e')).some((a) => a.type === 'revive')); } } }); void test('unsupported Worthless powers and forged targets do not consume cards or change state', () => { const g = fixture(); const baliset = hold(g, 'c', 'Baliset'); const before = structuredClone(g); assert.throws( () => send(g, 'c', { type: 'card', mode: 'choam', card: baliset, target: 'e' }), RuleError, ); assert.throws(() => play(g, 'La La La', 'missing'), RuleError); assert.deepEqual(g, before); });