Files
Dune/tests/fixture-classic-independent-nexus-reactions.ts

138 lines
8.2 KiB
TypeScript

import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { territory } from '../game/board';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export const reactionPlayer = (game: Game, id: string) => {
const player = game.players.find(seat => seat.id === id);
assert.ok(player);
return player;
};
export function reactionAction(game: Game, actor: string, action: Action, actions?: ClassicIndependentNexusStep[]) {
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
/** Original setup and turn-one play, followed by a real worm, reciprocal
* alliance and closing draw. Only unused original deck singletons move. */
export function drawClassicNexusReaction(face: FactionId, owner: string,
options: ClassicIndependentNexusOptions) {
const fixture = createClassicIndependentNexusFixture({ ...options,
roster: options.roster ?? ['atreides', 'harkonnen', 'guild'] });
const actions = fixture.actions;
let game = fixture.game;
const take = (worm: boolean) => {
const index = game.spiceDeck.findIndex(card => worm ? 'worm' in card : 'territory' in card);
assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
};
game.spiceDeck.unshift(take(false), ...(game.advanced ? [take(false)] : []),
take(true), take(false), ...(game.advanced ? [take(false)] : []));
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus &&
!state.spiceWindow && !state.spiceResolution && classicDiscoveryNexusClean(state), actions);
const others = game.players.filter(player => player.id !== owner);
game = reactionAction(game, others[0].id, { type: 'alliance', target: others[1].id }, actions);
game = reactionAction(game, others[1].id, { type: 'alliance', target: others[0].id }, actions);
game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const beforeDraw = structuredClone(game);
game = reactionAction(game, owner, { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 }, actions);
return { ...fixture, game, owner, actions, beforeDraw, afterDraw: structuredClone(game) };
}
type ClassicNexusReactionOptions = Pick<ClassicIndependentNexusOptions,
'advanced' | 'skills' | 'independent' | 'tech' | 'strongholds' | 'homeworlds'>;
export function createIndependentHarkonnenCall(options: ClassicNexusReactionOptions & { remote?: boolean } = {}) {
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : HARASS_WITHDRAW_CARD;
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options,
startingCards: { atreides: [starting], guild: [counter] } });
const actions = fixture.actions;
let game = advanceClassicIndependentNexus(fixture.game, state => state.phase === 5 &&
state.active === 'atreides' && classicDiscoveryNexusClean(state), actions);
const identity = reactionPlayer(game, 'atreides').leaders.find(leader =>
reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id;
assert.ok(identity, 'Use a real offered opposing Traitor, never assign one.');
const opponent = options.remote ? 'guild' : 'harkonnen';
const location = options.remote ? 'tueks_sietch' : 'carthag';
const sector = territory(location).sectors[0], key = `${location}:${sector}`;
assert.ok(!territory(location).sectors.includes(game.storm));
const beforeShipment = structuredClone(game);
game = reactionAction(game, 'atreides', { type: 'ship', territory: location, sector,
amount: 1, elite: 0, allyPayment: 0 }, actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && classicDiscoveryNexusClean(state), actions);
const afterShipment = structuredClone(game);
game = reactionAction(game, game.active!, { type: 'chooseBattle', territory: location,
target: game.active === 'atreides' ? opponent : 'atreides' }, actions);
for (let count = 0; count < 100; count++) {
const next = nextClassicDiscoveryNexusStep(game);
if (!next) break;
if (next.action.type === 'leaderSkillVisibility') next.action.hide = true;
game = applyClassicDiscoveryNexusStep(game, next, actions);
}
assert.ok(game.battle && !game.battle.revealed && classicDiscoveryNexusClean(game));
const opponentLeader = reactionPlayer(game, opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((one, two) => two.strength - one.strength)[0].id;
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
game = reactionAction(game, actor, { type: 'battlePlan', dial: 0, support: 0,
leader: actor === 'atreides' ? identity : opponentLeader,
weapon: actor === 'atreides' && options.independent !== false ? HARASS_WITHDRAW_CARD : null, defense: null }, actions);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.ok(game.battle?.revealed);
for (const voter of viewGame(game, 'atreides').battle!.traitorVoters)
if (voter !== 'harkonnen' && game.battle!.traitorCalls[voter] === undefined)
game = reactionAction(game, voter, { type: 'traitorCall', call: false }, actions);
const beforeCall = structuredClone(game);
game = reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true }, actions);
return { ...fixture, game, beforeCall, beforeShipment, afterShipment, identity, opponent, key, counter };
}
export function createIndependentIxianPurchase(options: ClassicNexusReactionOptions & { owner?: 'atreides' | 'harkonnen' } = {}) {
const owner = options.owner ?? 'atreides';
const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : 'ecaz-recruits';
const fixture = drawClassicNexusReaction('ixians', owner, { ...options,
startingCards: { [owner]: [starting] } });
const actions = fixture.actions;
let game = advanceClassicIndependentNexus(fixture.game, state => state.phase === 2 && classicDiscoveryNexusClean(state), actions);
const lots = options.independent === false
? [baseDeck().find(card => card.kind === 'poison')!.id, baseDeck().find(card => card.kind === 'shield')!.id]
: [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD];
const ordered = lots.map(id => {
const index = game.deck.findIndex(card => card.id === id); assert.ok(index >= 0);
return game.deck.splice(index, 1)[0];
});
game.deck.unshift(...ordered);
game = advanceClassicIndependentNexus(game, state => state.phase === 3 && !!state.auction &&
classicDiscoveryNexusClean(state), actions);
let beforeBid: Game | undefined;
for (let count = 0; game.decision?.kind !== 'auctionPayment' && count < 100; count++) {
if (classicDiscoveryNexusClean(game) && game.auction?.active === owner && game.auction.bid === 0) {
beforeBid = structuredClone(game);
game = reactionAction(game, owner, { type: 'bid', amount: 2, allyPayment: 0 }, actions);
} else {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.ok(beforeBid);
assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(game.decision.player, owner);
const beforePayment = structuredClone(game);
game = reactionAction(game, owner, { type: 'decision', karama: false }, actions);
return { ...fixture, game, beforeBid, beforePayment };
}