206 lines
11 KiB
TypeScript
206 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, initializeIxianNexusReplacementGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import type { Card } from '../game/cards';
|
|
import { advanceToNextStorm } from './fixture-advanced-source';
|
|
|
|
export const IXIAN_REPLACEMENT_FACTIONS = ['atreides', 'emperor', 'guild'] as const;
|
|
export type IxianReplacementFixtureOptions = {
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
karama?: boolean;
|
|
face?: 'ixians' | 'richese';
|
|
seatIds?: string[];
|
|
purchasedKind?: 'worthless' | 'shield';
|
|
endingAuction?: boolean;
|
|
fullHand?: boolean;
|
|
otherDraw?: {player: string; face: 'ixians' | 'richese'};
|
|
};
|
|
export type IxianReplacementFixture = {
|
|
beforePayment: Game;
|
|
game: Game;
|
|
buyer: string;
|
|
event: string;
|
|
purchased: Card;
|
|
paymentAction: Action;
|
|
beforeSpice: number;
|
|
paidSpice: number;
|
|
emperor: string | null;
|
|
beforeEmperorSpice: number | null;
|
|
nextCard: Card | null;
|
|
auctionIndex: number;
|
|
};
|
|
export function settleIxianReplacementFixture(state: Game): Game {
|
|
let game = state;
|
|
for (let step = 0; step < 100; step++) {
|
|
if (game.pendingTreacheryDiscard) game = applyAction(game, game.players[0].id, {type:'advanceBots'});
|
|
else if (game.response) game = applyAction(game,
|
|
game.players.find(p => !game.response!.passed.includes(p.id))!.id, {type:'passResponse'});
|
|
else return game;
|
|
}
|
|
throw new Error('The original native auction response did not settle.');
|
|
}
|
|
function stepNative(game: Game): Game {
|
|
if (game.response || game.pendingTreacheryDiscard) return settleIxianReplacementFixture(game);
|
|
if (game.phaseOpening) return applyAction(game,
|
|
game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, {type:'ready'});
|
|
if (game.status === 'playing' && !game.decision) {
|
|
if (game.phase === 0 && game.stormPending === null) {
|
|
const id = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
|
assert.ok(id, 'The native Storm source must supply the next actual dialer.');
|
|
return applyAction(game,id,{type:'stormDial',amount:game.turn === 1 ? 0 : 1});
|
|
}
|
|
if (game.phase === 3 && game.auction) return applyAction(game,game.auction.active,{type:'passBid'});
|
|
if (game.phase === 5) return applyAction(game,game.active!,{type:'endMovement'});
|
|
}
|
|
for (const p of game.players) {
|
|
const view = viewGame(game,p.id);
|
|
view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
|
|
const actions = botActions(view);
|
|
const action = actions.find(a => a.type === 'ready') ?? actions[0];
|
|
if (action) return applyAction(game,p.id,action);
|
|
}
|
|
throw new Error(`Native Ixian fixture stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
}
|
|
/** Conserves the original undealt inventory, putting the buyer's printed Karama
|
|
* into the genuine starting deal rather than manufacturing an owned card. */
|
|
function orderStartingCards(game: Game,buyer: string): void {
|
|
const pool = [...game.deck];
|
|
const front: Card[] = [];
|
|
for (const p of game.players) {
|
|
const quantity = p.faction === 'harkonnen' ? 2 : 1;
|
|
for (let i = 0; i < quantity; i++) {
|
|
const index = pool.findIndex(c => p.id === buyer && i === 0 ? c.effect === 'karama' : c.effect !== 'karama');
|
|
assert.ok(index >= 0);
|
|
front.push(pool.splice(index,1)[0]);
|
|
}
|
|
}
|
|
game.deck = [...front,...pool];
|
|
}
|
|
/** Genuine fresh setup, native first turn, printed Nexus drawing and normal
|
|
* winner declaration. The returned parent is before the original payment. */
|
|
export function createIxianNexusReplacementFixture(options: IxianReplacementFixtureOptions = {}): IxianReplacementFixture {
|
|
let game: Game;
|
|
if (options.initial) {
|
|
game = structuredClone(options.initial);
|
|
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
|
|
game.nexusIxianReplacementPreview === true && game.turn === 1 && game.phase === 0 &&
|
|
game.players.every(player => player.hand.length === 0)),
|
|
'Continue only an admitted fresh lobby or its undealt native prototype setup.');
|
|
if (options.advanced !== undefined) assert.equal(game.advanced,options.advanced);
|
|
} else {
|
|
const ids = options.seatIds ?? ['p0','p1','p2'];
|
|
assert.equal(ids.length,3);
|
|
game = createGame('IXIANREPLACE',newPlayer(ids[0],'Atreides','atreides'),options.advanced ?? false,[]);
|
|
joinGame(game,newPlayer(ids[1],'Emperor','emperor'));
|
|
joinGame(game,newPlayer(ids[2],'Guild','guild'));
|
|
}
|
|
const buyer = game.players.find(p => p.faction === 'atreides')?.id ??
|
|
game.players.find(p => p.faction !== 'harkonnen')!.id;
|
|
const face = options.face ?? 'ixians';
|
|
if (options.otherDraw) {
|
|
assert.ok(game.players.some(p => p.id === options.otherDraw!.player));
|
|
assert.notEqual(options.otherDraw.player,buyer);
|
|
assert.notEqual(options.otherDraw.face,face);
|
|
}
|
|
const partners = game.players.filter(p => p.id !== buyer && p.id !== options.otherDraw?.player);
|
|
assert.ok(partners.length >= 2,
|
|
'Native Nexus draws require an alliance between two seats other than the requested recipients.');
|
|
if (game.status === 'lobby') {
|
|
for (const p of game.players) if (!p.ready) game = applyAction(game,p.id,{type:'ready'});
|
|
game.nexusCards ??= {cards:null,phase:null};
|
|
game = initializeIxianNexusReplacementGameForAudit(game);
|
|
}
|
|
orderStartingCards(game,buyer);
|
|
// Prepare the conserved faces before any native Nexus acquisition. Setup
|
|
// initializes an empty Nexus hand; only a qualifying closing Nexus draws.
|
|
const nexus = game.nexusCards!.cards!;
|
|
const faces: typeof nexus.deck = [face,...(options.otherDraw ? [options.otherDraw.face] : [])];
|
|
assert.ok(faces.every(card => nexus.deck.includes(card)));
|
|
nexus.deck = [...faces,...nexus.deck.filter(card => !faces.includes(card))];
|
|
for (let step = 0; game.status === 'setup' && step < 100; step++) game = stepNative(game);
|
|
assert.equal(game.status,'playing');
|
|
// A complete native first turn supplies the next-turn source; no phase/turn
|
|
// assignment or Basic-to-Advanced flag flip is used.
|
|
for (let position = 0; position < 2; position++) {
|
|
const land = game.spiceDeck.findIndex((c,index) => index >= position && 'territory' in c);
|
|
assert.ok(land >= position);
|
|
game.spiceDeck.splice(position,0,game.spiceDeck.splice(land,1)[0]);
|
|
}
|
|
if (options.fullHand) {
|
|
for (let step = 0; step < 1200; step++) {
|
|
if (game.turn === 2 && game.phase === 0 && !game.phaseOpening && !game.response && !game.decision) break;
|
|
if (game.decision?.kind === 'auctionPayment')
|
|
game = applyAction(game,game.decision.player,{type:'decision',karama:false});
|
|
else if (!game.phaseOpening && !game.response && !game.decision && game.phase === 3 &&
|
|
game.auction?.active === buyer && game.auction.bid === 0 &&
|
|
game.players.find(p => p.id === buyer)!.hand.length < 3)
|
|
game = applyAction(game,buyer,{type:'bid',amount:2});
|
|
else game = stepNative(game);
|
|
}
|
|
assert.equal(game.players.find(p => p.id === buyer)!.hand.length,3);
|
|
} else game = advanceToNextStorm(game);
|
|
assert.equal(game.turn,2);
|
|
for (let step = 0; game.phase !== 1 && step < 200; step++) game = stepNative(game);
|
|
const worm = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed);
|
|
assert.ok(worm >= 0);
|
|
game.spiceDeck.splice(0,0,game.spiceDeck.splice(worm,1)[0]);
|
|
for (let step = 0; !(game.nexus && !game.spiceWindow && !game.spiceResolution &&
|
|
!game.phaseOpening && !game.response && !game.decision) && step < 200; step++) game = stepNative(game);
|
|
assert.equal(game.phase,1);
|
|
assert.equal(game.nexus,true);
|
|
// The publisher lifecycle grants no card without an alliance. Negotiate it
|
|
// through the actual native actions, leaving every requested recipient free.
|
|
game = applyAction(game,partners[0].id,{type:'alliance',target:partners[1].id});
|
|
game = applyAction(game,partners[1].id,{type:'alliance',target:partners[0].id});
|
|
for (let step = 0; game.nexusCards?.phase?.stage !== 'drawing' && step < 200; step++) game = stepNative(game);
|
|
assert.equal(game.nexusCards?.phase?.stage,'drawing');
|
|
game = applyAction(game,buyer,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
|
assert.equal(game.nexusCards!.cards!.hands[buyer],face);
|
|
for (const id of game.nexusCards!.phase!.eligible) {
|
|
if (game.nexusCards!.phase!.done.includes(id)) continue;
|
|
const other: IxianReplacementFixtureOptions['otherDraw'] | null =
|
|
options.otherDraw?.player === id ? options.otherDraw : null;
|
|
game = applyAction(game,id,{type:'nexusCardChoice',turn:game.turn,card:null,choice:other ? 'draw' : 'keep',ownRedraws:0});
|
|
if (other) assert.equal(game.nexusCards!.cards!.hands[id],other.face);
|
|
}
|
|
// Reorder only the unplayed deck, before native auction allocation.
|
|
const kind = options.purchasedKind ?? 'worthless';
|
|
const wanted = game.deck.findIndex(c => c.kind === kind);
|
|
assert.ok(wanted >= 0);
|
|
const auctionOffset = options.endingAuction ? game.players.filter(p => p.hand.length < (p.faction === 'harkonnen' ? 8 : 4)).length - 1 : 0;
|
|
game.deck.splice(auctionOffset,0,game.deck.splice(wanted,1)[0]);
|
|
for (let step = 0; !(game.decision?.kind === 'auctionPayment' && game.decision.player === buyer) && step < 300; step++) {
|
|
if (game.decision?.kind === 'auctionPayment')
|
|
game = applyAction(game,game.decision.player,{type:'decision',karama:false});
|
|
else if (game.phaseOpening || game.response || game.decision || !game.auction) game = stepNative(game);
|
|
else if (options.endingAuction && game.auction.index < game.auction.cards.length - 1) {
|
|
const active = game.players.find(p => p.id === game.auction!.active)!;
|
|
game = applyAction(game,active.id,active.id !== buyer && active.faction !== 'harkonnen' &&
|
|
game.auction.bid === 0 ? {type:'bid',amount:1} : {type:'passBid'});
|
|
}
|
|
else if (game.auction.active === buyer && game.auction.bid === 0)
|
|
game = applyAction(game,buyer,{type:'bid',amount:2});
|
|
else game = applyAction(game,game.auction.active,{type:'passBid'});
|
|
}
|
|
assert.deepEqual(game.decision,{kind:'auctionPayment',player:buyer});
|
|
assert.equal(game.auction!.bidder,buyer);
|
|
assert.equal(game.auction!.bid,2);
|
|
assert.equal(game.auction!.allyPayment ?? 0,0);
|
|
const beforePayment = structuredClone(game);
|
|
const owner = game.players.find(p => p.id === buyer)!;
|
|
const emperor = game.players.find(p => p.faction === 'emperor')?.id ?? null;
|
|
const beforeEmperorSpice = emperor ? game.players.find(p => p.id === emperor)!.spice : null;
|
|
const beforeSpice = owner.spice;
|
|
const purchased = structuredClone(game.auction!.cards[game.auction!.index]);
|
|
const paymentAction: Action = options.karama
|
|
? {type:'decision',karama:true,card:owner.hand.find(c => c.effect === 'karama')!.id}
|
|
: {type:'decision',karama:false};
|
|
game = applyAction(game,buyer,paymentAction);
|
|
assert.ok(game.pendingNexusIxianReplacement);
|
|
const event = game.pendingNexusIxianReplacement.event;
|
|
return {beforePayment,game,buyer,event,purchased,paymentAction,beforeSpice,
|
|
paidSpice:game.players.find(p => p.id === buyer)!.spice,emperor,beforeEmperorSpice,
|
|
nextCard:game.deck[0] ? structuredClone(game.deck[0]) : null,auctionIndex:beforePayment.auction!.index};
|
|
}
|