Files
Dune/tests/fixture-basic-native-early-leaderless.ts
T
Quintine f73a6e0a9c
Verify and publish checkpoint container / container (push) Successful in 10m14s
Announce leaderless native Basic battles before plans
2026-10-29 20:33:33 +11:00

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};
}