import assert from 'node:assert/strict'; import test from 'node:test'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { BattlePreparation } from '../components/battle-preparation'; import { baseDeck } from '../game/cards'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { botActions } from '../game/bots'; import { applyAction, battles, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; type Power = 'voice' | 'prescience'; const send = (game: Game, id: string, action: Action) => applyAction(game, id, action); const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!; /** Group entry is unavailable in the lobby. Stage a Nexus and form the pair * and group with real actions, then stage conserved forces at an unresolved * battle. Card acquisition is a physical transfer from the undealt deck. */ function source(power: Power, beneficiary: 'e' | 'h' | 'owner', group = true) { const faction = power === 'voice' ? 'beneGesserit' : 'atreides'; const owner = power === 'voice' ? 'b' : 'a'; let game = createGame('GROUPBATTLE', newPlayer(owner, faction, faction), false); game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild')); Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:[owner,'e','h','g'], ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); game.coalitionState = emptyCoalitionState(); game = send(game, owner, {type:'alliance', target:'e'}); game = send(game, 'e', {type:'alliance', target:owner}); if (group) { const pair = coalitionOf(game.players, game.coalitionState, owner); assert.ok(pair); game = send(game, owner, {type:'coalitionPropose', groupId:pair.id, entrant:'h', expectedRevision:game.coalitionState!.revision}); for (const id of ['e','h']) { const proposal = game.coalitionState!.proposal!; game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus, turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision}); } } const beneficiaryId = beneficiary === 'owner' ? owner : beneficiary; Object.assign(game, {phase:6, nexus:false, ready:[], response:null, decision:null, phaseOpening:null, battle:null}); for (const seat of game.players) { seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10; seat.hand = []; seat.traitors = []; seat.traitorChoices = []; } for (const id of [beneficiaryId,'g']) { player(game,id).forces = {'arrakeen:10':4}; player(game,id).reserves = 16; } const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const [karama] = game.deck.splice(index, 1); player(game,'g').hand.push(karama); const choice = battles(game).find(item => item.territory === 'arrakeen' && [item.attacker,item.defender].includes(beneficiaryId) && [item.attacker,item.defender].includes('g')); assert.ok(choice, 'The staged force position offers an unresolved legal battle.'); game.active = choice.chooser; const enemy = choice.attacker === choice.chooser ? choice.defender : choice.attacker; game = send(game, choice.chooser, {type:'chooseBattle', territory:'arrakeen', target:enemy}); return {game, owner, beneficiary:beneficiaryId, karama}; } function passResponses(game: Game): Game { for (let count = 0; game.response && count < 24; count++) { const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next); game = send(game, next.id, {type:'passResponse'}); } assert.equal(game.response ?? null, null); return game; } const action = (power: Power): Action => power === 'voice' ? {type:'voice', kind:'projectile', must:false} : {type:'prescience', field:'weapon'}; for (const power of ['voice','prescience'] as const) { for (const ally of ['e','h'] as const) { void test(`Basic group ${power} supports ${ally} in their battle against an outsider`, () => { const {game:before, owner, beneficiary} = source(power, ally); assert.deepEqual(before.battle?.preparation, {kind:power, owner, beneficiary}); let game = send(before, owner, action(power)); assert.equal(game.response?.kind, power); assert.equal(game.response?.owner, owner); if (power === 'voice') assert.deepEqual(game.battle?.voice, {target:'g', kind:'projectile', must:false}); else assert.deepEqual(game.battle?.prescience, {player:beneficiary, field:'weapon'}); game = passResponses(game); if (power === 'prescience') { assert.equal(game.battle?.preparation?.kind, 'prescienceAnswer'); assert.equal(game.battle?.preparation?.owner, 'g'); game = send(game, 'g', {type:'prescienceAnswer', value:null}); assert.equal(game.battle?.prescience?.value, null); } assert.equal(game.battle?.revealed, false); assert.deepEqual(game.battle?.plans, {}); }); void test(`Karama cancels group ${power} offered to ${ally}`, () => { const {game:before, owner, karama} = source(power, ally); let game = send(before, owner, action(power)); game = send(game, 'g', {type:'card', mode:'cancel', card:karama.id}); game = passResponses(game); assert.equal(game.battle?.[power], undefined); assert.equal(game.discard.filter(card => card.id === karama.id).length, 1); assert.equal(game.battle?.revealed, false); }); } for (const mode of ['pair','owner'] as const) void test(`Basic ${power} retains the ${mode === 'pair' ? 'pair ally' : 'owner'} beneficiary`, () => { const {game, owner, beneficiary} = source(power, mode === 'pair' ? 'e' : 'owner', mode !== 'pair'); assert.deepEqual(game.battle?.preparation, {kind:power, owner, beneficiary}); assert.equal(send(game, owner, action(power)).response?.kind, power); }); void test(`a Medium bot proposes a legal group ${power} decision`, () => { const {game, owner} = source(power, 'h'); const view = viewGame(game, owner); view.players.find(seat => seat.id === owner)!.bot = 'Medium'; const choice = botActions(view).find(candidate => candidate.type === power || candidate.type === 'declineBattlePower'); assert.ok(choice); const after = send(game, owner, choice); assert.ok(after.response || after.battle?.preparation?.kind !== power); }); void test(`group ${power} renders the owner's controls and names its beneficiary`, () => { const {game, owner, beneficiary} = source(power, 'h'); const own = viewGame(game, owner); assert.equal(own.battle?.preparation?.beneficiary, beneficiary); assert.ok([own.battle!.attacker, own.battle!.defender].includes('g')); const ownMarkup = renderToStaticMarkup(createElement(BattlePreparation, { game:own, busy:false, send:async () => {}, })); assert.match(ownMarkup, /Harkonnen/); assert.match(ownMarkup, power === 'voice' ? /Use the Voice/ : /Reveal weapon/); const observer = viewGame(game, 'g'); const observerMarkup = renderToStaticMarkup(createElement(BattlePreparation, { game:observer, busy:false, send:async () => {}, })); assert.match(observerMarkup, /Waiting for/); assert.doesNotMatch(observerMarkup, /Use the Voice|Reveal weapon/); }); } void test('group Voice and Prescience both aid the third ally in the same battle', () => { let game = createGame('GROUPBOTH', newPlayer('b', 'Bene Gesserit', 'beneGesserit'), false); game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('e', 'Emperor', 'emperor'), newPlayer('g', 'Guild', 'guild')); Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['b','a','e','g'], ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); game.coalitionState = emptyCoalitionState(); game = send(game, 'b', {type:'alliance', target:'a'}); game = send(game, 'a', {type:'alliance', target:'b'}); const pair = coalitionOf(game.players, game.coalitionState, 'b'); assert.ok(pair); game = send(game, 'b', {type:'coalitionPropose', groupId:pair.id, entrant:'e', expectedRevision:game.coalitionState!.revision}); for (const id of ['a','e']) { const proposal = game.coalitionState!.proposal!; game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus, turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision}); } Object.assign(game, {phase:6, nexus:false, ready:[], response:null, decision:null, phaseOpening:null, battle:null}); for (const seat of game.players) { seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10; seat.hand = []; seat.traitors = []; seat.traitorChoices = []; } for (const id of ['e','g']) { player(game,id).forces = {'arrakeen:10':4}; player(game,id).reserves = 16; } const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karamaIndex >= 0); player(game, 'g').hand.push(...game.deck.splice(karamaIndex, 1)); const choice = battles(game).find(item => item.territory === 'arrakeen' && [item.attacker,item.defender].includes('e') && [item.attacker,item.defender].includes('g')); assert.ok(choice); game.active = choice.chooser; const enemy = choice.attacker === choice.chooser ? choice.defender : choice.attacker; game = send(game, choice.chooser, {type:'chooseBattle', territory:'arrakeen', target:enemy}); assert.deepEqual(game.battle?.preparation, {kind:'voice', owner:'b', beneficiary:'e'}); game = send(game, 'b', {type:'voice', kind:'projectile', must:false}); assert.equal(game.response?.kind, 'voice'); assert.deepEqual(game.battle?.voice, {target:'g', kind:'projectile', must:false}); game = passResponses(game); assert.deepEqual(game.battle?.preparation, {kind:'prescience', owner:'a', beneficiary:'e'}); game = send(game, 'a', {type:'prescience', field:'weapon'}); assert.equal(game.response?.kind, 'prescience'); game = passResponses(game); assert.deepEqual(game.battle?.prescience, {player:'e', field:'weapon'}); assert.equal(game.battle?.preparation?.owner, 'g'); game = send(game, 'g', {type:'prescienceAnswer', value:null}); assert.equal(game.battle?.prescience?.value, null); assert.equal(game.battle?.voice?.target, 'g'); assert.deepEqual(game.battle?.plans, {}); });