200 lines
14 KiB
TypeScript
200 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import {
|
||
applyAction, createGame, initializeHomeworldGameForAudit, joinGame, newPlayer,
|
||
viewGame, type Action, type Game,
|
||
} from '../game/engine';
|
||
import { botActions } from '../game/bots';
|
||
import { homeworldContext } from '../game/homeworld-game';
|
||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||
import { positionVictoryArmy, positionVictoryInvader, victoryInventory } from './fixture-caladan-victory';
|
||
|
||
export type CaladanFaceDanceHomeworldStep = { actor: string; action: Action };
|
||
export const caladanFaceDanceHomeworldPlayer = (game: Game, actor = 'a') => {
|
||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||
};
|
||
export function caladanFaceDanceHomeworldInventory(game: Game) {
|
||
victoryInventory(game);
|
||
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
|
||
.map(card => card.id).sort();
|
||
}
|
||
export function caladanFaceDanceHomeworldTraitors(game: Game) {
|
||
const ids = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => [
|
||
...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader),
|
||
])];
|
||
assert.equal(new Set(ids).size, ids.length, 'Each original Traitor identity occupies one physical slot');
|
||
return ids.sort();
|
||
}
|
||
export function caladanFaceDanceHomeworldPhysical(game: Game) {
|
||
return {
|
||
players: game.players.map(player => ({ id: player.id, reserves: player.reserves, tanks: player.tanks,
|
||
forces: structuredClone(player.forces), spice: player.spice, shipped: player.shipped, moved: player.moved,
|
||
battleLosses: player.battleLosses })),
|
||
custody: structuredClone(game.homeworlds!.custody),
|
||
};
|
||
}
|
||
export function caladanFaceDanceHomeworldArmy(game: Game, actor: string, homeworld = 'homeworld:tleilaxu') {
|
||
const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||
.find(home => home.id === homeworld); assert.ok(home);
|
||
return home.forces[actor]?.normal ?? 0;
|
||
}
|
||
export function actCaladanFaceDanceHomeworld(game: Game, actor: string, action: Action,
|
||
actions: CaladanFaceDanceHomeworldStep[] = []): Game {
|
||
const next = applyAction(game, actor, action); actions.push({ actor, action: structuredClone(action) }); return next;
|
||
}
|
||
/** Original preparation and aftermath only. Both optional choices remain owned. */
|
||
export function nextCaladanFaceDanceHomeworld(game: Game): CaladanFaceDanceHomeworldStep | null {
|
||
if (game.response) {
|
||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||
return { actor: actor.id, action: { type: 'passResponse' } };
|
||
}
|
||
if (game.phaseOpening) {
|
||
const actor = game.players.find(player => !game.phaseOpening!.passed.includes(player.id)); assert.ok(actor);
|
||
return { actor: actor.id, action: { type: 'ready' } };
|
||
}
|
||
if (game.decision?.kind === 'caladanReinforcement' || game.decision?.kind === 'faceDance') return null;
|
||
if (game.decision?.kind === 'fullPlanOffer')
|
||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||
if (game.decision?.kind === 'battleCards')
|
||
return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
|
||
if (game.decision?.kind === 'battleLosses')
|
||
return { actor: game.decision.player, action: { type: 'decision', choice: 0 } };
|
||
const battle = game.battle;
|
||
if (battle?.preLeader && !battle.preLeader.closed) {
|
||
const actor = [battle.attacker, battle.defender].find(id => !battle.preLeader!.ready.includes(id)); assert.ok(actor);
|
||
return { actor, action: { type: 'battlePreparationReady', event: battle.event } };
|
||
}
|
||
if (battle?.preparation) return { actor: battle.preparation.owner, action: { type: 'declineBattlePower' } };
|
||
if (battle?.outcomeWindow && !battle.outcomeWindow.closed) {
|
||
const actor = [battle.attacker, battle.defender].find(id => !battle.outcomeWindow!.ready.includes(id)); assert.ok(actor);
|
||
return { actor, action: { type: 'battleOutcomeReady', event: battle.event } };
|
||
}
|
||
if (battle?.revealed) {
|
||
const actor = viewGame(game, battle.attacker).battle!.traitorVoters
|
||
.find(id => battle.traitorCalls[id] === undefined);
|
||
if (actor) return { actor, action: { type: 'traitorCall', call: false } };
|
||
}
|
||
return null;
|
||
}
|
||
export function advanceCaladanFaceDanceHomeworld(game: Game, until: (state: Game) => boolean,
|
||
actions: CaladanFaceDanceHomeworldStep[] = []): Game {
|
||
for (let count = 0; count < 160; count++) {
|
||
if (until(game)) return game;
|
||
const next = nextCaladanFaceDanceHomeworld(game);
|
||
assert.ok(next, `Choose the original Caladan, Face Dance or plan control at ${game.phase}/${game.decision?.kind}`);
|
||
game = actCaladanFaceDanceHomeworld(game, next.actor, next.action, actions);
|
||
}
|
||
throw Error('The original Homeworld battle did not reach its bounded continuation');
|
||
}
|
||
/** Finish this original aftermath only. A declined Face Dance can leave a
|
||
* further mandatory battle against the noncombatant native Tleilaxu army. */
|
||
export function finishCaladanFaceDanceHomeworld(game: Game, actions: CaladanFaceDanceHomeworldStep[] = []): Game {
|
||
const event = game.lastBattleContext?.event; assert.ok(event);
|
||
return advanceCaladanFaceDanceHomeworld(game,
|
||
state => state.lastBattleContext?.event === event && !state.pendingFaceDance &&
|
||
state.lastBattleContext.caladanReinforcement?.completed === true &&
|
||
!state.battle && !state.response && !state.decision && !state.phaseOpening, actions);
|
||
}
|
||
|
||
/** Genuine original Ix/Homeworld audit setup, then an explicitly CONTROLLED
|
||
* conserved invasion and matching Traitor exchange. Battle and both subsequent
|
||
* printed choices are actual actions. This is not a native invasion programme. */
|
||
export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boolean } = {}) {
|
||
const advanced = options.advanced ?? false, atreides = 'a', tleilaxu = 't', observer = 'g';
|
||
const territory = 'homeworld:tleilaxu', destination = territory;
|
||
let game = createGame('CALADANFACEDANCEHOMEWORLD', newPlayer(atreides, 'Atreides', 'atreides'), advanced, ['ix']);
|
||
joinGame(game, newPlayer(observer, 'Guild', 'guild')); joinGame(game, newPlayer(tleilaxu, 'Tleilaxu', 'tleilaxu'));
|
||
const actions: CaladanFaceDanceHomeworldStep[] = [];
|
||
const act = (actor: string, action: Action) => { game = actCaladanFaceDanceHomeworld(game, actor, action, actions); };
|
||
act(atreides, { type: 'homeworlds', enabled: true });
|
||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||
const lobby = structuredClone(game); game = initializeHomeworldGameForAudit(game);
|
||
const setup = structuredClone(game);
|
||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||
let next: CaladanFaceDanceHomeworldStep | null = null;
|
||
for (const player of game.players) {
|
||
const view = viewGame(game, player.id); view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||
const action = botActions(view)[0]; if (action) { next = { actor: player.id, action }; break; }
|
||
}
|
||
assert.ok(next, 'Original Ix/Homeworld setup requires a legal native action'); act(next.actor, next.action);
|
||
}
|
||
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
|
||
const originalInventory = caladanFaceDanceHomeworldInventory(game);
|
||
const originalTraitors = caladanFaceDanceHomeworldTraitors(game);
|
||
const originalLeaders = game.players.map(player => ({ player: player.id, ids: player.leaders.map(leader => leader.id).sort() }));
|
||
// The same labelled seam as fixture-caladan-victory: retain original cards,
|
||
// native discs/custody profile, but select a conserved later battle position.
|
||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||
Object.assign(game, { phase: 6, turn: 2, storm: 18, phaseOpening: null, response: null,
|
||
decision: null, active: atreides, ready: [], order: [atreides, observer, tleilaxu] });
|
||
positionVictoryArmy(game, atreides, 9, 0);
|
||
positionVictoryArmy(game, observer, 20, 0);
|
||
positionVictoryArmy(game, tleilaxu, 2, 0);
|
||
positionVictoryInvader(game, atreides, territory, 3);
|
||
positionVictoryInvader(game, observer, territory, 2);
|
||
const defender = observer;
|
||
const winner = caladanFaceDanceHomeworldPlayer(game, atreides), native = caladanFaceDanceHomeworldPlayer(game, tleilaxu);
|
||
const opposing = caladanFaceDanceHomeworldPlayer(game, defender);
|
||
const winnerDisc = [...winner.leaders].filter(leader => !leader.dead && !leader.usedAt)
|
||
.sort((a, b) => b.strength - a.strength)[0];
|
||
const loserDisc = [...opposing.leaders].filter(leader => !leader.dead && !leader.usedAt)
|
||
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(winnerDisc && loserDisc && winnerDisc.strength > loserDisc.strength);
|
||
const winnerLeader = winnerDisc.id, loserLeader = loserDisc.id;
|
||
let exchange: { from: string; returned: string; selected: string } | null = null;
|
||
if (!native.faceDancers!.some(card => card.leader === winnerLeader)) {
|
||
const dancer = native.faceDancers!.find(card => !card.revealed); assert.ok(dancer);
|
||
const returned = dancer.leader, index = game.traitorReserve!.indexOf(winnerLeader);
|
||
if (index >= 0) {
|
||
game.traitorReserve![index] = returned; exchange = { from: 'traitorReserve', returned, selected: winnerLeader };
|
||
} else {
|
||
const holder = game.players.find(player => player.traitors.includes(winnerLeader)); assert.ok(holder);
|
||
holder.traitors[holder.traitors.indexOf(winnerLeader)] = returned;
|
||
exchange = { from: holder.id, returned, selected: winnerLeader };
|
||
}
|
||
dancer.leader = winnerLeader;
|
||
}
|
||
const controlled = structuredClone(game);
|
||
assert.deepEqual(caladanFaceDanceHomeworldInventory(game), originalInventory);
|
||
assert.deepEqual(caladanFaceDanceHomeworldTraitors(game), originalTraitors);
|
||
const battleAction: Action = { type: 'chooseBattle', territory, target: defender };
|
||
act(atreides, battleAction); const beforePreparation = structuredClone(game);
|
||
game = advanceCaladanFaceDanceHomeworld(game, state => !!state.battle && !state.response && !state.decision &&
|
||
!state.phaseOpening && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
|
||
const beforePlans = structuredClone(game);
|
||
const planSteps: CaladanFaceDanceHomeworldStep[] = [
|
||
{ actor: atreides, action: { type: 'battlePlan', leader: winnerLeader, dial: 0, support: 0, weapon: null, defense: null } },
|
||
{ actor: defender, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
|
||
];
|
||
for (const step of planSteps) act(step.actor, step.action);
|
||
const revealed = structuredClone(game);
|
||
game = advanceCaladanFaceDanceHomeworld(game, state => state.decision?.kind === 'caladanReinforcement' &&
|
||
!state.response && !state.phaseOpening, actions);
|
||
assert.equal(game.lastBattleContext!.winner, atreides); assert.equal(game.lastBattleContext!.result, 'normal');
|
||
assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).reserves, 6);
|
||
assert.equal(caladanFaceDanceHomeworldArmy(game, atreides), 3);
|
||
assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).tanks, 0);
|
||
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).tanks, 0);
|
||
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).reserves, 2);
|
||
assert.equal(caladanFaceDanceHomeworldPlayer(game, defender).tanks, 2);
|
||
assert.ok(game.pendingFaceDance && game.pendingFaceDance.identity === winnerLeader);
|
||
const event = game.homeworldVictoryReinforcement!.event;
|
||
const reinforcementAction: Action = { type: 'decision', event, destination, amount: 1 };
|
||
const reinforcementDecline: Action = { type: 'decision', event, decline: true };
|
||
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: { 'polar_sink:0': 4 } };
|
||
const faceDanceDecline: Action = { type: 'decision', reveal: false };
|
||
return { game, source: structuredClone(game), advanced, atreides, winner: atreides, owner: atreides, tleilaxu,
|
||
observer, defender, actor: atreides, territory, destination, event, winnerLeader, loserLeader,
|
||
action: reinforcementAction, reinforcementAction, reinforcementDecline, faceDanceAction, faceDanceDecline,
|
||
battleAction, planSteps, lobby, setup, afterSetup, controlled, exchange, beforePreparation, beforePlans, revealed,
|
||
originalInventory, originalTraitors, originalLeaders, originalPendingFaceDance: structuredClone(game.pendingFaceDance),
|
||
originalPhysical: caladanFaceDanceHomeworldPhysical(game), originalContext: structuredClone(game.lastBattleContext),
|
||
actions, steps: actions, sourceActionCount: actions.length,
|
||
staging: [
|
||
'Actual original A/G/Tleilaxu Ix47 Homeworld audit setup and logged native setup choices; Basic/Advanced is selected before the original initializer. Original occupation state/preview flags remain unchanged; no occupied benefit or native-only Face Dance restriction is disabled.',
|
||
'CONTROLLED conserved later phase-six position: original dealt cards return to their original deck; A has Caladan6, three visitors on Tleilax and Polar11; Tleilaxu has native2 and Polar18; Guild has native18 and two visitors on Tleilax. Exact original custody quotes move A3 from Caladan9 and Guild2 from Junction20 into their visitor pools. Tleilaxu stays a noncombatant native, so A never solely occupies Tleilax. No extra counter, card or disc is created.',
|
||
'CONTROLLED matching Face Dancer availability exchanges one original Traitor identity with its actual reserve/held slot when necessary. All original Traitors/discs stay unique; no replacement or reveal status is installed.',
|
||
'Actual A-versus-Guild chooseBattle, preparation/response passes, zero-dial unarmed faction-leader plans and outcome readiness create a normal A victory, no A ordinary casualties and Guild2 Tanks; two visiting combatants have no Homeworld Traitor voters. Native Tleilaxu2 remains, permitting its native Face Dance without occupied-Tleilax immunity. Stop at the original Caladan choice. Declining Face Dance leaves the later A–T battle unresolved rather than claiming Collection.',
|
||
],
|
||
};
|
||
}
|
||
export type CaladanFaceDanceHomeworldFixture = ReturnType<typeof createCaladanFaceDanceHomeworldFixture>;
|