import assert from 'node:assert/strict'; import test from 'node:test'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { FremenRevivalGrant } from '../components/fremen-revival-grant'; import { baseDeck } from '../game/cards'; import { botActions, runBots } from '../game/bots'; import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; const act = (game:Game, id:string, action:Action) => applyAction(game,id,action); const seat = (game:Game, id:string) => { const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player; }; /** Real Nexus proposals, acceptance and group admission. The original ordinary * phase, force pools, wallets and holdings are staged at Revival, with tanks * moved from reserves and the counter card taken from the physical deck. */ function fixture(difficulty:Difficulty, bot:'a'|'h' = 'a', grouped = true) { let game = createGame('GROUPFREMENBOT',newPlayer('f','Fremen','fremen'),false); game.players.push(newPlayer('a','Atreides','atreides'),newPlayer('h','Harkonnen','harkonnen'), newPlayer('g','Guild','guild')); Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,order:['f','a','h','g'], ready:[],storm:18,deck:baseDeck(),response:null,decision:null,phaseOpening:null}); game.coalitionState = emptyCoalitionState(); game = act(game,'f',{type:'alliance',target:'a'}); game = act(game,'a',{type:'alliance',target:'f'}); if (grouped) { const group = coalitionOf(game.players,game.coalitionState,'f'); assert.ok(group); game = act(game,'f',{type:'coalitionPropose',groupId:group.id,entrant:'h', expectedRevision:game.coalitionState!.revision}); for (const id of ['a','h']) { const proposal = game.coalitionState!.proposal!; game = act(game,id,{type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus, turn:proposal.turn,accept:true,expectedRevision:game.coalitionState!.revision}); } } Object.assign(game,{phase:4,nexus:false,ready:[],response:null,decision:null}); for (const player of game.players) { player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10; player.hand = []; player.traitors = []; player.traitorChoices = []; } for (const id of ['a','h']) { seat(game,id).reserves = 16; seat(game,id).tanks = 4; } const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const [karama] = game.deck.splice(index,1); seat(game,'g').hand.push(karama); game = act(game,bot,{type:'setAutopilot',difficulty}); return {game,bot,karama:karama.id}; } function pass(game:Game):Game { for (let count = 0; game.response && count < 24; count++) { const player = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(player); game = act(game,player.id,{type:'passResponse'}); } assert.equal(game.response ?? null,null); return game; } for (const difficulty of DIFFICULTIES) for (const bot of ['a','h'] as const) void test( `${difficulty} group ally ${bot} waits for human Fremen and uses the granted free revival`, () => { let {game} = fixture(difficulty,bot); const priorSpice = seat(game,bot).spice, priorTanks = seat(game,bot).tanks; assert.equal(viewGame(game,bot).revival.freeRemaining,2); assert.equal(viewGame(game,'f').fremenGroupRevivalGrant?.find(row => row.recipient === bot)?.blocked,null); game = runBots(game,1); assert.equal(seat(game,bot).tanks,priorTanks); assert.equal(seat(game,bot).spice,priorSpice); assert.equal(seat(game,bot).revived,0); assert.ok(!game.ready.includes(bot)); game = act(game,'f',{type:'grantRevival',target:bot}); assert.equal(game.response?.kind,'fremenRevivalGrant'); game = pass(game); assert.equal(viewGame(game,bot).revival.freeRemaining,3); assert.ok(botActions(viewGame(game,bot)).some(action => action.type === 'revive' && action.amount === 3)); game = runBots(game,1); assert.equal(seat(game,bot).tanks,priorTanks - 3); assert.equal(seat(game,bot).spice,priorSpice); assert.equal(seat(game,bot).freeForcesRevived,3); }, ); void test('Fremen Ready declines further group grants and releases the waiting ally', () => { const {game: initial,bot} = fixture('Easy'); let game = initial; game = runBots(game,1); assert.equal(seat(game,bot).revived,0); game = act(game,'f',{type:'ready'}); assert.ok(game.ready.includes('f')); game = runBots(game,1); assert.ok(seat(game,bot).revived > 0 || game.ready.includes(bot)); }); void test('actual Karama prevents one group grant and releases that waiting ally', () => { const {game: initial,bot,karama} = fixture('Medium'); let game = initial; game = act(game,'h',{type:'setAutopilot',difficulty:'Hard'}); game = act(game,'f',{type:'grantRevival',target:bot}); assert.equal(game.response?.kind,'fremenRevivalGrant'); game = act(game,'g',{type:'card',card:karama,mode:'cancel'}); assert.equal(game.response ?? null,null); assert.ok(game.revivalRules?.fremenGrantBlocked?.includes(bot)); assert.equal(viewGame(game,bot).revival.freeRemaining,2); game = runBots(game,1); assert.ok(seat(game,bot).revived > 0 || game.ready.includes(bot)); assert.equal(seat(game,'h').revived,0); assert.ok(!game.ready.includes('h')); }); void test('an automated non-ally does not wait for a group grant it cannot receive', () => { let {game} = fixture('Easy'); game = act(game,'g',{type:'setAutopilot',difficulty:'Hard'}); seat(game,'g').reserves = 16; seat(game,'g').tanks = 4; game = runBots(game,1); assert.ok(seat(game,'g').revived > 0 || game.ready.includes('g')); assert.equal(seat(game,'a').revived,0); }); void test('a group ally with only its two already-free forces in Tanks does not wait', () => { const {game: initial,bot} = fixture('Easy'); let game = initial; // Move surplus counters back to the same native reserve pool. seat(game,bot).reserves += 2; seat(game,bot).tanks -= 2; assert.equal(viewGame(game,bot).revival.freeRemaining,2); game = runBots(game,1); assert.equal(seat(game,bot).tanks,0); assert.equal(seat(game,bot).revived,2); }); void test('a normal pair in a coalition-enabled game also waits for the human grant', () => { const {game: initial,bot} = fixture('Easy','a',false); let game = initial; assert.ok(game.coalitionState); assert.equal(seat(game,'f').ally,bot); game = runBots(game,1); assert.equal(seat(game,bot).revived,0); assert.ok(!game.ready.includes(bot)); }); void test('two automated group allies both wait for the human decision', () => { let {game} = fixture('Easy','a'); game = act(game,'h',{type:'setAutopilot',difficulty:'Hard'}); game = runBots(game,12); for (const id of ['a','h']) { assert.equal(seat(game,id).revived,0); assert.equal(seat(game,id).tanks,4); assert.ok(!game.ready.includes(id)); } game = act(game,'f',{type:'grantRevival',target:'a'}); game = pass(game); game = runBots(game,1); assert.equal(seat(game,'a').revived,3); assert.equal(seat(game,'h').revived,0); assert.ok(!game.ready.includes('h')); }); void test('group grant guidance is visible only to Fremen and does not disclose an ally’s Tanks', () => { const {game} = fixture('Easy'); const markup = (id:string) => renderToStaticMarkup(createElement(FremenRevivalGrant, { game:viewGame(game,id),busy:false,act() {}, })); const before = markup('f'); assert.match(before,/When a grant would help an automated ally, it waits to revive forces/); assert.match(before,/Grant Atreides free revival/); assert.doesNotMatch(before,/Tanks|four forces|4 forces/); seat(game,'a').reserves += 2; seat(game,'a').tanks -= 2; assert.equal(markup('f'),before); assert.equal(markup('a'),''); assert.equal(markup('g'),''); });