Grant Duke Vidal through occupied Ecaz Homeworld

This commit is contained in:
Quintine committed 2026-10-26 06:44:16 +11:00
1 parent d5c5342d2d
commit 78bcf22bca
18 files changed
+1291 -9

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game, type Plan } from '../game/engine';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type HomeworldEcazDukeStep = { actor: string; action: Action };
export const actHomeworldEcazDuke = actAdvisorPreparation;
export const homeworldEcazDukePlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const homeworldEcazDukeClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function homeworldEcazDukeInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'The original native Treachery Cards retain distinct physical slots'); return ids;
}
export const homeworldEcazDukePhysical = (game: Game) => ({ homeworlds: game.homeworlds,
players: game.players.map(player => ({ id: player.id, spice: player.spice, bribes: player.bribes,
forces: player.forces, elites: player.elites, reserves: player.reserves, tanks: player.tanks,
leaders: player.leaders, shipped: player.shipped, moved: player.moved })) });
/** Resolve only native controls. Quiet unsealed Battle Plans and Duke choice
* remain owned; a bot cannot select the newly offered grant for the source. */
export function nextHomeworldEcazDukeStep(game: Game): HomeworldEcazDukeStep | null {
if (game.truthtrance || game.pendingTreacheryDiscard) return null;
if (game.phaseOpening) {
const actor = game.players.find(player => !game.phaseOpening!.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'ready' } };
}
if (game.response) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'passResponse' } };
}
const decision = game.decision;
if (decision?.kind === 'ecazPlacement') return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'homeworldShipmentGuild')
return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'guildShipment') return { actor: decision.player, action: { type: 'decision', allow: true } };
if (decision?.kind === 'auctionPayment') return { actor: decision.player, action: { type: 'decision', karama: false } };
if (decision?.kind === 'fullPlanOffer') return { actor: decision.player, action: { type: 'decision', decline: true } };
if (!decision && game.status === 'setup' && game.setupStage === 'forces' &&
homeworldEcazDukePlayer(game, 'ecaz').reserves === 20)
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
if (!decision && game.battle) {
const battle = game.battle;
if (battle.preLeader && !battle.preLeader.closed) {
const actor = [battle.attacker, battle.defender].find(id => !battle.preLeader!.ready.includes(id)); assert.ok(actor);
return { actor, action: { type: 'battlePreparationReady', event: battle.preLeader.event } };
}
if (battle.preparation) return { actor: battle.preparation.owner, action: { type: 'declineBattlePower' } };
if (!battle.revealed) return null;
const voters = viewGame(game, game.host).battle!.traitorVoters;
const voter = voters.find(id => battle.traitorCalls[id] === undefined);
if (voter) return { actor: voter, action: { type: 'traitorCall', call: false } };
if (battle.outcomeWindow && !battle.outcomeWindow.closed) {
const actor = [battle.attacker, battle.defender].find(id => !battle.outcomeWindow!.ready.includes(id)); assert.ok(actor);
return { actor, action: { type: 'battleOutcomeReady', event: battle.outcomeWindow.event } };
}
}
if (!decision && game.status === 'playing') {
if (game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
}
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
if ([1, 2, 4, 7, 8].includes(game.phase) || game.phase === 0 && game.stormPending !== null) {
const actor = game.players.find(player => !game.ready.includes(player.id));
if (actor) return { actor: actor.id, action: { type: 'ready' } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceHomeworldEcazDuke(game: Game, until: (state: Game) => boolean,
actions: HomeworldEcazDukeStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextHomeworldEcazDukeStep(game);
assert.ok(step, 'Choose the original Battle Plan or Duke recipient before continuing');
game = actHomeworldEcazDuke(game, step.actor, step.action, actions);
}
throw Error('The genuine occupied-Ecaz chronology did not reach its finite boundary');
}
function planReady(game: Game) {
return homeworldEcazDukeClean(game) && !!game.battle && !game.battle.preparation &&
(!game.battle.preLeader || game.battle.preLeader.closed) && !game.battle.revealed;
}
function settleNativeBattle(game: Game, plans: Record<string, Plan>, actions: HomeworldEcazDukeStep[]) {
assert.ok(planReady(game)); const battle = game.battle!;
for (const actor of [battle.attacker, battle.defender])
game = actHomeworldEcazDuke(game, actor, { type: 'battlePlan', ...plans[actor] }, actions);
return advanceHomeworldEcazDuke(game, state => !state.battle && homeworldEcazDukeClean(state), actions);
}
/** Genuine two-turn source: paid Dune garrison, deferred real funding, native
* worm alliance, typed invasion and a real World victory. All physical sources
* and later Duke battle are earned; only unopened card/Spice orders are chosen. */
export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; allied?: boolean } = {}) {
const advanced = options.advanced ?? false, allied = options.allied ?? false;
const ecaz = 'ecaz', occupier = 'emperor', guild = 'guild', opponent = 'atreides', ally = allied ? guild : null;
let game = createGame('HOMEWORLDECAZDUKE', newPlayer(ecaz, 'Ecaz', 'ecaz'), advanced, ['ecaz']);
for (const faction of [occupier, guild, opponent] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: HomeworldEcazDukeStep[] = [];
const act = (actor: string, action: Action) => { game = actHomeworldEcazDuke(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldEcazDuke(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const enableHomeworlds = transition(() => { act(ecaz, { type: 'homeworlds', enabled: true }); });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(ecaz, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.ok(game.homeworlds);
assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
!game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = homeworldEcazDukeInventory(game); assert.equal(originalInventory.length, 33);
const poisons = game.deck.filter(card => card.kind === 'poison' && !card.effect).slice(0, 2);
const projectile = game.deck.find(card => card.kind === 'projectile' && !card.effect),
snooper = game.deck.find(card => card.kind === 'snooper');
assert.equal(poisons.length, 2); assert.ok(projectile && snooper);
const selected = [snooper, poisons[0], poisons[1], projectile];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(game.dukeVidal?.leader.id, DUKE_VIDAL_ID); assert.equal(game.dukeVidal?.leader.dead, false);
assert.equal(game.dukeVidal?.controller, null);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
const orderBlow = (worm: boolean, position: number) => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed
: 'territory' in card && !card.discovery));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); spiceOrder.push({ position, from, card: structuredClone(card) });
};
const firstTurnBlows = advanced ? 2 : 1;
for (let index = 0; index < firstTurnBlows; index++) orderBlow(false, index);
orderBlow(true, firstTurnBlows);
for (let index = 0; index < firstTurnBlows; index++) orderBlow(false, firstTurnBlows + 1 + index);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 2 && homeworldEcazDukeClean(state));
const funding = transition(() => { act(guild, { type: 'bribe', target: occupier, amount: 1 }); });
assert.equal(homeworldEcazDukePlayer(funding.after, occupier).spice, homeworldEcazDukePlayer(funding.before, occupier).spice);
assert.equal(homeworldEcazDukePlayer(funding.after, occupier).bribes, homeworldEcazDukePlayer(funding.before, occupier).bribes + 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === occupier && homeworldEcazDukeClean(state));
const duneShipment = transition(() => {
const sources = nativeShipmentSources(viewGame(game, occupier), 2, 0); assert.ok(sources);
act(occupier, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0,
homeworldSources: sources });
reach(state => homeworldEcazDukeClean(state) && !state.pendingShipment);
});
assert.equal(homeworldEcazDukePlayer(game, occupier).forces['carthag:11'], 2);
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
!state.spiceResolution && homeworldEcazDukeClean(state));
const nexus = structuredClone(game);
const alliance = transition(() => {
if (allied) { act(occupier, { type: 'alliance', target: guild }); act(guild, { type: 'alliance', target: occupier }); }
});
assert.equal(homeworldEcazDukePlayer(game, occupier).ally, ally);
const secondTerritory = allied ? 'tueks_sietch' : 'carthag', secondSector = allied ? 5 : 11;
const recipient = ally ?? occupier, world = 'homeworld:ecaz';
let invasion: ReturnType<typeof transition> | null = null, secondEntrant: ReturnType<typeof transition> | null = null;
reach(state => state.turn === 2 && state.phase === 5 && homeworldEcazDukeClean(state));
for (let count = 0; game.phase === 5 && count < 300; count++) {
if (!homeworldEcazDukeClean(game)) {
const step = nextHomeworldEcazDukeStep(game); assert.ok(step); act(step.actor, step.action); continue;
}
const actor = game.active!;
if (actor === occupier && !homeworldEcazDukePlayer(game, actor).shipped) invasion = transition(() => {
const sources = nativeShipmentSources(viewGame(game, actor), 4, 0); assert.ok(sources);
const selected = Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0));
const choice = homeworldShipmentChoice(viewGame(game, actor), world, selected);
assert.equal(choice.blocked, null); assert.ok(choice.action);
act(actor, choice.action); reach(state => homeworldEcazDukeClean(state) && !state.pendingHomeworldShipment);
});
if (actor === opponent && !homeworldEcazDukePlayer(game, actor).shipped) secondEntrant = transition(() => {
const sources = nativeShipmentSources(viewGame(game, actor), 1, 0); assert.ok(sources);
act(actor, { type: 'ship', territory: secondTerritory, sector: secondSector, amount: 1, elite: 0,
allyPayment: 0, homeworldSources: sources });
reach(state => homeworldEcazDukeClean(state) && !state.pendingShipment);
});
act(actor, { type: 'endMovement' });
}
assert.ok(invasion && secondEntrant); assert.equal(game.phase, 6);
reach(state => homeworldEcazDukeClean(state) && !state.battle && !!state.active);
const beforeWorldBattle = structuredClone(game), chooser = game.active!;
const worldChoice = viewGame(game, chooser).battleChoices.find(choice => choice.territory === world); assert.ok(worldChoice);
const worldBattle = transition(() => {
act(chooser, { type: 'chooseBattle', territory: world, target: chooser === occupier ? ecaz : occupier });
reach(planReady);
});
const emperorLeader = homeworldEcazDukePlayer(game, occupier).leaders.filter(leader => !leader.dead)
.sort((a, b) => b.strength - a.strength)[0];
const ecazLeader = homeworldEcazDukePlayer(game, ecaz).leaders.filter(leader => !leader.dead)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(emperorLeader && ecazLeader);
const worldPlans: Record<string, Plan> = {
[occupier]: { leader: emperorLeader.id, dial: 1, support: advanced ? 1 : 0, weapon: null, defense: null },
[ecaz]: { leader: ecazLeader.id, dial: 0, support: 0, weapon: null, defense: null },
};
const worldResolution = transition(() => { game = settleNativeBattle(game, worldPlans, actions); });
const occupied = structuredClone(game), groups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
const ecazWorld = groups.find(group => group.id === world); assert.ok(ecazWorld);
assert.equal((ecazWorld.forces[ecaz]?.normal ?? 0) + (ecazWorld.forces[ecaz]?.elite ?? 0), 0);
assert.equal(ecazWorld.forces[occupier]?.normal, 3); assert.equal(ecazWorld.forces[occupier]?.elite, 0);
const qualification = game.homeworldOccupationHistory!.qualifications.find(record =>
record.world === world && record.player === occupier && record.turn === game.turn); assert.ok(qualification);
assert.equal(qualification.cause, 'sole'); assert.equal(game.dukeVidal?.leader.dead, false);
if (!allied) assert.equal(game.dukeVidal?.controller, occupier);
else assert.equal(game.dukeVidal?.controller, null);
reach(state => homeworldEcazDukeClean(state) && !state.battle && !!state.active &&
viewGame(state, state.active).battleChoices.some(choice => choice.territory === secondTerritory));
const secondBattle = transition(() => {
const actor = game.active!;
act(actor, { type: 'chooseBattle', territory: secondTerritory, target: actor === recipient ? opponent : recipient });
reach(planReady);
});
assert.ok(game.battle && [game.battle.attacker, game.battle.defender].includes(recipient));
assert.deepEqual(game.battle.plans, {}); assert.equal(game.battle.revealed, false);
assert.deepEqual(homeworldEcazDukeInventory(game), originalInventory);
return { game, beforePower: structuredClone(game), advanced, allied, occupier, ally, ecaz, guild, opponent,
recipient, dukeId: DUKE_VIDAL_ID, world, secondTerritory, secondSector,
secondLocation: `${secondTerritory}:${secondSector}`,
poison: { [occupier]: poisons[0].id, [guild]: poisons[1].id }, projectile: projectile.id,
initial, enableHomeworlds, lobby, setup, beforeStartingDeal, afterSetup, dealOrder, originalInventory,
inventory: originalInventory, originalSpice, spiceOrder, funding, duneShipment, nexus, alliance,
invasion, secondEntrant, beforeWorldBattle, worldBattle, worldPlans, worldResolution, occupied,
qualification: structuredClone(qualification), secondBattle,
actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Actual public Basic or Advanced preview Ecaz/E/G/A enables only Homeworlds, readies and starts. Only original unopened four-card/Spice positions and original scalar lotteries are selected; all33 physical cards and native pools remain.',
'Native Ecaz setup places Basin6. G really bribes E one, deferred to Mentat; E really pays to ship two native counters to empty Carthag. Native Storm, auctions, Collection and Mentat run before the actual turn2 worm Nexus.',
allied ? 'E and G actually form their reciprocal alliance during that real Nexus; no alliance field is installed.'
: 'The real Nexus is completed without an alliance; the later single eligible Duke recipient needs no manual confirmation.',
'E submits a real owner-view typed four-normal invasion of Ecaz World. A separately pays to ship one native counter into the existing E Carthag group or G Tueks group, preserving a real second battle. Native Guild decisions and payments complete normally.',
'The real World battle uses E native leader5/dial1 (one paid support in Advanced) against Ecaz native leader2/dial0 and its native World bonus. Actual loss/cleanup leaves three E visitors alone, zero Ecaz natives, and the living shared Duke. No force, wallet, custody, leader, clock or occupation receipt is installed.',
'Stop at the actual next unsealed native Arrakis battle. Unallied source already receives the real automatic self-grant; allied source retains both actual recipient choices before either plan seals.',
],
};
}
export type HomeworldEcazDukeFixture = ReturnType<typeof createHomeworldEcazDukeFixture>;
/** Actual one-battle use, with optional real opposing projectile/own poison
* killing both leaders. No result, death or shared-disc state is assigned. */
export function playHomeworldEcazDukeBattle(f: HomeworldEcazDukeFixture, source: Game,
options: { killDuke?: boolean } = {}) {
let game = source; const actions: HomeworldEcazDukeStep[] = [];
assert.equal(game.dukeVidal?.controller, f.recipient); assert.ok(planReady(game));
const opponentLeader = homeworldEcazDukePlayer(game, f.opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(opponentLeader);
const plans: Record<string, Plan> = {
[f.recipient]: { leader: DUKE_VIDAL_ID, dial: 1, support: f.advanced ? 1 : 0,
weapon: options.killDuke ? f.poison[f.recipient as keyof typeof f.poison] : null, defense: null },
[f.opponent]: { leader: opponentLeader.id, dial: 0, support: 0,
weapon: options.killDuke ? f.projectile : null, defense: null },
};
const before = structuredClone(game); game = settleNativeBattle(game, plans, actions);
return { game, before, plans, actions };
}
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createHomeworldEcazDukeFixture as fixture,
homeworldEcazDukePlayer as player,
homeworldEcazDukeInventory as inventory,
homeworldEcazDukePhysical as physical,
} from './fixture-homeworld-ecaz-duke';
const cards = (game: Game) => ({
deck: game.deck, discard: game.discard, auction: game.auction,
hands: game.players.map(p => ({ id: p.id, hand: p.hand })),
});
for (const advanced of [false, true]) void test(
`all four minimal profiles natively grant the ${advanced ? 'Advanced' : 'Basic'} occupied Ecaz Duke to an offered recipient`, () => {
const f = fixture({ advanced, allied: true }), before = structuredClone(f.game);
assert.ok(f.ally); assert.equal(player(f.game, f.occupier).ally, f.ally);
assert.equal(player(f.game, f.ally).ally, f.occupier);
assert.equal(f.game.dukeVidal?.controller, null); assert.equal(f.game.dukeVidal?.leader.dead, false);
assert.equal(f.game.dukeVidal?.leader.id, f.dukeId);
assert.deepEqual(f.game.battle?.plans, {}); assert.equal(f.game.battle?.revealed, false);
assert.deepEqual(inventory(f.game), f.originalInventory);
const originalPhysical = structuredClone(physical(f.game)), originalCards = structuredClone(cards(f.game));
for (const profile of DIFFICULTIES) {
const own = viewGame(f.game, f.occupier), offer = own.homeworldEcazDuke;
assert.ok(offer && offer.event); assert.equal(offer.blocked, null); assert.equal(offer.occupier, f.occupier);
assert.deepEqual(offer.recipients.map(recipient => recipient.id).sort(), [f.occupier, f.ally].sort());
own.players.find(p => p.id === f.occupier)!.bot = profile;
const projected = structuredClone(own), proposals = botActions(own);
assert.deepEqual(own, projected, profile); assert.equal(proposals.length, 1, profile);
const action = proposals[0];
assert.equal(action.type, 'homeworldDuke'); assert.equal(action.event, offer.event);
assert.equal(typeof action.recipient, 'string');
assert.ok(offer.recipients.some(recipient => recipient.id === action.recipient), profile);
assert.deepEqual(action, { type: 'homeworldDuke', event: offer.event, recipient: action.recipient });
const granted = applyAction(f.game, f.occupier, action);
assert.deepEqual(f.game, before, 'Bot metadata and native application leave the original genuine source unchanged');
assert.equal(granted.dukeVidal?.leader.id, f.dukeId);
assert.deepEqual(granted.dukeVidal?.leader, before.dukeVidal!.leader);
assert.equal(granted.dukeVidal?.controller, action.recipient);
assert.equal(granted.dukeVidal?.source, 'homeworldEcaz');
assert.equal(granted.dukeVidal?.acquiredTurn, f.game.turn);
assert.deepEqual(granted.homeworldEcazDukeGrants, [{
turn: f.game.turn, qualification: f.qualification.event,
occupier: f.occupier, recipient: action.recipient,
}]);
assert.deepEqual(physical(granted), originalPhysical); assert.deepEqual(cards(granted), originalCards);
assert.deepEqual(granted.battle, before.battle); assert.deepEqual(inventory(granted), f.originalInventory);
const unavailable = viewGame(granted, f.occupier).homeworldEcazDuke;
assert.ok(unavailable?.blocked); assert.equal(unavailable.event, null);
}
},
);
void test('minimal profiles propose no occupied Ecaz Duke grant from blocked missing-event or foreign projections', () => {
const f = fixture({ allied: true }), before = structuredClone(f.game);
for (const profile of DIFFICULTIES) {
// Deliberately controlled projection-only priority states. Native custody,
// occupation, battle and the original two-choice source remain untouched.
for (const unavailable of ['blocked', 'event', 'absent'] as const) {
const own = viewGame(f.game, f.occupier); assert.ok(own.homeworldEcazDuke);
own.players.find(p => p.id === f.occupier)!.bot = profile;
if (unavailable === 'blocked') own.homeworldEcazDuke.blocked = 'Finish the current interaction before granting Duke Vidal.';
if (unavailable === 'event') own.homeworldEcazDuke.event = null;
if (unavailable === 'absent') own.homeworldEcazDuke = null;
const projected = structuredClone(own);
assert.ok(botActions(own).every(action => action.type !== 'homeworldDuke'), `${profile}: ${unavailable}`);
assert.deepEqual(own, projected);
}
const foreign = viewGame(f.game, f.ally!); assert.equal(foreign.homeworldEcazDuke, null);
foreign.players.find(p => p.id === f.ally)!.bot = profile;
assert.ok(botActions(foreign).every(action => action.type !== 'homeworldDuke'), profile);
}
assert.deepEqual(f.game, before); assert.deepEqual(inventory(f.game), f.originalInventory);
});
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { HomeworldEcazDuke } from '../components/homeworld-ecaz-duke';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
// Controlled owner projections isolate this hook-free UI. They do not claim
// native occupation, shared-Duke custody or grant eligibility; those belong to
// the separate helper/native source suite. No played Game state is installed.
function projected(advanced = true): GameView {
const game = createGame('HOMEWORLDECAZDUKECONTROLS', newPlayer('a', 'Atreides', 'atreides'), advanced, ['ecaz']);
joinGame(game, newPlayer('e', 'Ecaz', 'ecaz'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
const view = viewGame(game, 'a');
view.homeworldEcazDuke = {
event: 'controlled-occupation-duke-event', blocked: null, occupier: 'a',
recipients: [{ id: 'a', name: 'Atreides' }, { id: 'g', name: 'Guild' }],
};
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(HomeworldEcazDuke, { game, busy, act() {} }));
}
function click(node: ReactNode, recipient: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode; onClick?: () => void; 'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === `homeworld-ecaz-duke-${recipient}` && child.props.onClick) {
child.props.onClick(); return true;
}
if (click(child.props.children, recipient)) return true;
}
return false;
}
function grant(game: GameView, recipient = 'a', busy = false): Action[] {
const actions: Action[] = [];
click(HomeworldEcazDuke({ game, busy, act(action) { actions.push(action); } }), recipient);
return actions;
}
void test('the occupied Ecaz controls send one exact event-bound action for each projected recipient', () => {
for (const advanced of [false, true]) {
const game = projected(advanced), html = markup(game), event = game.homeworldEcazDuke!.event;
assert.match(html, /aria-label="Occupied Ecaz Duke grant"/);
assert.match(html, /Grant Duke Vidal to Atreides/); assert.match(html, /Grant Duke Vidal to Guild/);
assert.doesNotMatch(html, /disabled=""/);
assert.deepEqual(grant(game), [{ type: 'homeworldDuke', event, recipient: 'a' }]);
assert.deepEqual(grant(game, 'g'), [{ type: 'homeworldDuke', event, recipient: 'g' }]);
assert.deepEqual(grant(game, 'e'), [], 'The control never invents an unoffered recipient');
}
});
void test('busy controls suppress their actual callback and retain readable provisional guidance', () => {
const game = projected();
assert.deepEqual(grant(game, 'a', true), []); assert.deepEqual(grant(game, 'g', true), []);
const html = markup(game, true);
assert.equal((html.match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
assert.match(html, /Provisional occupation benefit/); assert.match(html, /Only Ecaz may revive him/);
});
void test('authoritative unavailable reasons remain visible across renders and block every grant', () => {
for (const reason of [
'Finish the current response before granting Duke Vidal.',
'Finish the current decision or phase opening first.',
'The shared Duke is dead and must be revived by Ecaz.',
'The shared Duke is captured or held as a foreign Ghola.',
]) {
const game = projected(); game.homeworldEcazDuke!.blocked = reason;
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.ok(html.includes(reason)); assert.match(html, /aria-describedby="homeworld-ecaz-duke-unavailable"/);
assert.match(html, /Provisional occupation benefit/);
assert.deepEqual(grant(game), []); assert.deepEqual(grant(game, 'g'), []);
}
}
});
void test('an unavailable event or empty recipient projection cannot manufacture a grant', () => {
const missingEvent = projected(); missingEvent.homeworldEcazDuke!.event = null;
assert.match(markup(missingEvent), /This Duke grant is not currently available/);
assert.deepEqual(grant(missingEvent), []);
const empty = projected(); empty.homeworldEcazDuke!.recipients = [];
assert.match(markup(empty), /No eligible recipient is currently available/);
assert.doesNotMatch(markup(empty), /<button\b/); assert.deepEqual(grant(empty), []);
});
void test('the grant panel is restricted to its owner-only occupier descriptor', () => {
const absent = projected(); absent.homeworldEcazDuke = null;
assert.equal(markup(absent), ''); assert.deepEqual(grant(absent), []);
const other = projected(); other.me = 'g';
assert.equal(markup(other), ''); assert.deepEqual(grant(other), []);
const noOccupier = projected(); noOccupier.homeworldEcazDuke!.occupier = null;
assert.equal(markup(noOccupier), ''); assert.deepEqual(grant(noOccupier), []);
});
void test('persistent grant guidance describes the one-battle shared Duke without inventing a new numeric allowance', () => {
const game = projected();
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.match(html, /Provisional occupation benefit/);
assert.match(html, /grant the living shared Duke Vidal to yourself or your reciprocal ally/);
assert.match(html, /may use him in one battle, following the Duke’s ordinary lifecycle/);
assert.match(html, /Only Ecaz may revive him/);
assert.doesNotMatch(html, /once per (?:turn|phase|game)|\d spice|Confirm|Acknowledge/);
}
});
void test('recipient labels and eligibility come entirely from the projected grant', () => {
const game = projected();
Object.defineProperty(game, 'players', { get() { throw Error('The grant control read hidden player state'); } });
assert.match(markup(game), /Grant Duke Vidal to Guild/);
assert.deepEqual(grant(game, 'g'), [{
type: 'homeworldDuke', event: 'controlled-occupation-duke-event', recipient: 'g',
}]);
});
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { quoteEcazDukeRevival } from '../game/ecaz-duke-revival';
import {
createHomeworldEcazDukeFixture as fixture, actHomeworldEcazDuke as act,
advanceHomeworldEcazDuke as advance, homeworldEcazDukeClean as clean,
homeworldEcazDukePlayer as player, homeworldEcazDukeInventory as inventory,
homeworldEcazDukePhysical as physical, playHomeworldEcazDukeBattle as playBattle,
type HomeworldEcazDukeFixture,
} from './fixture-homeworld-ecaz-duke';
function reject(game: Game, actor: string, action: Action, pattern?: RegExp) {
const before = structuredClone(game);
if (pattern) assert.throws(() => applyAction(game, actor, action), pattern);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function nativeSource(f: HomeworldEcazDukeFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.ok(f.setup.homeworlds);
assert.deepEqual(f.setup.expansions, ['ecaz']); assert.ok(f.beforeStartingDeal.players.every(player => !player.hand.length));
assert.equal(f.originalInventory.length, 33); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.equal(f.funding.steps[0].actor, f.guild); assert.equal(f.funding.steps[0].action.type, 'bribe');
assert.equal(player(f.funding.after, f.occupier).spice, player(f.funding.before, f.occupier).spice);
assert.equal(player(f.funding.after, f.occupier).bribes, player(f.funding.before, f.occupier).bribes + 1);
assert.equal(player(f.duneShipment.after, f.occupier).forces['carthag:11'], 2);
assert.equal(f.nexus.nexus, true); assert.equal(player(f.game, f.occupier).ally, f.ally);
assert.equal(f.alliance.steps.length, f.allied ? 2 : 0);
assert.ok(f.invasion && f.secondEntrant);
assert.equal(f.invasion.steps[0].actor, f.occupier); assert.equal(f.invasion.steps[0].action.type, 'homeworldShip');
assert.equal(f.secondEntrant.steps[0].actor, f.opponent); assert.equal(f.secondEntrant.steps[0].action.type, 'ship');
assert.equal(f.qualification.world, f.world); assert.equal(f.qualification.player, f.occupier);
assert.equal(f.qualification.turn, 2); assert.equal(f.qualification.cause, 'sole');
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 6); assert.ok(clean(f.game));
assert.equal(f.game.battle?.territory, f.secondTerritory); assert.deepEqual(f.game.battle?.plans, {});
assert.equal(f.game.dukeVidal?.leader.dead, false); assert.equal(f.game.dukeVidal?.leader.id, f.dukeId);
assert.equal(f.sourceActionCount, f.actions.length);
}
function grant(f: HomeworldEcazDukeFixture, recipient: string) {
const game = structuredClone(f.game), option = viewGame(game, f.occupier).homeworldEcazDuke;
assert.ok(option); assert.equal(option.blocked, null); assert.ok(option.recipients.some(player => player.id === recipient));
const action: Action = { type: 'homeworldDuke', event: option.event, recipient };
const before = structuredClone(physical(game)), granted = act(game, f.occupier, action);
assert.deepEqual(physical(granted), before); assert.equal(granted.dukeVidal?.controller, recipient);
assert.equal(granted.dukeVidal?.source, 'homeworldEcaz'); assert.equal(granted.dukeVidal?.acquiredTurn, granted.turn);
return { game: granted, action, option };
}
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native sole Ecaz World occupation automatically grants the single recipient Duke for one actual battle`, () => {
const f = fixture({ advanced }); nativeSource(f);
assert.equal(f.game.dukeVidal?.controller, f.occupier); assert.equal(f.game.dukeVidal?.source, 'homeworldEcaz');
assert.equal(f.game.homeworldEcazDukeGrants?.length, 1);
const battle = playBattle(f, structuredClone(f.game));
assert.equal(battle.game.dukeVidal?.controller, null); assert.equal(battle.game.dukeVidal?.leader.dead, false);
assert.equal(battle.game.homeworldEcazDukeGrants?.length, 1);
assert.deepEqual(inventory(battle.game), f.originalInventory);
});
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native allied Ecaz World occupation retains both real Duke choices and the ally uses one battle`, () => {
const f = fixture({ advanced, allied: true }); nativeSource(f); assert.ok(f.ally);
assert.equal(f.game.dukeVidal?.controller, null);
const option = viewGame(f.game, f.occupier).homeworldEcazDuke; assert.ok(option);
assert.deepEqual(option.recipients.map(player => player.id).sort(), [f.occupier, f.ally].sort());
const self = grant(f, f.occupier);
assert.equal(self.game.dukeVidal?.controller, f.occupier);
const allied = grant(f, f.ally); assert.equal(allied.game.homeworldEcazDukeGrants?.length, 1);
reject(allied.game, f.occupier, allied.action);
const battle = playBattle(f, allied.game);
assert.equal(battle.game.dukeVidal?.controller, null); assert.equal(battle.game.dukeVidal?.leader.dead, false);
assert.equal(battle.game.homeworldEcazDukeGrants?.length, 1);
reject(battle.game, f.occupier, allied.action);
assert.deepEqual(inventory(battle.game), f.originalInventory);
});
void test('native occupied Ecaz Duke choice rejects stale events, foreign actors and unrelated recipients before any control changes', () => {
const f = fixture({ allied: true }); nativeSource(f);
const option = viewGame(f.game, f.occupier).homeworldEcazDuke; assert.ok(option);
assert.equal(viewGame(f.game, f.opponent).homeworldEcazDuke ?? null, null);
const action: Action = { type: 'homeworldDuke', event: option.event, recipient: f.occupier };
reject(f.game, f.occupier, { ...action, event: `${option.event}:stale` });
reject(f.game, f.opponent, action); reject(f.game, f.occupier, { ...action, recipient: f.opponent });
reject(f.game, f.occupier, { ...action, recipient: f.ecaz });
assert.equal(f.game.dukeVidal?.controller, null); assert.deepEqual(inventory(f.game), f.originalInventory);
});
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} occupied Ecaz Duke use can die natively without granting its foreign ally revival permission`, () => {
const f = fixture({ advanced, allied: true }); nativeSource(f); assert.ok(f.ally);
const granted = grant(f, f.ally), battle = playBattle(f, granted.game, { killDuke: true });
assert.equal(battle.game.dukeVidal?.leader.dead, true); assert.equal(battle.game.dukeVidal?.controller, null);
const game = advance(battle.game, state => state.turn === 3 && state.phase === 4 && clean(state));
assert.equal(game.dukeVidal?.leader.dead, true);
reject(game, f.ally, { type: 'reviveLeader', leader: f.dukeId }, /Ecaz/i);
assert.equal(game.dukeVidal?.leader.dead, true);
const dead = structuredClone(game.dukeVidal!.leader), cash = player(game, f.ecaz).spice;
const quote = quoteEcazDukeRevival(game, f.ecaz, false);
assert.equal(quote.cost, 5); assert.ok(cash >= quote.cost, 'Original native Ecaz cash must fund its actual five-spice revival');
const revived = act(game, f.ecaz, { type: 'reviveLeader', leader: f.dukeId });
assert.equal(player(revived, f.ecaz).spice, cash - quote.cost);
assert.equal(revived.dukeVidal?.leader.id, dead.id); assert.equal(revived.dukeVidal?.leader.deaths, dead.deaths);
assert.equal(revived.dukeVidal?.leader.dead, false); assert.equal(revived.dukeVidal?.controller, null);
assert.equal(revived.dukeVidal?.source, null); assert.equal(revived.dukeVidal?.acquiredTurn, null);
assert.equal(player(revived, f.ecaz).leaders.some(leader => leader.id === f.dukeId), false);
const renewed = viewGame(revived, f.occupier).homeworldEcazDuke; assert.ok(renewed);
assert.equal(renewed.blocked, null);
assert.deepEqual(renewed.recipients.map(recipient => recipient.id).sort(), [f.occupier, f.ally].sort());
assert.deepEqual(inventory(revived), f.originalInventory);
});
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { newPlayer } from '../game/engine';
import { acquireDuke, captureDuke, consumeDuke, createDukeVidal } from '../game/duke-vidal';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { createHomeworldOccupationHistory, observeHomeworldOccupation } from '../game/homeworld-occupation-history';
import {
HOMEWORLD_ECAZ_DUKE_SOURCE, HomeworldEcazDukeError,
quoteHomeworldEcazDuke as quote, createHomeworldEcazDukeReceipt as receipt,
quoteHomeworldEcazDukeLifecycle as lifecycle, validateHomeworldEcazDukeGrants,
type HomeworldEcazDukeContext as Context,
} from '../game/homeworld-ecaz-duke';
const player = (g: Context, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; };
function observe(g: Context, event: string, cause: 'change' | 'turnStart' = 'change') {
g.homeworldOccupationHistory = observeHomeworldOccupation(g.homeworldOccupationHistory!, homeworldContext(g),
g.homeworlds!.custody!, g.turn, cause, event);
}
/** Controlled pure-rule inputs, not a claim of native gameplay chronology.
* Native20 pools, conserved casualties/visitor1 and the canonical observer
* produce actual valid qualification history; no engine receipt is installed. */
function position(advanced = true, allied = false): Context {
const players = ['ecaz', 'guild', 'atreides', 'moritani'].map(faction => {
const id = faction as 'ecaz' | 'guild' | 'atreides' | 'moritani';
return newPlayer(id, id, id);
});
const context = homeworldContext({ advanced, players });
const custody = createHomeworldCustody(context);
const g: Context = { status: 'playing', advanced, turn: 1, players,
homeworlds: { custody, historyVersion: 1 }, homeworldOccupationPreview: true,
homeworldOccupationHistory: createHomeworldOccupationHistory(context, custody, 1, 'controlled-native-pools'),
dukeVidal: createDukeVidal() };
player(g, 'ecaz').tanks += player(g, 'ecaz').reserves; player(g, 'ecaz').reserves = 0;
player(g, 'guild').reserves--;
custody.visitors['homeworld:ecaz'] = { guild: { normal: 1, elite: 0 } };
observe(g, 'controlled-ecaz-sole-guild');
if (allied) { player(g, 'guild').ally = 'atreides'; player(g, 'atreides').ally = 'guild'; }
return g;
}
function grant(g: Context, recipient = 'guild') {
const offered = quote(g); assert.equal(offered.blocked, null); assert.ok(offered.owner && offered.event);
const earned = receipt(g, offered.owner, offered.event, recipient);
(g.homeworldEcazDukeGrants ??= []).push(earned);
g.dukeVidal = acquireDuke(g.dukeVidal!, recipient, g.turn, HOMEWORLD_ECAZ_DUKE_SOURCE);
return earned;
}
function depart(g: Context, id = 'guild', event = 'controlled-occupier-departure') {
const group = g.homeworlds!.custody!.visitors['homeworld:ecaz'][id];
player(g, id).reserves += group.normal + group.elite;
delete g.homeworlds!.custody!.visitors['homeworld:ecaz'][id];
observe(g, event);
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`${band} occupied Ecaz quotes the real sole occupier without acquiring or inventing a grant`, () => {
const g = position(advanced), before = structuredClone(g), offered = quote(g);
assert.equal(offered.owner, 'guild'); assert.deepEqual(offered.recipients, ['guild']);
assert.equal(offered.qualification, 'controlled-ecaz-sole-guild');
assert.equal(offered.event, JSON.stringify(['homeworldEcazDuke', 1, 'controlled-ecaz-sole-guild', 'guild']));
assert.equal(offered.nativeAcquisitionBlocked, true); assert.equal(offered.claimed, null); assert.equal(offered.blocked, null);
assert.deepEqual(receipt(g, 'guild', offered.event!, 'guild'), {
turn: 1, qualification: offered.qualification, occupier: 'guild', recipient: 'guild',
});
assert.deepEqual(g, before); assert.equal(Object.hasOwn(g, 'homeworldEcazDukeGrants'), false);
const old = structuredClone(g); delete old.homeworldOccupationPreview;
assert.equal(quote(old).owner, null); assert.equal(quote(old).nativeAcquisitionBlocked, false);
assert.equal(Object.hasOwn(old, 'homeworldEcazDukeGrants'), false);
});
void test(`${band} occupied Ecaz recipient choice belongs to the occupier and rechecks its reciprocal ally`, () => {
const g = position(advanced); player(g, 'guild').ally = 'atreides';
assert.deepEqual(quote(g).recipients, ['guild']);
player(g, 'atreides').ally = 'guild'; const offered = quote(g); assert.ok(offered.event);
assert.deepEqual(offered.recipients, ['guild', 'atreides']);
assert.equal(receipt(g, 'guild', offered.event, 'atreides').recipient, 'atreides');
const before = structuredClone(g);
assert.throws(() => receipt(g, 'atreides', offered.event!, 'atreides'), HomeworldEcazDukeError);
assert.throws(() => receipt(g, 'guild', offered.event!, 'moritani'), HomeworldEcazDukeError);
assert.throws(() => receipt(g, 'guild', `${offered.event}-stale`, 'guild'), HomeworldEcazDukeError);
player(g, 'atreides').ally = null;
assert.throws(() => receipt(g, 'guild', offered.event!, 'atreides'), HomeworldEcazDukeError);
player(g, 'atreides').ally = 'guild'; assert.deepEqual(g, before);
});
void test(`${band} occupation priority overrides ordinary native holders and a later loss restores nobody`, () => {
for (const native of ['ecaz', 'moritani'] as const) {
const g = position(advanced);
g.dukeVidal = acquireDuke(g.dukeVidal!, native, g.turn, native);
const held = structuredClone(g.dukeVidal); assert.equal(quote(g).nativeAcquisitionBlocked, true);
assert.equal(quote(g).blocked, null); assert.deepEqual(g.dukeVidal, held);
grant(g); assert.equal(g.dukeVidal!.controller, 'guild');
depart(g); const ending = lifecycle(g);
assert.deepEqual(ending, { release: true, owner: 'guild', reason: 'occupationEnded', blocked: null });
g.dukeVidal = consumeDuke(g.dukeVidal!);
assert.equal(g.dukeVidal.controller, null); assert.equal(g.dukeVidal.source, null);
assert.equal(g.dukeVidal.leader.dead, false); assert.equal(g.dukeVidal.leader.deaths, held.leader.deaths);
assert.equal(quote(g).nativeAcquisitionBlocked, false);
}
});
void test(`${band} the original choice survives use and prevents a second same-turn grant`, () => {
const g = position(advanced, true), chosen = grant(g, 'atreides');
assert.deepEqual(lifecycle(g), { release: false, owner: 'atreides', reason: null, blocked: null });
assert.deepEqual(quote(g).claimed, chosen); assert.equal(quote(g).nativeAcquisitionBlocked, true);
g.dukeVidal!.leader.usedAt = 'arrakeen';
assert.equal(lifecycle(g).reason, 'used'); g.dukeVidal = consumeDuke(g.dukeVidal!);
const offered = quote(g); assert.ok(offered.blocked); assert.equal(offered.nativeAcquisitionBlocked, true);
assert.throws(() => receipt(g, 'guild', offered.event!, 'guild'), HomeworldEcazDukeError);
assert.equal(g.homeworldEcazDukeGrants!.length, 1); assert.equal(g.dukeVidal.controller, null);
});
void test(`${band} losing the selected ally ends only this grant and does not reopen its choice`, () => {
const g = position(advanced, true); grant(g, 'atreides');
player(g, 'atreides').ally = null;
assert.deepEqual(lifecycle(g), { release: true, owner: 'atreides', reason: 'recipientChanged', blocked: null });
g.dukeVidal = consumeDuke(g.dukeVidal!);
assert.deepEqual(quote(g).recipients, ['guild']); assert.ok(quote(g).blocked);
assert.equal(quote(g).nativeAcquisitionBlocked, true);
assert.throws(() => receipt(g, 'guild', quote(g).event!, 'guild'), HomeworldEcazDukeError);
});
void test(`${band} a continuing qualification offers a fresh choice next turn after unused release`, () => {
const g = position(advanced, true), prior = grant(g);
g.turn++; observe(g, 'controlled-next-turn', 'turnStart');
assert.equal(lifecycle(g).release, true); assert.equal(lifecycle(g).reason, 'turnEnded');
g.dukeVidal = consumeDuke(g.dukeVidal!);
const offered = quote(g); assert.equal(offered.blocked, null); assert.equal(offered.claimed, null);
assert.notEqual(offered.event, JSON.stringify(['homeworldEcazDuke', prior.turn, prior.qualification, prior.occupier]));
const renewed = grant(g, 'atreides'); assert.equal(renewed.turn, 2);
assert.equal(g.homeworldEcazDukeGrants!.length, 2); assert.equal(lifecycle(g).release, false);
});
}
void test('Basic contest/repopulation ends a grant while Advanced retains its observed occupation', () => {
for (const advanced of [false, true]) for (const change of ['contest', 'repopulation'] as const) {
const g = position(advanced); grant(g);
if (change === 'contest') {
player(g, 'moritani').reserves--;
g.homeworlds!.custody!.visitors['homeworld:ecaz'].moritani = { normal: 1, elite: 0 };
} else {
player(g, 'ecaz').reserves += player(g, 'ecaz').tanks; player(g, 'ecaz').tanks = 0;
}
observe(g, `controlled-${change}`);
assert.equal(lifecycle(g).release, !advanced); assert.equal(quote(g).nativeAcquisitionBlocked, advanced);
assert.equal(lifecycle(g).reason, advanced ? null : 'occupationEnded');
}
});
void test('a fresh Advanced sole qualification can replace an unused grant without losing the latest receipt identity', () => {
const g = position(true); const first = grant(g);
depart(g); assert.equal(lifecycle(g).release, true); g.dukeVidal = consumeDuke(g.dukeVidal!);
player(g, 'guild').reserves--;
g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
observe(g, 'controlled-new-sole-guild');
const second = grant(g); assert.notEqual(second.qualification, first.qualification);
assert.equal(g.homeworldEcazDukeGrants!.length, 2); assert.equal(lifecycle(g).release, false);
const used = position(true); grant(used); used.dukeVidal!.leader.usedAt = 'carthag';
used.dukeVidal = consumeDuke(used.dukeVidal!); depart(used);
player(used, 'guild').reserves--;
used.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
observe(used, 'controlled-new-qualification-after-use');
assert.match(quote(used).blocked!, /already been used/);
assert.throws(() => receipt(used, 'guild', quote(used).event!, 'guild'), HomeworldEcazDukeError);
assert.equal(used.homeworldEcazDukeGrants!.length, 1, 'A new qualification cannot erase actual prior battle use');
});
void test('occupied Ecaz cannot acquire used or dead Duke and supplies no foreign revival', () => {
const g = position(); g.dukeVidal!.leader.usedAt = 'arrakeen';
assert.match(quote(g).blocked!, /already been used/); assert.equal(quote(g).nativeAcquisitionBlocked, true);
delete g.dukeVidal!.leader.usedAt; g.dukeVidal!.leader.dead = true; g.dukeVidal!.leader.deaths = 1;
const before = structuredClone(g);
assert.match(quote(g).blocked!, /only Ecaz may revive/);
assert.throws(() => receipt(g, 'guild', quote(g).event!, 'guild'), HomeworldEcazDukeError);
assert.deepEqual(g, before); assert.equal(g.dukeVidal!.leader.dead, true);
});
void test('captured and malformed foreign-Ghola custody stay explicitly unsupported and are never erased', () => {
const g = position(); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz', 1, 'ecaz');
g.dukeVidal = captureDuke(g.dukeVidal, 'moritani', 'ecaz', 1, 'arrakeen');
const before = structuredClone(g); assert.match(quote(g).blocked!, /captured, concealed or Ghola/);
assert.equal(quote(g).nativeAcquisitionBlocked, true); assert.deepEqual(g, before);
const active = position(); grant(active);
active.dukeVidal = captureDuke(active.dukeVidal!, 'moritani', 'guild', 1, 'arrakeen');
const held = structuredClone(active); assert.equal(lifecycle(active).release, false); assert.ok(lifecycle(active).blocked);
assert.deepEqual(active, held);
// Deliberately invalid negative input, not an allegedly legal foreign revival.
const malformed = position(); malformed.dukeVidal!.leader.gholaBy = 'moritani';
assert.match(quote(malformed).blocked!, /captured, concealed or Ghola/);
});
void test('ordinary native tenure is outside this lifecycle and missing occupation evidence awards nothing', () => {
const g = position(); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz', 1, 'ecaz');
assert.deepEqual(lifecycle(g), { release: false, owner: null, reason: null, blocked: null });
delete g.homeworldOccupationHistory;
const before = structuredClone(g); assert.ok(quote(g).blocked); assert.equal(quote(g).owner, null);
assert.throws(() => receipt(g, 'guild', 'invented', 'guild'), HomeworldEcazDukeError); assert.deepEqual(g, before);
});
void test('one original qualification cannot carry duplicate or source-swapped grant choices', () => {
const g = position(); const original = grant(g);
const duplicate = structuredClone(g); duplicate.homeworldEcazDukeGrants!.push({ ...original });
assert.throws(() => validateHomeworldEcazDukeGrants(duplicate), HomeworldEcazDukeError);
const swapped = structuredClone(g); swapped.homeworldEcazDukeGrants![0].qualification = 'absent-source';
assert.throws(() => quote(swapped), HomeworldEcazDukeError);
assert.equal(g.homeworldEcazDukeGrants!.length, 1); assert.equal(quote(g).claimed!.qualification, original.qualification);
});