Route classic Homeworld Harass returns through native custody
This commit is contained in:
1 parent
5509abf1e5
commit
643add3f85
18 files changed
+1174
-83
No files matched your search
@@ -215,7 +215,7 @@ void test('KH prevents an invalid stored true call and adds two only to an unstu
|
||||
assert.equal(q.scores!.defender, 2);
|
||||
assert.equal(q.bounty, null);
|
||||
});
|
||||
void test('Stone validates physical ambiguity and mode before traitors, and uses undialed tokens rather than leader scores', () => {
|
||||
void test('Stone validates revealed mode before traitors and uses undialed tokens rather than leader scores', () => {
|
||||
const g = input();
|
||||
play(g, 'attacker', 'weapon', (c) => c.effect === 'stoneBurner');
|
||||
g.attacker.stoneMode = 'ignore';
|
||||
@@ -235,19 +235,6 @@ void test('Stone validates physical ambiguity and mode before traitors, and uses
|
||||
delete g.attacker.stoneMode;
|
||||
call(g, 'attacker');
|
||||
reject(g, /revealed mode/);
|
||||
g.attacker.stoneMode = 'ignore';
|
||||
g.attacker.forces = {
|
||||
normal: 5,
|
||||
elite: 1,
|
||||
eliteStrength: 2,
|
||||
freeSupport: false,
|
||||
};
|
||||
g.attacker.plan.dial = 3;
|
||||
g.attacker.plan.support = 1;
|
||||
g.defender.forces.normal = 3;
|
||||
g.defender.plan.dial = 1;
|
||||
g.defender.plan.support = 1;
|
||||
reject(g, /unresolved physical casualty/);
|
||||
});
|
||||
void test('copied Stone needs each revealed mode and keeps physical Mirror custody on victory', () => {
|
||||
const g = input();
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody';
|
||||
import { harassWithdrawCommitments, type HarassWithdrawSelection } from '../game/harass-withdraw';
|
||||
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type HarassHomeworldArrakisStep = { actor: string; action: Action };
|
||||
export type HarassHomeworldArrakisOptions = {
|
||||
advanced?: boolean;
|
||||
outcome?: 'winner' | 'loser';
|
||||
traitor?: 'owner' | 'opponent' | 'both';
|
||||
slot?: 'weapon' | 'defense';
|
||||
seatIds?: [string, string];
|
||||
/** An original undealt classic Emperor/Guild lobby, preserving live seat IDs. */
|
||||
initial?: Game;
|
||||
};
|
||||
export type HarassHomeworldArrakisFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeBattle: Game;
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
planActions: [HarassHomeworldArrakisStep, HarassHomeworldArrakisStep];
|
||||
battleStep: HarassHomeworldArrakisStep;
|
||||
shipments: { before: Game; after: Game; step: HarassHomeworldArrakisStep }[];
|
||||
nativeSteps: HarassHomeworldArrakisStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export function harassHomeworldArrakisPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(p => p.id === actor);
|
||||
assert.ok(player); return player;
|
||||
}
|
||||
export const harassHomeworldArrakisClean = (game: Game) =>
|
||||
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
|
||||
|
||||
export function harassHomeworldArrakisPool(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 };
|
||||
}
|
||||
export function assertHarassHomeworldArrakisCustody(game: Game): void {
|
||||
homeworldGameIntegrity(game);
|
||||
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
|
||||
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 + sum(p.forces) + visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20, p.faction);
|
||||
assert.ok(p.hand.length <= 4);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + sum(p.elites.forces) + visitors.reduce((n, pool) => n + pool.elite, 0), 5);
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
for (const [key, amount] of Object.entries(p.elites.forces)) assert.ok(amount <= (p.forces[key] ?? 0));
|
||||
}
|
||||
const native = homes.filter(h => h.native === p.id).map(h => h.forces[p.id]);
|
||||
assert.equal(native.reduce((n, pool) => n + pool.normal + pool.elite, 0), p.reserves);
|
||||
assert.equal(native.reduce((n, pool) => n + pool.elite, 0), p.elites?.reserves ?? 0);
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
/** Follow entitled original controls only; never invent a phase or a plan. */
|
||||
export function nextHarassHomeworldArrakisStep(game: Game): HarassHomeworldArrakisStep | null {
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
const d = game.decision;
|
||||
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 === 'homeworldRevivalDeployment' || d?.kind === 'caladanReinforcement')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (!d && game.biddingEnd) {
|
||||
const actor = game.biddingEnd.owners.find(id => !game.biddingEnd!.ready.includes(id));
|
||||
if (actor) return { actor, action: { type: 'biddingEnd', event: game.biddingEnd.event, mode: 'ready' } };
|
||||
}
|
||||
}
|
||||
const step = nextStrongholdFactionsNativeStep(game);
|
||||
if (step?.action.type === 'stormDial' && game.turn === 1) step.action.amount = 0;
|
||||
return step;
|
||||
}
|
||||
export function advanceHarassHomeworldArrakis(state: Game, until: (game: Game) => boolean,
|
||||
actions?: HarassHomeworldArrakisStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
const step = nextHarassHomeworldArrakisStep(game);
|
||||
assert.ok(step, 'The original native boundary requires a real plan or allocation');
|
||||
actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Original Homeworld Harass programme did not reach its boundary');
|
||||
}
|
||||
function initialize(game: Game): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
|
||||
};
|
||||
try { return initializeEcazTreacheryGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = harassHomeworldArrakisPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const old = player.traitors[0]; assert.ok(old);
|
||||
const index = game.traitorReserve!.indexOf(identity);
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(index >= 0 || donor, 'Exchange an original physical Traitor, never manufacture one');
|
||||
if (index >= 0) game.traitorReserve![index] = old;
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = old;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved original Traitor ${identity} exchanged with ${old} into ${actor}'s hand.`);
|
||||
}
|
||||
|
||||
/** Original classic two-seat setup, original board armies and wallets, native
|
||||
* turn-one shipments and battle admission. Only conserved card/Traitor custody
|
||||
* is staged. Basic retains five physical Sardaukar without Advanced strength. */
|
||||
export function createHarassHomeworldArrakisFixture(options: HarassHomeworldArrakisOptions = {}): HarassHomeworldArrakisFixture {
|
||||
const ids = options.seatIds ?? ['emperor', 'guild'];
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('HARASSHWARRAKIS', newPlayer(ids[0], 'Emperor', 'emperor'), options.advanced ?? false);
|
||||
if (!options.initial) joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
game.ecazTreachery === true && game.homeworlds && game.players.every(p => p.hand.length === 0),
|
||||
'Continue an undealt lobby or its original fresh Homeworld independent-card setup');
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['emperor', 'guild']);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.equal(game.advanced, options.advanced ?? game.advanced);
|
||||
if (game.status === 'lobby') {
|
||||
assert.ok(!game.ecazTreachery, 'Select independent cards through the initializer, not a redeal');
|
||||
game.homeworlds = { custody: null };
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initialize(game);
|
||||
}
|
||||
const initial = structuredClone(game), nativeSteps: HarassHomeworldArrakisStep[] = [];
|
||||
game = advanceHarassHomeworldArrakis(game, g => g.status === 'playing', nativeSteps);
|
||||
assertHarassHomeworldArrakisCustody(game);
|
||||
const afterSetup = structuredClone(game);
|
||||
const owner = game.players.find(p => p.faction === 'emperor')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const locationTerritory = 'tueks_sietch', sector = territory(locationTerritory).sectors[0];
|
||||
const location = `${locationTerritory}:${sector}`;
|
||||
const shipments: HarassHomeworldArrakisFixture['shipments'] = [];
|
||||
const act = (step: HarassHomeworldArrakisStep) => {
|
||||
nativeSteps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
};
|
||||
const until = (predicate: (state: Game) => boolean) => { game = advanceHarassHomeworldArrakis(game, predicate, nativeSteps); };
|
||||
// Follow actual turn order (including the Guild's genuine timing choice).
|
||||
const waiting = new Set([owner, opponent]);
|
||||
until(g => g.phase === 5 && harassHomeworldArrakisClean(g));
|
||||
while (waiting.size) {
|
||||
const actor = game.active!;
|
||||
assert.ok(waiting.has(actor), 'Each original shipment turn is consumed once');
|
||||
const imperial = actor === owner;
|
||||
const sources = imperial ? game.advanced
|
||||
? { 'homeworld:emperor': { normal: 3, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite: 4 } }
|
||||
: { 'homeworld:emperor': { normal: 3, elite: 4 } }
|
||||
: { 'homeworld:guild': { normal: 2, elite: 0 } };
|
||||
const step: HarassHomeworldArrakisStep = { actor, action: { type: 'ship', territory: locationTerritory,
|
||||
sector, amount: imperial ? 7 : 2, elite: imperial ? 4 : 0, homeworldSources: sources, allyPayment: 0 } };
|
||||
const before = structuredClone(game); act(step); until(harassHomeworldArrakisClean);
|
||||
shipments.push({ before, after: structuredClone(game), step });
|
||||
waiting.delete(actor); act({ actor, action: { type: 'endMovement' } });
|
||||
until(g => (g.phase === 5 || g.phase === 6) && harassHomeworldArrakisClean(g));
|
||||
}
|
||||
until(g => g.phase === 6 && harassHomeworldArrakisClean(g));
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
takeHarassCard(game, owner, 'ecaz-harass-withdraw');
|
||||
const staging = ['After genuine setup and native shipments: return unselected original hands to the remaining deck and conserve the physical Harass & Withdraw into the Emperor hand. No forces, wallets, phases or histories are staged.'];
|
||||
const winner = options.outcome === 'winner';
|
||||
const ownLeaders = harassHomeworldArrakisPlayer(game, owner).leaders.filter(l => !l.dead)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const otherLeaders = harassHomeworldArrakisPlayer(game, opponent).leaders.filter(l => !l.dead)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const own = winner ? ownLeaders.at(-1)! : ownLeaders[0];
|
||||
const other = winner ? otherLeaders[0] : otherLeaders.at(-1)!;
|
||||
if (options.traitor === 'owner' || options.traitor === 'both') exchangeTraitor(game, owner, other.id, staging);
|
||||
if (options.traitor === 'opponent' || options.traitor === 'both') exchangeTraitor(game, opponent, own.id, staging);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === locationTerritory);
|
||||
assert.ok(choice);
|
||||
const battleStep: HarassHomeworldArrakisStep = { actor: choice.chooser, action: { type: 'chooseBattle',
|
||||
territory: locationTerritory, target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep); game = finishHarassPreparation(game);
|
||||
assert.ok(game.battle && !game.battle.revealed && harassHomeworldArrakisClean(game));
|
||||
assert.equal(Object.keys(game.battle.plans).length, 0);
|
||||
const slot = options.slot ?? 'defense';
|
||||
const planActions: HarassHomeworldArrakisFixture['planActions'] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: 2, support: game.advanced ? 1 : 0,
|
||||
leader: own.id, weapon: slot === 'weapon' ? 'ecaz-harass-withdraw' : null,
|
||||
defense: slot === 'defense' ? 'ecaz-harass-withdraw' : null } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: winner ? 0 : 3,
|
||||
support: game.advanced && !winner ? 3 : 0, leader: other.id } },
|
||||
];
|
||||
assertHarassHomeworldArrakisCustody(game);
|
||||
return { initial, afterSetup, beforeBattle, game, owner, opponent, territory: locationTerritory,
|
||||
location, planActions, battleStep, shipments, nativeSteps, staging };
|
||||
}
|
||||
export function sealHarassHomeworldArrakisPlan(game: Game, step: HarassHomeworldArrakisStep): Game {
|
||||
return finishHarassPreparation(applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function revealHarassHomeworldArrakisBattle(fixture: HarassHomeworldArrakisFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) game = sealHarassHomeworldArrakisPlan(game, step);
|
||||
assert.equal(game.battle?.revealed, true);
|
||||
assert.equal(game.decision?.kind, 'harassWithdraw');
|
||||
return game;
|
||||
}
|
||||
/** Enumerate actual current legal complete allocations, not guessed dials. */
|
||||
export function pendingHarassHomeworldArrakisAllocations(game: Game, actor: string): HarassWithdrawSelection[] {
|
||||
const offer = viewGame(game, actor).battle?.harassAllocation;
|
||||
assert.ok(offer && !offer.selection && game.decision?.kind === 'harassWithdraw');
|
||||
assert.equal(Object.keys(offer.context.locations).length, 1);
|
||||
const [location, pool] = Object.entries(offer.context.locations)[0];
|
||||
return harassWithdrawCommitments(offer.context, offer.dial, offer.support).map(committed => ({
|
||||
[location]: { normal: pool.normal - committed.normal, elite: pool.elite - committed.elite },
|
||||
}));
|
||||
}
|
||||
export function chooseHarassHomeworldArrakisAllocation(game: Game, actor: string, returns: HarassWithdrawSelection): Game {
|
||||
assert.equal(game.decision?.kind, 'harassWithdraw');
|
||||
return applyAction(game, actor, { type: 'decision', event: game.decision.event, returns });
|
||||
}
|
||||
/** Native Traitor declarations, losses, card retention and cleanup. Allocation
|
||||
* remains an explicit after-reveal human boundary unless supplied by the caller. */
|
||||
export function finishHarassHomeworldArrakisBattle(state: Game,
|
||||
options: { callers?: string[]; returns?: HarassWithdrawSelection } = {},
|
||||
actions?: HarassHomeworldArrakisStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
if (!game.battle && harassHomeworldArrakisClean(game)) return game;
|
||||
let step: HarassHomeworldArrakisStep | null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
|
||||
assert.ok(options.returns, 'Choose actual revealed typed returns');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: options.returns } };
|
||||
} else step = nextHarassHomeworldArrakisStep(game);
|
||||
assert.ok(step);
|
||||
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||
actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Original Homeworld Harass aftermath did not finish');
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazTreacheryGameForAudit, 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';
|
||||
|
||||
export const HARASS_HOMEWORLD_CARD = 'ecaz-harass-withdraw';
|
||||
export type HarassHomeworldStep = { actor: string; action: Action };
|
||||
export type HarassHomeworldOptions = {
|
||||
advanced?: 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) {
|
||||
const d = game.decision;
|
||||
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): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
|
||||
};
|
||||
try { 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. No expansion
|
||||
* roster, other optional module, preselected variant flag or synthetic phase. */
|
||||
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.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.discoveries && !game.greatMaker);
|
||||
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
assert.deepEqual(game.homeworlds, { custody: null });
|
||||
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);
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { casualtyOptions } from '../game/combat';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldPopulations } from '../game/homeworld-population';
|
||||
import { homeworldSardaukarFreeSupport } from '../game/homeworld-benefits';
|
||||
import {
|
||||
advanceHarassHomeworldArrakis, assertHarassHomeworldArrakisCustody,
|
||||
chooseHarassHomeworldArrakisAllocation, createHarassHomeworldArrakisFixture,
|
||||
finishHarassHomeworldArrakisBattle, harassHomeworldArrakisClean,
|
||||
harassHomeworldArrakisPlayer as player, harassHomeworldArrakisPool as pool,
|
||||
pendingHarassHomeworldArrakisAllocations, revealHarassHomeworldArrakisBattle,
|
||||
sealHarassHomeworldArrakisPlan, type HarassHomeworldArrakisStep,
|
||||
} from './fixture-harass-homeworld-arrakis';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
|
||||
for (const outcome of ['winner', 'loser'] as const) {
|
||||
for (const slot of ['weapon', 'defense'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${outcome} ${slot}: every real typed return preserves its original commitment`, () => {
|
||||
const f = createHarassHomeworldArrakisFixture({ advanced, outcome, slot });
|
||||
const onePlan = sealHarassHomeworldArrakisPlan(f.game, f.planActions[0]);
|
||||
assert.equal(onePlan.battle!.harassAllocation, undefined);
|
||||
assert.equal(viewGame(onePlan, f.opponent).battle!.plans[f.owner], undefined);
|
||||
const revealed = sealHarassHomeworldArrakisPlan(onePlan, f.planActions[1]);
|
||||
assert.equal(revealed.battle!.revealed, true);
|
||||
assert.equal(revealed.decision?.kind, 'harassWithdraw');
|
||||
assert.equal(viewGame(revealed, f.opponent).battle!.harassAllocation, null);
|
||||
const offer = viewGame(revealed, f.owner).battle!.harassAllocation!;
|
||||
const allocations = pendingHarassHomeworldArrakisAllocations(revealed, f.owner);
|
||||
assert.ok(allocations.length >= 3, 'Actual physical Basic/Advanced alternatives remain after reveal');
|
||||
const sealedPlan = structuredClone(revealed.battle!.plans[f.owner]);
|
||||
const event = revealed.battle!.event;
|
||||
for (const returns of allocations) {
|
||||
const returned = returns[f.location];
|
||||
const committed = { normal: 3 - returned.normal, elite: 4 - returned.elite };
|
||||
assert.ok(casualtyOptions(offer.context.forces, offer.dial, offer.support)
|
||||
.some(c => c.normal === committed.normal && c.elite === committed.elite));
|
||||
let game = chooseHarassHomeworldArrakisAllocation(revealed, f.owner, returns);
|
||||
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
|
||||
assert.equal(player(game, f.owner).reserves, 13, 'Selection alone does not return counters before Traitor calls');
|
||||
assert.deepEqual(game.homeworlds, revealed.homeworlds);
|
||||
game = advanceHarassHomeworldArrakis(game, g => g.lastBattleContext?.event === event);
|
||||
assert.equal(game.lastBattleContext!.winner, outcome === 'winner' ? f.owner : f.opponent);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(player(game, f.owner).spice, player(revealed, f.owner).spice - (advanced ? 1 : 0), 'Sealed support charged once, not from returned population');
|
||||
const opponentCollection = advanced && game.phase >= 7 && (player(game, f.opponent).forces[f.location] ?? 0) > 0 ? 1 : 0;
|
||||
assert.equal(player(game, f.opponent).spice, player(revealed, f.opponent).spice - (advanced && outcome === 'loser' ? 3 : 0) + opponentCollection);
|
||||
if (outcome === 'winner' && game.decision?.kind === 'battleLosses') {
|
||||
assert.deepEqual(game.decision.options.map(c => ({ normal: c.normal, elite: c.elite })), [committed], 'The selected Harass complement fixes winner losses');
|
||||
}
|
||||
game = finishHarassHomeworldArrakisBattle(game);
|
||||
const own = player(game, f.owner);
|
||||
assert.equal(own.reserves, 13 + returned.normal + returned.elite);
|
||||
assert.equal(own.elites!.reserves, 1 + returned.elite);
|
||||
assert.equal(own.tanks, committed.normal + committed.elite);
|
||||
assert.equal(own.elites!.tanks, committed.elite);
|
||||
assert.equal(own.battleLosses, committed.normal + committed.elite, 'Returns are not battle losses');
|
||||
assert.equal(own.forces[f.location] ?? 0, 0, 'Returned counters cannot survive as occupying board forces');
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, 0);
|
||||
const other = player(game, f.opponent);
|
||||
assert.equal(other.reserves, 13, 'Opponent never receives the Harass user’s returned forces');
|
||||
assert.equal(other.tanks, outcome === 'winner' ? 7 : 3);
|
||||
assert.equal(other.battleLosses, outcome === 'winner' ? 7 : 3);
|
||||
assert.equal(other.forces[f.location] ?? 0, outcome === 'winner' ? 0 : 4);
|
||||
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), advanced
|
||||
? { normal: 12 + returned.normal, elite: 0 }
|
||||
: { normal: 12 + returned.normal, elite: 1 + returned.elite });
|
||||
assert.deepEqual(game.homeworlds!.custody!.salusa, advanced ? { normal: 0, elite: 1 + returned.elite } : null);
|
||||
assert.deepEqual(game.homeworlds!.custody!.visitors, {});
|
||||
assert.equal(game.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1);
|
||||
assert.ok(!own.hand.some(c => c.id === 'ecaz-harass-withdraw'));
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(harassHomeworldArrakisClean(game));
|
||||
assertHarassHomeworldArrakisCustody(game);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const caller of ['opponent', 'owner', 'both'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} actual ${caller} Traitor call keeps native return and disposal precedence`, () => {
|
||||
const f = createHarassHomeworldArrakisFixture({ advanced, traitor: caller, outcome: 'loser' });
|
||||
const revealed = revealHarassHomeworldArrakisBattle(f);
|
||||
const returns = { [f.location]: { normal: 2, elite: 3 } };
|
||||
assert.ok(pendingHarassHomeworldArrakisAllocations(revealed, f.owner)
|
||||
.some(choice => JSON.stringify(choice) === JSON.stringify(returns)));
|
||||
const callers = caller === 'both' ? [f.owner, f.opponent] : [caller === 'owner' ? f.owner : f.opponent];
|
||||
const actions: HarassHomeworldArrakisStep[] = [];
|
||||
const game = finishHarassHomeworldArrakisBattle(revealed, { returns, callers }, actions);
|
||||
assert.equal(actions.filter(s => s.action.type === 'traitorCall' && s.action.call === true).length, callers.length);
|
||||
assert.equal(game.lastBattleContext!.result, caller === 'both' ? 'mutualTraitors' : 'traitor');
|
||||
assert.equal(game.lastBattleContext!.winner, caller === 'both' ? null : caller === 'owner' ? f.owner : f.opponent);
|
||||
const own = player(game, f.owner), canceled = caller !== 'owner';
|
||||
assert.equal(own.reserves, canceled ? 13 : 18);
|
||||
assert.equal(own.elites!.reserves, canceled ? 1 : 4);
|
||||
assert.equal(own.tanks, canceled ? 7 : 0);
|
||||
assert.equal(own.elites!.tanks, canceled ? 4 : 0);
|
||||
assert.equal(own.battleLosses, canceled ? 7 : 0);
|
||||
assert.equal(own.forces[f.location] ?? 0, canceled ? 0 : 2, 'Own Traitor winner keeps its committed counters, not its returned counters');
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, canceled ? 0 : 1);
|
||||
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), advanced
|
||||
? { normal: canceled ? 12 : 14, elite: 0 }
|
||||
: { normal: canceled ? 12 : 14, elite: canceled ? 1 : 4 });
|
||||
assert.deepEqual(game.homeworlds!.custody!.salusa, advanced ? { normal: 0, elite: canceled ? 1 : 4 } : null);
|
||||
const otherPlan = f.planActions[1].action;
|
||||
assert.equal(otherPlan.type, 'battlePlan');
|
||||
const bounty = player(revealed, f.opponent).leaders.find(l => l.id === otherPlan.leader)!.strength;
|
||||
const ownerCollection = advanced && game.phase >= 7 && (own.forces[f.location] ?? 0) > 0 ? 1 : 0;
|
||||
const paidSupport = advanced && caller !== 'owner' ? 1 : 0;
|
||||
assert.equal(own.spice, player(revealed, f.owner).spice + (caller === 'owner' ? bounty : 0) - paidSupport + ownerCollection,
|
||||
'Only the sole Traitor winner waives support; later Collection remains separate.');
|
||||
if (canceled) {
|
||||
const ownPlan = f.planActions[0].action;
|
||||
assert.equal(ownPlan.type, 'battlePlan');
|
||||
assert.equal(own.leaders.find(l => l.id === ownPlan.leader)!.dead, true);
|
||||
}
|
||||
assert.equal(game.discard.filter(c => c.id === 'ecaz-harass-withdraw').length, 1, 'After-use disposal also beats own-Traitor winner retention');
|
||||
assert.ok(!own.hand.some(c => c.id === 'ecaz-harass-withdraw'));
|
||||
assertHarassHomeworldArrakisCustody(game);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('Advanced Salusa low→high return does not retroactively rescore sealed support, and later native shipment uses the real reserves', () => {
|
||||
const f = createHarassHomeworldArrakisFixture({ advanced: true, outcome: 'winner' });
|
||||
const revealed = revealHarassHomeworldArrakisBattle(f);
|
||||
const offer = viewGame(revealed, f.owner).battle!.harassAllocation!;
|
||||
assert.equal(homeworldSardaukarFreeSupport(revealed, f.owner), false);
|
||||
assert.ok(casualtyOptions(offer.context.forces, 2, 1).some(c => c.normal === 1 && c.elite === 1));
|
||||
assert.ok(!casualtyOptions({ ...offer.context.forces, eliteFreeSupport: true }, 2, 1)
|
||||
.some(c => c.normal === 1 && c.elite === 1), 'Requoting against high Salusa would erase the sealed physical commitment');
|
||||
const game = finishHarassHomeworldArrakisBattle(revealed, { returns: { [f.location]: { normal: 2, elite: 3 } } });
|
||||
assert.equal(player(game, f.owner).tanks, 2);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, 1);
|
||||
assert.equal(player(game, f.owner).spice, player(revealed, f.owner).spice - 1);
|
||||
assert.equal(homeworldSardaukarFreeSupport(game, f.owner), true);
|
||||
const salusa = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(h => h.location === 'homeworld:emperor:salusa')!;
|
||||
assert.equal(salusa.population, 4);
|
||||
assert.equal(salusa.side, 'high');
|
||||
let next = advanceHarassHomeworldArrakis(game,
|
||||
g => g.turn === 2 && g.phase === 5 && g.active === f.owner && harassHomeworldArrakisClean(g));
|
||||
assert.ok(viewGame(next, f.owner).homeworldMove, 'The native next movement control remains available');
|
||||
const before = structuredClone(next);
|
||||
next = applyAction(next, f.owner, { type: 'ship', territory: f.territory,
|
||||
sector: Number(f.location.split(':').at(-1)), amount: 1, elite: 1, allyPayment: 0,
|
||||
homeworldSources: { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } } });
|
||||
next = advanceHarassHomeworldArrakis(next, harassHomeworldArrakisClean);
|
||||
assert.equal(player(next, f.owner).reserves, player(before, f.owner).reserves - 1);
|
||||
assert.equal(player(next, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves - 1);
|
||||
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor'), pool(before, f.owner, 'homeworld:emperor'));
|
||||
assert.deepEqual(pool(next, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 3 });
|
||||
assert.equal(player(next, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(next, f.owner).elites!.forces[f.location], 1);
|
||||
assert.equal(player(next, f.owner).tanks, player(before, f.owner).tanks);
|
||||
assertHarassHomeworldArrakisCustody(next);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { combatArmy, homeworldBattleLocation } from '../game/combat-location';
|
||||
import {
|
||||
HARASS_HOMEWORLD_CARD, advanceHarassHomeworld,
|
||||
assertHarassHomeworldCustody, chooseHarassHomeworldReturn, createHarassHomeworldFixture,
|
||||
finishHarassHomeworldBattle, harassHomeworldClean, harassHomeworldPlayer as player,
|
||||
harassHomeworldPool, revealHarassHomeworldBattle, sealHarassHomeworldPlan,
|
||||
type HarassHomeworldStep,
|
||||
} from './fixture-harass-homeworld';
|
||||
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action, reason?: RegExp): void {
|
||||
const before = JSON.stringify(game);
|
||||
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
|
||||
else assert.throws(() => applyAction(game, actor, action));
|
||||
assert.equal(JSON.stringify(game), before, 'An illegal action must not mutate original custody or sealed plans');
|
||||
}
|
||||
function assertFinished(game: Game, owner: string, location: string): void {
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(!game.homeworldBattleLoss && !game.pendingTreacheryDiscard && harassHomeworldClean(game));
|
||||
assert.equal(game.lastBattleContext!.territory, location);
|
||||
assert.equal(game.discard.filter(c => c.id === HARASS_HOMEWORLD_CARD).length, 1);
|
||||
assert.ok(!player(game, owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD));
|
||||
assertHarassHomeworldCustody(game);
|
||||
}
|
||||
|
||||
for (const sample of [
|
||||
{ advanced: false, slot: 'weapon' as const },
|
||||
{ advanced: true, slot: 'defense' as const },
|
||||
]) void test(`${sample.advanced ? 'Advanced' : 'Basic'} native visitor ${sample.slot} returns actual typed counters, then loses only its original commitment`, () => {
|
||||
const f = createHarassHomeworldFixture(sample);
|
||||
for (const state of [f.afterSetup, f.beforeShipment, f.afterShipment, f.beforeBattle, f.game])
|
||||
assertHarassHomeworldCustody(state);
|
||||
assert.equal(f.shipmentStep.action.type, 'homeworldShip');
|
||||
assert.equal(f.battleStep.action.type, 'chooseBattle');
|
||||
assert.deepEqual(combatArmy(f.beforeShipment, f.visitor, f.location), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(combatArmy(f.afterShipment, f.visitor, f.location), { normal: 3, elite: 4 });
|
||||
assert.equal(player(f.afterShipment, f.visitor).reserves, player(f.beforeShipment, f.visitor).reserves - 7);
|
||||
assert.equal(player(f.afterShipment, f.visitor).elites!.reserves, player(f.beforeShipment, f.visitor).elites!.reserves - 4);
|
||||
assert.equal(f.game.battle!.harassAllocationVersion, 1);
|
||||
let game = sealHarassHomeworldPlan(f.game, f.planActions[0]);
|
||||
assert.ok(!game.battle!.revealed);
|
||||
assert.equal(game.battle!.harassAllocation, undefined);
|
||||
assert.equal(viewGame(game, f.opponent).battle!.plans[f.owner], undefined);
|
||||
const sealedPlan = structuredClone(game.battle!.plans[f.owner]);
|
||||
assert.equal(sealedPlan[sample.slot], HARASS_HOMEWORLD_CARD);
|
||||
game = sealHarassHomeworldPlan(game, f.planActions[1]);
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.equal(game.decision?.kind, 'harassWithdraw');
|
||||
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
|
||||
const offer = viewGame(game, f.owner).battle!.harassAllocation!;
|
||||
assert.deepEqual(offer.context.locations, { [f.location]: { normal: 3, elite: 4 } });
|
||||
assert.deepEqual(offer.context.forces.normal, 3);
|
||||
assert.deepEqual(offer.context.forces.elite, 4);
|
||||
assert.equal(viewGame(game, f.opponent).battle!.harassAllocation, null);
|
||||
const beforeSelection = structuredClone(game);
|
||||
game = chooseHarassHomeworldReturn(game, f.returns);
|
||||
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
|
||||
assert.deepEqual(game.homeworlds, beforeSelection.homeworlds, 'Allocation selection is not a premature withdrawal');
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves, player(beforeSelection, p.id).reserves);
|
||||
assert.equal(p.tanks, player(beforeSelection, p.id).tanks);
|
||||
}
|
||||
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
|
||||
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: false });
|
||||
const actions: HarassHomeworldStep[] = [];
|
||||
const done = finishHarassHomeworldBattle(game, { actions });
|
||||
const returned = f.returns[f.location], amount = returned.normal + returned.elite;
|
||||
const before = player(f.game, f.owner), after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.native);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(after.reserves, before.reserves + amount);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + returned.elite);
|
||||
assert.equal(after.tanks, before.tanks + 7 - amount);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 4 - returned.elite);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 7 - amount);
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(after.elites!.revived, before.elites!.revived);
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
const primary = 'homeworld:emperor';
|
||||
const homeBefore = harassHomeworldPool(f.game, f.owner, primary);
|
||||
assert.deepEqual(harassHomeworldPool(done, f.owner, primary), sample.advanced
|
||||
? { normal: homeBefore.normal + returned.normal, elite: homeBefore.elite }
|
||||
: { normal: homeBefore.normal + returned.normal, elite: homeBefore.elite + returned.elite });
|
||||
if (sample.advanced) {
|
||||
const salusa = harassHomeworldPool(f.game, f.owner, 'homeworld:emperor:salusa');
|
||||
assert.deepEqual(harassHomeworldPool(done, f.owner, 'homeworld:emperor:salusa'),
|
||||
{ normal: salusa.normal, elite: salusa.elite + returned.elite });
|
||||
}
|
||||
assert.equal(player(done, f.native).tanks - player(f.game, f.native).tanks, 3);
|
||||
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
|
||||
assert.equal(after.leaders.find(l => l.id === sealedPlan.leader)!.dead, false, 'Ordinary leader resolution is unchanged');
|
||||
assertFinished(done, f.owner, f.location);
|
||||
const continued = advanceHarassHomeworld(done, g => g.phase === 7 && harassHomeworldClean(g));
|
||||
assert.equal(continued.turn, f.game.turn);
|
||||
assertFinished(continued, f.owner, f.location);
|
||||
});
|
||||
|
||||
void test('Advanced visitor winner pays its fixed original support and discards Harass instead of retaining it', () => {
|
||||
const f = createHarassHomeworldFixture({ advanced: true, slot: 'weapon', outcome: 'win' });
|
||||
let game = revealHarassHomeworldBattle(f);
|
||||
const sealedPlan = structuredClone(game.battle!.plans[f.owner]);
|
||||
game = chooseHarassHomeworldReturn(game, f.returns);
|
||||
assert.deepEqual(game.battle!.plans[f.owner], sealedPlan);
|
||||
const before = player(f.game, f.owner);
|
||||
const done = finishHarassHomeworldBattle(game);
|
||||
const after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(after.reserves, before.reserves + 5);
|
||||
assert.equal(after.tanks, before.tanks + 2);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 2);
|
||||
assert.equal(after.spice, before.spice - 1, 'No leader dies here; original one-spice support is charged exactly once');
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assertFinished(done, f.owner, f.location);
|
||||
});
|
||||
|
||||
for (const sample of [
|
||||
{ advanced: false, secondary: false, slot: 'weapon' as const },
|
||||
{ advanced: true, secondary: false, slot: 'defense' as const },
|
||||
{ advanced: true, secondary: true, slot: 'weapon' as const },
|
||||
]) void test(`${sample.advanced ? 'Advanced' : 'Basic'} own ${sample.secondary ? 'Salusa' : 'Kaitain'} forbids Harass immutably but accepts the plain original plan`, () => {
|
||||
const f = createHarassHomeworldFixture({ ...sample, role: 'native' });
|
||||
assert.equal(homeworldBattleLocation(f.game, f.location)!.native, f.owner);
|
||||
const preview = viewGame(f.game, f.owner).battle!.harassWithdraw!;
|
||||
assert.match(preview.blocked!, /own.*Homeworld/i);
|
||||
rejectsUnchanged(f.game, f.owner, f.planActions[0].action, /own.*Homeworld/i);
|
||||
assert.equal(Object.keys(f.game.battle!.plans).length, 0);
|
||||
assert.ok(player(f.game, f.owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD));
|
||||
const plain: HarassHomeworldStep = { actor: f.owner, action: { ...f.planActions[0].action, weapon: null, defense: null } };
|
||||
let game = sealHarassHomeworldPlan(f.game, plain);
|
||||
game = sealHarassHomeworldPlan(game, f.planActions[1]);
|
||||
assert.equal(game.battle!.revealed, true);
|
||||
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.owner]);
|
||||
const actions: HarassHomeworldStep[] = [];
|
||||
game = finishHarassHomeworldBattle(game, { actions });
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(harassHomeworldClean(game));
|
||||
assert.equal(game.lastBattleContext!.territory, f.location);
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.ok(player(game, f.owner).hand.some(c => c.id === HARASS_HOMEWORLD_CARD), 'Rejected card was never used');
|
||||
assert.equal(game.discard.filter(c => c.id === HARASS_HOMEWORLD_CARD).length, 0);
|
||||
assert.ok(actions.filter(s => s.action.type === 'traitorCall').every(s => s.actor === f.owner));
|
||||
assertHarassHomeworldCustody(game);
|
||||
});
|
||||
|
||||
void test('the real opposing native Traitor cancels a selected typed withdrawal; no invader vote is invented', () => {
|
||||
const f = createHarassHomeworldFixture({ advanced: true, slot: 'defense', outcome: 'traitor' });
|
||||
let game = revealHarassHomeworldBattle(f);
|
||||
assert.ok(player(game, f.native).traitors.includes(game.battle!.plans[f.owner].leader!));
|
||||
game = chooseHarassHomeworldReturn(game, f.returns);
|
||||
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
|
||||
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: true });
|
||||
const actions: HarassHomeworldStep[] = [];
|
||||
const done = finishHarassHomeworldBattle(game, { callers: [f.native], actions });
|
||||
const before = player(f.game, f.owner), after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext!.winner, f.native);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves);
|
||||
assert.equal(after.tanks, before.tanks + 7);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 4);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 7);
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.elites!.revived, before.elites!.revived);
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assert.equal(player(done, f.native).tanks, player(f.game, f.native).tanks);
|
||||
assert.equal(after.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
|
||||
assertFinished(done, f.owner, f.location);
|
||||
});
|
||||
|
||||
void test('Harass precedes Lasgun/Shield: returned visitors survive while native losses retain the printed Homeworld exception', () => {
|
||||
const f = createHarassHomeworldFixture({ advanced: true, slot: 'defense', outcome: 'explosion' });
|
||||
let game = revealHarassHomeworldBattle(f);
|
||||
const nativeBefore = combatArmy(f.game, f.native, f.location);
|
||||
const printed = homeworldBattleLocation(f.game, f.location)!.nativeBattleStrength;
|
||||
assert.ok(nativeBefore.normal > printed);
|
||||
const ownPlan = structuredClone(game.battle!.plans[f.owner]);
|
||||
const enemyPlan = structuredClone(game.battle!.plans[f.opponent]);
|
||||
assert.equal(player(game, f.owner).hand.find(c => c.id === ownPlan.weapon)!.kind, 'lasgun');
|
||||
assert.equal(player(game, f.opponent).hand.find(c => c.id === enemyPlan.defense)!.kind, 'shield');
|
||||
game = chooseHarassHomeworldReturn(game, f.returns);
|
||||
assert.deepEqual(game.battle!.plans[f.owner], ownPlan);
|
||||
const actions: HarassHomeworldStep[] = [];
|
||||
const done = finishHarassHomeworldBattle(game, { actions });
|
||||
const before = player(f.game, f.owner), after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.result, 'explosion');
|
||||
assert.equal(done.lastBattleContext!.winner, null);
|
||||
assert.equal(after.reserves, before.reserves + 5);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
|
||||
assert.equal(after.tanks, before.tanks + 2);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 2);
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(after.elites!.revived, before.elites!.revived);
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(combatArmy(done, f.native, f.location), { normal: nativeBefore.normal - printed, elite: nativeBefore.elite });
|
||||
assert.equal(player(done, f.native).reserves, player(f.game, f.native).reserves - printed);
|
||||
assert.equal(player(done, f.native).tanks, player(f.game, f.native).tanks + printed);
|
||||
assert.equal(player(done, f.native).battleLosses, player(f.game, f.native).battleLosses + printed);
|
||||
assert.equal(after.leaders.find(l => l.id === ownPlan.leader)!.dead, true);
|
||||
assert.equal(player(done, f.opponent).leaders.find(l => l.id === enemyPlan.leader)!.dead, true);
|
||||
for (const id of [HARASS_HOMEWORLD_CARD, ownPlan.weapon, enemyPlan.defense])
|
||||
assert.equal(done.discard.filter(c => c.id === id).length, 1);
|
||||
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
|
||||
assertFinished(done, f.owner, f.location);
|
||||
});
|
||||
Reference in new issue
Block a user