298 lines
18 KiB
TypeScript
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');
|
|
}
|