Retire occupied Ecaz Duke tenure through captivity

This commit is contained in:
Quintine committed 2026-10-27 15:36:44 +11:00
1 parent f0ebdf9c87
commit b5fb6aeb64
15 files changed
+767 -45

No files matched your search

@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import { applyAction, captureCandidates, viewGame, type Action, type Game } from '../game/engine';
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { quoteHomeworldCustody } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import {
createHomeworldEcazDukeFixture, actHomeworldEcazDuke,
homeworldEcazDukeClean, homeworldEcazDukePlayer, homeworldEcazDukeInventory,
nextHomeworldEcazDukeStep, type HomeworldEcazDukeStep,
} from './fixture-homeworld-ecaz-duke';
export const ecazDukeCaptivePlayer = homeworldEcazDukePlayer;
export const ecazDukeCaptiveInventory = homeworldEcazDukeInventory;
export const ecazDukeCaptiveClean = homeworldEcazDukeClean;
export type EcazDukeCaptiveStep = HomeworldEcazDukeStep;
/** Only selects the original random permutation over the engine's actual
* capture pool. Native UUID/non-scalar entropy is untouched. */
function selectDukeCapture<T>(game: Game, run: () => T): T {
const capture = game.pendingCapture; assert.ok(capture);
const pool = captureCandidates(game,capture.loser,capture.territory,capture.duke);
const duke = pool.find(l => l.id === DUKE_VIDAL_ID); assert.ok(duke);
const desired = [duke,...pool.filter(l => l.id !== DUKE_VIDAL_ID)];
const current = [...pool], rolls: number[] = [];
for (let i = current.length-1; i > 0; i--) {
const j = current.findIndex(l => l.id === desired[i].id);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j+0.5)*0x100000000/(i+1)));
[current[i],current[j]] = [current[j],current[i]];
}
assert.deepEqual(current.map(l => l.id),desired.map(l => l.id));
const descriptor = Object.getOwnPropertyDescriptor(crypto,'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView|null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1 && index < rolls.length)
array[0] = rolls[index++];
else Reflect.apply(native,crypto,[array]);
return array;
};
try { const result=run(); assert.ok(index===0 || index===rolls.length); return result; }
finally {
if (descriptor) Object.defineProperty(crypto,'getRandomValues',descriptor);
else Reflect.deleteProperty(crypto,'getRandomValues');
}
}
export function actEcazDukeCaptive(game: Game, actor: string, action: Action,
actions: EcazDukeCaptiveStep[] = []): Game {
if (game.pendingCapture && (game.response?.kind === 'capture' ||
game.decision?.kind === 'captureOffer' && action.type === 'decision' && action.accept === true)) {
const result = selectDukeCapture(game,() => applyAction(game,actor,action));
actions.push({actor,action:structuredClone(action)}); return result;
}
return actHomeworldEcazDuke(game,actor,action,actions);
}
export function advanceEcazDukeCaptive(game: Game, until: (game: Game) => boolean,
actions: EcazDukeCaptiveStep[] = []): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status,'finished');
const next = game.decision?.kind === 'captureOffer'
? {actor:game.decision.player,action:{type:'decision',accept:false} as Action}
: nextHomeworldEcazDukeStep(game);
assert.ok(next,`Choose the current native Duke/battle action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actEcazDukeCaptive(game,next.actor,next.action,actions);
}
assert.fail('The bounded occupied-Duke captive chronology did not reach its boundary.');
}
function planReady(game: Game) {
return homeworldEcazDukeClean(game) && !!game.battle && !game.battle.revealed &&
!game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed);
}
function nativeLeader(game: Game, actor: string, strongest: boolean) {
const leaders = homeworldEcazDukePlayer(game,actor).leaders.filter(l =>
!l.dead && !l.capturedBy && !l.gholaBy && !l.usedAt);
leaders.sort((a,b) => strongest ? b.strength-a.strength : a.strength-b.strength);
assert.ok(leaders[0]); return leaders[0].id;
}
/** Entire public original E/G/A/Ecaz/H source: real paid Tabr garrison and
* Harkonnen entry, actual Ecaz World victory/qualification/automatic lease,
* actual Harkonnen victory over E WITHOUT using Duke, then original native
* unused-leader capture lottery and Keep. No played custody is installed. */
export function createHomeworldEcazDukeCaptiveFixture() {
const source = createHomeworldEcazDukeFixture({advanced:true,harkonnen:true});
let game = structuredClone(source.game);
const actions: EcazDukeCaptiveStep[] = [...source.sourceActions], start = actions.length;
const owner = source.occupier, captor = source.opponent;
assert.equal(captor,'harkonnen'); assert.equal(game.battle?.territory,'sietch_tabr');
assert.equal(game.dukeVidal?.source,'homeworldEcaz');
assert.equal(game.dukeVidal?.controller,owner); assert.equal(game.dukeVidal?.leader.usedAt,undefined);
const beforeBattle = structuredClone(game);
const plans = {
[owner]:{leader:nativeLeader(game,owner,false),dial:0,support:0},
[captor]:{leader:nativeLeader(game,captor,true),dial:0,support:0},
};
for (const actor of [game.battle!.attacker,game.battle!.defender])
game = actEcazDukeCaptive(game,actor,{type:'battlePlan',...plans[actor]},actions);
game = advanceEcazDukeCaptive(game,g => g.decision?.kind === 'captureOffer',actions);
assert.equal(game.lastBattleContext?.winner,captor);
const captureOffer = structuredClone(game), pending = game.pendingCapture!;
const candidates = captureCandidates(game,pending.loser,pending.territory,pending.duke).map(l => l.id);
assert.ok(candidates.includes(DUKE_VIDAL_ID));
game = actEcazDukeCaptive(game,captor,{type:'decision',accept:true},actions);
game = advanceEcazDukeCaptive(game,g => g.decision?.kind === 'capturedLeader',actions);
assert.equal(game.decision?.kind === 'capturedLeader' && game.decision.leader,DUKE_VIDAL_ID);
const selected = structuredClone(game);
game = actEcazDukeCaptive(game,captor,{type:'decision',mode:'keep'},actions);
game = advanceEcazDukeCaptive(game,g => !g.battle && homeworldEcazDukeClean(g),actions);
assert.equal(game.dukeVidal?.leader.capturedBy,captor);
assert.equal(dukeController(game.dukeVidal!),captor);
assert.equal(game.dukeVidal?.controller,owner);
assert.equal(game.dukeVidal?.leader.usedAt,undefined);
assert.deepEqual(homeworldEcazDukeInventory(game),source.originalInventory);
homeworldGameIntegrity(game);
return {game,source,beforeBattle,captureOffer,selected,candidates,plans,owner,captor,
dukeId:DUKE_VIDAL_ID,world:source.world,territory:source.secondTerritory,sector:source.secondSector,
actions,sourceActions:actions,sourceCount:actions.length,captureActions:actions.slice(start),
disclosure:'Original public Advanced Ecaz/E/G/A/H with Homeworlds only; selected unopened original six-card/Spice orders and native scalar lotteries. Real paid shipments, World occupation/grant, second battle, emitted unused-Duke capture lottery and Keep; no installed occupation, lease or capture receipt.'};
}
export type HomeworldEcazDukeCaptiveFixture = ReturnType<typeof createHomeworldEcazDukeCaptiveFixture>;
export function expireEcazDukeCaptive(f: HomeworldEcazDukeCaptiveFixture, source = f.game) {
const actions: EcazDukeCaptiveStep[] = [], before = structuredClone(source);
const game = advanceEcazDukeCaptive(structuredClone(source),g => g.turn > before.turn &&
g.phase === 0 && homeworldEcazDukeClean(g),actions);
assert.equal(game.dukeVidal?.leader.capturedBy,f.captor);
return {game,before,actions};
}
/** Actual later paid E entry into surviving Harkonnen Tabr1, actual native
* captive-Duke plan, reveal/calls and battle release. No army is restaged. */
export function useExpiredEcazDukeCaptive(f: HomeworldEcazDukeCaptiveFixture, source: Game) {
let game = structuredClone(source); const actions: EcazDukeCaptiveStep[] = [];
game = advanceEcazDukeCaptive(game,g => g.phase === 5 && g.active === f.owner &&
homeworldEcazDukeClean(g),actions);
const homeworldSources = nativeShipmentSources(viewGame(game,f.owner),1,0); assert.ok(homeworldSources);
const beforeShip = structuredClone(game);
game = actEcazDukeCaptive(game,f.owner,{type:'ship',territory:f.territory,sector:f.sector,
amount:1,elite:0,allyPayment:0,homeworldSources},actions);
game = advanceEcazDukeCaptive(game,g => g.phase === 6 && !!g.active &&
!g.battle && homeworldEcazDukeClean(g),actions);
const actor = game.active!;
assert.ok(viewGame(game,actor).battleChoices.some(choice => choice.territory === f.territory));
game = actEcazDukeCaptive(game,actor,{type:'chooseBattle',territory:f.territory,
target:actor === f.owner ? f.captor : f.owner},actions);
game = advanceEcazDukeCaptive(game,planReady,actions);
const beforeBattle = structuredClone(game), enemy = nativeLeader(game,f.owner,false);
for (const id of [game.battle!.attacker,game.battle!.defender])
game = actEcazDukeCaptive(game,id,{type:'battlePlan',leader:id === f.captor ? DUKE_VIDAL_ID : enemy,
dial:0,support:0},actions);
game = advanceEcazDukeCaptive(game,g => !g.battle && homeworldEcazDukeClean(g),actions);
assert.equal(game.dukeVidal?.leader.capturedBy,undefined);
homeworldGameIntegrity(game);
return {game,beforeShip,beforeBattle,actions};
}
/** LABELLED CONTROLLED HELPER SUPPLEMENT: move the three actually earned E
* visitors back into their actual native World, conserving pools. This does
* not claim a played departure or synthesize an occupation observation. It is
* used only to ask the release helper about an already-lost current entitlement. */
export function controlledEcazDukeCaptiveDeparture(source: Game) {
const game = structuredClone(source), owner = 'emperor', world = 'homeworld:ecaz';
const group = game.homeworlds!.custody!.visitors[world][owner];
assert.ok(group && group.normal > 0 && group.elite === 0);
const quote = quoteHomeworldCustody(homeworldContext(game),game.homeworlds!.custody!,[
{homeworld:world,player:owner,withdraw:{...group},deposit:{normal:0,elite:0}},
{homeworld:'homeworld:emperor',player:owner,withdraw:{normal:0,elite:0},deposit:{...group}},
]);
game.homeworlds!.custody = quote.state;
for (const seat of quote.players) homeworldEcazDukePlayer(game,seat.id).reserves = seat.reserves;
homeworldGameIntegrity(game);
return game;
}
+21 -10
View File
@@ -66,6 +66,8 @@ export function nextHomeworldEcazDukeStep(game: Game): HomeworldEcazDukeStep | n
}
}
if (!decision && game.status === 'playing') {
if (game.phase === 2 && game.charityOrder || game.phase === 4 && game.revivalOrder)
return nextStrongholdFactionsNativeStep(game);
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 } };
@@ -104,11 +106,13 @@ function settleNativeBattle(game: Game, plans: Record<string, Plan>, actions: Ho
/** 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 } = {}) {
export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; allied?: boolean; harkonnen?: boolean } = {}) {
const advanced = options.advanced ?? false, allied = options.allied ?? false;
const ecaz = 'ecaz', occupier = 'emperor', guild = 'guild', opponent = 'atreides', ally = allied ? guild : null;
assert.ok(!options.harkonnen || !allied, 'The native Harkonnen capture source uses the unallied grant.');
const ecaz = 'ecaz', occupier = 'emperor', guild = 'guild', opponent = options.harkonnen ? 'harkonnen' : '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));
for (const faction of [occupier, guild, 'atreides', ...(options.harkonnen ? ['harkonnen'] as const : [])] 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); };
@@ -131,7 +135,10 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
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 harkonnenCards = options.harkonnen ? [game.deck.find(card => card.kind === 'worthless'),
game.deck.find(card => card.kind === 'shield')] : [];
assert.ok(harkonnenCards.every(Boolean));
const selected = [snooper, poisons[0], poisons[1], projectile, ...harkonnenCards.filter(card => !!card)];
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]];
@@ -162,13 +169,14 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
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 garrisonTerritory = options.harkonnen ? 'sietch_tabr' : 'carthag', garrisonSector = options.harkonnen ? 14 : 11;
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,
act(occupier, { type: 'ship', territory: garrisonTerritory, sector: garrisonSector, amount: 2, elite: 0, allyPayment: 0,
homeworldSources: sources });
reach(state => homeworldEcazDukeClean(state) && !state.pendingShipment);
});
assert.equal(homeworldEcazDukePlayer(game, occupier).forces['carthag:11'], 2);
assert.equal(homeworldEcazDukePlayer(game, occupier).forces[`${garrisonTerritory}:${garrisonSector}`], 2);
reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow &&
!state.spiceResolution && homeworldEcazDukeClean(state));
const nexus = structuredClone(game);
@@ -176,7 +184,7 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
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 secondTerritory = allied ? 'tueks_sietch' : garrisonTerritory, secondSector = allied ? 5 : garrisonSector;
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));
@@ -238,6 +246,7 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
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,
harkonnen:options.harkonnen ?? false,garrisonTerritory,garrisonSector,
recipient, dukeId: DUKE_VIDAL_ID, world, secondTerritory, secondSector,
secondLocation: `${secondTerritory}:${secondSector}`,
poison: { [occupier]: poisons[0].id, [guild]: poisons[1].id }, projectile: projectile.id,
@@ -247,11 +256,13 @@ export function createHomeworldEcazDukeFixture(options: { advanced?: boolean; al
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.',
options.harkonnen ? 'Actual public Basic or Advanced preview Ecaz/E/G/A/H enables only Homeworlds, readies and starts. Original unopened six-card slots include Harkonnen native two-card start; all33 cards and original native armies remain.'
: '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 ${garrisonTerritory}. 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.',
options.harkonnen ? 'E submits the original typed four-normal invasion of Ecaz World. Original Harkonnen ships one native counter into E Sietch Tabr2; original H10 Carthag stays untouched. Native Guild decisions and payments finish before the two actual battles.'
: '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.',
],
@@ -0,0 +1,167 @@
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 { DUKE_VIDAL_ID, dukeController, retireHomeworldDukeTenure } from '../game/duke-vidal';
import { quoteHomeworldEcazDuke, releaseCapturedHomeworldDuke } from '../game/homeworld-ecaz-duke';
import {
createHomeworldEcazDukeCaptiveFixture, expireEcazDukeCaptive,
useExpiredEcazDukeCaptive, controlledEcazDukeCaptiveDeparture,
actEcazDukeCaptive as act, ecazDukeCaptivePlayer as p,
ecazDukeCaptiveInventory as inventory,
type HomeworldEcazDukeCaptiveFixture,
} from './fixture-homeworld-ecaz-duke-captive';
let earned: HomeworldEcazDukeCaptiveFixture | undefined;
const fixture = () => earned ??= createHomeworldEcazDukeCaptiveFixture();
const reload = (g: Game): Game => structuredClone(g);
function reject(game: Game, actor: string, action: Action) {
const before = reload(game);
assert.throws(() => applyAction(game,actor,action));
assert.deepEqual(game,before);
}
function disc(game: Game) {
assert.equal(game.dukeVidal!.leader.id,DUKE_VIDAL_ID);
assert.equal(game.players.flatMap(p => p.leaders).filter(l => l.id === DUKE_VIDAL_ID).length,0);
assert.deepEqual(inventory(game),fixture().source.originalInventory);
}
void test('native occupied-Ecaz capture starts with original Harkonnen, actual World lease and unused-Duke lottery', () => {
const f = fixture(), source = f.source;
assert.match(f.disclosure,/Original public Advanced.*no installed occupation, lease or capture receipt/);
assert.equal(source.initial.players.length,5);
assert.equal(source.initial.players.find(p => p.id === f.captor)!.faction,'harkonnen');
assert.equal(p(source.afterSetup,f.captor).forces['carthag:11'],10);
assert.equal(p(source.afterSetup,f.captor).hand.length,2);
assert.equal(source.originalInventory.length,33);
assert.equal(p(source.duneShipment.after,f.owner).forces['sietch_tabr:14'],2);
assert.equal(source.secondEntrant!.steps[0].actor,f.captor);
assert.equal(source.qualification.player,f.owner);
assert.equal(source.qualification.cause,'sole');
assert.equal(f.beforeBattle.dukeVidal!.controller,f.owner);
assert.equal(f.beforeBattle.dukeVidal!.source,'homeworldEcaz');
assert.equal(f.beforeBattle.dukeVidal!.leader.usedAt,undefined);
assert.ok(Object.values(f.plans).every(plan => plan.leader !== f.dukeId));
assert.ok(f.candidates.includes(f.dukeId));
assert.equal(f.selected.decision?.kind === 'capturedLeader' && f.selected.decision.leader,f.dukeId);
assert.equal(f.game.dukeVidal!.leader.capturedBy,f.captor);
assert.equal(dukeController(f.game.dukeVidal!),f.captor);
assert.deepEqual(f.game.dukeVidal!.captureVisibility,{controller:f.owner,acquiredTurn:2,source:'homeworldEcaz'});
assert.equal(f.game.dukeVidal!.leader.usedAt,undefined);
assert.equal(f.sourceCount,f.sourceActions.length);
assert.equal(f.captureActions.filter(step => step.action.type === 'battlePlan').length,2);
assert.equal(f.captureActions.filter(step => step.action.type === 'decision' && step.action.accept === true).length,1);
disc(f.game);
});
void test('actual next-turn expiry retires occupied-Ecaz underlay while Harkonnen keeps the same unused living captive', () => {
const f = fixture(), expired = expireEcazDukeCaptive(f), duke = expired.game.dukeVidal!;
assert.equal(expired.game.turn,3);
assert.ok(expired.actions.length > 0);
assert.equal(duke.controller,null);
assert.equal(duke.acquiredTurn,null);
assert.equal(duke.source,null);
assert.deepEqual(duke.captureVisibility,{controller:null,acquiredTurn:null,source:null});
assert.equal(duke.leader.capturedBy,f.captor);
assert.equal(duke.leader.concealed!.controller,f.owner);
assert.equal(duke.leader.dead,false);
assert.equal(duke.leader.deaths,f.game.dukeVidal!.leader.deaths);
assert.equal(duke.leader.usedAt,undefined);
assert.equal(dukeController(duke),f.captor);
assert.deepEqual(expired.game.homeworldEcazDukeGrants,f.game.homeworldEcazDukeGrants);
const offer = quoteHomeworldEcazDuke(expired.game);
assert.equal(offer.owner,f.owner);
assert.match(offer.blocked!,/captur|conceal|custody/i);
assert.deepEqual(retireHomeworldDukeTenure(duke,expired.game.turn),duke);
disc(expired.game);
});
void test('real later captive-Duke battle use returns the expired shared disc set aside after a paid native entry', () => {
const f = fixture(), expired = expireEcazDukeCaptive(f);
const result = useExpiredEcazDukeCaptive(f,expired.game), game = result.game, duke = game.dukeVidal!;
assert.ok(result.actions.some(step => step.actor === f.owner && step.action.type === 'ship' &&
step.action.territory === f.territory && step.action.amount === 1));
assert.ok(result.actions.some(step => step.actor === f.captor && step.action.type === 'battlePlan' &&
step.action.leader === f.dukeId));
assert.equal(result.beforeBattle.dukeVidal!.leader.capturedBy,f.captor);
assert.equal(game.lastBattleContext!.winner,f.captor);
assert.equal(duke.leader.id,f.dukeId);
assert.equal(duke.leader.dead,false);
assert.equal(duke.leader.deaths,f.game.dukeVidal!.leader.deaths);
assert.equal(duke.leader.usedAt,f.territory);
assert.equal(duke.leader.capturedBy,undefined);
assert.equal(duke.leader.concealed,undefined);
assert.equal(duke.captureVisibility,undefined);
assert.equal(duke.controller,null);
assert.equal(duke.source,null);
assert.equal(duke.acquiredTurn,null);
disc(game);
});
void test('earned captive helper release restores the still-current qualified original occupied-Ecaz tenure', () => {
const f = fixture(), game = reload(f.game), before = reload(game);
const released = releaseCapturedHomeworldDuke(game,f.captor);
assert.equal(game.turn,2);
assert.equal(released.controller,f.owner);
assert.equal(released.source,'homeworldEcaz');
assert.equal(released.acquiredTurn,game.turn);
assert.equal(released.leader.id,f.dukeId);
assert.equal(released.leader.dead,false);
assert.equal(released.leader.deaths,game.dukeVidal!.leader.deaths);
assert.equal(released.leader.capturedBy,undefined);
assert.equal(released.leader.concealed,undefined);
assert.equal(released.captureVisibility,undefined);
assert.deepEqual(game,before,'This focused helper exercise is not a claimed native early-release action.');
});
void test('controlled conserved same-turn departure helper context cannot restore an ended occupied-Ecaz entitlement', () => {
const f = fixture(), game = controlledEcazDukeCaptiveDeparture(f.game);
// Explicit unit supplement: only present physical custody changed. No played
// departure, occupation observation, lease/capture receipt or clock is added.
assert.equal(game.turn,f.game.turn);
assert.deepEqual(game.homeworldOccupationHistory,f.game.homeworldOccupationHistory);
assert.deepEqual(game.homeworldEcazDukeGrants,f.game.homeworldEcazDukeGrants);
assert.equal(quoteHomeworldEcazDuke(game).owner,null);
const before = reload(game), released = releaseCapturedHomeworldDuke(game,f.captor);
assert.equal(released.controller,null);
assert.equal(released.source,null);
assert.equal(released.acquiredTurn,null);
assert.equal(released.leader.id,f.dukeId);
assert.equal(released.leader.dead,false);
assert.equal(released.leader.capturedBy,undefined);
assert.equal(released.captureVisibility,undefined);
assert.deepEqual(game,before);
disc(game);
});
void test('actual occupied-Ecaz acquisition remains blocked on the captive and foreign helper release rejects before mutation', () => {
const f = fixture(), expired = expireEcazDukeCaptive(f), game = expired.game;
const quote = quoteHomeworldEcazDuke(game); assert.ok(quote.event);
reject(game,f.owner,{type:'homeworldDuke',event:quote.event,recipient:f.owner});
const before = reload(game);
assert.throws(() => releaseCapturedHomeworldDuke(game,f.owner));
assert.deepEqual(game,before);
assert.equal(dukeController(game.dukeVidal!),f.captor);
disc(game);
});
void test('all four minimal profiles answer the actual occupied-Duke captured-leader choice legally', () => {
const f = fixture();
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
const game = reload(f.selected), view = viewGame(game,f.captor);
view.players.find(p => p.id === f.captor)!.bot = profile;
const action = botActions(view)[0];
assert.ok(action,`${profile} needs the actual retained/executed captive decision.`);
const next = act(game,f.captor,action);
if (action.mode === 'execute') {
assert.equal(next.dukeVidal!.leader.dead,true);
assert.equal(next.dukeVidal!.leader.capturedBy,undefined);
// The native Keep baseline includes the same automatic Carthag income.
assert.equal(p(next,f.captor).spice,p(f.game,f.captor).spice+2);
} else {
assert.equal(action.mode,'keep');
assert.equal(next.dukeVidal!.leader.capturedBy,f.captor);
}
disc(next);
}
});
+179
View File
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { newPlayer } from '../game/engine';
import {
acquireDuke, captureDuke, createDukeVidal, dukeController, executeCapturedDuke,
expireDuke, releaseCapturedDuke, retireHomeworldDukeTenure, returnedDukeCapture,
} 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 {
createHomeworldEcazDukeReceipt, quoteHomeworldEcazDuke,
quoteHomeworldEcazDukeLifecycle as lifecycle, releaseCapturedHomeworldDuke,
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) {
g.homeworldOccupationHistory = observeHomeworldOccupation(g.homeworldOccupationHistory!,
homeworldContext(g), g.homeworlds!.custody!, g.turn, 'change', event);
}
/** Controlled helper positions, not native battle/capture chronology. Original
* typed pools and canonical observation create the occupied-source receipt;
* real Duke capture/release/execution helpers then exercise its lifecycle. */
function position(allied = false): Context {
const players = (['ecaz', 'guild', 'atreides', 'harkonnen'] as const)
.map(faction => newPlayer(faction, faction, faction));
const context = homeworldContext({ advanced: true, players });
const custody = createHomeworldCustody(context);
const g: Context = { status: 'playing', advanced: true, 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-guild-occupation');
if (allied) {
player(g, 'guild').ally = 'atreides';
player(g, 'atreides').ally = 'guild';
}
const offer = quoteHomeworldEcazDuke(g);
assert.ok(offer.event && offer.owner); assert.equal(offer.blocked, null);
const recipient = allied ? 'atreides' : 'guild';
g.homeworldEcazDukeGrants = [createHomeworldEcazDukeReceipt(g, offer.owner, offer.event, recipient)];
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, recipient, 1, 'homeworldEcaz'),
'harkonnen', recipient, 1, 'sietch_tabr');
return g;
}
function depart(g: Context) {
const group = g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild;
player(g, 'guild').reserves += group.normal + group.elite;
delete g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild;
observe(g, 'controlled-guild-departure');
}
const noTenure = { controller: null, acquiredTurn: null, source: null };
void test('early captive return restores a still-qualified occupied Ecaz tenure without a second grant', () => {
const g = position(), before = structuredClone(g);
assert.equal(lifecycle(g).release, false);
assert.match(lifecycle(g).blocked!, /captured, concealed or Ghola/);
const released = releaseCapturedHomeworldDuke(g, 'harkonnen');
assert.equal(released.controller, 'guild'); assert.equal(released.source, 'homeworldEcaz');
assert.equal(released.acquiredTurn, 1); assert.equal(released.leader.capturedBy, undefined);
assert.equal(released.captureVisibility, undefined); assert.equal(released.leader.concealed, undefined);
assert.deepEqual(g, before);
g.dukeVidal = released;
assert.equal(quoteHomeworldEcazDuke(g).claimed?.recipient, 'guild');
assert.match(quoteHomeworldEcazDuke(g).blocked!, /already chose/);
});
void test('public turn-end retirement preserves the captive disc and later release cannot revive its lease', () => {
const g = position(), held = g.dukeVidal!;
held.leader.deaths = 2; held.leader.concealed!.deaths = 2;
held.leader.usedAt = 'carthag';
const before = structuredClone(held), retired = expireDuke(held, 1);
assert.deepEqual(held, before);
assert.deepEqual(retired.leader, held.leader);
assert.equal(dukeController(retired), 'harkonnen');
assert.equal(retired.controller, null); assert.equal(retired.source, null);
assert.equal(retired.acquiredTurn, null); assert.deepEqual(retired.captureVisibility, noTenure);
assert.equal(retired.leader.concealed?.controller, 'guild');
g.dukeVidal = retired;
const released = releaseCapturedHomeworldDuke(g, 'harkonnen', 2);
assert.equal(dukeController(released), null); assert.equal(released.source, null);
assert.equal(released.leader.usedAt, 'carthag'); assert.equal(released.leader.deaths, 2);
assert.equal(released.leader.concealed, undefined);
assert.throws(() => releaseCapturedHomeworldDuke(g, 'atreides', 2));
});
void test('departure, replacement qualification and lost recipient are rechecked during actual captive return', () => {
for (const reason of ['occupationEnded', 'occupationChanged', 'recipientChanged'] as const) {
const g = position(reason === 'recipientChanged');
if (reason === 'recipientChanged') player(g, 'atreides').ally = null;
else {
depart(g);
if (reason === 'occupationChanged') {
player(g, 'guild').reserves--;
g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
observe(g, 'controlled-new-guild-qualification');
}
}
const before = structuredClone(g);
assert.equal(lifecycle(g).reason, reason); assert.equal(lifecycle(g).release, true);
assert.equal(lifecycle(g).blocked, null);
const released = releaseCapturedHomeworldDuke(g, 'harkonnen');
assert.equal(released.controller, null); assert.equal(released.source, null);
assert.equal(released.leader.capturedBy, undefined);
assert.deepEqual(g, before, 'release recheck is pure even before settled-boundary reconciliation');
}
});
void test('secret execution retains the same public lease until public expiry and retires its visibility only then', () => {
const living = position(), executed = structuredClone(living);
executed.dukeVidal = executeCapturedDuke(executed.dukeVidal!, 'harkonnen', 1);
assert.equal(returnedDukeCapture(executed.dukeVidal), true);
assert.deepEqual(lifecycle(executed), lifecycle(living), 'actual private death is not a lease-expiry reason');
const original = structuredClone(executed.dukeVidal);
const atDeadline = expireDuke(executed.dukeVidal, 1);
assert.deepEqual(executed.dukeVidal, original);
assert.deepEqual(atDeadline.leader, original.leader);
assert.deepEqual(atDeadline.captureVisibility, noTenure);
assert.equal(returnedDukeCapture(atDeadline), true);
depart(living); depart(executed);
assert.deepEqual(lifecycle(executed), lifecycle(living));
assert.equal(lifecycle(executed).reason, 'occupationEnded');
executed.dukeVidal = retireHomeworldDukeTenure(executed.dukeVidal, 1);
assert.equal(returnedDukeCapture(executed.dukeVidal), true);
assert.equal(executed.dukeVidal.leader.dead, true);
assert.equal(executed.dukeVidal.leader.deaths, 2);
assert.equal(executed.dukeVidal.leader.concealed?.controller, 'guild');
});
void test('retired custody still permits the original captor execution and Ecaz-only return mechanisms', () => {
const g = position();
const retired = retireHomeworldDukeTenure(g.dukeVidal!, 1);
const executed = executeCapturedDuke(retired, 'harkonnen', 1);
assert.equal(returnedDukeCapture(executed), true);
assert.deepEqual(executed.captureVisibility, noTenure);
assert.equal(executed.leader.deaths, 2); assert.equal(executed.leader.concealed?.controller, 'guild');
const revived = acquireDuke(executed, 'ecaz', 1, 'ecazNexus');
assert.equal(revived.controller, 'ecaz'); assert.equal(revived.source, 'ecazNexus');
assert.equal(revived.leader.deaths, 2); assert.equal(revived.leader.dead, false);
assert.equal(revived.captureVisibility, undefined);
const returned = releaseCapturedDuke(retired, 'harkonnen', 1);
assert.equal(returned.controller, null); assert.equal(returned.leader.dead, false);
assert.equal(returned.leader.id, retired.leader.id);
});
void test('non-homeworld captive return and expiry keep their original tenure rules', () => {
const g = position();
for (const source of ['ecaz', 'moritani', 'ecazNexus', 'ally'] as const) {
const captured = captureDuke(acquireDuke(createDukeVidal(), 'guild', 1, source),
'harkonnen', 'guild', 1, 'sietch_tabr');
g.dukeVidal = captured;
assert.deepEqual(expireDuke(captured, 1), captured);
assert.deepEqual(retireHomeworldDukeTenure(captured, 1), captured);
assert.deepEqual(releaseCapturedHomeworldDuke(g, 'harkonnen'), releaseCapturedDuke(captured, 'harkonnen', 1));
}
});
void test('a retired captured or concealed disc still cannot receive a fresh occupied Ecaz grant', () => {
const g = position();
const held = retireHomeworldDukeTenure(g.dukeVidal!, 1);
depart(g);
player(g, 'guild').reserves--;
g.homeworlds!.custody!.visitors['homeworld:ecaz'].guild = { normal: 1, elite: 0 };
observe(g, 'controlled-new-qualification');
for (const state of [held, executeCapturedDuke(held, 'harkonnen', 1)]) {
g.dukeVidal = state;
assert.match(quoteHomeworldEcazDuke(g).blocked!, /captured, concealed or Ghola/);
}
// Deliberately invalid negative input: no legal foreign Duke revival is asserted.
g.dukeVidal = createDukeVidal(); g.dukeVidal.leader.gholaBy = 'atreides';
assert.match(quoteHomeworldEcazDuke(g).blocked!, /captured, concealed or Ghola/);
});
+5 -1
View File
@@ -90,10 +90,14 @@ for (const advanced of [false, true]) void test(
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));
let 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);
// Preserve the foreign-revival denial above, then wait for Ecaz's actual
// ordered Advanced opportunity before making its authorized revival.
game = advance(game, state => state.phase === 4 && clean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === f.ecaz));
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');