193 lines
9.6 KiB
TypeScript
193 lines
9.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeHomeworldGameForAudit, joinGame,
|
|
newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
|
import { location, territory as boardTerritory } from '../game/board';
|
|
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { placeTerror, type TerrorKind } from '../game/moritani-terror';
|
|
import {
|
|
caladanVictoryFixture, holdVictoryCard, positionVictoryArmy,
|
|
victoryPlayer, victoryReload,
|
|
} from './fixture-caladan-victory';
|
|
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
|
|
|
export const caladanArrivalPlayer = victoryPlayer;
|
|
export const caladanArrivalReload = victoryReload;
|
|
export type CaladanArrivalStep = { actor: string; action: Action };
|
|
|
|
/** Controlled, conserved battle source. The inherited Homeworld preview
|
|
* initializer is not a public start journey. Wallets, later turn/phase and
|
|
* positions, selected held original cards, and the placed original Terror token are
|
|
* scenario inputs. Only the recorded battle and arrival actions are native
|
|
* chronology; no victory, entry, reaction or reinforcement receipt is installed. */
|
|
export const caladanArrivalDisclosure =
|
|
'Controlled Homeworld preview setup and later battle positions/purses/cards/token placement; recorded native battle and arrival actions, not public acquisition history.';
|
|
|
|
export function actCaladanArrival(
|
|
game: Game, actor: string, action: Action, actions: CaladanArrivalStep[] = [],
|
|
): Game {
|
|
const result = normalizeAutomaticGame(applyAction(game, actor, action));
|
|
actions.push({ actor, action: structuredClone(action) });
|
|
return result;
|
|
}
|
|
|
|
/** Resolve only actual neutral native controls, preserving the decisions under
|
|
* test. It never seals a plan, chooses an entry reaction or advances a new phase. */
|
|
export function advanceCaladanArrival(
|
|
game: Game, actions: CaladanArrivalStep[] = [],
|
|
): Game {
|
|
let g = game;
|
|
for (let n = 0; n < 100; n++) {
|
|
if (g.decision && ['caladanReinforcement', 'intrusion', 'moritaniTerror',
|
|
'ambassador', 'arrivalOverlap'].includes(g.decision.kind)) return g;
|
|
if (!g.battle && !g.response && !g.pendingTreacheryDiscard) return g;
|
|
const next = nextStrongholdFactionsNativeStep(g);
|
|
if (!next) return g;
|
|
g = actCaladanArrival(g, next.actor, next.action, actions);
|
|
}
|
|
assert.fail('The bounded native Caladan continuation did not stop.');
|
|
}
|
|
|
|
export function allowCaladanArrivalResponses(
|
|
game: Game, actions: CaladanArrivalStep[] = [],
|
|
): Game {
|
|
let g = game;
|
|
for (let n = 0; g.response && n < 40; n++) {
|
|
const actor = g.players.find(p => !g.response!.passed.includes(p.id));
|
|
assert.ok(actor);
|
|
g = actCaladanArrival(g, actor.id, { type: 'passResponse' }, actions);
|
|
}
|
|
assert.equal(g.response, null);
|
|
return advanceCaladanArrival(g, actions);
|
|
}
|
|
|
|
export function caladanArrivalInventory(g: Game) {
|
|
homeworldGameIntegrity(g);
|
|
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)];
|
|
assert.equal(cards.length, g.expansions.includes('ix') ? 47 : 33);
|
|
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
|
|
for (const p of g.players) {
|
|
const visitors = Object.values(g.homeworlds!.custody!.visitors).reduce(
|
|
(sum, world) => sum + (world[p.id]?.normal ?? 0) + (world[p.id]?.elite ?? 0), 0);
|
|
const stars = p.elites ? p.elites.reserves + p.elites.tanks +
|
|
Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0) : 0;
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0) + visitors, 20);
|
|
assert.ok(stars >= 0 && p.spice >= 0);
|
|
}
|
|
return { cards: cards.length, leaders: g.players.flatMap(p => p.leaders).length };
|
|
}
|
|
|
|
function hmsBattleSource(): Game {
|
|
let g = createGame('CALADANHMSARRIVAL', newPlayer('a', 'Atreides', 'atreides'), true, ['ix']);
|
|
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
|
joinGame(g, newPlayer('i', 'Ixians', 'ixians'));
|
|
g = applyAction(g, 'a', { type: 'homeworlds', enabled: true });
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = initializeHomeworldGameForAudit(g);
|
|
for (let n = 0; (g.status === 'setup' || !g.mobileStronghold?.location) && n < 140; n++) {
|
|
const next = nextStrongholdFactionsNativeStep(g);
|
|
if (next) g = applyAction(g, next.actor, next.action);
|
|
else {
|
|
const choice = g.players.map(p => ({actor:p.id, action:botActions(viewGame(g,p.id))[0]})).find(c => c.action);
|
|
assert.ok(choice);
|
|
g = applyAction(g, choice.actor, choice.action);
|
|
}
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
assert.ok(g.mobileStronghold?.location, 'The original Ix setup must place its actual HMS.');
|
|
// Conserved later scenario positions, not a claimed native movement journey.
|
|
const ix = victoryPlayer(g, 'i');
|
|
ix.reserves += Object.values(ix.forces).reduce((sum, count) => sum + count, 0);
|
|
ix.forces = {};
|
|
ix.elites!.reserves += Object.values(ix.elites!.forces).reduce((sum, count) => sum + count, 0);
|
|
ix.elites!.forces = {};
|
|
for (const p of g.players) { g.deck.push(...p.hand.splice(0)); p.spice = 20; }
|
|
Object.assign(g, { phase:6, turn:2, storm:18, phaseOpening:null, response:null,
|
|
decision:null, active:'a', ready:[], order:g.players.map(p => p.id) });
|
|
positionVictoryArmy(g, 'a', 6, 3);
|
|
positionVictoryArmy(g, 'g', 17, 3);
|
|
for (const id of ['a', 'g']) {
|
|
const p = victoryPlayer(g, id);
|
|
delete p.forces['hagga_basin:12'];
|
|
p.forces['hidden_mobile_stronghold:0'] = 3;
|
|
}
|
|
caladanArrivalInventory(g);
|
|
return g;
|
|
}
|
|
|
|
export function createCaladanArrivalFixture(options: {
|
|
bg?: boolean; terror?: TerrorKind; hms?: boolean; ambassador?: 'first' | 'second';
|
|
} = {}) {
|
|
assert.ok(!options.hms || (!options.bg && !options.terror));
|
|
let g = options.hms ? hmsBattleSource() : caladanVictoryFixture({
|
|
advanced:true,
|
|
extraSeats: options.bg ? [{id:'bg',faction:'beneGesserit'}] :
|
|
options.terror ? [{id:'m',faction:'moritani'}, ...(options.ambassador ? [{id:'ec',faction:'ecaz'} as const] : [])] : [],
|
|
});
|
|
const territory = options.hms ? 'hidden_mobile_stronghold' : options.terror ? 'arrakeen' : 'hagga_basin';
|
|
const destination = `${territory}:${options.hms ? 0 : options.terror ? 10 : 12}`;
|
|
let token: string | undefined;
|
|
let heldCard: string | undefined;
|
|
if (options.bg) {
|
|
const bg = victoryPlayer(g, 'bg');
|
|
Object.assign(bg, {reserves:19,forces:{'hagga_basin:12':1},advisors:{}});
|
|
holdVictoryCard(g,'g','karama'); // Keep an actual preventable flip response open.
|
|
}
|
|
if (options.terror) {
|
|
// Ecaz/Moritani native roster preview; no extra Treachery module is added.
|
|
g.expansions.push('ecaz');
|
|
for (const id of ['a','g']) {
|
|
const p = victoryPlayer(g,id);
|
|
delete p.forces['hagga_basin:12'];
|
|
p.forces['arrakeen:10'] = 3;
|
|
}
|
|
const original = g.moritaniTerror!.tokens.find(t => t.kind === options.terror)!;
|
|
token = original.id;
|
|
g.moritaniTerror = placeTerror(g.moritaniTerror!, token, territory, 1);
|
|
if (options.terror === 'sabotage') {
|
|
heldCard = holdVictoryCard(g,'a','shield');
|
|
holdVictoryCard(g,'m','karama'); // An actual held card supplies the optional gift.
|
|
}
|
|
}
|
|
if (options.ambassador) {
|
|
assert.ok(options.terror);
|
|
// Controlled original-token supply and storm order; neither reaction has
|
|
// been accepted and no later arrival/continuation receipt is installed.
|
|
g.ecazAmbassadors = createAmbassadors(() => 0);
|
|
const ambassador = g.ecazAmbassadors.tokens.find(token => token.zone === 'supply')!;
|
|
g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors, ambassador.id, {turn:1,availableSpice:20,
|
|
destination:{id:territory,stronghold:true,inStorm:false,allowed:true}}).state;
|
|
g.order = options.ambassador === 'first' ? ['a','g','ec','m'] : ['a','g','m','ec'];
|
|
}
|
|
// A separately staged one-counter contest retains Battle phase after this
|
|
// victory, so unrelated automatic Collection income is not part of entry proof.
|
|
const other = location('shield_wall', boardTerritory('shield_wall').sectors[0]);
|
|
const atreides = victoryPlayer(g,'a'), guildArmy = victoryPlayer(g,'g');
|
|
assert.ok(atreides.forces['polar_sink:0'] > 0 && guildArmy.reserves > 0);
|
|
atreides.forces['polar_sink:0']--; atreides.forces[other] = 1;
|
|
guildArmy.reserves--; guildArmy.forces[other] = 1;
|
|
caladanArrivalInventory(g);
|
|
const source = victoryReload(g), actions: CaladanArrivalStep[] = [];
|
|
g = actCaladanArrival(g,'a',{type:'chooseBattle',territory,target:'g'},actions);
|
|
g = advanceCaladanArrival(g,actions);
|
|
assert.ok(g.battle && !g.battle.preparation && !g.decision && !g.response);
|
|
const a = victoryPlayer(g,'a').leaders.reduce((best,l) => l.strength > best.strength ? l : best);
|
|
const guild = victoryPlayer(g,'g').leaders.reduce((best,l) => l.strength < best.strength ? l : best);
|
|
g = actCaladanArrival(g,'a',{type:'battlePlan',leader:a.id,dial:1,support:1},actions);
|
|
g = actCaladanArrival(g,'g',{type:'battlePlan',leader:guild.id,dial:0,support:0},actions);
|
|
g = advanceCaladanArrival(g,actions);
|
|
assert.equal(g.decision?.kind,'caladanReinforcement');
|
|
assert.equal(g.lastBattleContext?.winner,'a');
|
|
assert.equal(victoryPlayer(g,'a').forces[destination],2);
|
|
const event = g.homeworldVictoryReinforcement!.event;
|
|
const offer = viewGame(g,'a').caladanReinforcement!;
|
|
caladanArrivalInventory(g);
|
|
return {game:g,source,actions,sourceActions:actions,sourceCount:actions.length,
|
|
disclosure:caladanArrivalDisclosure,territory,destination,event,token,heldCard,offer,
|
|
action:{type:'decision',event,destination,amount:1} as Action,
|
|
decline:{type:'decision',event,decline:true} as Action};
|
|
}
|