import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action, } from '../game/engine'; import { botActions } from '../game/bots'; import { homeworldRevivalDeploymentActions, homeworldRevivalActionBlock, } from '../game/homeworld-revival-deployment-options'; import { homeworldRevivalFixture, enterHomeworldRevival, positionRevivalForces, holdRevivalCard, revivalPlayer as p, revivalReload as reload, revivalInventory as inventory, rejectRevivalAction as reject, } from './fixture-homeworld-revival'; function choice(g: Game, actor: string, destination = 'arrakeen:10'): Action { const offer = viewGame(g, actor).homeworldRevivalDeployment; assert.ok(offer); assert.equal(offer.player, actor); assert.ok( offer.destinations.some( (target) => target.id === destination && !target.blocked, ), ); return { type: 'decision', event: offer.event, destination, amount: offer.normal + offer.elite, }; } function stable(g: Game) { inventory(g); const before = reload(g); assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), before); for (const player of g.players) { const view = viewGame(reload(g), player.id); for (const other of view.players.filter( (other) => other.id !== player.id, )) { assert.equal(other.hand, undefined); assert.equal(other.spice, undefined); } } } void test('actual high Southern revival moves every newly revived Basic star once while ordinary returns stay native', () => { let g = enterHomeworldRevival(homeworldRevivalFixture()); const old = structuredClone(p(g, 'f')); g = applyAction(g, 'f', { type: 'revive', amount: 3, elite: 2 }); assert.equal(g.decision?.kind, 'homeworldRevivalDeployment'); assert.equal(p(g, 'f').reserves, old.reserves + 3); assert.equal(p(g, 'f').elites!.reserves, old.elites!.reserves + 2); assert.deepEqual(g.homeworldRevivalReturn!.group, { amount: 3, elite: 2, free: 3, }); assert.equal(viewGame(g, 'f').homeworldRevivalDeployment!.normal, 0); assert.equal(viewGame(g, 'f').homeworldRevivalDeployment!.elite, 2); assert.ok( viewGame(g, 'f').homeworldRevivalDeployment!.destinations.every( (target) => !target.homeworld, ), ); stable(g); const action = choice(g, 'f'); for (const amount of [0, 3]) reject(g, 'f', { ...action, amount }, /Fedaykin must be an integer/); g = applyAction(reload(g), 'f', action); assert.equal(p(g, 'f').reserves, old.reserves + 1); assert.equal(p(g, 'f').elites!.reserves, old.elites!.reserves); assert.equal(p(g, 'f').forces['arrakeen:10'], old.forces['arrakeen:10'] + 2); assert.equal(p(g, 'f').elites!.forces['arrakeen:10'], 2); assert.equal(p(g, 'f').tanks, old.tanks - 3); assert.equal(p(g, 'f').spice, old.spice); assert.equal(p(g, 'f').shipped, old.shipped); assert.equal(p(g, 'f').moved, old.moved); assert.equal(g.homeworldRevivalReturn!.stage, 'complete'); reject(g, 'f', action); stable(g); }); void test('Advanced Southern keeps the shared one-star revival cap through decline and a later Ghola', () => { let g = enterHomeworldRevival(homeworldRevivalFixture({ advanced: true })); const ghola = holdRevivalCard(g, 'f', 'ghola'); reject(g, 'f', { type: 'revive', amount: 2, elite: 2 }, /one elite/); g = applyAction(g, 'f', { type: 'revive', amount: 1, elite: 1 }); const offer = viewGame(g, 'f').homeworldRevivalDeployment!; const returned = structuredClone(p(g, 'f')); g = applyAction(reload(g), 'f', { type: 'decision', event: offer.event, decline: true, }); assert.deepEqual(p(g, 'f'), returned); assert.equal(g.homeworldRevivalReturn!.destination, 'decline'); reject( g, 'f', { type: 'card', card: ghola, amount: 1, elite: 1 }, /one elite/, ); assert.ok(p(g, 'f').hand.some((card) => card.id === ghola)); stable(g); }); void test('high Tleilax mixed paid and free revival deploys only the actual free group and excludes old reserves', () => { let g = enterHomeworldRevival(homeworldRevivalFixture({ tleilaxu: true })); const old = structuredClone(p(g, 't')); g = applyAction(g, 't', { type: 'revive', amount: 3, elite: 0 }); assert.equal(p(g, 't').reserves, old.reserves + 3); assert.deepEqual(g.homeworldRevivalReturn!.group, { amount: 3, elite: 0, free: 2, }); assert.equal(viewGame(g, 't').homeworldRevivalDeployment!.normal, 2); const action = choice(g, 't'); reject(g, 't', { ...action, amount: 1 }, /entire/); reject(g, 't', { ...action, amount: 3 }, /entire/); const paid = p(g, 't').spice; g = applyAction(reload(g), 't', action); assert.equal(p(g, 't').reserves, old.reserves + 1); assert.equal(p(g, 't').forces['arrakeen:10'], old.forces['arrakeen:10'] + 2); assert.equal(p(g, 't').tanks, old.tanks - 3); assert.equal( p(g, 't').spice, old.spice - 1 + 1, 'Pay the Tleilaxu one-spice self-revival price for the single paid force; independent free-revival income settles once.', ); assert.ok(paid <= p(g, 't').spice); assert.equal(p(g, 't').revived, 3); assert.equal(p(g, 't').freeForcesRevived, 2); stable(g); }); void test('Emperor extra revival earns Southern placement without spending another ordinary revival allowance', () => { let g = homeworldRevivalFixture(); p(g, 'f').ally = 'e'; p(g, 'e').ally = 'f'; p(g, 'f').allySinceTurn = p(g, 'e').allySinceTurn = 1; g = enterHomeworldRevival(g); g = applyAction(g, 'f', { type: 'revive', amount: 3, elite: 0 }); assert.equal(g.homeworldRevivalReturn ?? null, null); const payer = p(g, 'e').spice; g = applyAction(g, 'e', { type: 'emperorRevival', amount: 2, elite: 2 }); assert.equal(g.homeworldRevivalReturn!.source, 'emperorExtra'); assert.deepEqual(g.homeworldRevivalReturn!.group, { amount: 2, elite: 2, free: 0, }); assert.equal(p(g, 'e').spice, payer - 4); const action = choice(g, 'f'); g = applyAction(reload(g), 'f', action); assert.equal(p(g, 'f').tanks, 0); assert.equal(p(g, 'f').revived, 3); assert.equal(p(g, 'f').freeForcesRevived, 3); assert.equal(g.emperorExtra.f, 2); assert.equal(p(g, 'e').spice, payer - 4); stable(g); }); void test('Tleilax free group can enter a nonallied Homeworld while allied Homeworlds reject without mutation', () => { const setup = enterHomeworldRevival( homeworldRevivalFixture({ tleilaxu: true }), ); const pending = applyAction(setup, 't', { type: 'revive', amount: 2, elite: 0, }); const target = viewGame( pending, 't', ).homeworldRevivalDeployment!.destinations.find( (destination) => destination.id === 'homeworld:emperor', ); assert.ok(target && !target.blocked); const action = choice(pending, 't', target.id); const returned = p(pending, 't').reserves; const done = applyAction(reload(pending), 't', action); const home = viewGame(done, 't').homeworlds!.worlds!.find( (world) => world.id === target.id, )!; assert.deepEqual(home.forces.t, { normal: 2, elite: 0 }); assert.equal( p(done, 't').reserves, returned - 2, 'Visiting counters leave native reserves and remain in explicit foreign custody.', ); assert.equal(p(done, 't').shipped, false); stable(done); const allied = reload(pending); p(allied, 't').ally = 'e'; p(allied, 'e').ally = 't'; p(allied, 't').allySinceTurn = p(allied, 'e').allySinceTurn = 1; assert.match( viewGame(allied, 't').homeworldRevivalDeployment!.destinations.find( (destination) => destination.id === target.id, )!.blocked!, /ally/, ); reject(allied, 't', action, /ally/); }); void test('Southern Ghola retires its physical card before choice and restores the independent Tleilaxu income response after placement', () => { let g = enterHomeworldRevival( homeworldRevivalFixture({ advanced: true, tleilaxu: true }), ); const ghola = holdRevivalCard(g, 'f', 'ghola'); holdRevivalCard(g, 'e', 'karama'); const initial = p(g, 't').spice; g = applyAction(g, 'f', { type: 'card', card: ghola, amount: 2, elite: 1 }); assert.equal(g.homeworldRevivalReturn!.source, 'ghola'); assert.equal(g.homeworldRevivalReturn!.card, ghola); assert.equal( p(g, 'f').hand.some((card) => card.id === ghola), false, ); assert.equal(g.discard.filter((card) => card.id === ghola).length, 1); assert.equal(g.response, null); assert.equal(g.homeworldRevivalReturn!.resumeResponse?.kind, 'revivalIncome'); assert.equal(p(g, 't').spice, initial); assert.equal(p(g, 'f').revived, 0); stable(g); for (const cancel of [false, true]) { let done = applyAction(reload(g), 'f', choice(g, 'f')); assert.equal(done.response?.kind, 'revivalIncome'); const command = cancel ? { type: 'card', mode: 'cancel', card: p(done, 'e').hand.find((card) => card.effect === 'karama')!.id, } : { type: 'passResponse' }; done = applyAction(reload(done), 'e', command); assert.equal(done.response, null); assert.equal(p(done, 't').spice, initial + (cancel ? 0 : 1)); assert.equal(done.discard.filter((card) => card.id === ghola).length, 1); stable(done); } }); void test('low populations do not invent a deployment; a return that first reaches high stages its own redeployment choice', () => { for (const ghola of [false, true]) { let g = homeworldRevivalFixture(); positionRevivalForces(g, 'f', { native: 1, tanks: 5, eliteTanks: 2 }); g = enterHomeworldRevival(g); const card = ghola ? holdRevivalCard(g, 'f', 'ghola') : undefined; const action = ghola ? { type: 'card', card, amount: 1, elite: 1 } : { type: 'revive', amount: 1, elite: 1 }; const low = applyAction(reload(g), 'f', action); assert.equal(low.homeworldRevivalReturn ?? null, null); assert.equal(viewGame(low, 'f').homeworldRevivalDeployment, null); assert.equal(p(low, 'f').reserves, 2); stable(low); // The crossing return now qualifies under the provisional post-return // population policy and may redeploy instead of only returning to reserves. const crossing = applyAction(reload(g), 'f', { ...action, amount: 2 }); assert.notEqual(crossing.homeworldRevivalReturn ?? null, null); assert.equal(viewGame(crossing, 'f').homeworldRevivalDeployment?.player, 'f'); const placed = applyAction(reload(crossing), 'f', choice(crossing, 'f')); assert.equal(placed.homeworldRevivalReturn!.stage, 'complete'); stable(placed); } let t = homeworldRevivalFixture({ tleilaxu: true }); positionRevivalForces(t, 't', { native: 8, tanks: 5 }); t = enterHomeworldRevival(t); const crossing = applyAction(reload(t), 't', { type: 'revive', amount: 1, elite: 0 }); assert.notEqual(crossing.homeworldRevivalReturn ?? null, null); assert.equal(viewGame(crossing, 't').homeworldRevivalDeployment?.player, 't'); stable(applyAction(reload(crossing), 't', choice(crossing, 't'))); const card = holdRevivalCard(t, 't', 'ghola'); const done = applyAction(t, 't', { type: 'card', card, amount: 2, elite: 0 }); assert.equal( done.homeworldRevivalReturn ?? null, null, 'Tleilax Ghola is not ordinary free revival.', ); stable(done); }); void test('signed deployment rejects corrupted original groups, missing choices, stale events and foreign destinations immutably', () => { const g = applyAction(enterHomeworldRevival(homeworldRevivalFixture()), 'f', { type: 'revive', amount: 2, elite: 2, }); const action = choice(g, 'f'); reject(g, 'e', action); reject(g, 'f', { ...action, event: 'old-event' }); reject(g, 'f', { ...action, destination: 'homeworld:emperor' }); reject(g, 'f', { ...action, decline: true }); for (const mutate of [ (bad: Game) => { bad.homeworldRevivalReturn!.group.elite = 1; }, (bad: Game) => { bad.homeworldRevivalReturn!.quote.elite = 1; }, (bad: Game) => { bad.homeworldRevivalReturn!.player = 'e'; }, (bad: Game) => { bad.homeworldRevivalReturn!.signature = 'forged'; }, (bad: Game) => { delete bad.homeworldRevivalReturn; }, (bad: Game) => { bad.decision = null; }, ]) { const bad = reload(g); mutate(bad); const before = structuredClone(bad); assert.throws(() => viewGame(bad, 'f'), /revival|returned group/i); assert.throws(() => normalizeAutomaticGame(bad), /revival|returned group/i); reject(bad, 'f', action, /revival|returned group/i); assert.deepEqual(bad, before); } stable(g); }); void test('real deployment projections expose destinations only to their owner and every bot profile can settle them legally', () => { const original = applyAction( enterHomeworldRevival(homeworldRevivalFixture()), 'f', { type: 'revive', amount: 2, elite: 2 }, ); const observer = viewGame(original, 'e'); assert.deepEqual(observer.homeworldRevivalDeployment?.destinations ?? [], []); assert.deepEqual(homeworldRevivalDeploymentActions(observer), []); assert.equal(observer.decision?.kind, 'homeworldRevivalDeployment'); for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { let done = reload(original), choices = 0; while (done.homeworldRevivalReturn!.stage !== 'complete' && choices < 3) { const view = viewGame(reload(done), 'f'); view.players.find((player) => player.id === 'f')!.bot = profile; const proposed = homeworldRevivalDeploymentActions(view); assert.ok(proposed.length > 0); assert.deepEqual(botActions(view), proposed); done = applyAction(reload(done), 'f', proposed[0]); choices++; } assert.equal(done.homeworldRevivalReturn!.stage, 'complete'); assert.ok(choices > 0 && choices <= 2); assert.equal(viewGame(done, 'f').homeworldRevivalDeployment, null); stable(done); } }); void test('every bot profile uses legal returns across the low-to-high crossing and the shared block permits a leader return', () => { for (const advanced of [false, true]) { let g = homeworldRevivalFixture({ advanced }); positionRevivalForces(g, 'f', { native: 2, tanks: 3, eliteTanks: 3 }); g = enterHomeworldRevival(g); const ghola = holdRevivalCard(g, 'f', 'ghola'); const ownView = viewGame(g, 'f'); // A return that itself reaches the high side is now legal under the // provisional post-return population policy. for (const action of [ { type: 'revive', amount: 1, elite: 1 }, { type: 'card', card: ghola, amount: 1, elite: 1 }, { type: 'card', card: ghola, amount: 1 }, ] as Action[]) assert.equal( homeworldRevivalActionBlock(ownView, action), null, JSON.stringify(action), ); for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { const view = viewGame(reload(g), 'f'); view.players.find((player) => player.id === 'f')!.bot = profile; const actions = botActions(view); assert.ok( actions.length, `${profile} must retain a legal way to advance.`, ); for (const action of actions) { assert.equal(homeworldRevivalActionBlock(view, action), null); const next = applyAction(reload(g), 'f', action); inventory(next); } } const leader = p(g, 'f').leaders[0]; leader.dead = true; const leaderAction = { type: 'card', card: ghola, leader: leader.id }; assert.equal( homeworldRevivalActionBlock(viewGame(g, 'f'), leaderAction), null, ); const done = applyAction(g, 'f', leaderAction); assert.equal( p(done, 'f').leaders.find((candidate) => candidate.id === leader.id)! .dead, false, ); assert.equal(p(done, 'f').reserves, 2); assert.equal(p(done, 'f').tanks, 3); assert.equal(done.homeworldRevivalReturn ?? null, null); assert.equal(done.discard.filter((card) => card.id === ghola).length, 1); stable(done); } });