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 { actTruthCharityPayments as act, createTruthCharityPaymentsFixture, declareTruthCharityPayments, truthCharityPaymentsInventory as inventory, truthCharityPaymentsPlayer as player } from './fixture-truth-charity-payments'; let original: ReturnType | undefined; const source = () => original ??= createTruthCharityPaymentsFixture({source:'phase2',advanced:true}); /** Public Advanced selected-Ix setup, original starting cards and actual Amal * chronology. A real bribe before asking makes the chosen future actor eligible * for the native claim; no wallet, phase, order, readiness or promise is staged. */ function fixture(options: {poor?:boolean} = {}) { const f = source(); let game = structuredClone(f.game); assert.equal(game.advanced,true); assert.equal(game.phase,2); const queue = game.charityOrder; assert.ok(queue && queue.remaining.length >= 3); const target = queue.remaining.find(id => id !== f.asker && id !== queue.remaining[0]); assert.ok(target); const amount = player(game,target).spice - 1; if (options.poor !== false && amount > 0) game = act(game,target,{type:'bribe',target:f.asker,amount}); if (options.poor !== false) assert.ok(player(game,target).spice < 2); assert.deepEqual(game.charityOrder,queue); assert.equal(player(game,target).charityTurn,undefined); return {f,game,target,asker:f.asker,queue:structuredClone(queue)}; } function reject(game:Game, actor:string, action:Action) { const before = structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before); } function ask(f:ReturnType, source:Game = f.game) { const declared = declareTruthCharityPayments(f.f,source).game; return act(declared,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:source.turn}}); } function bind(f:ReturnType, answer:'yes'|'no', source:Game = 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})}; } function toTarget(game:Game, target:string):Game { 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; } void test('Advanced Charity Yes preserves the order and guides only the actual current turn', () => { const f = fixture(); const {game: bound} = bind(f,'yes'); assert.deepEqual(bound.charityOrder,f.queue); assert.equal(viewGame(bound,f.target).truthCharityGuide,null); assert.ok(viewGame(bound,f.target).truthCharityPromises.some(row => row.answer && !row.fulfilled && !row.released)); reject(bound,f.target,{type:'charity'}); reject(bound,f.target,{type:'ready'}); let game = toTarget(bound,f.target); reject(game,f.target,{type:'ready'}); const guide = viewGame(game,f.target).truthCharityGuide; assert.ok(guide); assert.equal(guide.action.type,'charity'); const queue = structuredClone(game.charityOrder); game = act(game,f.target,guide.action); assert.deepEqual(game.charityOrder,queue); assert.equal(player(game,f.target).charityTurn,game.turn); assert.ok(viewGame(game,f.target).truthCharityPromises.some(row => row.answer && row.fulfilled)); game = act(game,f.target,{type:'ready'}); assert.ok(game.phase !== 2 || !game.charityOrder?.remaining.includes(f.target)); assert.deepEqual(inventory(game),f.f.originalCards); }); void test('Advanced Charity No remains binding through the owned turn then completes', () => { const f = fixture(); const {game: bound} = bind(f,'no'); assert.deepEqual(bound.charityOrder,f.queue); let game = toTarget(bound,f.target); reject(game,f.target,{type:'charity'}); assert.equal(viewGame(game,f.target).truthCharityGuide,null); 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.equal(player(game,f.target).charityTurn,undefined); assert.deepEqual(inventory(game),f.f.originalCards); }); void test('Advanced Charity accepts a question during the respondent’s own unfinished turn', () => { const f = fixture(), current = toTarget(f.game,f.target), queue = structuredClone(current.charityOrder); const {game} = bind(f,'yes',current); assert.deepEqual(game.charityOrder,queue); assert.equal(game.charityOrder?.remaining[0],f.target); reject(game,f.target,{type:'ready'}); const guide = viewGame(game,f.target).truthCharityGuide; assert.ok(guide); const completed = act(game,f.target,guide.action); assert.equal(player(completed,f.target).charityTurn,completed.turn); }); void test('Advanced Charity Yes guides a real bribe only once the future target owns its turn', () => { const f = fixture({poor:false}); assert.ok(player(f.game,f.target).spice >= 2); let {game} = bind(f,'yes'); assert.equal(viewGame(game,f.target).truthCharityGuide,null); assert.deepEqual(game.charityOrder,f.queue); game = toTarget(game,f.target); reject(game,f.target,{type:'ready'}); const bribe = viewGame(game,f.target).truthCharityGuide; assert.ok(bribe); assert.equal(bribe.action.type,'bribe'); const before = player(game,f.target).spice; game = act(game,f.target,bribe.action); assert.ok(player(game,f.target).spice < 2); assert.equal(player(game,f.target).spice,before - Number(bribe.action.amount)); const claim = viewGame(game,f.target).truthCharityGuide; assert.ok(claim); assert.equal(claim.action.type,'charity'); game = act(game,f.target,claim.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.f.originalCards); }); void test('a No by the final Advanced Charity actor completes on the actual phase transition', () => { const base = source(); let game = structuredClone(base.game); const queue = game.charityOrder; assert.ok(queue && queue.remaining.length >= 3); const target = queue.remaining.at(-1)!; let asker = base.asker; // If the final actor holds Truthtrance, exchange it with another real hand // card so a different player can ask; this conserves the physical inventory. if (target === asker) { const other = game.players.find(seat => seat.id !== target && seat.hand.length); assert.ok(other); const holder = player(game,asker), index = holder.hand.findIndex(card => card.id === base.truths[0]); assert.ok(index >= 0); [holder.hand[index],other.hand[0]] = [other.hand[0],holder.hand[index]]; asker = other.id; } const amount = player(game,target).spice - 1; if (amount > 0) game = act(game,target,{type:'bribe',target:asker,amount}); const alternate = {...base,asker}; const declared = declareTruthCharityPayments(alternate,game).game; game = act(declared,asker,{type:'truthAsk',question:{kind:'charity',target,turn:game.turn}}); assert.ok(viewGame(game,target).truthCharityAnswers?.includes('no')); game = act(game,target,{type:'truthAnswer',answer:'no'}); game = toTarget(game,target); assert.deepEqual(game.charityOrder?.remaining,[target]); reject(game,target,{type:'charity'}); game = act(game,target,{type:'ready'}); assert.notEqual(game.phase,2); assert.ok(viewGame(game,target).truthCharityPromises.some(row => !row.answer && row.fulfilled)); assert.deepEqual(inventory(game),base.originalCards); }); void test('Advanced Charity cannot reopen an already finished ordered turn', () => { const f = fixture(); let game = toTarget(f.game,f.target); game = act(game,f.target,{type:'ready'}); if (game.phase !== 2) { assert.equal(game.charityOrder ?? null,null); return; } assert.ok(!game.charityOrder?.remaining.includes(f.target)); const declared = declareTruthCharityPayments(f.f,game).game; reject(declared,f.asker,{type:'truthAsk',question:{kind:'charity',target:f.target,turn:game.turn}}); }); void test('all four minimal bots use the native Charity guide on their actual ordered turn', () => { const f = fixture(), asked = ask(f); for (const difficulty of DIFFICULTIES) { const answerView = viewGame(asked,f.target); answerView.players.find(row => row.id === f.target)!.bot = difficulty; const answer = botActions(answerView)[0]; assert.ok(answer); assert.equal(answer.type,'truthAnswer'); assert.ok(answerView.truthCharityAnswers?.includes(answer.answer as 'yes'|'no')); // A separate Yes branch tests the promised legal path for every profile, // regardless of its provisional answering strategy. let game = act(structuredClone(asked),f.target,{type:'truthAnswer',answer:'yes'}); const waiting = viewGame(game,f.target); waiting.players.find(row => row.id === f.target)!.bot = difficulty; assert.equal(waiting.truthCharityGuide,null); assert.ok(!botActions(waiting).some(action => action.type === 'charity' || action.type === 'ready')); game = toTarget(game,f.target); const view = viewGame(game,f.target); view.players.find(row => row.id === f.target)!.bot = difficulty; const guide = view.truthCharityGuide; assert.ok(guide); const action = botActions(view).find(candidate => candidate.type === 'charity'); assert.deepEqual(action,guide.action); const completed = act(game,f.target,action!); assert.equal(player(completed,f.target).charityTurn,completed.turn); } });