Files
Dune/tests/fixture-nexus-short-revival.ts
T

154 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { emperorNexusRevivalCount } from '../game/nexus-emperor-secret-ally';
import {
createClassicIndependentNexusFixture, advanceClassicIndependentNexus, type ClassicIndependentNexusStep,
} from './fixture-classic-independent-nexus';
import {
applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep,
} from './fixture-discovery-classic-nexus';
export type NexusShortRevivalStep = ClassicIndependentNexusStep;
export const actNexusShortRevival = (game: Game, actor: string, action: Action,
actions: NexusShortRevivalStep[] = []) => applyClassicDiscoveryNexusStep(game,{actor,action},actions);
export const nexusShortRevivalPlayer = (game: Game, actor = 'atreides') => {
const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
};
export const nexusShortRevivalClean = (game: Game) => classicDiscoveryNexusClean(game) &&
!game.pendingRevival && !game.pendingShipment && !game.pendingGuildBetrayal;
export function nexusShortRevivalInventory(game: Game) {
const cards = [...game.deck,...game.discard,...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index+Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(c => c.id).sort();
assert.deepEqual(cards,baseDeck().map(c => c.id).sort());
const state = game.nexusCards!.cards!;
const nexus = [...state.deck,...state.discard,...Object.values(state.hands).filter(card => card !== null)].sort();
assert.deepEqual(nexus,[...NEXUS_FACTIONS].sort());
const traitors = [...(game.traitorReserve ?? []),...game.players.flatMap(p => [...p.traitors,...p.traitorChoices])].sort();
const spice = [...game.spiceDeck,...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
for (const p of game.players)
assert.equal(p.reserves+p.tanks+Object.values(p.forces).reduce((sum,n) => sum+n,0),20);
return {cards,nexus,traitors,spice};
}
export function nextNexusShortRevival(game: Game, losses?:1|2): NexusShortRevivalStep|null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'guildTiming')
return {actor:game.decision.player,action:{type:'decision',take:false}};
if (game.decision?.kind === 'battleLosses' && game.decision.player === 'atreides' && losses) {
const choice = game.decision.options.findIndex(loss => loss.normal === losses && loss.elite === 0);
assert.ok(choice >= 0,'Select the actual paid ordinary loss promised by this source.');
return {actor:'atreides',action:{type:'decision',choice}};
}
if (game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) {
const actor = [game.battle.attacker,game.battle.defender]
.find(id => !game.battle!.outcomeWindow!.ready.includes(id)); assert.ok(actor);
return {actor,action:{type:'battleOutcomeReady',event:game.battle.outcomeWindow.event}};
}
}
return nextClassicDiscoveryNexusStep(game);
}
export function advanceNexusShortRevival(game: Game, until: (game: Game) => boolean,
actions: NexusShortRevivalStep[] = [], losses?:1|2): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status,'finished');
if (game.pendingGuildBetrayal) {
game = advanceClassicIndependentNexus(game,g => !g.pendingGuildBetrayal,actions);
continue;
}
const step = nextNexusShortRevival(game,losses);
assert.ok(step,`Choose the native original plan/control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actNexusShortRevival(game,step.actor,step.action,actions);
}
assert.fail('The genuine short-revival chronology did not reach its finite boundary.');
}
/** Actual public A/G/H Nexus-only setup and battle-to-revival producer. Initial
* unopened deals/Spice order/Nexus draw and scalar entropy are the only choices
* outside actions. Dead counters, card holding, alliance, phase and allowances
* are all native results; no played resource or receipt is installed. */
export function createNexusShortRevivalFixture(options: {amount?:1|2} = {}) {
const amount = options.amount ?? 1, actor = 'atreides', guild = 'guild', harkonnen = 'harkonnen';
const cards = baseDeck(), shield = cards.find(c => c.kind === 'shield')!, projectile = cards.find(c => c.kind === 'projectile')!;
const worthless = cards.find(c => c.kind === 'worthless')!, snooper = cards.find(c => c.kind === 'snooper')!;
const base = createClassicIndependentNexusFixture({advanced:true,independent:false,
roster:['atreides','guild','harkonnen'],startingCards:{atreides:[shield.id],guild:[projectile.id],
harkonnen:[worthless.id,snooper.id]}});
let game = base.game; const actions = [...base.actions];
const original = nexusShortRevivalInventory(game), afterSetup = structuredClone(game);
const act = (owner: string, action: Action) => {game = actNexusShortRevival(game,owner,action,actions);};
const reach = (until: (game: Game) => boolean) => {game = advanceNexusShortRevival(game,until,actions,amount);};
const spiceOrder: {position:number;from:number;card:Game['spiceDeck'][number]}[] = [];
for (const [position,worm] of [false,false,true,false,false].entries()) {
const from = game.spiceDeck.findIndex((card,index) => index >= position &&
(worm ? 'worm' in card : 'territory' in card)); assert.ok(from >= position);
const [card] = game.spiceDeck.splice(from,1); game.spiceDeck.splice(position,0,card);
spiceOrder.push({position,from,card:structuredClone(card)});
}
reach(g => g.turn === 1 && g.phase === 5 && g.active === guild && nexusShortRevivalClean(g));
const beforeShipment = structuredClone(game), shipmentAt = actions.length;
act(guild,{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0});
reach(nexusShortRevivalClean);
const shipment = {before:beforeShipment,after:structuredClone(game),steps:structuredClone(actions.slice(shipmentAt))};
assert.equal(nexusShortRevivalPlayer(game,guild).forces['arrakeen:10'],1);
reach(g => g.turn === 1 && g.phase === 6 && !!g.active && nexusShortRevivalClean(g));
const beforeBattle = structuredClone(game), battleAt = actions.length, chooser = game.active!;
assert.ok(viewGame(game,chooser).battleChoices.some(choice => choice.territory === 'arrakeen'));
act(chooser,{type:'chooseBattle',territory:'arrakeen',target:chooser === actor ? guild : actor});
reach(g => nexusShortRevivalClean(g) && !!g.battle && !g.battle.revealed &&
!g.battle.preparation && g.battle.preLeader?.closed !== false);
const own = nexusShortRevivalPlayer(game,actor).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a,b) => b.strength-a.strength)[0];
const other = nexusShortRevivalPlayer(game,guild).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a,b) => a.strength-b.strength)[0]; assert.ok(own && other);
const plans: Record<string, {leader:string;dial:number;support:number}> = {
[actor]:{leader:own.id,dial:amount,support:amount},[guild]:{leader:other.id,dial:0,support:0},
};
for (const owner of [game.battle!.attacker,game.battle!.defender]) act(owner,{type:'battlePlan',...plans[owner]});
reach(g => !g.battle && nexusShortRevivalClean(g));
const battleResolution = structuredClone(game), battleActions = structuredClone(actions.slice(battleAt));
assert.equal(game.lastBattleContext!.winner,actor);
assert.equal(nexusShortRevivalPlayer(game,actor).tanks,amount);
assert.equal(nexusShortRevivalPlayer(game,actor).forces['arrakeen:10'],10-amount);
reach(g => g.turn === 2 && g.nexus && !g.spiceWindow && !g.spiceResolution && nexusShortRevivalClean(g));
const nexus = structuredClone(game), allianceAt = actions.length;
act(guild,{type:'alliance',target:harkonnen}); act(harkonnen,{type:'alliance',target:guild});
const alliance = {steps:structuredClone(actions.slice(allianceAt)),after:structuredClone(game)};
reach(g => g.nexusCards?.phase?.stage === 'drawing');
const state = game.nexusCards!.cards!, from = state.deck.indexOf('emperor'); assert.ok(from >= 0);
state.deck.unshift(state.deck.splice(from,1)[0]);
const beforeDraw = structuredClone(game), drawAt = actions.length;
act(actor,{type:'nexusCardChoice',turn:game.turn,card:state.hands[actor],choice:'draw',ownRedraws:0});
const afterDraw = structuredClone(game), drawActions = structuredClone(actions.slice(drawAt));
assert.equal(game.nexusCards!.cards!.hands[actor],'emperor');
reach(g => g.turn === 2 && g.phase === 4 && nexusShortRevivalClean(g) &&
(!g.revivalOrder || g.revivalOrder.remaining[0] === actor));
assert.equal(nexusShortRevivalPlayer(game,actor).tanks,amount);
assert.equal(nexusShortRevivalPlayer(game,actor).revived,0);
const offer = viewGame(game,actor).nexusEmperorSecretAlly; assert.ok(offer);
assert.equal(offer.revival.amount,amount); assert.equal(offer.revival.blocked,null);
assert.deepEqual(nexusShortRevivalInventory(game),original);
return {game,source:structuredClone(game),actor,amount,event:offer.event,
action:{type:'nexusEmperorRevive',event:offer.event,elite:0} as Action,
base,afterSetup,shipment,beforeBattle,plans,battleResolution,battleActions,nexus,alliance,
beforeDraw,afterDraw,drawActions,nexusOrder:{from,card:'emperor' as const},
beforeRevival:structuredClone(game),spiceOrder,original,actions,sourceActions:actions,sourceCount:actions.length,
disclosure:'Original public Advanced A/G/H Nexus-only base33 setup, selected unopened four-card deal, five original Spice positions and scalar lotteries. Guild really ships1 to original A10 Arrakeen; real A leader5/dial1or2/paid support1or2 wins and creates exactly the requested one/two ordinary Tanks. Actual next-turn worm and G–H reciprocal alliance leave A unallied; original unused Emperor Nexus position is selected before A’s real closing draw. Native phase controls and current Revival queue stop before A ordinary return. No played hand, purse, army, Tank, phase, alliance, Nexus holding, quota or receipt is installed.'};
}
export type NexusShortRevivalFixture = ReturnType<typeof createNexusShortRevivalFixture>;
export function applyNexusShortRevival(f: NexusShortRevivalFixture, source = f.game) {
const actions: NexusShortRevivalStep[] = [], before = structuredClone(source);
let game = actNexusShortRevival(structuredClone(source),f.actor,f.action,actions);
const declared = structuredClone(game);
game = normalizeAutomaticGame(game);
assert.equal(game.nexusEmperorSecretHistory!.at(-1)!.stage,'returned');
assert.equal(emperorNexusRevivalCount(game.nexusEmperorSecretHistory!.at(-1)!),f.amount);
assert.equal(nexusShortRevivalPlayer(game,f.actor).tanks,0);
assert.deepEqual(nexusShortRevivalInventory(game),f.original);
return {game,before,declared,actions};
}