89 lines
5.4 KiB
TypeScript
89 lines
5.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { actBasicSemutaAmbassador as act } from './fixture-basic-semuta-ambassador';
|
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
|
|
|
export type NativeLeaderlessFaction = Extract<FactionId, 'ixians' | 'tleilaxu' | 'choam' | 'richese' | 'ecaz' | 'moritani'>;
|
|
export const nativeLeaderlessPlayer = (game: Game, id: string) => {
|
|
const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player;
|
|
};
|
|
export const nativeLeaderlessClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
|
!game.truthtrance && !game.pendingTreacheryDiscard;
|
|
export function advanceNativeLeaderless(game: Game, until: (state: Game) => boolean): Game {
|
|
for (let count = 0; count < 1600; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished');
|
|
const step = nextRicheseStrongholdsNativeStep(game);
|
|
assert.ok(step, `No native continuation at ${game.status}/${game.phase}/${game.decision?.kind}`);
|
|
game = act(game, step.actor, step.action);
|
|
}
|
|
throw Error('The native public battle did not reach the requested boundary');
|
|
}
|
|
|
|
/** Fresh public selected-deck setup, original phase progression and paid
|
|
* shipments create the battle. Only private leader-use/death and conserved
|
|
* hand/deck history are staged before the genuine chooseBattle action. */
|
|
export function basicNativeLeaderlessFixture(faction: NativeLeaderlessFaction,
|
|
options: { dead?: boolean; hero?: boolean; ghola?: boolean } = {}) {
|
|
const expansions = faction === 'ixians' || faction === 'tleilaxu' ? ['ix'] :
|
|
faction === 'choam' || faction === 'richese' ? ['choam'] : ['ecaz'];
|
|
let game = withClassicDiscoveryNexusLottery(() => createGame(`NATIVELEADERLESS-${faction}`,
|
|
newPlayer(faction, faction, faction), false, expansions));
|
|
for (const other of ['guild', 'emperor'] as const) joinGame(game, newPlayer(other, other, other));
|
|
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
|
game = act(game, faction, {type:'start'});
|
|
const setup = structuredClone(game);
|
|
game = advanceNativeLeaderless(game, state => state.status === 'playing' && state.phase === 5 && nativeLeaderlessClean(state));
|
|
const selected = ['pasty_mesa', 'false_wall_west', 'shield_wall'].map(id => {
|
|
const sectors: Record<string, number[]> = { pasty_mesa:[5,6,7,8], false_wall_west:[16,17,18], shield_wall:[8,9] };
|
|
return {territory:id, sector:sectors[id].find(value => value !== game.storm)};
|
|
}).find(row => row.sector !== undefined && game.players.every(player =>
|
|
Object.keys(player.forces).every(key => !key.startsWith(`${row.territory}:`))));
|
|
assert.ok(selected && selected.sector !== undefined);
|
|
const territory = selected.territory, sector = selected.sector;
|
|
while (game.phase === 5) {
|
|
if (!nativeLeaderlessClean(game)) {
|
|
game = advanceNativeLeaderless(game, state => state.phase !== 5 || nativeLeaderlessClean(state));
|
|
continue;
|
|
}
|
|
const actor = game.active!;
|
|
if (actor === faction || actor === 'guild') {
|
|
game = act(game, actor, {type:'ship', territory, sector, amount:1, elite:0, allyPayment:0});
|
|
game = advanceNativeLeaderless(game, state => state.phase !== 5 || state.active === actor && nativeLeaderlessClean(state));
|
|
}
|
|
if (game.phase === 5) game = act(game, actor, {type:'endMovement'});
|
|
}
|
|
game = advanceNativeLeaderless(game, state => state.phase === 6 && nativeLeaderlessClean(state) && !state.battle && !!state.active);
|
|
const player = nativeLeaderlessPlayer(game, faction);
|
|
for (const leader of player.leaders) {
|
|
assert.ok(!leader.dead && !leader.capturedBy);
|
|
leader.usedAt = territory === 'carthag' ? 'arrakeen' : 'carthag';
|
|
}
|
|
if (options.dead) {
|
|
player.leaders[0].dead = true; player.leaders[0].deaths = Math.max(1, player.leaders[0].deaths ?? 0);
|
|
delete player.leaders[0].usedAt;
|
|
}
|
|
// Exchange exact original physical cards with the remaining deck; never
|
|
// create an extra card or increase the player's hand size.
|
|
const swap = (handIndex: number, predicate: (card: Game['deck'][number]) => boolean) => {
|
|
const index = game.deck.findIndex(predicate); assert.ok(index >= 0);
|
|
[player.hand[handIndex], game.deck[index]] = [game.deck[index], player.hand[handIndex]];
|
|
};
|
|
for (let i = 0; i < player.hand.length; i++) if (player.hand[i].kind === 'hero')
|
|
swap(i, card => card.kind !== 'hero' && (!options.ghola || card.effect !== 'ghola'));
|
|
if (options.hero || options.ghola) {
|
|
assert.ok(player.hand.length > 0);
|
|
swap(0, card => options.hero ? card.kind === 'hero' : card.effect === 'ghola');
|
|
}
|
|
const chooser = game.active!;
|
|
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === territory &&
|
|
[row.attacker, row.defender].includes(faction) && [row.attacker, row.defender].includes('guild'));
|
|
assert.ok(choice);
|
|
const choose: Action = {type:'chooseBattle', territory, target:choice.attacker === chooser ? choice.defender : choice.attacker};
|
|
const beforeBattle = structuredClone(game);
|
|
game = act(game, chooser, choose);
|
|
return {game, setup, beforeBattle, faction, opponent:'guild', territory, sector, chooser, choose};
|
|
}
|