290 lines
21 KiB
TypeScript
290 lines
21 KiB
TypeScript
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 === 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 } };
|
|
}
|
|
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; harkonnen?: boolean } = {}) {
|
|
const advanced = options.advanced ?? false, allied = options.allied ?? false;
|
|
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, '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); };
|
|
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 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]];
|
|
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 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: garrisonTerritory, sector: garrisonSector, amount: 2, elite: 0, allyPayment: 0,
|
|
homeworldSources: sources });
|
|
reach(state => homeworldEcazDukeClean(state) && !state.pendingShipment);
|
|
});
|
|
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);
|
|
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' : 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));
|
|
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,
|
|
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,
|
|
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: [
|
|
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.',
|
|
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.',
|
|
],
|
|
};
|
|
}
|
|
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 };
|
|
}
|