import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, RuleError, type Game, type Action, } from '../game/engine'; import { baseDeck } from '../game/cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; function fixture(allied = true) { const g = createGame('CFORCES2', newPlayer('c', 'CHOAM', 'choam'), true, [ 'choam', ]); g.players.push( newPlayer('g', 'Guild', 'guild'), newPlayer('e', 'Emperor', 'emperor'), ); g.status = 'playing'; g.phase = 6; g.storm = 18; g.active = 'g'; g.order = ['g', 'e', 'c']; g.deck = baseDeck(); for (const p of g.players) { p.spice = 10; p.traitorChoices = []; p.traitors = []; p.hand = []; p.forces = p.id === 'c' ? {} : { 'red_chasm:7': 8 }; p.reserves = p.id === 'c' ? 20 : 12; for (const l of p.leaders) l.strength = 0; } if (allied) { g.players[0].ally = 'g'; g.players[1].ally = 'c'; } 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 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 start(state = fixture(), amount = 0) { let g = send(state, 'g', { type: 'chooseBattle', territory: 'red_chasm', target: 'e', }); if (g.decision?.kind === 'choamBattleFunding') g = send(g, 'c', { type: 'decision', amount }); return allow(g); } function plan( g: Game, id: string, support: number, extra: Record = {}, ) { return send(g, id, { type: 'battlePlan', dial: support, support, leader: player(g, id).leaders[0]?.id ?? null, ...extra, }); } function settle(state: Game, call: string[] = []) { let g = state; for (const id of ['g', 'e']) if (g.battle) g = send(g, id, { type: 'traitorCall', call: call.includes(id) }); for ( let n = 0; g.decision && ['battleLosses', 'battleCards'].includes(g.decision.kind) && n < 8; n++ ) { const d = g.decision; g = send( g, d.player, d.kind === 'battleLosses' ? { type: 'decision', choice: 0 } : { type: 'decision', discard: [] }, ); } return g; } function hold(g: Game, id: string, name: string) { const i = g.deck.findIndex((c) => c.name === name); assert.ok(i >= 0); const card = g.deck.splice(i, 1)[0]; player(g, id).hand.push(card); return card.id; } void test('CHOAM explicitly funds its ally before battle preparation; unrelated battles have no funding decision', () => { let g = send(contest(fixture()), 'g', { type: 'chooseBattle', territory: 'red_chasm', target: 'e', }); assert.equal(g.decision?.kind, 'choamBattleFunding'); assert.throws( () => send(g, 'g', { type: 'decision', amount: 3 }), /pending decision/, ); assert.throws(() => plan(g, 'g', 3), /pending decision/); g = send(g, 'c', { type: 'decision', amount: 4 }); assert.equal(player(g, 'c').spice, 6); assert.equal(g.response?.kind, 'choamBattleAid'); assert.equal(start(fixture(false)).decision, null); }); void test('only advanced CHOAM may pledge during Battle, with mutual alliance and atomic affordable amounts', () => { const g = fixture(); const snapshot = structuredClone(g); for (const amount of [-1, 11, 1.5, '2']) assert.throws(() => send(g, 'c', { type: 'pledgeAid', amount }), RuleError); assert.throws( () => send(g, 'g', { type: 'pledgeAid', amount: 2 }), RuleError, ); assert.deepEqual(g, snapshot); g.advanced = false; assert.throws( () => send(g, 'c', { type: 'pledgeAid', amount: 2 }), RuleError, ); g.advanced = true; player(g, 'g').ally = null; assert.throws( () => send(g, 'c', { type: 'pledgeAid', amount: 2 }), RuleError, ); }); void test('a sealed plan combines real own spice and reserved CHOAM spice, then locks only its declared shares', () => { const initial = contest(fixture()); player(initial, 'g').spice = 2; let g = start(initial, 4); g = plan(g, 'g', 5); assert.equal(g.battle!.plans.g.allyPayment, 3); assert.equal(player(g, 'g').spice, 2); assert.equal(g.aid.c.amount, 4); assert.throws( () => send(g, 'c', { type: 'pledgeAid', amount: 2 }), RuleError, ); assert.throws( () => send(g, 'g', { type: 'bribe', target: 'e', amount: 1 }), RuleError, ); g = send(g, 'c', { type: 'pledgeAid', amount: 3 }); assert.equal(player(g, 'c').spice, 7); g = settle(plan(g, 'e', 3)); assert.equal(player(g, 'g').spice, 0); assert.equal(g.aid.c.amount, 0); assert.equal(player(g, 'e').spice, 7); assert.equal(g.response?.kind, 'choamBattleIncome'); assert.equal(g.response.amount, 2); assert.equal(player(g, 'c').spice, 7); g = allow(g); assert.equal(player(g, 'c').spice, 9); }); void test('the ally can choose CHOAM to pay despite having its own spice; forged or unfunded shares fail', () => { const g = start(fixture(), 4); assert.throws(() => plan(g, 'g', 3, { allyPayment: 4 }), RuleError); assert.throws(() => plan(g, 'e', 3, { allyPayment: 1 }), RuleError); assert.throws(() => plan(g, 'g', 3, { allyPayment: -1 }), RuleError); const result = settle(plan(plan(g, 'g', 3, { allyPayment: 3 }), 'e', 2)); assert.equal(player(result, 'g').spice, 10); assert.equal(result.aid.c.amount, 1); assert.equal(result.response, null); assert.equal(player(result, 'c').spice, 7); // Six after funding, plus one from opponent; no self rebate. }); void test('income rounds down the combined support and is collected only once after combat choices', () => { let g = settle(plan(plan(start(fixture(false)), 'g', 3), 'e', 3)); assert.equal(g.response, null); assert.equal(player(g, 'c').spice, 13); // Half of 3 + 3; uncontested income settles immediately. assert.equal(player(g, 'g').spice, 7); assert.equal(player(g, 'e').spice, 7); g = allow(g); assert.equal(player(g, 'c').spice, 13); assert.equal(g.pendingChoamBattleIncome, null); assert.equal(g.decision?.kind, 'choamMarket'); g = send(g, 'c', { type: 'decision', done: true }); assert.equal(player(g, 'c').spice, 13); }); void test('two other combatants paying three and five yield four CHOAM income without changing either debit', () => { let g = settle(plan(plan(start(fixture(false)), 'g', 3), 'e', 5)); assert.equal(player(g, 'c').spice, 14); assert.equal(player(g, 'g').spice, 7); assert.equal(player(g, 'e').spice, 5); assert.equal(g.pendingChoamBattleIncome, null); assert.equal(g.decision?.kind, 'choamMarket'); g = send(g, 'c', { type: 'decision', done: true }); assert.equal(player(g, 'c').spice, 14); }); void test('Karama redirects just this battle income to the bank without refunding force payments', () => { const initial = fixture(false); const card = hold(initial, 'e', 'Karama'); let g = settle(plan(plan(start(initial), 'g', 4), 'e', 2)); g = send(g, 'e', { type: 'card', card, mode: 'cancel' }); assert.equal(player(g, 'c').spice, 10); assert.equal(player(g, 'g').spice, 6); assert.equal(player(g, 'e').spice, 8); assert.equal(g.pendingChoamBattleIncome, null); assert.equal(g.decision?.kind, 'choamMarket'); assert.equal( g.discard.some((c) => c.id === card), true, ); }); void test('canceling CHOAM allied funding leaves escrow intact but makes the ally plan with its own wealth', () => { const initial = fixture(); player(initial, 'g').spice = 0; const card = hold(initial, 'e', 'Karama'); let g = send(initial, 'g', { type: 'chooseBattle', territory: 'red_chasm', target: 'e', }); g = send(g, 'c', { type: 'decision', amount: 4 }); g = send(g, 'e', { type: 'card', card, mode: 'cancel' }); assert.equal(g.battle?.choamAidBlocked, true); assert.equal(g.aid.c.amount, 4); assert.equal(viewGame(g, 'g').aid.available, 0); assert.throws(() => plan(g, 'g', 1), RuleError); g = send(g, 'c', { type: 'pledgeAid', amount: 5 }); assert.throws(() => plan(g, 'g', 1), RuleError); g = settle(plan(plan(g, 'g', 0, { dial: 4 }), 'e', 2)); g = allow(g); g = send(g, 'c', { type: 'decision', done: true }); assert.deepEqual(g.aid, {}); assert.equal(player(g, 'c').spice, 11); }); void test('traitor victory preserves the winning side support and all CHOAM income is suppressed', () => { const initial = fixture(); player(initial, 'g').spice = 0; player(initial, 'g').traitors = [player(initial, 'e').leaders[0].id]; let g = start(initial, 4); g = settle(plan(plan(g, 'g', 3), 'e', 3), ['g']); assert.equal(player(g, 'g').spice, 0); assert.equal(g.aid.c.amount, 4); assert.equal(player(g, 'e').spice, 7); assert.equal(player(g, 'c').spice, 6); assert.equal(g.pendingChoamBattleIncome ?? null, null); g = send(g, 'c', { type: 'decision', done: true }); assert.equal(player(g, 'c').spice, 10); }); void test('mutual traitors still consume both support payments but give no CHOAM income', () => { const initial = fixture(false); player(initial, 'g').traitors = [player(initial, 'e').leaders[0].id]; player(initial, 'e').traitors = [player(initial, 'g').leaders[0].id]; const g = settle(plan(plan(start(initial), 'g', 3), 'e', 3), ['g', 'e']); assert.equal(player(g, 'g').spice, 7); assert.equal(player(g, 'e').spice, 7); assert.equal(player(g, 'c').spice, 10); assert.equal(g.response, null); }); void test('CHOAM own support goes entirely to the bank; only its opponent contributes income', () => { let g = fixture(false); player(g, 'c').forces = { 'red_chasm:7': 8 }; player(g, 'c').reserves = 12; player(g, 'g').forces = {}; g.active = 'c'; g.order = ['c', 'e', 'g']; g = send(g, 'c', { type: 'chooseBattle', territory: 'red_chasm', target: 'e', }); g = allow(g); g = plan(plan(g, 'c', 4), 'e', 3); g = send(g, 'c', { type: 'traitorCall', call: false }); g = send(g, 'e', { type: 'traitorCall', call: false }); assert.equal(player(g, 'c').forces['red_chasm:7'], 4); assert.equal(player(g, 'c').tanks, 4); assert.equal(g.response, null); g = allow(g); assert.equal(player(g, 'c').spice, 7); }); void test('no income response occurs for basic combat or payments too small to produce income', () => { let g = fixture(false); g.advanced = false; g = settle(plan(plan(start(g), 'g', 3), 'e', 2)); assert.equal(g.response, null); assert.equal(player(g, 'c').spice, 10); g = settle(plan(plan(start(fixture(false)), 'g', 1), 'e', 1)); assert.equal(g.response, null); }); void test('private projections and reconnects retain reserved funding without revealing an ally plan or spice', () => { const initial = fixture(); player(initial, 'g').spice = 1; const g = plan(start(initial, 4), 'g', 4); const own = viewGame(g, 'g'), donor = viewGame(g, 'c'), observer = viewGame(g, 'e'); assert.equal(own.aid.available, 4); assert.equal(donor.aid.pledged, 4); assert.equal(observer.aid.available, 0); assert.equal(donor.players.find((p) => p.id === 'g')!.spice, undefined); assert.equal(donor.battle?.plans.g, undefined); assert.equal(observer.battle?.plans.g, undefined); assert.deepEqual(viewGame(JSON.parse(JSON.stringify(g)), 'g'), own); const waiting = settle(plan(g, 'e', 2)); assert.deepEqual(allow(JSON.parse(JSON.stringify(waiting))), allow(waiting)); }); void test('all AI levels allocate allied support, use funded legal plans and finish income responses', () => { for (const difficulty of DIFFICULTIES) { let g = fixture(); for (const p of g.players) p.bot = difficulty; player(g, 'g').spice = 0; g = send(g, 'g', { type: 'chooseBattle', territory: 'red_chasm', target: 'e', }); const funding = botActions(viewGame(g, 'c'))[0]; assert.equal(funding.type, 'decision'); assert.ok(Number(funding.amount) > 0); g = send(g, 'c', funding); g = allow(g); const actions = botActions(viewGame(g, 'g')); assert.ok(actions.some((a) => a.type === 'battlePlan')); const legal = actions.find((a) => { try { send(g, 'g', a); return true; } catch { return false; } }); assert.ok(legal); g = send(g, 'g', legal); assert.ok((g.battle?.plans.g.support ?? 0) <= g.aid.c.amount); g = settle(plan(g, 'e', 3)); if (g.response) { const action = botActions(viewGame(g, 'c'))[0]; assert.equal(action.type, 'passResponse'); } } }); void test('a binding supported-dial promise is feasible through existing CHOAM funding and protects its own share', () => { const initial = fixture(); player(initial, 'g').spice = 1; let g = start(initial, 4); g.battle!.truthPromises = [ { player: 'g', asker: 'e', claim: { kind: 'support', compare: 'gte', value: 5 }, answer: true, }, ]; const completion = viewGame(g, 'g').battle!.compliantPlan; assert.ok(completion); assert.equal(completion.allyPayment, 4); g = send(g, 'g', { type: 'battlePlan', ...completion }); assert.throws( () => send(g, 'c', { type: 'pledgeAid', amount: 3 }), RuleError, ); }); void test('canceling allied aid affects one battle; the same unspent pledge can fund a later territory', () => { const initial = fixture(); for (const id of ['g', 'e']) { player(initial, id).forces['rock_outcroppings:13'] = 3; player(initial, id).reserves -= 3; } const card = hold(initial, 'e', 'Karama'); let g = send(initial, 'g', { type: 'chooseBattle', territory: 'red_chasm', target: 'e', }); g = send(g, 'c', { type: 'decision', amount: 4 }); g = send(g, 'e', { type: 'card', card, mode: 'cancel' }); g = settle(plan(plan(g, 'g', 1), 'e', 1)); assert.equal(g.active, 'g'); assert.equal(g.aid.c.amount, 4); g = send(g, 'g', { type: 'chooseBattle', territory: 'rock_outcroppings', target: 'e', }); g = send(g, 'c', { type: 'decision', amount: 4 }); g = allow(g); assert.equal(g.battle?.choamAidBlocked, false); assert.equal(viewGame(g, 'g').aid.available, 4); const leader = player(g, 'g').leaders.find((l) => !l.usedAt)!.id; g = plan(g, 'g', 3, { allyPayment: 3, leader }); assert.equal(g.battle!.plans.g.allyPayment, 3); }); void test('lasgun–shield explosion still pays support and CHOAM income when no traitor is revealed', () => { const initial = fixture(false); const weapon = hold(initial, 'g', 'Lasgun'); const defense = hold(initial, 'e', 'Shield'); let g = start(initial); g = plan(g, 'g', 4, { weapon }); g = plan(g, 'e', 2, { defense }); g = settle(g); assert.equal(g.response, null); assert.equal(player(g, 'c').spice, 13); assert.equal(player(g, 'g').tanks, 8); assert.equal(player(g, 'e').tanks, 8); g = allow(g); assert.equal(player(g, 'c').spice, 13); }); void test('free Fremen support produces no income, while its paying opponent does', () => { const initial = fixture(false); const fremen = newPlayer('g', 'Fremen', 'fremen'); Object.assign(fremen, { forces: { 'red_chasm:7': 8 }, reserves: 12, spice: 0, traitorChoices: [], traitors: [], }); for (const l of fremen.leaders) l.strength = 0; initial.players[1] = fremen; const g = settle(plan(plan(start(initial), 'g', 0, { dial: 5 }), 'e', 3)); assert.equal(g.response, null); assert.equal(player(g, 'c').spice, 11); assert.equal(player(g, 'g').spice, 0); }); void test('Bene Gesserit Worthless Karama cancellation preserves the pending income through its counter-window', () => { const initial = fixture(false); const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit'); initial.players.push(bg); initial.order.push('b'); const worthless = hold(initial, 'b', 'Baliset'); contest(initial); let g = settle(plan(plan(start(initial), 'g', 4), 'e', 2)); g = send(g, 'b', { type: 'card', card: worthless, mode: 'cancel' }); assert.equal(g.response?.kind, 'worthlessKarama'); assert.equal(g.pendingChoamBattleIncome?.amount, 3); g = allow(JSON.parse(JSON.stringify(g))); assert.equal(player(g, 'c').spice, 10); assert.equal(g.pendingChoamBattleIncome, null); }); void test('full-plan prescience offer survives the CHOAM funding decision and cancellation sequence', () => { const initial = fixture(); initial.players.push(newPlayer('a', 'Atreides', 'atreides')); initial.order.push('a'); let g = send(initial, 'g', { type: 'chooseBattle', territory: 'red_chasm', target: 'e', }); assert.equal(g.decision?.kind, 'choamBattleFunding'); g = send(g, 'c', { type: 'decision', amount: 3 }); assert.equal(g.decision?.kind, 'fullPlanOffer'); g = allow(g); assert.equal(g.decision?.kind, 'fullPlanOffer'); assert.equal(g.decision.player, 'a'); g = send(g, 'a', { type: 'decision', decline: true }); assert.equal(g.decision, null); assert.equal(g.aid.c.amount, 3); g = plan(g, 'g', 3, { allyPayment: 3 }); assert.equal(g.battle!.plans.g.allyPayment, 3); }); void test('each AI level fulfills a five-spice support promise using its CHOAM pledge and no own spice', () => { for (const difficulty of DIFFICULTIES) { const initial = fixture(); player(initial, 'g').spice = 0; player(initial, 'g').bot = difficulty; let g = start(initial, 5); g.battle!.truthPromises = [ { player: 'g', asker: 'e', claim: { kind: 'support', compare: 'eq', value: 5 }, answer: true, }, ]; const actions = botActions(viewGame(g, 'g')); let result: Game | undefined; for (const action of actions) { try { result = send(g, 'g', action); break; } catch { /* Candidate must pass the authoritative validator. */ } } assert.ok(result, difficulty); g = result; assert.equal(g.battle!.plans.g.support, 5); assert.equal(g.battle!.plans.g.allyPayment, 5); } });