import assert from 'node:assert/strict'; import test from 'node:test'; import { baseDeck } from '../game/cards'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { CoalitionEmperorAid } from '../components/coalition-emperor-aid'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { botActions } from '../game/bots'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; 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)!; /** Stage a Basic Nexus because group play has no lobby entry. The actual * alliance and unanimous admission actions create the three-member group. * Tanks and reserves are subsequently set to conserved ordinary forces. */ function fixture(phase = 4) { let game = createGame('GROUPEMP', newPlayer('e', 'Emperor', 'emperor'), 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:['e','a','h','g'], ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); game.coalitionState = emptyCoalitionState(); game = send(game, 'e', {type:'alliance', target:'a'}); game = send(game, 'a', {type:'alliance', target:'e'}); const pair = coalitionOf(game.players, game.coalitionState, 'e'); assert.ok(pair); game = send(game, 'e', {type:'coalitionPropose', groupId:pair.id, entrant:'h', expectedRevision:game.coalitionState!.revision}); for (const id of ['a', '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}); } assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'e')!.members, ['a','e','h']); Object.assign(game, {phase, nexus:false, ready:[], response:null, decision: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 ['a','h']) { player(game, id).reserves = 16; player(game, id).tanks = 4; } 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); return {game, 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, 'Each response has a remaining eligible player.'); game = send(game, next.id, {type:'passResponse'}); } assert.equal(game.response ?? null, null); return game; } function reject(game: Game, id: string, action: Action) { const before = structuredClone(game); assert.throws(() => send(game, id, action)); assert.deepEqual(game, before); } function conserved(game: Game) { for (const seat of game.players) assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((a,b) => a+b, 0), 20); } void test('Emperor gifts can reach either group ally and settle from the Emperor wallet', () => { let {game} = fixture(3); let pending = send(game, 'e', {type:'emperorGift', target:'a', amount:3}); assert.equal(pending.response?.recipient, 'a'); assert.equal(player(pending, 'e').spice, 10); game = passResponses(pending); assert.equal(player(game, 'e').spice, 7); assert.equal(player(game, 'a').spice, 13); assert.equal(player(game, 'h').spice, 10); pending = send(game, 'e', {type:'emperorGift', target:'h', amount:2}); assert.equal(pending.response?.recipient, 'h'); game = passResponses(pending); assert.equal(player(game, 'e').spice, 5); assert.equal(player(game, 'a').spice, 13); assert.equal(player(game, 'h').spice, 12); reject(game, 'e', {type:'emperorGift', target:'a', amount:6}); conserved(game); }); void test('Emperor revival reaches each group ally, costs two per force and tracks their separate caps', () => { let {game} = fixture(); player(game, 'e').spice = 12; let pending = send(game, 'e', {type:'emperorRevival', target:'a', amount:3, elite:0}); assert.equal(pending.response?.recipient, 'a'); assert.equal(player(pending, 'a').tanks, 4); game = passResponses(pending); assert.equal(player(game, 'e').spice, 6); assert.equal(player(game, 'a').tanks, 1); assert.equal(player(game, 'a').reserves, 19); assert.equal(player(game, 'a').revived, 0); assert.equal(game.emperorExtra.a, 3); reject(game, 'e', {type:'emperorRevival', target:'a', amount:1, elite:0}); pending = send(game, 'e', {type:'emperorRevival', target:'h', amount:3, elite:0}); assert.equal(pending.response?.recipient, 'h'); game = passResponses(pending); assert.equal(player(game, 'e').spice, 0); assert.equal(player(game, 'h').tanks, 1); assert.equal(player(game, 'h').reserves, 19); assert.equal(player(game, 'h').revived, 0); assert.equal(game.emperorExtra.h, 3); conserved(game); }); void test('Emperor aid rejects outsiders, self, missing targets and non-Emperor actors', () => { const {game} = fixture(); for (const type of ['emperorGift', 'emperorRevival'] as const) { for (const target of ['g','e','missing']) reject(game, 'e', {type, target, amount:1, elite:0}); reject(game, 'e', {type, amount:1, elite:0}); reject(game, 'a', {type, target:'h', amount:1, elite:0}); } }); void test('Emperor revival requires Revival, available tanks and enough Emperor spice', () => { const {game} = fixture(3); reject(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0}); game.phase = 4; reject(game, 'e', {type:'emperorRevival', target:'h', amount:5, elite:0}); player(game, 'e').spice = 3; reject(game, 'e', {type:'emperorRevival', target:'h', amount:2, elite:0}); const done = passResponses(send(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0})); assert.equal(player(done, 'e').spice, 1); assert.equal(player(done, 'h').tanks, 3); conserved(done); }); for (const type of ['emperorGift', 'emperorRevival'] as const) void test(`Karama cancels the selected group Emperor ${type} without payment or revival`, () => { const {game, karama} = fixture(); const declared = send(game, 'e', {type, target:'h', amount:2, elite:0}); assert.equal(declared.response?.recipient, 'h'); const done = passResponses(send(declared, 'g', {type:'card', mode:'cancel', card:karama.id})); assert.equal(player(done, 'e').spice, 10); assert.equal(player(done, 'h').spice, 10); assert.equal(player(done, 'h').tanks, 4); assert.equal(done.emperorExtra.h ?? 0, 0); conserved(done); }); void test('a Medium Emperor bot proposes a legal targeted group revival', () => { const {game} = fixture(); const view = viewGame(game, 'e'); view.players.find(seat => seat.id === 'e')!.bot = 'Medium'; const revival = botActions(view).find(action => action.type === 'emperorRevival'); assert.ok(revival); assert.ok(revival.target === 'a' || revival.target === 'h'); const declared = send(game, 'e', revival); assert.equal(declared.response?.recipient, revival.target); conserved(passResponses(declared)); }); void test('group Emperor controls render both recipients and require an explicit selection', () => { const {game} = fixture(); const view = viewGame(game, 'e'); const projection = view.coalitionEmperor!; const html = renderToStaticMarkup(createElement(CoalitionEmperorAid, { allies:view.players.filter(seat => projection.allies.includes(seat.id)), phase:view.phase, advanced:view.advanced, availableSpice:projection.availableSpice, emperorExtra:view.emperorExtra, act:() => {}, })); assert.match(html, /Atreides/); assert.match(html, /Harkonnen/); assert.match(html, /Choose an ally/); assert.match(html, /disabled=""[^>]*>Share spice/); assert.match(html, /disabled=""[^>]*>Pay for extra ally revivals/); }); void test('a two-player alliance retains the original implicit Emperor recipient', () => { let game = createGame('PAIREMP', newPlayer('e', 'Emperor', 'emperor'), false); game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('g', 'Guild', 'guild')); Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['e','a','g'], ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); game.coalitionState = emptyCoalitionState(); game = send(game, 'e', {type:'alliance', target:'a'}); game = send(game, 'a', {type:'alliance', target:'e'}); Object.assign(game, {phase:4, nexus:false, ready:[]}); for (const seat of game.players) { seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10; seat.hand = []; seat.traitors = []; seat.traitorChoices = []; } player(game, 'a').reserves = 18; player(game, 'a').tanks = 2; assert.equal(viewGame(game, 'e').coalitionEmperor, null); game = passResponses(send(game, 'e', {type:'emperorGift', amount:2})); assert.equal(player(game, 'a').spice, 12); game = passResponses(send(game, 'e', {type:'emperorRevival', amount:2, elite:0})); assert.equal(player(game, 'e').spice, 4); assert.equal(player(game, 'a').tanks, 0); assert.equal(game.emperorExtra.a, 2); conserved(game); }); void test('the staged Advanced Emperor benefit identifies both group allies', () => { const {game} = fixture(); game.advanced = true; assert.deepEqual(viewGame(game, 'e').coalitionEmperor?.allies, ['a', 'h']); const gifted = passResponses(send(game, 'e', {type:'emperorGift', target:'a', amount:1})); assert.equal(player(gifted, 'a').spice, 11); }); void test('group Emperor aid projects and respects spice committed to its own leading bid', () => { let {game} = fixture(2); Object.assign(game, {active:null, ready:[]}); for (const seat of game.players) game = send(game, seat.id, {type:'ready'}); assert.equal(game.phase, 3); game = passResponses(game); assert.equal(game.auction?.active, 'e'); game = send(game, 'e', {type:'bid', amount:6, allyPayment:0}); assert.equal(game.auction?.bidder, 'e'); assert.equal(viewGame(game, 'e').coalitionEmperor?.availableSpice, 4); reject(game, 'e', {type:'emperorGift', target:'h', amount:5}); const pending = send(game, 'e', {type:'emperorGift', target:'h', amount:4}); assert.equal(pending.response?.recipient, 'h'); game = passResponses(pending); assert.equal(player(game, 'e').spice, 6); assert.equal(player(game, 'h').spice, 14); assert.equal(game.auction?.bidder, 'e'); assert.equal(game.auction?.bid, 6); assert.equal(viewGame(game, 'e').coalitionEmperor?.availableSpice, 0); });