import assert from 'node:assert/strict'; import test from 'node:test'; import { registerHooks } from 'node:module'; import { readFileSync } from 'node:fs'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; 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'; import { traitorDeck } from '../game/traitors'; const aliases = registerHooks({ resolve(specifier, context, next) { return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); }, load(url, context, next) { if (!url.endsWith('.module.css')) return next(url, context); const css = readFileSync(new URL(url), 'utf8'); const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true}; }, }); const {GameTable} = await import('../components/game-table'); aliases.deregister(); 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)!; const physicalTraitors = (game: Game) => [ ...(game.traitorReserve ?? []), ...game.players.flatMap(seat => [...seat.traitors, ...seat.traitorChoices]), ].sort(); /** Three-member groups cannot be started in the lobby. Stage a Nexus, form * the pair and group through actual actions, then stage conserved forces in * Arrakeen and transfer physical Traitor identities out of their reserve. */ function source(ally: 'e' | 'f' | 'h', options: { group?: boolean; mutual?: boolean } = {}) { let game = createGame('GROUPTRAITOR', newPlayer('h', 'Harkonnen', 'harkonnen'), false); game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'), newPlayer('g', 'Guild', 'guild')); Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['h','e','f','g'], ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); game.coalitionState = emptyCoalitionState(); game = send(game, 'h', {type:'alliance', target:'e'}); game = send(game, 'e', {type:'alliance', target:'h'}); if (options.group !== false) { const pair = coalitionOf(game.players, game.coalitionState, 'h'); assert.ok(pair); game = send(game, 'h', {type:'coalitionPropose', groupId:pair.id, entrant:'f', expectedRevision:game.coalitionState!.revision}); for (const id of ['e','f']) { 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, 'h')!.members, ['e','f','h']); } 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 [ally,'g']) { player(game, id).forces = {'arrakeen:10':4}; player(game, id).reserves = 16; } const opponentLeader = player(game, 'g').leaders[0].id; const allyLeader = player(game, ally).leaders[0].id; game.traitorReserve = traitorDeck(game.players); const inventory = physicalTraitors(game); const transfer = (identity: string, holder: string) => { const index = game.traitorReserve!.indexOf(identity); assert.ok(index >= 0); player(game, holder).traitors.push(...game.traitorReserve!.splice(index, 1)); }; transfer(opponentLeader, 'h'); if (options.mutual) transfer(allyLeader, 'g'); assert.deepEqual(physicalTraitors(game), inventory); const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karamaIndex >= 0); const [karama] = game.deck.splice(karamaIndex, 1); player(game, 'g').hand.push(karama); const choice = battles(game).find(candidate => candidate.territory === 'arrakeen' && [candidate.attacker,candidate.defender].includes(ally) && [candidate.attacker,candidate.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.ok(game.battle && !game.battle.preparation); game = send(game, ally, {type:'battlePlan', leader:allyLeader, dial:0, support:0, weapon:null, defense:null}); assert.ok(!game.battle?.revealed); game = send(game, 'g', {type:'battlePlan', leader:opponentLeader, dial:0, support:0, weapon:null, defense:null}); assert.equal(game.battle?.revealed, true); return {game, ally, opponentLeader, allyLeader, karama, inventory}; } 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; } 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); } function reject(game: Game, id: string, action: Action) { const before = structuredClone(game); assert.throws(() => send(game, id, action)); assert.deepEqual(game, before); } for (const ally of ['e','f'] as const) { void test(`Harkonnen may reveal its Guild traitor for group ally ${ally}`, () => { const f = source(ally); let game = f.game; const own = viewGame(game, 'h').battle!; assert.ok(own.traitorVoters.includes('h')); assert.equal(own.traitorBeneficiary, ally); assert.ok(own.traitorCallChoices?.includes(true)); game = send(game, 'h', {type:'traitorCall', call:true}); assert.equal(game.response?.kind, 'harkonnenTraitor'); assert.equal(game.battle?.traitorCalls.h, true); game = passResponses(game); game = send(game, ally, {type:'traitorCall', call:false}); game = send(game, 'g', {type:'traitorCall', call:false}); assert.equal(game.lastBattleContext?.result, 'traitor'); assert.equal(game.lastBattleContext?.winner, ally); assert.equal(player(game, 'g').leaders.find(leader => leader.id === f.opponentLeader)?.dead, true); assert.deepEqual(physicalTraitors(game), f.inventory); conserved(game); }); void test(`Karama cancels Harkonnen's group traitor call for ${ally}`, () => { const f = source(ally); let game = send(f.game, 'h', {type:'traitorCall', call:true}); assert.equal(game.response?.kind, 'harkonnenTraitor'); game = send(game, 'g', {type:'card', mode:'cancel', card:f.karama.id}); game = passResponses(game); assert.equal(game.battle?.traitorCalls.h, false); reject(game, 'h', {type:'traitorCall', call:true}); game = send(game, ally, {type:'traitorCall', call:false}); game = send(game, 'g', {type:'traitorCall', call:false}); assert.equal(game.lastBattleContext?.result, 'normal'); assert.equal(game.discard.filter(card => card.id === f.karama.id).length, 1); assert.deepEqual(physicalTraitors(game), f.inventory); conserved(game); }); } void test('an opposing Guild call and Harkonnen group call destroy both sides', () => { const f = source('f', {mutual:true}); let game = send(f.game, 'g', {type:'traitorCall', call:true}); game = send(game, 'h', {type:'traitorCall', call:true}); game = passResponses(game); game = send(game, 'f', {type:'traitorCall', call:false}); assert.equal(game.lastBattleContext?.result, 'mutualTraitors'); assert.equal(player(game, 'g').leaders.find(leader => leader.id === f.opponentLeader)?.dead, true); assert.equal(player(game, 'f').leaders.find(leader => leader.id === f.allyLeader)?.dead, true); assert.deepEqual(physicalTraitors(game), f.inventory); conserved(game); }); void test('the existing two-member Harkonnen ally call keeps its implicit beneficiary', () => { const f = source('e', {group:false}); assert.equal(viewGame(f.game, 'h').battle?.traitorBeneficiary, 'e'); const game = send(f.game, 'h', {type:'traitorCall', call:true}); assert.equal(game.response?.kind, 'harkonnenTraitor'); }); void test('Harkonnen still calls its own traitor as a combatant in a group', () => { const f = source('h'); assert.equal(viewGame(f.game, 'h').battle?.traitorBeneficiary, 'h'); const game = send(f.game, 'h', {type:'traitorCall', call:true}); assert.equal(game.battle?.traitorCalls.h, true); assert.notEqual(game.response?.kind, 'harkonnenTraitor'); }); void test('a Medium Harkonnen bot offers a legal allied group traitor call', () => { const {game} = source('f'); const view = viewGame(game, 'h'); view.players.find(seat => seat.id === 'h')!.bot = 'Medium'; const action = botActions(view).find(candidate => candidate.type === 'traitorCall'); assert.ok(action); assert.equal(action.call, true); assert.equal(send(game, 'h', action).response?.kind, 'harkonnenTraitor'); }); void test('the actual group table gives Harkonnen an enabled allied call without revealing its hand to the other ally', () => { const {game, opponentLeader} = source('e'); const own = viewGame(game, 'h'); const ownMarkup = renderToStaticMarkup(createElement(GameTable, { game:own, busy:false, send:async () => {}, onExit() {}, })); const button = ownMarkup.match(/]*>(?:(?!<\/button>)[\s\S])*Reveal ally’s opponent as traitor(?:(?!<\/button>)[\s\S])*<\/button>/)?.[0]; assert.ok(button, 'The allied call is offered to Harkonnen.'); assert.doesNotMatch(button, /\bdisabled(?:=|\s|>)/); assert.ok(own.players.find(seat => seat.id === 'h')?.traitors?.includes(opponentLeader)); const observer = viewGame(game, 'f'); assert.equal(observer.players.find(seat => seat.id === 'h')?.traitors, undefined); const observerMarkup = renderToStaticMarkup(createElement(GameTable, { game:observer, busy:false, send:async () => {}, onExit() {}, })); assert.doesNotMatch(observerMarkup, /Reveal ally’s opponent as traitor/); });