import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { actFremenRevivalGrant as act, advanceFremenRevivalGrant as advance, createFremenRevivalGrantFixture as fixture, fremenRevivalGrantInventory as inventory, fremenRevivalGrantPlayer as player } from './fixture-fremen-revival-grant'; const pay = (target: string, amount: number): Action => ({type:'bribe', target, amount}); function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before, 'A rejected bribe leaves its pending parent unchanged.'); } /** Real classic Basic setup, native first auction and an originally dealt * Emperor Karama. The shared fixture selects unopened starting positions and * uses actual setup actions; no response, wallet or auction is fabricated. */ function auctionSource() { const f = fixture(); const game = advance(structuredClone(f.afterSetup), state => state.turn === 1 && state.phase === 3 && state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction); assert.equal(game.response?.owner, f.ally); assert.ok(player(game, f.observer).hand.some(card => card.id === f.karama)); return {f, game}; } let auctionCache: ReturnType | undefined; const auction = () => auctionCache ??= auctionSource(); /** Real original paid battle, casualties, later worm/Nexus and alliance lead * to Fremen's actual revival grant, held open by Emperor's physical Karama. */ function grantSource(advanced = false) { const f = fixture({advanced}); const game = act(f.game, f.fremen, f.grant); assert.equal(game.response?.kind, 'fremenRevivalGrant'); assert.ok(player(game, f.observer).hand.some(card => card.id === f.karama)); return {f, game}; } let grantCache: ReturnType | undefined; const grant = () => grantCache ??= grantSource(); void test('a Basic auction inspection response retains its original lot and passes during an out-of-turn bribe', () => { const {f, game: source} = auction(); const before = structuredClone(source), response = structuredClone(before.response), lot = structuredClone(before.auction); const oldPurse = player(before, f.fremen).spice, oldCash = player(before, f.observer).spice; const oldBribes = player(before, f.observer).bribes, cards = inventory(before); assert.ok(oldPurse >= 1); assert.equal(viewGame(before, f.fremen).bribeOptions.blocked, null); const paid = act(before, f.fremen, pay(f.observer, 1)); assert.deepEqual(paid.response, response); assert.deepEqual(paid.auction, lot); assert.equal(player(paid, f.fremen).spice, oldPurse - 1); assert.equal(player(paid, f.observer).spice, oldCash); assert.equal(player(paid, f.observer).bribes, oldBribes + 1); assert.deepEqual(inventory(paid), cards); const control = act(before, f.observer, {type:'passResponse'}); const continued = act(paid, f.observer, {type:'passResponse'}); assert.notEqual(continued.response?.kind, 'atreidesAuction'); assert.deepEqual(continued.auction, control.auction); assert.equal(player(continued, f.observer).bribes, oldBribes + 1); assert.deepEqual(inventory(continued), cards); }); void test('a Basic auction inspection can still be canceled by the original Karama after a bribe', () => { const {f, game: source} = auction(), before = structuredClone(source); const paid = act(before, f.fremen, pay(f.observer, 1)); const control = act(before, f.observer, {type:'card', card:f.karama, mode:'cancel'}); const canceled = act(paid, f.observer, {type:'card', card:f.karama, mode:'cancel'}); assert.deepEqual(canceled.auction, control.auction); assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1); assert.equal(player(canceled, f.observer).bribes, player(before, f.observer).bribes + 1); }); void test('a Basic Fremen grant response keeps its exact source and grants the ally after the original pass', () => { const {f, game: source} = grant(), before = structuredClone(source); const response = structuredClone(before.response), oldPurse = player(before, f.observer).spice; const oldCash = player(before, f.ally).spice, oldBribes = player(before, f.ally).bribes, cards = inventory(before); assert.ok(oldPurse >= 1); assert.equal(viewGame(before, f.observer).bribeOptions.blocked, null); const paid = act(before, f.observer, pay(f.ally, 1)); assert.deepEqual(paid.response, response); assert.equal(player(paid, f.observer).spice, oldPurse - 1); assert.equal(player(paid, f.ally).spice, oldCash); assert.equal(player(paid, f.ally).bribes, oldBribes + 1); assert.ok(!paid.freeRevival.includes(f.ally)); const control = act(before, f.observer, {type:'passResponse'}); const continued = act(paid, f.observer, {type:'passResponse'}); assert.notEqual(continued.response?.kind, 'fremenRevivalGrant'); assert.deepEqual(continued.freeRevival, control.freeRevival); assert.ok(continued.freeRevival.includes(f.ally)); assert.deepEqual(inventory(continued), cards); }); void test('a Basic Fremen grant can still be canceled after the payment', () => { const {f, game: source} = grant(), before = structuredClone(source); const paid = act(before, f.observer, pay(f.ally, 1)); const control = act(before, f.observer, {type:'card', card:f.karama, mode:'cancel'}); const canceled = act(paid, f.observer, {type:'card', card:f.karama, mode:'cancel'}); assert.deepEqual(canceled.freeRevival, control.freeRevival); assert.ok(!canceled.freeRevival.includes(f.ally)); assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1); assert.equal(player(canceled, f.ally).bribes, player(before, f.ally).bribes + 1); }); void test('invalid or allied bribes leave a pending Basic grant untouched', () => { const {f, game: source} = grant(), game = structuredClone(source); reject(game, f.fremen, pay(f.ally, 1)); reject(game, f.observer, pay(f.observer, 1)); reject(game, f.observer, pay(f.ally, 0)); reject(game, f.observer, pay(f.ally, player(game, f.observer).spice + 1)); }); void test('an Advanced grant remains outside the Basic interruption profile', () => { const {f, game} = grantSource(true); assert.ok(viewGame(game, f.observer).bribeOptions.blocked); reject(game, f.observer, pay(f.ally, 1)); }); void test('all four minimal bot profiles can continue the preserved Basic grant response', () => { const {f, game: source} = grant(), paid = act(source, f.observer, pay(f.ally, 1)); for (const difficulty of DIFFICULTIES) { const game = structuredClone(paid), view = viewGame(game, f.observer); view.players.find(seat => seat.id === f.observer)!.bot = difficulty; const action = botActions(view).find(choice => choice.type === 'passResponse' || choice.type === 'card' && choice.mode === 'cancel'); assert.ok(action, `${difficulty} needs an original legal response`); const next = act(game, f.observer, action); assert.ok(next.response?.kind !== 'fremenRevivalGrant' || next.response.passed.includes(f.observer) || next.pendingKull || next.pendingKarama || next.pendingTreacheryDiscard, `${difficulty} must advance the original response`); assert.equal(player(next, f.ally).bribes, player(source, f.ally).bribes + 1); } });