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