Files
Dune/tests/fixture-harass-homeworld.ts

298 lines
18 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeLeaderSkillsGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { traitorDeck } from '../game/traitors';
import { combatArmy } from '../game/combat-location';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { skillOfferRolls } from './fixture-independent-card-modules';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
export const HARASS_HOMEWORLD_CARD = 'ecaz-harass-withdraw';
export type HarassHomeworldStep = { actor: string; action: Action };
export type HarassHomeworldOptions = {
advanced?: boolean;
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
tech?: boolean;
strongholds?: boolean;
slot?: 'weapon' | 'defense';
role?: 'visitor' | 'native';
/** Salusa is an Advanced own-World rejection case, not a second faction. */
secondary?: boolean;
outcome?: 'loss' | 'win' | 'traitor' | 'explosion';
seatIds?: [string, string, string];
/** Original undealt classic lobby, e.g. with authenticated smoke seat IDs. */
initial?: Game;
};
export type HarassHomeworldSetup = {
initial: Game; initialized: Game; afterSetup: Game;
owner: string; opponent: string; observer: string;
nativeSteps: HarassHomeworldStep[];
};
export type HarassHomeworldFixture = HarassHomeworldSetup & {
options: HarassHomeworldOptions;
role: 'visitor' | 'native'; location: string; native: string; visitor: string;
beforeShipment: Game; shipmentStep: HarassHomeworldStep; afterShipment: Game;
beforeBattle: Game; battleStep: HarassHomeworldStep;
/** Genuine original prepared battle; no plan has been sealed. */
game: Game;
planActions: [HarassHomeworldStep, HarassHomeworldStep];
returns: Record<string, HomeworldForces>;
staging: string[];
};
export function harassHomeworldPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
export const harassHomeworldClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
export function harassHomeworldPool(game: Game, actor: string, location: string): HomeworldForces {
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location);
assert.ok(home); return { ...(home.forces[actor] ?? { normal: 0, elite: 0 }) };
}
/** Includes typed Basic identity and visitor custody outside Player.forces. */
export function assertHarassHomeworldCustody(game: Game): void {
homeworldGameIntegrity(game);
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
for (const p of game.players) {
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0) +
visitors.reduce((n, f) => n + f.normal + f.elite, 0), 20, p.faction);
for (const map of [p.forces, p.elites?.forces ?? {}]) {
for (const [key, value] of Object.entries(map)) {
assert.ok(!key.includes('undefined') && !key.includes('NaN') && !key.startsWith('homeworld:'), key);
assert.ok(Number.isSafeInteger(value) && value >= 0, key);
}
}
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0) + visitors.reduce((n, f) => n + f.elite, 0), 5);
assert.ok(p.hand.length <= 4, p.faction);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.equal(cards.length, 36);
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck([]), ...ecazTreacheryCards()].map(c => c.id).sort());
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])].sort(),
traitorDeck(game.players).sort());
}
export function actHarassHomeworld(game: Game, step: HarassHomeworldStep, actions?: HarassHomeworldStep[]): Game {
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
/** Never invent a plan, native vote or late-defense permission. */
export function nextHarassHomeworldStep(game: Game): HarassHomeworldStep | null {
if (!game.response && !game.phaseOpening) {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = offer.cards.find(card => !own.unavailableSkills?.[card]), leader = own.eligibleLeaders[0];
assert.ok(skill && leader, 'Choose an original offered skill and eligible native disc');
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
}
const d = game.decision;
if (d?.kind === 'mentatQuestion' && d.stage === 'name')
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'leaderSkillVisibility')
return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
if (d?.kind === 'harassWithdraw') return null;
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
if (d?.kind === 'homeworldExplosion') return { actor: d.player, action: { type: 'decision', event: d.event, choice: 0 } };
if (d?.kind === 'homeworldRevivalDeployment' || d?.kind === 'caladanReinforcement')
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'wormProtection') return { actor: d.player, action: { type: 'decision', accept: true } };
if (d?.kind === 'wormRide') return { actor: d.player, action: { type: 'decision', accept: false } };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceHarassHomeworld(game: Game, until: (game: Game) => boolean,
actions?: HarassHomeworldStep[]): Game {
for (let n = 0; n < 1600; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextHarassHomeworldStep(game); assert.ok(next, 'Original Homeworld continuation needs a real human plan or allocation');
game = actHarassHomeworld(game, next, actions);
}
throw Error('Original Harass Homeworld programme did not reach its boundary');
}
function initialize(game: Game, options: Pick<HarassHomeworldOptions, 'skills' | 'training'>): Game {
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try {
if (options.skills) {
game.ecazTreachery = true;
return initializeLeaderSkillsGameForAudit(game);
}
return initializeEcazTreacheryGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Homeworlds selected before the independent 33+3 physical setup. Original
* Tech/Advanced Strongholds may be configured in a fresh classic initial lobby. */
export function createHarassHomeworldSetup(options: HarassHomeworldOptions = {}): HarassHomeworldSetup {
const ids = options.seatIds ?? ['emperor', 'guild', 'atreides'];
let game = options.initial ? structuredClone(options.initial)
: createGame('HARASSHOMEWORLD', newPlayer(ids[0], 'Emperor', 'emperor'), options.advanced ?? false);
if (!options.initial) {
joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
joinGame(game, newPlayer(ids[2], 'Atreides', 'atreides'));
}
assert.equal(game.status, 'lobby'); assert.equal(game.advanced, options.advanced ?? game.advanced);
assert.deepEqual(game.expansions, []);
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'emperor', 'guild']);
assert.ok(!game.ecazTreachery && !game.nexusCards && !game.leaderSkills &&
!game.discoveryEnabled && !game.discoveries && !game.greatMaker);
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
assert.deepEqual(game.homeworlds, { custody: null });
if (options.tech !== undefined && !!game.techTokens !== options.tech)
game = applyAction(game, game.host, { type: 'techTokens', enabled: options.tech });
if (options.strongholds !== undefined && !!game.strongholdCards !== options.strongholds)
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: options.strongholds });
const nativeSteps: HarassHomeworldStep[] = [];
for (const p of game.players) if (!p.ready)
game = actHarassHomeworld(game, { actor: p.id, action: { type: 'ready' } }, nativeSteps);
const initial = structuredClone(game);
game = initialize(game, options);
const initialized = structuredClone(game);
game = advanceHarassHomeworld(game, g => g.status === 'playing', nativeSteps);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const observer = game.players.find(p => p.faction === 'atreides')!.id;
assertHarassHomeworldCustody(game);
return { initial, initialized, afterSetup: structuredClone(game), owner, opponent, observer, nativeSteps };
}
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const p = harassHomeworldPlayer(game, actor);
if (p.traitors.includes(identity)) return;
const returned = p.traitors[0]; assert.ok(returned);
const index = game.traitorReserve!.indexOf(identity);
const donor = game.players.find(other => other.id !== actor && other.traitors.includes(identity));
assert.ok(index >= 0 || donor, 'Only exchange a real original Traitor identity');
if (index >= 0) game.traitorReserve!.splice(index, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
p.traitors[0] = identity;
staging.push(`Conserved Traitor ${identity}: original ${donor?.id ?? 'reserve'} exchanged with ${actor}'s ${returned}.`);
}
/** Actual first-turn typed invasion, chooseBattle and shared preparation. Only
* unopened Spice order and conserved physical card custody are staged. */
export function createHarassHomeworldFixture(options: HarassHomeworldOptions = {}): HarassHomeworldFixture {
const setup = createHarassHomeworldSetup(options), role = options.role ?? 'visitor';
const outcome = options.outcome ?? 'loss', slot = options.slot ?? 'defense';
assert.ok(!options.secondary || role === 'native' && setup.afterSetup.advanced);
assert.ok(outcome !== 'explosion' || role === 'visitor');
assert.ok(outcome !== 'explosion' || slot === 'defense', 'Lasgun uses the actual weapon slot');
let game = structuredClone(setup.afterSetup);
const staging: string[] = [];
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
const at = game.spiceDeck.findIndex((c, index) => index >= i && 'territory' in c && !c.discovery);
assert.ok(at >= 0); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
}
staging.push('Position unopened original non-worm Spice Cards for the first native Spice Blow; no storm/phase/history assignment.');
const native = role === 'visitor' ? setup.opponent : setup.owner;
const visitor = role === 'visitor' ? setup.owner : setup.opponent;
const location = role === 'visitor' ? 'homeworld:guild' : options.secondary ? 'homeworld:emperor:salusa' : 'homeworld:emperor';
game = advanceHarassHomeworld(game, g => g.phase === 5 && g.active === visitor && harassHomeworldClean(g), setup.nativeSteps);
const beforeShipment = structuredClone(game);
const amount = role === 'visitor' ? 7 : 5, elite = role === 'visitor' ? 4 : 0;
const sources = nativeShipmentSources(viewGame(game, visitor), amount, elite); assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(game, visitor), location,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? 'Original typed invasion must be available');
const shipmentStep = { actor: visitor, action: choice.action };
game = actHarassHomeworld(game, shipmentStep, setup.nativeSteps);
game = advanceHarassHomeworld(game, g => !g.pendingHomeworldShipment && !g.pendingShipment && harassHomeworldClean(g), setup.nativeSteps);
const afterShipment = structuredClone(game);
assert.deepEqual(combatArmy(game, visitor, location), role === 'visitor' ? { normal: 3, elite: 4 } : { normal: 5, elite: 0 });
game = advanceHarassHomeworld(game, g => g.phase === 6 && harassHomeworldClean(g) && !!g.active, setup.nativeSteps);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
takeHarassCard(game, setup.owner, HARASS_HOMEWORLD_CARD);
let laser: string | undefined, shield: string | undefined;
if (outcome === 'explosion') {
laser = game.deck.find(c => c.kind === 'lasgun')!.id;
shield = game.deck.find(c => c.kind === 'shield')!.id;
takeHarassCard(game, setup.owner, laser); takeHarassCard(game, setup.opponent, shield);
}
staging.push('Return original hands to the undealt deck, then conserve the physical Harass and, for explosion only, original Lasgun/Shield to their actual owners; all hands <=4.');
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const ownLeader = outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
const otherLeader = outcome === 'win' ? living(setup.opponent)[0] : living(setup.opponent).at(-1)!;
if (outcome === 'traitor') {
assert.equal(role, 'visitor', 'Only the opposing native, not an invader, calls Traitor');
conserveTraitor(game, setup.opponent, ownLeader.id, staging);
}
const beforeBattle = structuredClone(game);
const battleChoice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location); assert.ok(battleChoice);
const battleStep: HarassHomeworldStep = { actor: battleChoice.chooser, action: { type: 'chooseBattle', territory: location,
target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } };
game = actHarassHomeworld(game, battleStep, setup.nativeSteps);
game = advanceHarassHomeworld(game, g => !!g.battle && harassHomeworldClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, setup.nativeSteps);
assert.equal(Object.keys(game.battle!.plans).length, 0);
const dial = role === 'native' ? 0 : 2;
const support = game.advanced && role === 'visitor' ? 1 : 0;
const planActions: [HarassHomeworldStep, HarassHomeworldStep] = [
{ actor: setup.owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
weapon: slot === 'weapon' ? HARASS_HOMEWORLD_CARD : laser ?? null,
defense: slot === 'defense' ? HARASS_HOMEWORLD_CARD : null } },
{ actor: setup.opponent, action: { type: 'battlePlan', dial: role === 'native' || outcome === 'win' || outcome === 'explosion' ? 0 : 3,
support: game.advanced && role === 'visitor' && outcome !== 'win' && outcome !== 'explosion' ? 3 : 0,
leader: otherLeader.id, defense: shield ?? null } },
];
const returns = { [location]: { normal: 2, elite: 3 } };
assertHarassHomeworldCustody(game);
return { ...setup, options, role, location, native, visitor, beforeShipment, shipmentStep, afterShipment,
beforeBattle, battleStep, game, planActions, returns, staging };
}
export function sealHarassHomeworldPlan(game: Game, step: HarassHomeworldStep): Game {
return finishHarassPreparation(applyAction(game, step.actor, step.action));
}
export function revealHarassHomeworldBattle(fixture: HarassHomeworldFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.planActions) game = sealHarassHomeworldPlan(game, step);
assert.ok(game.battle?.revealed); return game;
}
export function chooseHarassHomeworldReturn(game: Game, returns: Record<string, HomeworldForces>): Game {
assert.equal(game.decision?.kind, 'harassWithdraw');
assert.ok(game.decision && 'event' in game.decision);
return applyAction(game, game.decision.player, { type: 'decision', event: game.decision.event, returns });
}
/** Original native declarations, typed losses, mandatory discard, retention and
* Caladan aftercare; never fabricate an invader's forbidden Traitor vote. */
export function finishHarassHomeworldBattle(game: Game, options: {
returns?: Record<string, HomeworldForces>; callers?: string[]; actions?: HarassHomeworldStep[];
} = {}): Game {
for (let n = 0; n < 240; n++) {
if (!game.battle && !game.homeworldBattleLoss && harassHomeworldClean(game)) return game;
let step: HarassHomeworldStep | null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
assert.ok(options.returns, 'Use the actual after-reveal typed allocation');
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: options.returns } };
} else step = nextHarassHomeworldStep(game);
assert.ok(step, 'Finish requires both genuine plans');
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
game = actHarassHomeworld(game, step, options.actions);
}
throw Error('Original Harass Homeworld battle did not complete');
}