import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; import { registerHooks } from 'node:module'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { applyAction, viewGame, type Game } from '../game/engine'; import { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion'; 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 render = (game: Game, actor: string, busy = false) => renderToStaticMarkup(createElement(GameTable, {game:viewGame(game, actor), busy, send:async () => { assert.fail('Reading the announcement must not submit an action.'); }, onExit() {}})); /** Public Basic setup, native shipments and a completed first battle create * the real second Guild/Emperor fight. Stage only Guild's leader availability * and a conserved Hero hand transfer when needed for the paired control. */ function ready(cheapHero: boolean) { const f = createBattleTerritoryExplosionFixture({advanced:false}); const game = structuredClone(f.game); assert.equal(game.advanced,false); assert.ok(game.battle && !game.battle.revealed); assert.deepEqual(new Set([game.battle.attacker,game.battle.defender]),new Set(['guild','emperor'])); const guild = game.players.find(player => player.id === 'guild'); assert.ok(guild); // These living discs were already used in another territory; no deaths or // replacements are fabricated. The first real battle remains in history. for (const leader of guild.leaders) leader.usedAt = 'carthag'; if (cheapHero) { let hero = guild.hand.find(card => card.kind === 'hero'); if (!hero) { for (const zone of [game.deck,game.discard,...game.players.filter(player => player.id !== 'guild').map(player => player.hand)]) { const index = zone.findIndex(card => card.kind === 'hero'); if (index >= 0) { [hero] = zone.splice(index,1); guild.hand.push(hero); break; } } } assert.ok(hero,'One real physical Cheap Hero must be available'); return {game, hero:hero.id}; } // A real cheap hero held by Guild would make a null leader illegal; return // any such card to the original deck rather than deleting it. const heroes = guild.hand.filter(card => card.kind === 'hero'); guild.hand = guild.hand.filter(card => card.kind !== 'hero'); game.deck.push(...heroes); return {game,hero:null}; } void test('a Basic sealed leaderless plan announces publicly and keeps the plan private across rendering and reload', () => { const f = ready(false); const game = applyAction(f.game,'guild',{type:'battlePlan',dial:1,support:0,leader:null,weapon:null,defense:null}); assert.ok(game.battle && !game.battle.revealed); assert.deepEqual(game.battle.basicLeaderlessAnnouncements,['guild']); assert.equal(viewGame(game,'guild').battle!.plans.guild.dial,1); for (const id of ['emperor','atreides']) { const view = viewGame(game,id); assert.deepEqual(view.battle!.leaderless,['guild']); assert.deepEqual(view.battle!.plans,{}); assert.deepEqual(view.battle!.cards,[]); for (const busy of [false,true]) { const html = render(game,id,busy); assert.match(html,/Guild announced a Battle Plan without a leader or Cheap Hero\./); assert.match(html,/No battle cards can be played; other plan choices remain sealed\./); } } const saved = JSON.stringify(game), restored = JSON.parse(saved) as Game; assert.deepEqual(viewGame(restored,'emperor').battle!.leaderless,['guild']); assert.match(render(restored,'atreides'),/Guild announced a Battle Plan without a leader or Cheap Hero\./); assert.equal(JSON.stringify(restored),saved); }); void test('a real Cheap Hero plan does not announce the faction as leaderless', () => { const f = ready(true); assert.ok(f.hero); const game = applyAction(f.game,'guild',{type:'battlePlan',dial:1,support:0,leader:f.hero,weapon:null,defense:null}); assert.ok(game.battle && !game.battle.revealed); assert.ok(!game.battle.basicLeaderlessAnnouncements?.includes('guild')); for (const id of ['emperor','atreides']) { const view = viewGame(game,id); assert.deepEqual(view.battle!.leaderless,[]); assert.deepEqual(view.battle!.plans,{}); assert.deepEqual(view.battle!.cards,[]); assert.doesNotMatch(render(game,id),/Guild announced a Battle Plan without a leader or Cheap Hero/); } });