194 lines
11 KiB
TypeScript
194 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 { treacheryDeck, type Card } from '../game/cards';
|
|
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
|
import { advanceToNextStorm } from './fixture-advanced-source';
|
|
import {
|
|
settleIxianReplacementFixture, type IxianReplacementFixture,
|
|
} from './fixture-nexus-ixian-replacement';
|
|
|
|
export type NativeIxianReplacementOptions = {
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
buyerFaction?: 'atreides' | 'tleilaxu';
|
|
purchasedId?: string;
|
|
replacementId?: string;
|
|
endingAuction?: boolean;
|
|
};
|
|
export type NativeIxianReplacementFixture = IxianReplacementFixture & {
|
|
setup: Game;
|
|
beforeNexusDraw: Game;
|
|
nexusDrawAction: Action;
|
|
nextCard: Card;
|
|
};
|
|
|
|
export function assertNativeIxianReplacementInventory(game: Game): void {
|
|
const physical = [
|
|
...game.players.flatMap(player => player.hand), ...game.deck, ...game.discard,
|
|
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
|
|
];
|
|
assert.deepEqual(physical.map(card => card.id).sort(), treacheryDeck(['ix']).map(card => card.id).sort());
|
|
const nexus = game.nexusCards!.cards!;
|
|
validateNexusCards(nexus, game.players);
|
|
assert.deepEqual([
|
|
...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter(face => face !== null),
|
|
].sort(), [...NEXUS_FACTIONS].sort());
|
|
}
|
|
|
|
function nativeStep(game: Game): Game {
|
|
if (game.response || game.pendingTreacheryDiscard) return settleIxianReplacementFixture(game);
|
|
if (game.phaseOpening) return applyAction(game,
|
|
game.players.find(player => !game.phaseOpening!.passed.includes(player.id))!.id, { type: 'ready' });
|
|
if (game.status === 'playing' && !game.decision) {
|
|
if (game.phase === 0 && game.stormPending === null) {
|
|
const dialer = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
|
assert.ok(dialer);
|
|
return applyAction(game, dialer, { 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 player of game.players) {
|
|
const view = viewGame(game, player.id);
|
|
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
|
const actions = botActions(view);
|
|
const action = actions.find(candidate => candidate.type === 'ready') ?? actions[0];
|
|
if (action) return applyAction(game, player.id, action);
|
|
}
|
|
throw new Error(`Native replacement fixture stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
}
|
|
|
|
/** Only reorder undealt original cards; never manufacture hand, sale or Nexus receipts. */
|
|
function orderStartingCards(game: Game, buyer: string, reserved: readonly string[]): void {
|
|
const pool = [...game.deck];
|
|
const front: Card[] = [];
|
|
for (const player of game.players) {
|
|
for (let count = 0; count < (player.faction === 'harkonnen' ? 2 : 1); count++) {
|
|
const index = pool.findIndex(card => !reserved.includes(card.id) &&
|
|
(player.id === buyer && count === 0 ? card.effect === 'karama' : card.effect !== 'karama'));
|
|
assert.ok(index >= 0);
|
|
front.push(pool.splice(index, 1)[0]);
|
|
}
|
|
}
|
|
// Keep the requested turn-two cards outside the native turn-one auction row.
|
|
game.deck = [...front, ...pool.filter(card => !reserved.includes(card.id)),
|
|
...pool.filter(card => reserved.includes(card.id))];
|
|
}
|
|
|
|
/** Native Tleilaxu + classic setup, a full first turn, an actual alliance-
|
|
* qualified closing Nexus draw, and an original paid normal-auction purchase.
|
|
* The unplayed deck is ordered before native auction allocation, not the row. */
|
|
export function createNativeIxianReplacementFixture(
|
|
options: NativeIxianReplacementOptions = {},
|
|
): NativeIxianReplacementFixture {
|
|
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)));
|
|
} else {
|
|
game = createGame('NATIXREP', newPlayer('a', 'Atreides', 'atreides'), options.advanced ?? false, ['ix']);
|
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
|
}
|
|
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
|
assert.deepEqual(game.expansions, ['ix']);
|
|
assert.ok(game.players.some(player => player.faction === 'tleilaxu'));
|
|
const buyer = game.players.find(player => player.faction === (options.buyerFaction ?? 'atreides'))!.id;
|
|
const partners = game.players.filter(player => player.id !== buyer);
|
|
assert.ok(partners.length >= 2);
|
|
if (game.status === 'lobby') {
|
|
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
|
game.nexusCards ??= { cards: null, phase: null };
|
|
game = initializeIxianNexusReplacementGameForAudit(game);
|
|
}
|
|
const purchasedId = options.purchasedId ?? 'ix-thumper';
|
|
const replacementId = options.replacementId ?? 'ix-amal';
|
|
assert.notEqual(purchasedId, replacementId);
|
|
assert.ok(game.deck.some(card => card.id === purchasedId));
|
|
assert.ok(game.deck.some(card => card.id === replacementId));
|
|
orderStartingCards(game, buyer, [purchasedId, replacementId]);
|
|
const nexus = game.nexusCards!.cards!;
|
|
assert.ok(Object.values(nexus.hands).every(face => face === null));
|
|
nexus.deck = ['ixians', ...nexus.deck.filter(face => face !== 'ixians')];
|
|
const setup = structuredClone(game);
|
|
for (let step = 0; game.status === 'setup' && step < 100; step++) game = nativeStep(game);
|
|
assert.equal(game.status, 'playing');
|
|
assert.equal(game.players.find(player => player.faction === 'tleilaxu')!.faceDancers!.length, 3);
|
|
assertNativeIxianReplacementInventory(game);
|
|
for (let position = 0; position < 2; position++) {
|
|
const index = game.spiceDeck.findIndex((card, at) => at >= position && 'territory' in card);
|
|
assert.ok(index >= position);
|
|
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
game = advanceToNextStorm(game);
|
|
assert.equal(game.turn, 2);
|
|
for (let step = 0; game.phase !== 1 && step < 200; step++) game = nativeStep(game);
|
|
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.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 = nativeStep(game);
|
|
assert.equal(game.nexus, true);
|
|
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 = nativeStep(game);
|
|
assert.equal(game.nexusCards?.phase?.stage, 'drawing');
|
|
const beforeNexusDraw = structuredClone(game);
|
|
const nexusDrawAction: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
|
|
game = applyAction(game, buyer, nexusDrawAction);
|
|
assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians');
|
|
for (const id of game.nexusCards!.phase!.eligible)
|
|
if (!game.nexusCards!.phase!.done.includes(id))
|
|
game = applyAction(game, id, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 });
|
|
|
|
const rowSize = game.players.filter(player => player.hand.length < (player.faction === 'harkonnen' ? 8 : 4)).length;
|
|
const auctionOffset = options.endingAuction ? rowSize - 1 : 0;
|
|
const purchasedIndex = game.deck.findIndex(card => card.id === purchasedId);
|
|
assert.ok(purchasedIndex >= 0);
|
|
const originalPurchase = game.deck.splice(purchasedIndex, 1)[0];
|
|
const replacementIndex = game.deck.findIndex(card => card.id === replacementId);
|
|
assert.ok(replacementIndex >= 0);
|
|
const originalReplacement = game.deck.splice(replacementIndex, 1)[0];
|
|
game.deck.splice(auctionOffset, 0, originalPurchase);
|
|
// Native allocation takes one lot per non-full seat; its remaining deck top
|
|
// is the requested original replacement, not an unsold reserved lot.
|
|
game.deck.splice(rowSize, 0, originalReplacement);
|
|
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 = nativeStep(game);
|
|
else if (options.endingAuction && game.auction.index < game.auction.cards.length - 1) {
|
|
const active = game.players.find(player => player.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 game = applyAction(game, game.auction.active,
|
|
game.auction.active === buyer && game.auction.bid === 0 ? { type: 'bid', amount: 2 } : { type: 'passBid' });
|
|
}
|
|
assert.deepEqual(game.decision, { kind: 'auctionPayment', player: buyer });
|
|
assert.equal(game.auction!.cards[game.auction!.index].id, purchasedId);
|
|
assert.equal(game.deck[0].id, replacementId);
|
|
assert.equal(game.auction!.bid, 2);
|
|
assert.equal(game.auction!.allyPayment ?? 0, 0);
|
|
const beforePayment = structuredClone(game);
|
|
const beforeSpice = game.players.find(player => player.id === buyer)!.spice;
|
|
const emperor = game.players.find(player => player.faction === 'emperor')?.id ?? null;
|
|
const beforeEmperorSpice = emperor ? game.players.find(player => player.id === emperor)!.spice : null;
|
|
const purchased = structuredClone(game.auction!.cards[game.auction!.index]);
|
|
const paymentAction: Action = { type: 'decision', karama: false };
|
|
game = applyAction(game, buyer, paymentAction);
|
|
assert.ok(game.pendingNexusIxianReplacement);
|
|
assertNativeIxianReplacementInventory(game);
|
|
return {
|
|
setup, beforeNexusDraw, nexusDrawAction, beforePayment, game, buyer,
|
|
event: game.pendingNexusIxianReplacement.event, purchased, paymentAction, beforeSpice,
|
|
paidSpice: game.players.find(player => player.id === buyer)!.spice, emperor, beforeEmperorSpice,
|
|
nextCard: structuredClone(game.deck[0]), auctionIndex: beforePayment.auction!.index,
|
|
};
|
|
}
|