Retire occupied Ecaz Duke tenure through captivity
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user