Allow individual revived Fedaykin placement

This commit is contained in:
Quintine committed 2026-10-27 09:22:27 +11:00
1 parent 6a0fba860d
commit ff2826b980
25 files changed
+1301 -124

No files matched your search

+5 -2
View File
@@ -66,6 +66,8 @@ export function passRevivalArrivalReactions(game: Game, actions: RevivalArrivalR
/** Earn the original returned-group decision; no destination or BG reaction is chosen. */
export function openRevivalArrivalReactions(game: Game, fixture: { owner: string; revivalAction: Action },
actions: RevivalArrivalReactionsStep[] = []): Game {
game = advanceRevivalArrivalReactions(game, state => state.phase === 4 && revivalArrivalReactionsClean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === fixture.owner), actions);
game = actRevivalArrivalReactions(game, fixture.owner, fixture.revivalAction, actions);
game = advanceRevivalArrivalReactions(game, state =>
state.decision?.kind === 'homeworldRevivalDeployment' && !state.response && !state.phaseOpening, actions);
@@ -169,7 +171,8 @@ export function createRevivalArrivalReactionsFixture(options: { advanced?: boole
assert.equal(game.storm, 13);
assert.equal(revivalArrivalReactionsPlayer(game, owner).forces[casualtyLocation], undefined);
assert.equal(revivalArrivalReactionsPlayer(game, owner).tanks, 1);
reach(state => state.turn === 2 && state.phase === 4 && revivalArrivalReactionsClean(state));
reach(state => state.turn === 2 && state.phase === 4 && revivalArrivalReactionsClean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === owner));
assert.equal(revivalArrivalReactionsPlayer(game, owner).tanks, 1);
assert.equal(revivalArrivalReactionsPlayer(game, owner).reserves, 19);
assert.equal(revivalArrivalReactionsPlayer(game, bg).reserves, resident ? 18 : 19);
@@ -194,7 +197,7 @@ export function createRevivalArrivalReactionsFixture(options: { advanced?: boole
dealOrder, spiceOrder, originalSpice, originalInventory, originalCards: originalInventory,
steps: actions, actions, sourceActionCount: actions.length,
staging: [
'Public Basic/Advanced Bene Gesserit/Tleilaxu with actual Homeworlds lobby setting and original 47-card E1 deck. Only two original unopened starting-deal positions, unopened ordinary land Spice positions and native scalar shuffle order are selected. BG starts with one actual Polar fighter (Advanced uses its original advisorSetup); Tleilaxu receives its original Karama and original three Face Dancers. Actual first dials end Storm at11 in Basic (10+0) or12 in Advanced (11+0). Tleilaxu pays two spice to ship one native counter into Hagga Basin13; BG really declines that first Spiritual offer. The next native Storm (Basic 1+1 dials; Advanced original six-card shuffle/draw1) ends at13 and destroys the counter. Original phase controls reach the clean second Revival with Tanks1 and High19 native Tleilaxu/BG. Optional resident branch has BG actually pay to ship one fighter to Carthag11, leaving High18. No played hand, wallet, army, Tank, phase, stance, threshold, placement or reaction is installed. Source stops before ordinary free revive1; caller owns placement and every BG entry reaction.',
'Public Basic/Advanced Bene Gesserit/Tleilaxu with actual Homeworlds lobby setting and original 47-card E1 deck. Only two original unopened starting-deal positions, unopened ordinary land Spice positions and native scalar shuffle order are selected. BG starts with one actual Polar fighter (Advanced uses its original advisorSetup); Tleilaxu receives its original Karama and original three Face Dancers. Actual first dials end Storm at11 in Basic (10+0) or12 in Advanced (11+0). Tleilaxu pays two spice to ship one native counter into Hagga Basin13; BG really declines that first Spiritual offer. The next native Storm (Basic 1+1 dials; Advanced original six-card shuffle/draw1) ends at13 and destroys the counter. Original phase controls reach the clean second Revival with Tanks1 and High19 native Tleilaxu/BG, finishing preceding Advanced seats through their actual Ready controls until Tleilaxu owns the opportunity. Optional resident branch has BG actually pay to ship one fighter to Carthag11, leaving High18. No played hand, wallet, army, Tank, phase, stance, threshold, placement or reaction is installed. Source stops before ordinary free revive1; caller owns placement and every BG entry reaction.',
],
};
}
+191
View File
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type SouthernAllocationStep = TruthMovementStep;
export const southernPlayer = (game: Game, actor = 'fremen') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const southernReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const actSouthernAllocation = actTruthMovement;
export const southernAllocationClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
/** Count physical live slots, never historical references to those cards. */
export function southernAllocationInventory(game: Game) {
homeworldGameIntegrity(game);
const worlds = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
const sum = (counts: Record<string, number>) => Object.values(counts).reduce((a, b) => a + b, 0);
for (const player of game.players) {
const visitors = worlds.filter(world => world.native !== player.id)
.map(world => world.forces[player.id] ?? { normal: 0, elite: 0 });
assert.equal(player.reserves + player.tanks + sum(player.forces) +
visitors.reduce((total, group) => total + group.normal + group.elite, 0), 20, player.faction);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
visitors.reduce((total, group) => total + group.elite, 0), player.faction === 'emperor' ? 5 : 3);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
.map(card => card.id).sort();
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader)])].sort();
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
assert.equal(new Set(cards).size, cards.length);
assert.equal(new Set(traitors).size, traitors.length);
return { cards, traitors, spice };
}
/** Native controls only. Stop at an owned allocation or unsealed human plan. */
export function nextSouthernAllocationStep(game: Game): SouthernAllocationStep | null {
if (game.decision?.kind === 'homeworldRevivalDeployment') return null;
if (!game.response && !game.phaseOpening) {
if (game.status === 'setup' && game.setupStage === 'forces' && southernPlayer(game).reserves === 20)
return { actor: 'fremen', action: { type: 'fremenSetup',
placements: { 'sietch_tabr:14': 4, 'false_wall_south:5': 3, 'false_wall_west:18': 3 },
elitePlacements: { 'sietch_tabr:14': 3 } } };
if (game.decision?.kind === 'ecazPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.decision?.kind === 'ecazAmbassador')
return { actor: game.decision.player, action: { type: 'decision', event: game.pendingAmbassador!.event, decline: true } };
if (game.decision?.kind === 'revivalStop')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.decision?.kind === 'faceDance')
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
if (game.decision?.kind === 'wormRide' || game.decision?.kind === 'wormPlacement')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceSouthernAllocation(game: Game, until: (state: Game) => boolean,
actions: SouthernAllocationStep[] = []): Game {
for (let count = 0; count < 1000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Southern chronology ended before its requested choice');
const step = nextSouthernAllocationStep(game);
assert.ok(step, `Choose the original allocation or Battle Plans at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSouthernAllocation(game, step.actor, step.action, actions);
}
throw Error('The finite original Southern chronology did not reach its requested boundary');
}
/** Genuine bare Basic Homeworld source. Original public setup distributes the
* three actual stars; a paid Emperor invasion and chosen native casualties put
* two or three in the Tanks. The next ordinary free revival is still unplayed.
* Only original scalar lotteries and unopened non-worm Spice order are chosen.
* No played hand, purse, army, Tank, phase, population or receipt is installed.
*/
export function createSouthernAllocationFixture(options: { stars?: 2 | 3; third?: 'tleilaxu' | 'ecaz' } = {}) {
const stars = options.stars ?? 3;
let game = createGame('SOUTHERNALLOCATION', newPlayer('fremen', 'Fremen', 'fremen'), false,
options.third === 'tleilaxu' ? ['ix'] : options.third === 'ecaz' ? ['ecaz'] : []);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
if (options.third) joinGame(game, newPlayer(options.third, options.third, options.third));
const initial = structuredClone(game), actions: SouthernAllocationStep[] = [];
const act = (actor: string, action: Action) => { game = actSouthernAllocation(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSouthernAllocation(game, until, actions); };
act(game.host, { type: 'homeworlds', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
assert.ok(game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupSteps = structuredClone(actions);
const original = southernAllocationInventory(game);
assert.equal(southernPlayer(game).reserves, 10);
assert.deepEqual(southernPlayer(game).elites?.forces, { 'sietch_tabr:14': 3 });
assert.equal(game.storm, 1);
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
!card.discovery && !['sietch_tabr', 'false_wall_south', 'false_wall_west'].includes(card.territory));
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1);
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
// This optional original token will first be declined on the Emperor's entry,
// then provide a genuine phase-four arrival interruption after revival.
if (options.third === 'ecaz') {
reach(state => state.turn === 1 && state.decision?.kind === 'ecazPlacement');
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply');
assert.ok(token);
act('ecaz', { type: 'decision', token: token.id, territory: 'sietch_tabr' });
}
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'emperor' && southernAllocationClean(state));
const beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, 'emperor'), 1, 0);
assert.ok(sources);
// Homeworld shipment quotes govern World destinations. Tabr uses the
// ordinary Arrakis shipment with the exact original native reserve sources.
const shipmentStep: SouthernAllocationStep = { actor: 'emperor', action: {
type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 1, elite: 0,
allyPayment: 0, homeworldSources: sources,
} };
act(shipmentStep.actor, shipmentStep.action);
const shipmentCost = southernPlayer(beforeShipment, 'emperor').spice - southernPlayer(game, 'emperor').spice;
assert.equal(shipmentCost, 1);
reach(state => state.turn === 1 && state.phase === 6 && !!state.active && southernAllocationClean(state));
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'sietch_tabr'); assert.ok(choice);
act(choice.chooser, { type: 'chooseBattle', territory: 'sietch_tabr',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && southernAllocationClean(state));
const fremenLeader = southernPlayer(game).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const emperorLeader = southernPlayer(game, 'emperor').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(fremenLeader && emperorLeader, 'Both original factions retain an eligible native leader');
assert.ok(stars + fremenLeader.strength > emperorLeader.strength,
'The actual no-card native plans leave Fremen victorious before its chosen starred casualties');
const plans: SouthernAllocationStep[] = [
{ actor: 'fremen', action: { type: 'battlePlan', dial: stars, support: 0,
leader: fremenLeader.id } },
{ actor: 'emperor', action: { type: 'battlePlan', dial: 0, support: 0,
leader: emperorLeader.id } },
];
for (const step of plans) act(step.actor, step.action);
reach(state => state.decision?.kind === 'battleLosses');
const losses = game.decision;
assert.ok(losses?.kind === 'battleLosses');
const lossChoice = losses.options.findIndex(loss => loss.normal === 0 && loss.elite === stars); assert.ok(lossChoice >= 0);
act('fremen', { type: 'decision', choice: lossChoice });
reach(state => !state.battle && southernAllocationClean(state));
const afterBattle = structuredClone(game), battleSteps = structuredClone(actions.slice(setupSteps.length));
assert.equal(southernPlayer(game).tanks, stars); assert.equal(southernPlayer(game).elites?.tanks, stars);
assert.equal(southernPlayer(game).forces['sietch_tabr:14'], 4 - stars);
assert.equal(southernPlayer(game, 'emperor').tanks, 1);
reach(state => state.turn === 2 && state.phase === 4 && southernAllocationClean(state));
assert.equal(game.storm, 3, 'Actual Basic second Storm is one plus one, outside every original Fremen group');
assert.equal(southernPlayer(game).tanks, stars);
assert.equal(southernPlayer(game).elites?.tanks, stars);
assert.equal(southernPlayer(game).reserves, 10);
assert.equal(southernPlayer(game).forces['false_wall_south:5'], 3);
assert.equal(southernPlayer(game).forces['false_wall_west:18'], 3);
assert.deepEqual(southernAllocationInventory(game), original);
const revivalAction: Action = { type: 'revive', amount: stars, elite: stars };
return { initial, lobby, setup, afterSetup, beforeShipment, shipmentStep, shipmentCost,
beforeBattle, plans, afterBattle, game, stars, third: options.third, actions, setupSteps, battleSteps,
spiceOrder, original, revivalAction,
sourceCounts: { setup: setupSteps.length, throughBattle: setupSteps.length + battleSteps.length, source: actions.length },
sourceDisclosure: 'Actual Basic Fremen/Emperor Homeworld setup; paid one-force Emperor invasion; native no-card battle and exact starred casualties; actual next Storm1+1 and phase-four return opportunity. Only original scalar lotteries and unopened Spice order selected.' };
}
export function openSouthernAllocation(fixture: ReturnType<typeof createSouthernAllocationFixture>,
actions: SouthernAllocationStep[] = []): Game {
let game = actSouthernAllocation(southernReload(fixture.game), 'fremen', fixture.revivalAction, actions);
game = advanceSouthernAllocation(game, state => state.decision?.kind === 'homeworldRevivalDeployment', actions);
assert.equal(viewGame(game, 'fremen').homeworldRevivalDeployment?.elite, fixture.stars);
return game;
}
export function southernAllocationAction(game: Game, destination: string, amount: number): Action {
const offer = viewGame(game, 'fremen').homeworldRevivalDeployment; assert.ok(offer);
assert.ok(offer.destinations.some(option => option.id === destination && !option.blocked));
return { type: 'decision', event: offer.event, destination, amount };
}
export function settleSouthernAllocation(game: Game, actions: SouthernAllocationStep[] = []): Game {
return advanceSouthernAllocation(game, state => state.decision?.kind === 'homeworldRevivalDeployment' ||
(state.homeworldRevivalReturn?.stage === 'complete' && !state.response && !state.pendingAmbassador), actions);
}
@@ -14,8 +14,20 @@ import {
import { homeworldRevivalAmbassadorFixture as fixture } from './fixture-homeworld-revival-ambassador';
function revivalOpportunity(g: Game, actor: string) {
for (let count = 0; g.revivalOrder && g.revivalOrder.remaining[0] !== actor && count < g.players.length; count++) {
const preceding = g.revivalOrder.remaining[0]; assert.ok(preceding);
assert.equal(g.phase, 4);
assert.equal(g.response ?? null, null); assert.equal(g.decision ?? null, null);
g = applyAction(g, preceding, { type: 'ready' });
}
assert.equal(g.phase, 4);
if (g.revivalOrder) assert.equal(g.revivalOrder.remaining[0], actor);
return g;
}
function open() {
let g = fixture();
let g = revivalOpportunity(fixture(), 'f');
g = applyAction(g, 'f', { type: 'revive', amount: 1, elite: 1 });
if (g.decision?.kind === 'revivalStop')
g = applyAction(g, 't', { type: 'decision', decline: true });
@@ -89,7 +101,7 @@ void test('actual phase-four revived arrival rejects fighter accompaniment and h
});
void test('a controlled native World source completes revived Tleilax Intrusion and Spiritual declines before its Fremen Ambassador relocation', () => {
let g = fixture();
let g = revivalOpportunity(fixture(), 't');
// This existing fixture conserves staged native armies/cards. The return,
// Intrusion, Spiritual choice and Ambassador continuations are real actions.
g = applyAction(g, 't', { type: 'revive', amount: 1, elite: 0 });
@@ -119,7 +131,7 @@ void test('a controlled native World source completes revived Tleilax Intrusion
});
void test('a controlled conserved Southern return permits Intrusion Karama without inventing Spiritual accompaniment', () => {
let g = fixture();
let g = revivalOpportunity(fixture(), 'f');
// Move existing Fremen/BG counters to a non-stronghold in this controlled
// fixture. No revival, entry, response or continuation is installed.
p(g, 'f').forces['arrakeen:10']--;
@@ -1,8 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { HomeworldRevivalDeployment } from '../components/homeworld-revival-deployment';
import { HomeworldRevivalDeployment, HomeworldRevivalDeploymentButtons } from '../components/homeworld-revival-deployment';
import { baseDeck } from '../game/cards';
import {
createGame,
@@ -15,6 +15,7 @@ import {
homeworldRevivalDeploymentActions,
homeworldRevivalDeploymentCanAct,
homeworldRevivalDeploymentChoice,
homeworldRevivalDeploymentQuantities,
homeworldRevivalActionBlock,
} from '../game/homeworld-revival-deployment-options';
@@ -82,11 +83,31 @@ function markup(game: GameView, busy = false) {
);
}
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<ButtonProps>[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ButtonProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function deploymentButton(props: Parameters<typeof HomeworldRevivalDeploymentButtons>[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<ButtonProps>) => (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: {
@@ -109,6 +130,92 @@ void test('revival controls commit the entire eligible typed group to one curren
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, /<option value="1" selected="">1<\/option>/);
assert.match(html, /Place 1 revived force/); assert.match(html, /Leave all remaining in reserves/);
}
});
void test('allocation metadata and amount bounds fail closed instead of reverting to a whole-group action', () => {
for (const allocation of [null,
{ total: 3, remaining: 2, placed: 0, kept: 0 },
{ total: 3, remaining: 3, placed: -1, kept: 1 },
{ total: 3, remaining: 1.5, placed: 1.5, kept: 0 },
{ total: 21, remaining: 3, placed: 18, kept: 0 },
{ total: 3, remaining: 3, placed: 0, kept: 0, quantity: 1 },
]) {
const view = allocated(); Object.assign(view.homeworldRevivalDeployment!, { allocation });
assert.deepEqual(homeworldRevivalDeploymentQuantities(view), []);
assert.match(homeworldRevivalDeploymentChoice(view, 'arrakeen:9').blocked!, /allocation is no longer available/);
assert.deepEqual(homeworldRevivalDeploymentActions(view), [{ type: 'decision', event: 'revival-deployment-1', decline: true }]);
}
for (const amount of [0, -1, 1.5, NaN, Infinity, 4])
assert.equal(homeworldRevivalDeploymentChoice(allocated(), 'arrakeen:9', amount).action, null);
for (const change of [
(view: GameView) => { view.advanced = true; },
(view: GameView) => { view.homeworldRevivalDeployment!.kind = 'tleilax'; },
(view: GameView) => { view.homeworldRevivalDeployment!.normal = 1; },
]) {
const view = allocated(); change(view); assert.deepEqual(homeworldRevivalDeploymentQuantities(view), []);
assert.equal(homeworldRevivalDeploymentChoice(view, 'arrakeen:9', 1).action, null);
}
});
void test('allocation callbacks submit only the selected native amount or all-remaining decline and honor busy and changed choices', () => {
const game = allocated(), sent: unknown[] = [];
const props = { game, busy: false, destination: 'arrakeen:9', amount: 2, act(action: unknown) { sent.push(action); } };
const place = deploymentButton(props, 'place'), reserves = deploymentButton(props, 'reserves');
assert.equal(place.props.disabled, false); click(place); click(reserves);
assert.deepEqual(sent, [{ type: 'decision', event: 'revival-deployment-1', destination: 'arrakeen:9', amount: 2 },
{ type: 'decision', event: 'revival-deployment-1', decline: true }]);
props.busy = true; click(place); click(reserves); assert.equal(sent.length, 2);
assert.equal(deploymentButton(props, 'place').props.disabled, true);
props.busy = false; game.homeworldRevivalDeployment!.destinations[2].blocked = 'The current arrival must finish.';
click(place); click(reserves); assert.equal(sent.length, 2);
assert.match(markup(game, true), /<select[^>]*aria-label="Starred forces to place now"[^>]*disabled/);
});
void test('each fresh child event resets the quantity and rejects old callbacks until original arrival children finish', () => {
const game = allocated(), sent: unknown[] = [];
const props = { game, busy: false, destination: 'arrakeen:9', amount: 2, act(action: unknown) { sent.push(action); } };
const oldKey = HomeworldRevivalDeployment(props)!.key;
const place = deploymentButton(props, 'place'), reserves = deploymentButton(props, 'reserves');
game.response = { kind: 'revivalIncome', owner: 'a', passed: [] };
assert.deepEqual(homeworldRevivalDeploymentQuantities(game), []); click(place); click(reserves); assert.equal(sent.length, 0);
game.response = null;
game.homeworldRevivalDeployment = { ...game.homeworldRevivalDeployment!, event: 'revival-deployment-2', elite: 1,
allocation: { total: 3, remaining: 1, placed: 2, kept: 0 } };
assert.equal(homeworldRevivalDeploymentCanAct(game), false);
game.decision = { kind: 'homeworldRevivalDeployment', player: 'f', event: 'revival-deployment-2' };
click(place); click(reserves); assert.equal(sent.length, 0);
assert.notEqual(HomeworldRevivalDeployment(props)!.key, oldKey);
assert.deepEqual(homeworldRevivalDeploymentQuantities(game), [1]);
const html = markup(game); assert.match(html, /1 starred remaining of 3 · 2 placed/);
assert.match(html, /<option value="1" selected="">1<\/option>/); assert.match(html, /Place 1 revived force/);
assert.deepEqual(homeworldRevivalDeploymentChoice(game, 'hagga_basin:8').action,
{ type: 'decision', event: 'revival-deployment-2', destination: 'hagga_basin:8', amount: 1 });
});
void test('current projected restrictions invalidate stale selections and preserve a decline path', () => {
@@ -75,8 +75,8 @@ void test('actual high Southern revival moves every newly revived Basic star onc
);
stable(g);
const action = choice(g, 'f');
for (const amount of [0, 1, 3])
reject(g, 'f', { ...action, amount }, /entire/);
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);
@@ -346,13 +346,18 @@ void test('real deployment projections expose destinations only to their owner a
assert.deepEqual(homeworldRevivalDeploymentActions(observer), []);
assert.equal(observer.decision?.kind, 'homeworldRevivalDeployment');
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(reload(original), 'f');
view.players.find((player) => player.id === 'f')!.bot = profile;
const proposed = homeworldRevivalDeploymentActions(view);
assert.equal(proposed.length, 1);
assert.deepEqual(botActions(view), proposed);
const done = applyAction(reload(original), 'f', proposed[0]);
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);
}
@@ -27,6 +27,41 @@ function fixture(faction: 'fremen' | 'tleilaxu', advanced = true) {
return { g, grant };
}
void test('Southern subset quotes debit only selected original stars while omitted quantities retain whole-group behavior', () => {
const { g, grant } = fixture('fremen', false), before = structuredClone(g);
const one = quoteHomeworldRevivalDestination(g, 'p', grant, 'arrakeen:10', 1);
assert.equal(one.transfer.players.find(player => player.id === 'p')!.reserves, 9);
assert.equal(one.transfer.players.find(player => player.id === 'p')!.eliteReserves, 1);
const full = quoteHomeworldRevivalDestination(g, 'p', grant, 'polar_sink:0');
assert.equal(full.transfer.players.find(player => player.id === 'p')!.reserves, 8);
assert.equal(full.transfer.players.find(player => player.id === 'p')!.eliteReserves, 0);
for (const amount of [0, -1, 1.5, 3, NaN, Infinity])
assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', grant, 'polar_sink:0', amount));
const t = fixture('tleilaxu', false);
assert.throws(() => quoteHomeworldRevivalDestination(t.g, 'p', t.grant, 'polar_sink:0', 1));
assert.equal(quoteHomeworldRevivalDestination(t.g, 'p', t.grant, 'polar_sink:0').transfer.players
.find(player => player.id === 'p')!.reserves, 9);
assert.deepEqual(g, before);
});
void test('later Southern groups retain the fixed earned grant below threshold but quote current destination presence', () => {
const { g, grant } = fixture('fremen', false);
g.players[0].reserves = 6;
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const originalGrant = { ...grant, beforePopulation: 3, afterPopulation: 6 };
const first = quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'arrakeen:10', 1);
const owner = first.transfer.players.find(player => player.id === 'p')!;
g.players[0].reserves = owner.reserves; g.players[0].elites!.reserves = owner.eliteReserves;
g.homeworlds.custody = first.transfer.state;
assert.equal(g.players[0].reserves, 5);
// A completed first child may change the board; never reuse its destination quote.
g.players[0].forces = { 'polar_sink:0': 1 };
assert.throws(() => quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'arrakeen:10', 1));
const second = quoteHomeworldRevivalDestination(g, 'p', originalGrant, 'polar_sink:0', 1);
assert.equal(second.transfer.players.find(player => player.id === 'p')!.eliteReserves, 0);
assert.deepEqual(originalGrant, { ...grant, beforePopulation: 3, afterPopulation: 6 });
});
void test('Southern lists existing territories only and withdraws the entire typed star group without mutation', () => {
const { g, grant } = fixture('fremen', false);
const before = structuredClone(g);
+124
View File
@@ -10,9 +10,133 @@ import {
validateHomeworldRevivalReturn,
homeworldRevivalResumeSignature,
homeworldRevivalArrivalSignature,
makeSouthernRevivalAllocation,
southernRevivalRemaining,
southernRevivalSummary,
currentHomeworldRevivalArrival,
currentHomeworldRevivalGroup,
openSouthernRevivalChoice,
commitSouthernRevivalChoice,
completeSouthernRevivalChild,
appendHomeworldRevivalAmbassador,
completeHomeworldRevivalAmbassador,
type HomeworldRevivalReturn,
} from '../game/homeworld-revival-return';
function southernFixture(stars = 3) {
const { g, original } = fixture();
g.advanced = false;
original.group.elite = stars;
original.quote.elite = stars;
g.players[0].elites!.reserves = stars;
const root = makeSouthernRevivalAllocation(original);
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
return { g, root, original };
}
function openSouthern(root: HomeworldRevivalReturn, event: string, response: Game['response'] = null) {
root.resumeResponse = structuredClone(response);
root.resumeSignature = homeworldRevivalResumeSignature(response);
openSouthernRevivalChoice(root, event);
}
void test('Southern v2 allocates three original stars to distinct current groups and reserves while holding one original income', () => {
const { g, root } = southernFixture();
validateHomeworldRevivalReturn(g, root);
assert.equal(currentHomeworldRevivalArrival(root), null);
assert.equal(currentHomeworldRevivalGroup(root), null);
const response: Game['response'] = { kind: 'revivalIncome', owner: 'v', recipient: 'p', amount: 1, passed: [] };
openSouthern(root, 'child-1', response);
const original = structuredClone({ event: root.event, source: root.source, group: root.group, quote: root.quote,
signature: root.signature, resumeResponse: root.resumeResponse, resumeSignature: root.resumeSignature });
const destinations = ['arrakeen:10', 'polar_sink:0', 'decline'];
for (const [index, destination] of destinations.entries()) {
const event = `child-${index + 1}`;
assert.equal(currentHomeworldRevivalArrival(root)!.event, event);
assert.equal(currentHomeworldRevivalGroup(root), null);
commitSouthernRevivalChoice(root, event, { amount: 1, destination });
assert.deepEqual(currentHomeworldRevivalGroup(root), { amount: 1, elite: 1 });
if (destination !== 'decline') { g.players[0].reserves--; g.players[0].elites!.reserves--; }
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
validateHomeworldRevivalReturn(g, root);
completeSouthernRevivalChild(root, event, index < 2 ? `child-${index + 2}` : undefined);
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
validateHomeworldRevivalReturn(g, root);
}
assert.equal(root.stage, 'complete');
assert.deepEqual(southernRevivalSummary(root), { total: 3, remaining: 0, placed: 2, kept: 1 });
assert.equal(g.players[0].elites!.reserves, 1);
assert.equal(root.allocation!.children.length, 3);
assert.deepEqual({ event: root.event, source: root.source, group: root.group, quote: root.quote,
signature: root.signature, resumeResponse: root.resumeResponse, resumeSignature: root.resumeSignature }, original);
validateHomeworldRevivalReturn(g, JSON.parse(JSON.stringify(root)) as HomeworldRevivalReturn);
});
void test('Southern v2 is a fresh Basic multi-star grant while minimal legacy identities and original whole frames remain unchanged', () => {
const legacy = fixture(), before = JSON.stringify(legacy.frame);
assert.equal(currentHomeworldRevivalArrival(legacy.frame), legacy.frame);
assert.deepEqual(currentHomeworldRevivalGroup(legacy.frame), { amount: 2, elite: 2 });
const minimal = { event: 'legacy', turn: 1, phase: 4, stage: 'arrival' as const, destination: 'arrakeen:10' };
assert.equal(currentHomeworldRevivalArrival(minimal), minimal);
assert.equal(currentHomeworldRevivalArrival(null), null);
assert.equal(currentHomeworldRevivalArrival(undefined), null);
assert.equal(JSON.stringify(legacy.frame), before);
assert.equal('allocation' in legacy.frame, false);
assert.throws(() => makeSouthernRevivalAllocation(fixture('tleilax').original));
const one = fixture().original; one.group.elite = 1; one.quote.elite = 1;
assert.throws(() => makeSouthernRevivalAllocation(one));
const { g, root } = southernFixture(2);
assert.throws(() => validateHomeworldRevivalReturn({ ...g, advanced: true }, root));
validateHomeworldRevivalReturn(g, root);
});
void test('Southern child selection rejects stale events, duplicate decisions and over-allocation before changing the root', () => {
const { g, root } = southernFixture(2);
openSouthern(root, 'child-1');
for (const selection of [{ amount: 0, destination: 'decline' }, { amount: 3, destination: 'arrakeen:10' },
{ amount: 1.5, destination: 'polar_sink:0' }, { amount: 1, destination: '' }]) {
const before = structuredClone(root);
assert.throws(() => commitSouthernRevivalChoice(root, 'child-1', selection));
assert.deepEqual(root, before);
}
commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'decline' });
const before = structuredClone(root);
for (const next of [undefined, 'child-1', root.event]) {
assert.throws(() => completeSouthernRevivalChild(root, 'child-1', next));
assert.deepEqual(root, before);
}
completeSouthernRevivalChild(root, 'child-1', 'child-2');
assert.equal(southernRevivalRemaining(root), 1);
assert.throws(() => commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'decline' }));
assert.throws(() => commitSouthernRevivalChoice(root, 'child-2', { amount: 2, destination: 'decline' }));
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
validateHomeworldRevivalReturn(g, root);
const changed = structuredClone(root); changed.allocation!.children[0].amount = 2;
assert.throws(() => makeHomeworldRevivalProgress(changed));
const regranted = structuredClone(root); regranted.quote.afterPopulation++;
assert.throws(() => makeHomeworldRevivalProgress(regranted));
});
void test('Southern children retain their own Ambassador completion before the next unique decision', () => {
const { g, root } = southernFixture(2);
openSouthern(root, 'child-1');
commitSouthernRevivalChoice(root, 'child-1', { amount: 1, destination: 'arrakeen:10' });
g.players[0].reserves--; g.players[0].elites!.reserves--;
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
const ambassador = { event: 'child-1-ambassador', entrant: 'p', territory: 'arrakeen', sector: 10, turn: 2, phase: 4 };
appendHomeworldRevivalAmbassador(root, ambassador);
const pending = { ...ambassador, revivalEvent: 'child-1' } as NonNullable<Game['pendingAmbassador']>;
validateHomeworldRevivalReturn({ ...g, pendingAmbassador: pending }, root);
assert.throws(() => completeSouthernRevivalChild(root, 'child-1', 'child-2'));
completeHomeworldRevivalAmbassador(root, ambassador.event);
validateHomeworldRevivalReturn({ ...g, pendingAmbassador: null }, root);
completeSouthernRevivalChild(root, 'child-1', 'child-2');
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
assert.equal(root.allocation!.children[0].ambassadors![0].completed, true);
assert.equal(currentHomeworldRevivalArrival(root)!.event, 'child-2');
assert.throws(() => completeHomeworldRevivalAmbassador(root, ambassador.event));
validateHomeworldRevivalReturn({ ...g, pendingAmbassador: null }, root);
});
function fixture(
kind: 'fedaykin' | 'tleilax' = 'fedaykin',
source: 'normal' | 'emperorExtra' | 'ghola' | 'nexusTleilaxu' = 'normal',
+31 -1
View File
@@ -4,7 +4,9 @@ import { createGame, newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody, HomeworldCustodyError } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { appendHomeworldArrivalAmbassador, homeworldArrivalSignature } from '../game/homeworld-arrival';
import { makeHomeworldRevivalReturn, makeHomeworldRevivalProgress } from '../game/homeworld-revival-return';
import { makeHomeworldRevivalReturn, makeHomeworldRevivalProgress, makeSouthernRevivalAllocation,
currentHomeworldRevivalArrival, openSouthernRevivalChoice, commitSouthernRevivalChoice,
completeSouthernRevivalChild } from '../game/homeworld-revival-return';
import { terrorEntrySignature, validateTerrorEntrySignature } from '../game/terror-entry-receipt';
import {
revivalOwnsTerrorEntry, validateRevivalTerrorEntry, validateRevivalArrivalSource,
@@ -37,6 +39,34 @@ function fixture(fremen = false) {
entry.entrySignature = terrorEntrySignature(entry);
return { g, frame, entry };
}
void test('Southern live Terror source binds each actual child subset and never the whole root or a retired child', () => {
const { g, entry } = fixture(true);
g.players[0].reserves = 3; g.players[0].elites!.reserves = 2;
const root = makeSouthernRevivalAllocation({ event: 'southern-root', turn: 2, phase: 4, player: 'native', source: 'normal',
group: { amount: 2, elite: 2, free: 2 },
quote: { kind: 'fedaykin', normal: 0, elite: 2, beforePopulation: 1, afterPopulation: 3, blocked: null } });
root.resumeResponse = null; root.resumeSignature = 'null';
openSouthernRevivalChoice(root, 'southern-child-1');
commitSouthernRevivalChoice(root, 'southern-child-1', { amount: 1, destination: 'arrakeen:10' });
g.homeworldRevivalReturn = root; g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
Object.assign(entry, { amount: 1, elite: 1, revivalArrival: { event: 'southern-child-1' } });
entry.entrySignature = terrorEntrySignature(entry);
validateRevivalTerrorEntry(g, entry);
assert.throws(() => validateRevivalArrivalSource(g, { event: root.event }, entry), HomeworldCustodyError);
assert.throws(() => validateRevivalArrivalSource(g, entry.revivalArrival!, { ...entry, amount: 2, elite: 2 }), HomeworldCustodyError);
const historical = packet(entry);
validateRevivalTerrorPacket({ ...g, revivalPlayer: root.player }, historical, 'arrakeen:10', 'southern-child-1');
completeSouthernRevivalChild(root, 'southern-child-1', 'southern-child-2');
commitSouthernRevivalChoice(root, 'southern-child-2', { amount: 1, destination: 'polar_sink:0' });
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(root);
assert.equal(currentHomeworldRevivalArrival(root)!.event, 'southern-child-2');
assert.throws(() => validateRevivalTerrorEntry(g, entry), HomeworldCustodyError);
// The old captured history remains historical without reopening its live source.
validateRevivalTerrorPacket({ ...g, revivalPlayer: root.player }, historical, 'arrakeen:10', 'southern-child-1');
validateRevivalArrivalSource(g, { event: 'southern-child-2' },
{ entrant: 'native', territory: 'polar_sink', sector: 0, amount: 1, elite: 1, cause: 'homeworldRevival' });
});
function spiritual(parent: RevivalEntryParent, territory = 'arrakeen', sector = 10): RevivalEntryParent {
return { ...structuredClone(parent), spiritual: { event: 'original-spiritual-packet', owner: 'bg', turn: 2, phase: 4,
territory, sector, amount: 1, revivalEvent: parent.event } };
+231
View File
@@ -0,0 +1,231 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { homeworldRevivalDeploymentActions } from '../game/homeworld-revival-deployment-options';
import {
actSouthernAllocation as act, advanceSouthernAllocation as advance,
createSouthernAllocationFixture as fixture, openSouthernAllocation as open,
settleSouthernAllocation as settle, southernAllocationAction as choice,
southernAllocationClean as clean, southernAllocationInventory as inventory,
southernPlayer as p, southernReload as reload, type SouthernAllocationStep,
} from './fixture-southern-allocation';
function reject(game: Game, actor: string, action: Action) {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected allocation leaves the native return and all physical custody unchanged');
}
function offer(game: Game) {
const current = viewGame(game, 'fremen').homeworldRevivalDeployment; assert.ok(current); return current;
}
function allocation(game: Game) {
const current = offer(game).allocation; assert.ok(current); return current;
}
function finish(game: Game, actions: SouthernAllocationStep[] = []) {
game = settle(game, actions);
assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
assert.equal(game.decision ?? null, null);
inventory(game);
return game;
}
function unchangedUses(before: Game, after: Game) {
for (const key of ['spice', 'shipped', 'moved', 'revived', 'freeForcesRevived'] as const)
assert.equal(p(after)[key], p(before)[key], key);
assert.deepEqual(after.homeworldRevivalReturn?.group, before.homeworldRevivalReturn?.group);
}
void test('genuine Southern source earns three starred Tanks through original setup paid invasion and battle', () => {
const f = fixture();
assert.equal(f.sourceCounts.setup, f.setupSteps.length);
assert.equal(f.sourceCounts.throughBattle, f.setupSteps.length + f.battleSteps.length);
assert.equal(f.sourceCounts.source, f.actions.length);
assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [0, 0, 1, 1]);
assert.equal(f.shipmentCost, 1);
assert.ok(f.actions.some(step => step.actor === 'emperor' && step.action.type === 'ship' && step.action.amount === 1));
assert.equal(p(f.afterBattle).tanks, 3); assert.equal(p(f.afterBattle).elites?.tanks, 3);
assert.equal(p(f.afterBattle).forces['sietch_tabr:14'], 1);
assert.equal(p(f.game).reserves, 10); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
assert.equal(f.game.advanced, false); assert.equal(f.game.storm, 3);
assert.deepEqual(inventory(f.game), f.original);
assert.equal(f.original.cards.length, f.setup.deck.length);
assert.ok(f.original.traitors.length > 0 && f.original.spice.length > 0);
assert.equal(f.game.homeworldRevivalReturn ?? null, null, 'The source has not installed or played its upcoming revival');
});
void test('one genuine free Basic return opens its original three-star allocation without spending shipment or movement', () => {
const f = fixture(), game = open(f);
assert.equal(p(game).spice, p(f.game).spice);
assert.equal(p(game).tanks, 0); assert.equal(p(game).reserves, 13);
assert.equal(p(game).elites?.reserves, 3); assert.equal(p(game).elites?.revived, 3);
assert.equal(p(game).revived, 3); assert.equal(p(game).freeForcesRevived, 3);
assert.deepEqual(game.homeworldRevivalReturn?.group, { amount: 3, elite: 3, free: 3 });
assert.equal(offer(game).normal, 0); assert.equal(offer(game).elite, 3);
assert.deepEqual(allocation(game), { total: 3, remaining: 3, placed: 0, kept: 0 });
assert.equal(p(game).shipped, p(f.game).shipped); assert.equal(p(game).moved, p(f.game).moved);
assert.ok(offer(game).destinations.filter(destination => !destination.blocked).length >= 2);
assert.deepEqual(inventory(game), f.original);
});
void test('three returned Southern stars split one plus two between distinct native occupied territories', () => {
const f = fixture(); let game = open(f);
const returned = reload(game), first = choice(game, 'false_wall_south:5', 1);
game = settle(act(reload(game), 'fremen', first));
assert.equal(p(game).forces['false_wall_south:5'], 4);
assert.equal(p(game).elites?.forces['false_wall_south:5'], 1);
assert.equal(p(game).reserves, 12); assert.equal(p(game).elites?.reserves, 2);
assert.deepEqual(allocation(game), { total: 3, remaining: 2, placed: 1, kept: 0 });
assert.notEqual(offer(game).event, first.event);
assert.equal(offer(game).elite, 2);
game = finish(act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 2)));
assert.equal(p(game).forces['false_wall_west:18'], 5);
assert.equal(p(game).elites?.forces['false_wall_west:18'], 2);
assert.equal(p(game).reserves, 10); assert.equal(p(game).elites?.reserves, 0);
unchangedUses(returned, game);
assert.deepEqual(inventory(game), f.original);
});
void test('one star may be placed and every remaining returned star kept at Southern Hemisphere', () => {
const f = fixture(); let game = open(f);
const returned = reload(game);
game = settle(act(game, 'fremen', choice(game, 'sietch_tabr:14', 1)));
const last = offer(game).event;
game = finish(act(reload(game), 'fremen', { type: 'decision', event: last, decline: true }));
assert.equal(p(game).forces['sietch_tabr:14'], 2);
assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
assert.equal(p(game).reserves, 12); assert.equal(p(game).elites?.reserves, 2);
assert.equal(p(game).tanks, 0);
unchangedUses(returned, game);
reject(game, 'fremen', { type: 'decision', event: last, decline: true });
});
void test('legacy whole-group packet still places all three stars at one native destination', () => {
const f = fixture(); let game = open(f);
const returned = reload(game), packet = choice(game, 'sietch_tabr:14', 3);
game = finish(act(reload(game), 'fremen', packet));
assert.equal(p(game).forces['sietch_tabr:14'], 4);
assert.equal(p(game).elites?.forces['sietch_tabr:14'], 3);
assert.equal(p(game).reserves, 10);
unchangedUses(returned, game);
reject(game, 'fremen', packet);
});
void test('declining the first child keeps the exact complete returned group without undoing revival', () => {
const f = fixture(); let game = open(f);
const returned = reload(game), event = offer(game).event;
game = finish(act(reload(game), 'fremen', { type: 'decision', event, decline: true }));
assert.equal(p(game).reserves, 13); assert.equal(p(game).elites?.reserves, 3);
assert.equal(p(game).tanks, 0); assert.equal(p(game).elites?.tanks, 0);
assert.deepEqual(p(game).forces, p(f.game).forces);
assert.deepEqual(p(game).elites?.forces, p(f.game).elites?.forces);
unchangedUses(returned, game);
assert.deepEqual(inventory(game), f.original);
});
void test('fresh child event fences stale packets overdraw wrong owner and an unearned destination', () => {
let game = open(fixture());
const old = choice(game, 'false_wall_south:5', 1);
game = settle(act(game, 'fremen', old));
const current = choice(game, 'false_wall_west:18', 1);
reject(game, 'fremen', old);
reject(game, 'emperor', current);
for (const amount of [0, -1, 3, 1.5]) reject(game, 'fremen', { ...current, amount });
reject(game, 'fremen', { ...current, destination: 'arrakeen:10' });
reject(game, 'fremen', { ...current, destination: 'homeworld:fremen' });
reject(game, 'fremen', { type: 'decision', event: current.event, decline: true, amount: 1 });
assert.deepEqual(allocation(game), { total: 3, remaining: 2, placed: 1, kept: 0 });
});
void test('pending allocations keep the original Revival control owned until all stars are disposed', () => {
let game = open(fixture());
reject(game, 'fremen', { type: 'ready' });
reject(game, 'fremen', { type: 'revive', amount: 1, elite: 0 });
reject(game, 'emperor', { type: 'revive', amount: 1, elite: 0 });
game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
reject(game, 'fremen', { type: 'ready' });
game = finish(act(game, 'fremen', { type: 'decision', event: offer(game).event, decline: true }));
game = advance(game, state => state.phase === 5 && clean(state));
assert.equal(p(game).revived, 3); assert.equal(p(game).elites?.revived, 3);
});
void test('two actual starred casualties produce only their exact two-star native allocation', () => {
const f = fixture({ stars: 2 }); let game = open(f);
assert.equal(p(f.game).elites?.forces['sietch_tabr:14'], 1);
assert.deepEqual(allocation(game), { total: 2, remaining: 2, placed: 0, kept: 0 });
game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
assert.equal(offer(game).elite, 1);
game = finish(act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 1)));
assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
assert.equal(p(game).elites?.forces['false_wall_south:5'], 1);
assert.equal(p(game).elites?.forces['false_wall_west:18'], 1);
assert.equal(p(game).revived, 2);
assert.deepEqual(inventory(game), f.original);
});
void test('original one-spice Tleilax free-revival income waits across every child and settles once after final disposition', () => {
const f = fixture({ third: 'tleilaxu' }); let game = open(f);
const provider = p(game, 'tleilaxu').spice;
const returned = reload(game);
assert.equal(game.homeworldRevivalReturn?.resumeResponse?.kind, 'revivalIncome');
const income = game.homeworldRevivalReturn!.resumeResponse!.amount;
assert.equal(income, 1, 'Normal free revival grants one native bank payment per faction and turn, regardless of returned group size');
assert.equal(game.revivalFreeIncome?.fremen, game.turn);
game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
assert.equal(p(game, 'tleilaxu').spice, provider);
assert.equal(game.response ?? null, null);
game = act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 2));
assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
assert.equal(game.response ?? null, null, 'Native normalization settles the restored income when nobody owns a cancellation choice');
assert.equal(p(game, 'tleilaxu').spice, provider + income!);
assert.equal(game.revivalFreeIncome?.fremen, game.turn);
game = finish(game);
assert.equal(p(game, 'tleilaxu').spice, provider + income!);
unchangedUses(returned, game);
assert.deepEqual(inventory(game), f.original);
});
void test('actual native Ecaz arrival finishes before the next fresh Southern child becomes available', () => {
const f = fixture({ third: 'ecaz' }); let game = open(f);
assert.ok(f.actions.some(step => step.actor === 'ecaz' && step.action.type === 'decision' && step.action.territory === 'sietch_tabr'));
const first = choice(game, 'sietch_tabr:14', 1);
game = act(game, 'fremen', first);
assert.equal(game.homeworldRevivalReturn?.stage, 'arrival');
assert.equal(game.pendingAmbassador?.stage, 'offer');
assert.equal(game.pendingAmbassador?.entrant, 'fremen');
assert.equal(game.pendingAmbassador?.phase, 4);
assert.equal(viewGame(game, 'fremen').homeworldRevivalDeployment ?? null, null);
assert.deepEqual(homeworldRevivalDeploymentActions(viewGame(game, 'fremen')), []);
reject(game, 'fremen', { ...first, amount: 2 });
const parent = game.pendingAmbassador!.event;
game = act(reload(game), 'ecaz', { type: 'decision', event: parent, decline: true });
game = settle(game);
assert.equal(game.pendingAmbassador ?? null, null);
assert.notEqual(offer(game).event, first.event);
assert.equal(offer(game).elite, 2);
assert.ok(offer(game).destinations.some(destination => destination.id === 'false_wall_west:18' && !destination.blocked));
game = finish(act(game, 'fremen', { type: 'decision', event: offer(game).event, decline: true }));
assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
assert.equal(p(game).elites?.reserves, 2);
assert.deepEqual(inventory(game), f.original);
});
void test('all four minimal profiles consume only exact current native Southern choices', () => {
const f = fixture();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
let game = open(f), decisions = 0;
while (game.homeworldRevivalReturn?.stage !== 'complete' && decisions < 4) {
const view = viewGame(reload(game), 'fremen');
view.players.find(player => player.id === 'fremen')!.bot = difficulty;
const offered = homeworldRevivalDeploymentActions(view); assert.ok(offered.length);
const action = botActions(view).find(action => action.type === 'decision' && action.event === offer(game).event);
assert.ok(action, `${difficulty} consumes its current owner-view allocation`);
assert.ok(offered.some(legal => JSON.stringify(legal) === JSON.stringify(action)));
game = settle(act(game, 'fremen', action)); decisions++;
}
game = finish(game);
assert.ok(decisions > 0 && decisions <= 3);
assert.equal(p(game).revived, 3); assert.equal(p(game).elites?.revived, 3);
assert.deepEqual(inventory(game), f.original);
}
});