Files
Dune/tests/nexus-emperor-ambassador-purchase.test.ts

238 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { createGame, handLimit, newPlayer, type Game } from '../game/engine';
import { createNexusCards, drawNexusCard } from '../game/nexus-cards';
import {
createAmbassadors, placeAmbassador, triggerAmbassador, validateAmbassadors,
type AmbassadorEffect,
} from '../game/ecaz-ambassadors';
import {
EmperorNexusPurchaseError, createEmperorNexusAmbassadorPurchase,
emperorNexusAmbassadorPurchaseModeSupported,
quoteEmperorNexusAmbassadorPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase,
type EmperorNexusAmbassadorPurchase,
} from '../game/nexus-emperor-purchase';
const ENTRY = 'ambassador:2:1:57';
type FixtureOptions = {
phase?: 1 | 5;
/** Ecaz allied with Harkonnen, so the ally would be the beneficiary. */
ally?: boolean;
beneficiary?: string;
/** Ecaz keeps the concealed Emperor Nexus Secret Ally card. */
holder?: boolean;
spice?: number;
fullHand?: boolean;
supply?: number;
/** Set false to leave the physical token unconsumed in its stronghold. */
consumed?: boolean;
/** The Bene Gesserit token copies the Richese effect instead of the real token. */
copy?: boolean;
copyChoices?: AmbassadorEffect[];
effect?: 'atreides' | 'richese';
entrant?: string;
turn?: number;
};
function fixture(options: FixtureOptions = {}) {
const g = createGame('AMBQUOTE', newPlayer('ec', 'Ecaz', 'ecaz'), false, ['ecaz']);
g.players.push(newPlayer('at', 'Atreides', 'atreides'), newPlayer('ha', 'Harkonnen', 'harkonnen'));
g.order = g.players.map(p => p.id);
g.status = 'playing';
g.turn = 2;
g.phase = options.phase ?? 1;
g.active = 'at';
g.movementRemaining = [...g.order];
g.deck = baseDeck();
g.discard = [];
for (const player of g.players) {
player.hand = [];
player.spice = 10;
player.ally = null;
player.reserves = 20;
player.tanks = 0;
player.forces = {};
}
const ecaz = g.players[0], harkonnen = g.players[2];
if (options.ally) {
ecaz.ally = 'ha';
harkonnen.ally = 'ec';
}
if (options.spice !== undefined) ecaz.spice = options.spice;
if (options.fullHand) ecaz.hand = g.deck.splice(0, handLimit(ecaz));
if (options.supply !== undefined) g.deck = g.deck.slice(0, options.supply);
const cards = createNexusCards(g.players, () => 0);
if ((options.holder ?? true) && !options.ally) {
cards.deck = ['emperor', ...cards.deck.filter(card => card !== 'emperor')];
g.nexusCards = { cards: drawNexusCard(cards, 'ec', g.players, () => 0), phase: null };
} else g.nexusCards = { cards, phase: null };
let state = createAmbassadors(() => 0.2);
const effect = options.copy ? 'beneGesserit' : 'richese';
const token = state.tokens.find(t => t.effect === effect)!;
state.cohort = [
token.id,
...state.tokens.filter(t => t.effect !== 'ecaz' && t.id !== token.id).slice(0, 4).map(t => t.id),
];
for (const candidate of state.tokens) {
candidate.zone = candidate.effect === 'ecaz' || state.cohort.includes(candidate.id) ? 'supply' : 'pool';
candidate.location = null;
}
state = placeAmbassador(state, token.id, {
turn: 1,
availableSpice: 20,
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
}).state;
if (options.consumed ?? true) state = triggerAmbassador(state, token.id);
validateAmbassadors(state);
g.ecazAmbassadors = state;
g.pendingAmbassador = {
event: ENTRY,
owner: 'ec',
entrant: options.entrant ?? 'at',
token: token.id,
territory: 'arrakeen',
sector: 10,
turn: options.turn ?? g.turn,
phase: g.phase,
stage: 'purchase',
resume: 'none',
copyChoices: options.copyChoices ?? (options.copy ? ['richese'] : []),
effect: options.effect ?? 'richese',
beneficiary: options.beneficiary ?? (options.ally ? 'ha' : 'ec'),
};
return { g, ecaz };
}
function lobby(factions: Parameters<typeof newPlayer>[2][], expansions: string[]): Game {
const g = createGame('AMBMODE', newPlayer(factions[0], factions[0], factions[0]), false, expansions);
for (const faction of factions.slice(1)) g.players.push(newPlayer(faction, faction, faction));
g.order = g.players.map(p => p.id);
return g;
}
void test('private room, spice, supply and the concealed Nexus face only disable Use, never the Pay confirmation', () => {
const cases: [string, FixtureOptions, string][] = [
['an allied buyer', { ally: true, beneficiary: 'ec' }, 'ec'],
['an allied beneficiary', { ally: true, beneficiary: 'ha' }, 'ha'],
['a buyer without the real card', { holder: false }, 'ec'],
['two spice', { spice: 2 }, 'ec'],
['a full hand', { fullHand: true }, 'ec'],
['an empty supply', { supply: 0 }, 'ec'],
];
for (const [label, options, owner] of cases) {
const { g } = fixture(options), before = structuredClone(g);
const quote = quoteEmperorNexusAmbassadorPurchase(g, owner);
assert.ok(quote.blocked, label);
assert.deepEqual(g, before, label);
if (options.supply !== 0) assert.throws(() => createEmperorNexusAmbassadorPurchase(g, owner, 'drawn-card'),
EmperorNexusPurchaseError, label);
}
const { g } = fixture();
g.players.push(newPlayer('em', 'Emperor', 'emperor'));
g.order.push('em');
assert.throws(() => quoteEmperorNexusAmbassadorPurchase(g, 'ec'), EmperorNexusPurchaseError);
});
void test('malformed original source or stale entry cannot be used to retain a purchase price', () => {
const cases: [string, FixtureOptions, string][] = [
['an unconsumed token', { consumed: false }, 'ec'],
['a copied effect outside this cohort', { copy: true, copyChoices: [] }, 'ec'],
['another copied effect', { copy: true, copyChoices: ['atreides'] }, 'ec'],
['a different effect', { effect: 'atreides' }, 'ec'],
['another beneficiary', {}, 'ha'],
['a stale turn', { turn: 1 }, 'ec'],
['the buyer entering its own Ambassador', { entrant: 'ec' }, 'ec'],
];
for (const [label, options, owner] of cases) {
const { g } = fixture(options);
assert.throws(() => quoteEmperorNexusAmbassadorPurchase(g, owner), EmperorNexusPurchaseError, label);
assert.throws(() => createEmperorNexusAmbassadorPurchase(g, owner, 'drawn-card'), EmperorNexusPurchaseError, label);
}
// A copied Bene Gesserit token is a legal original source for the same price.
assert.equal(quoteEmperorNexusAmbassadorPurchase(fixture({ copy: true }).g, 'ec').blocked, null);
});
void test('the offer keeps its supply proof before the draw and accepts the last drawn card afterwards', () => {
const { g } = fixture({ supply: 1 });
assert.equal(quoteEmperorNexusAmbassadorPurchase(g, 'ec').blocked, null);
const last = g.deck[0];
g.deck = [];
const receipt = createEmperorNexusAmbassadorPurchase(g, 'ec', last.id);
validateEmperorNexusPurchase(g, receipt);
const duplicated = fixture();
const shared = duplicated.g.deck[0];
duplicated.g.discard.push(shared);
assert.throws(() => createEmperorNexusAmbassadorPurchase(duplicated.g, 'ec', shared.id),
/single physically drawn Treachery Card/);
const undrawn = fixture();
assert.throws(() => createEmperorNexusAmbassadorPurchase(undrawn.g, 'ec', undrawn.g.deck[0].id),
/single physically drawn Treachery Card/);
const later = fixture();
later.ecaz.hand.push(later.g.deck.shift()!);
assert.throws(() => createEmperorNexusAmbassadorPurchase(later.g, 'ec', later.ecaz.hand.at(-1)!.id),
EmperorNexusPurchaseError);
});
void test('version three history rejects a changed trigger, price or retained balance even after re-signing', () => {
const { g } = fixture();
const card = g.deck[0];
g.deck = g.deck.slice(1);
const receipt = createEmperorNexusAmbassadorPurchase(g, 'ec', card.id);
const invalid = (patch: Partial<EmperorNexusAmbassadorPurchase>, resign = false) => {
const changed = { ...receipt, ...patch };
if (resign) changed.signature = signEmperorNexusPurchase(changed);
assert.throws(() => validateEmperorNexusPurchase(g, changed), EmperorNexusPurchaseError,
JSON.stringify(patch));
};
// Every recorded field is inside the fixed signature tuple.
for (const patch of [
{ entry: 'other-entry' }, { token: 'ambassador-1' }, { entrant: 'ha' },
{ card: 'other-card' }, { owner: 'ha' }, { price: 2 as never }, { sequence: 1 },
{ origin: 'cache' } as never, { phase: 5 as never }, { turn: 1 },
]) invalid(patch);
for (const patch of [
{ version: 2 as never }, { price: 2 as never }, { price: 4 as never },
{ phase: 0 as never }, { phase: 9 as never }, { turn: 0 }, { turn: 3 },
{ beforeSpice: 2, afterSpice: 2 }, { afterSpice: 9 },
{ entrant: 'ec' }, { entry: ' ' }, { token: ' ' }, { entrant: ' ' },
{ card: ' ' }, { advanced: true },
{ round: 'round' } as never, { lot: 'lot' } as never, { seller: 'ha' } as never,
{ auctionIndex: 0 } as never, { bankSubsidy: 3 } as never,
]) invalid(patch, true);
});
void test('the public Ecaz envelope reuses original E3 Nexus and Skills profiles without an Emperor', () => {
const standalone = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
standalone.nexusCards = { cards: null, phase: null };
assert.equal(emperorNexusAmbassadorPurchaseModeSupported(standalone), true);
// An undealt, concealed Nexus face is not part of the public mode proof.
const skills = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
skills.nexusCards = { cards: null, phase: null };
skills.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
assert.equal(emperorNexusAmbassadorPurchaseModeSupported(skills), true);
const paired = lobby(['ecaz', 'moritani', 'atreides'], ['ecaz']);
paired.nexusCards = { cards: null, phase: null };
assert.equal(emperorNexusAmbassadorPurchaseModeSupported(paired), true);
const mixed = lobby(['ecaz', 'ixians', 'atreides'], ['ecaz', 'ix']);
mixed.nexusCards = { cards: null, phase: null };
assert.equal(emperorNexusAmbassadorPurchaseModeSupported(mixed), true);
const withoutNexus = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
const withEmperor = lobby(['ecaz', 'emperor', 'harkonnen'], ['ecaz']);
withEmperor.nexusCards = { cards: null, phase: null };
const classicOnly = lobby(['atreides', 'harkonnen'], []);
classicOnly.nexusCards = { cards: null, phase: null };
const sandtrout = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
sandtrout.nexusCards = { cards: null, phase: null };
sandtrout.sandtrout = true;
const treachery = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
treachery.nexusCards = { cards: null, phase: null };
treachery.ecazTreachery = true;
for (const unsupported of [withoutNexus, withEmperor, classicOnly, sandtrout, treachery])
assert.equal(emperorNexusAmbassadorPurchaseModeSupported(unsupported), false);
});