import test from 'node:test'; import assert from 'node:assert/strict'; import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { HomeworldRevivalDeployment, HomeworldRevivalDeploymentButtons } from '../components/homeworld-revival-deployment'; import { baseDeck } from '../game/cards'; import { createGame, joinGame, newPlayer, viewGame, type GameView, } from '../game/engine'; import { homeworldRevivalDeploymentActions, homeworldRevivalDeploymentCanAct, homeworldRevivalDeploymentChoice, homeworldRevivalDeploymentQuantities, homeworldRevivalActionBlock, } from '../game/homeworld-revival-deployment-options'; // Projected decision fixtures isolate the controls and shared bot policy. // Engine and recovery suites separately verify revival deposits and custody. function fixture(kind: 'fedaykin' | 'tleilax' = 'fedaykin'): GameView { const game = createGame( 'REVIVALCONTROLS', newPlayer( 'f', 'Reviving player', kind === 'fedaykin' ? 'fremen' : 'tleilaxu', ), false, ['ix'], ); joinGame(game, newPlayer('a', 'Atreides', 'atreides')); game.status = 'playing'; game.phase = 4; game.active = 'a'; game.players[0].forces = { 'arrakeen:9': 1 }; const view = viewGame(game, 'f'); view.decision = { kind: 'homeworldRevivalDeployment', player: 'f', event: 'revival-deployment-1', }; view.homeworldRevivalDeployment = { event: 'revival-deployment-1', player: 'f', kind, normal: kind === 'tleilax' ? 3 : 0, elite: kind === 'fedaykin' ? 2 : 0, blocked: null, destinations: [ { id: 'hagga_basin:8', name: 'Hagga Basin · sector 8', territory: 'hagga_basin', sector: 8, blocked: null, }, { id: 'carthag:10', name: 'Carthag · sector 10', territory: 'carthag', sector: 10, blocked: 'The territory already contains two other factions.', }, { id: 'arrakeen:9', name: 'Arrakeen · sector 9', territory: 'arrakeen', sector: 9, blocked: null, }, ], }; return view; } function markup(game: GameView, busy = false) { return renderToStaticMarkup( createElement(HomeworldRevivalDeployment, { game, busy, act() {} }), ); } function allocated(remaining = 3, placed = 0): GameView { const view = fixture(), offer = view.homeworldRevivalDeployment!; offer.elite = remaining; offer.allocation = { total: remaining + placed, remaining, placed, kept: 0 }; return view; } type ButtonProps = { children?: ReactNode; [key: string]: unknown }; function elements(node: ReactNode): ReactElement[] { if (Array.isArray(node)) return node.flatMap(elements); if (!isValidElement(node)) return []; return [node, ...elements(node.props.children)]; } function deploymentButton(props: Parameters[0], action: 'place' | 'reserves') { const button = elements(HomeworldRevivalDeploymentButtons(props)).find(element => element.props['data-revival-deployment-action'] === action); assert.ok(button); return button; } const click = (button: ReactElement) => (button.props.onClick as () => void)(); void test('revival controls commit the entire eligible typed group to one current destination', () => { for (const kind of ['fedaykin', 'tleilax'] as const) { const view = fixture(kind); const offer = view.homeworldRevivalDeployment!; const before = structuredClone(view); assert.deepEqual(homeworldRevivalDeploymentQuantities(view), [offer.normal + offer.elite]); assert.equal(homeworldRevivalDeploymentChoice(view, 'arrakeen:9', 1).action, null); assert.deepEqual(homeworldRevivalDeploymentChoice(view, 'arrakeen:9'), { blocked: null, action: { type: 'decision', event: offer.event, destination: 'arrakeen:9', amount: offer.normal + offer.elite, }, }); assert.deepEqual(view, before); const html = markup(view); assert.match( html, new RegExp(`${offer.normal} ordinary · ${offer.elite} starred`), ); assert.match( html, new RegExp(`Place ${offer.normal + offer.elite} revived forces`), ); assert.doesNotMatch(html, /type="number"|type="range"/); assert.match(html, /Leave in reserves/); } const advanced = fixture(); advanced.advanced = true; advanced.homeworldRevivalDeployment!.elite = 1; assert.deepEqual(homeworldRevivalDeploymentQuantities(advanced), [1]); assert.equal(homeworldRevivalDeploymentChoice(advanced, 'arrakeen:9').action!.amount, 1); assert.doesNotMatch(markup(advanced), /Starred forces to place now|Provisional per-star interpretation/); }); void test('new Basic Southern allocation defaults to one and enumerates native destinations and quantities for every profile', () => { for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { const view = allocated(), offer = view.homeworldRevivalDeployment!; view.players[0].bot = profile; for (const key of ['hand', 'spice', 'traitors', 'forces']) Object.defineProperty(view.players[1], key, { get() { throw Error(`No foreign ${key} allocation inspection`); } }); assert.deepEqual(homeworldRevivalDeploymentQuantities(view), [1, 2, 3]); assert.deepEqual(homeworldRevivalDeploymentChoice(view, 'hagga_basin:8').action, { type: 'decision', event: offer.event, destination: 'hagga_basin:8', amount: 1 }); const actions = homeworldRevivalDeploymentActions(view); assert.deepEqual(actions.slice(0, 3).map(action => [action.destination, action.amount]), [['arrakeen:9', 1], ['arrakeen:9', 2], ['arrakeen:9', 3]]); assert.deepEqual(actions.slice(3, 6).map(action => [action.destination, action.amount]), [['hagga_basin:8', 1], ['hagga_basin:8', 2], ['hagga_basin:8', 3]]); assert.deepEqual(actions[6], { type: 'decision', event: offer.event, decline: true }); const html = markup(view); assert.match(html, /3 starred remaining of 3/); assert.match(html, /0 placed · 0 kept in reserves/); assert.match(html, /eligible batch is fixed by this one original revival/); assert.match(html, /Provisional per-star interpretation/); assert.match(html, /no publisher clarification/); assert.match(html, /Starred forces to place now/); assert.match(html, /