import assert from 'node:assert/strict'; import test from 'node:test'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { viewGame, type Action, type Game } from '../game/engine'; import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards'; import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal'; import { actTruthCharityPayments as act, advanceTruthCharityPayments as advance, createTruthCharityPaymentsFixture, truthCharityPaymentsClean as clean, truthCharityPaymentsInventory as inventory, truthCharityPaymentsPlayer as player } from './fixture-truth-charity-payments'; import { actTruthtranceChoamPromises as actChoam, createTruthtranceChoamPromisesFixture as choamFixture, truthtranceChoamPromisesInventory as choamInventory, truthtranceChoamPromisesPlayer as choamPlayer } from './fixture-truthtrance-choam-promises'; /** Genuine Advanced selected-Ix public start and original starting cards. Storm * uses a quiet opening after real confirmations; Spice uses the actual Amal * and unopened native Spice ordering. The Storm continuation selects a safe * unopened Spice prefix. No wallet, readiness, promise or phase is installed. */ function fixture(phase:0|1) { const base = phase === 0 ? createTruthTurnAmalFixture({phase:0,advanced:true}) : createTruthCharityPaymentsFixture({source:'afterAmal',advanced:true}); const game = phase === 0 ? advance(structuredClone(base.game), state => state.phase === 0 && clean(state), [], true) : structuredClone(base.game); assert.equal(game.phase,phase); assert.equal(game.advanced,true); assert.ok(clean(game)); assert.equal(player(game,base.target).charityTurn,undefined); assert.deepEqual(inventory(game),base.originalCards); return {...base,game,initialPhase:phase, charityQuestion:{kind:'charity' as const,target:base.target,turn:game.turn}}; } function ask(f:ReturnType, source = f.game):Game { let game = act(source,f.asker,{type:'card',card:f.truths[0]}); while (game.truthtrance?.stage === 'priority') { const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next); game = act(game,next.id,{type:'truthPass'}); } assert.equal(game.truthtrance?.stage,'ask'); return act(game,f.asker,{type:'truthAsk',question:f.charityQuestion}); } function bind(f:ReturnType, answer:'yes'|'no', source = f.game) { const asked = ask(f,source); assert.ok(viewGame(asked,f.target).truthCharityAnswers?.includes(answer)); return {asked, game:act(asked,f.target,{type:'truthAnswer',answer})}; } void test('an early Advanced Charity answer preserves a real prior Spice Blow Ready', () => { const f=fixture(1); const blow=advance(f.game,state => !!state.spiceWindow && clean(state),[],true); const ready=act(blow,f.target,{type:'ready'}); assert.ok(ready.ready.includes(f.target)); const {asked,game:bound}=bind(f,'yes',ready); assert.ok(asked.ready.includes(f.target)); assert.deepEqual(bound.ready,asked.ready); let game=reachCharity(bound); assert.ok(game.charityOrder); if (game.charityOrder.remaining[0]!==f.target) assert.equal(viewGame(game,f.target).truthCharityGuide,null); game=ownTurn(game,f.target); assert.ok(viewGame(game,f.target).truthCharityGuide); assert.deepEqual(inventory(game),f.originalCards); }); function reachCharity(source:Game):Game { let game = source; if (game.phase === 0) { game = advance(game,beforeIxSpiceDraw,[],true); orderIxSpiceCards(game,[{territory:'hagga_basin'},{territory:'broken_land'}]); } return advance(game,state => state.phase === 2 && !!state.charityOrder && clean(state),[],true); } function ownTurn(source:Game,target:string):Game { let game=source; for (let count=0; game.phase===2 && game.charityOrder?.remaining[0]!==target && count<6; count++) { const actor=game.charityOrder?.remaining[0]; assert.ok(actor); game=act(game,actor,{type:'ready'}); } assert.equal(game.phase,2); assert.equal(game.charityOrder?.remaining[0],target); return game; } function reject(game:Game,actor:string,action:Action) { const before=structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before); } for (const phase of [0,1] as const) { void test(`Advanced phase ${phase} Charity Yes survives openings and the ordered queue`, () => { const f=fixture(phase), {asked,game:bound}=bind(f,'yes'); assert.deepEqual(bound.ready,asked.ready); assert.equal(viewGame(bound,f.target).truthCharityGuide,null); assert.ok(viewGame(bound,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released)); assert.equal(bound.discard.filter(card => card.id===f.truths[0]).length,1); let game=reachCharity(bound); assert.equal(game.phase,2); assert.ok(game.charityOrder); assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released)); if (game.charityOrder.remaining[0]!==f.target) assert.equal(viewGame(game,f.target).truthCharityGuide,null); game=ownTurn(game,f.target); reject(game,f.target,{type:'ready'}); let guide=viewGame(game,f.target).truthCharityGuide; assert.ok(guide); game=act(game,f.target,guide.action); if (guide.action.type==='bribe') { guide=viewGame(game,f.target).truthCharityGuide; assert.ok(guide); assert.equal(guide.action.type,'charity'); game=act(game,f.target,guide.action); } assert.equal(player(game,f.target).charityTurn,game.turn); assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => row.answer && row.fulfilled)); assert.deepEqual(inventory(game),f.originalCards); }); void test(`Advanced phase ${phase} Charity No remains until the respondent's ordered turn closes`, () => { const f=fixture(phase), {asked,game:bound}=bind(f,'no'); assert.deepEqual(bound.ready,asked.ready); assert.equal(viewGame(bound,f.target).truthCharityGuide,null); let game=ownTurn(reachCharity(bound),f.target); assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled && !row.released)); const amount=player(game,f.target).spice-1; if (amount>0) game=act(game,f.target,{type:'bribe',target:f.asker,amount}); assert.ok(player(game,f.target).spice<2); reject(game,f.target,{type:'charity'}); game=act(game,f.target,{type:'ready'}); assert.ok(game.phase!==2 || !game.charityOrder?.remaining.includes(f.target)); assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && row.fulfilled)); assert.deepEqual(inventory(game),f.originalCards); }); } void test('all four bots can answer before Advanced Charity and use the later legal guide', () => { const f=fixture(1), asked=ask(f); for (const difficulty of DIFFICULTIES) { const response=viewGame(asked,f.target); response.players.find(row => row.id===f.target)!.bot=difficulty; const answer=botActions(response)[0]; assert.ok(answer); assert.equal(answer.type,'truthAnswer'); assert.ok(response.truthCharityAnswers?.includes(answer.answer as 'yes'|'no')); let game=act(structuredClone(asked),f.target,{type:'truthAnswer',answer:'yes'}); assert.equal(viewGame(game,f.target).truthCharityGuide,null); game=ownTurn(reachCharity(game),f.target); const own=viewGame(game,f.target); own.players.find(row => row.id===f.target)!.bot=difficulty; const guide=own.truthCharityGuide; assert.ok(guide); assert.deepEqual(botActions(own)[0],guide.action); game=act(game,f.target,guide.action); const claim=viewGame(game,f.target).truthCharityGuide; if (claim) game=act(game,f.target,claim.action); assert.equal(player(game,f.target).charityTurn,game.turn); assert.deepEqual(inventory(game),f.originalCards); } }); void test('an early Advanced CHOAM question does not invent unresolved opening income', () => { const f=choamFixture({advanced:true}), source=structuredClone(f.game); assert.equal(source.phase,0); assert.equal(source.choamCharity,undefined); assert.equal(choamPlayer(source,f.target).spice,f.rawCash); let game=actChoam(source,f.asker,{type:'card',card:f.truth}); while (game.truthtrance?.stage==='priority') { const actor=game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor); game=actChoam(game,actor.id,{type:'truthPass'}); } game=actChoam(game,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:game.turn}}); const own=viewGame(game,f.target); assert.ok(own.truthCharityAnswers?.includes('no')); assert.ok(own.truthCharityAnswers?.includes('unknown')); assert.ok(!own.truthCharityAnswers?.includes('yes')); const before=structuredClone(game); assert.throws(() => actChoam(game,f.target,{type:'truthAnswer',answer:'yes'})); assert.deepEqual(game,before); game=actChoam(game,f.target,{type:'truthAnswer',answer:'no'}); assert.equal(choamPlayer(game,f.target).spice,f.rawCash); assert.equal(game.choamCharity,undefined); assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => !row.answer && !row.fulfilled && !row.released)); assert.deepEqual(choamInventory(game),f.originalInventory); });