Connect Emperor Nexus retention to original Ambassador purchases
This commit is contained in:
1 parent
d3e1c2f242
commit
3ce2e1d32a
19 files changed
+1228
-107
No files matched your search
@@ -0,0 +1,219 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game, type Player } from '../game/engine';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { AMBASSADOR_EFFECTS, validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { nextE3NexusEcazStep } from './fixture-e3-nexus-ecaz';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type NexusEmperorAmbassadorStep = { actor: string; action: Action };
|
||||
export type NexusEmperorAmbassadorTransition = {
|
||||
before: Game; step: NexusEmperorAmbassadorStep; after: Game;
|
||||
};
|
||||
export type NexusEmperorAmbassadorFixture = {
|
||||
game: Game; owner: string; buyer: string; entrant: string; event: string; token: string;
|
||||
card: Card; actions: NexusEmperorAmbassadorStep[];
|
||||
wallets: Record<string, number>;
|
||||
chronology: {
|
||||
initial: Game; undealt: Game; afterSetup: Game; firstMentat: NexusEmperorAmbassadorTransition;
|
||||
worm: Game; alliance: Game; draw: NexusEmperorAmbassadorTransition; afterClosingDraw: Game;
|
||||
placement: NexusEmperorAmbassadorTransition; beforeArrival: Game;
|
||||
arrival: NexusEmperorAmbassadorTransition; entry: Game; trigger: NexusEmperorAmbassadorTransition;
|
||||
};
|
||||
};
|
||||
export const nexusEmperorAmbassadorClean = (g: Game): boolean =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.truthtrance && !g.pendingTreacheryDiscard;
|
||||
export function nexusEmperorAmbassadorPlayer(g: Game, id: string): Player {
|
||||
const player = g.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
}
|
||||
export function stepNexusEmperorAmbassador(g: Game, step: NexusEmperorAmbassadorStep,
|
||||
actions?: NexusEmperorAmbassadorStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(structuredClone(g), step.actor, structuredClone(step.action)));
|
||||
}
|
||||
export function nextNexusEmperorAmbassadorStep(g: Game): NexusEmperorAmbassadorStep {
|
||||
if (!g.response && !g.phaseOpening && g.decision?.kind === 'nexusEmperorAmbassadorPurchase')
|
||||
return { actor: g.decision.player,
|
||||
action: { type: 'nexusEmperorAmbassadorPurchase', event: g.decision.event, use: false } };
|
||||
const step = nextE3NexusEcazStep(g);
|
||||
assert.ok(step, `Original Ambassador chronology needs its owner at ${g.turn}/${g.phase}/${g.decision?.kind}`);
|
||||
return step;
|
||||
}
|
||||
export function advanceNexusEmperorAmbassador(g: Game, until: (g: Game) => boolean,
|
||||
actions?: NexusEmperorAmbassadorStep[]): Game {
|
||||
for (let n = 0; n < 2600; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
g = stepNexusEmperorAmbassador(g, nextNexusEmperorAmbassadorStep(g), actions);
|
||||
}
|
||||
throw Error('Original Ambassador chronology did not reach its requested boundary');
|
||||
}
|
||||
function transition(g: Game, step: NexusEmperorAmbassadorStep,
|
||||
actions: NexusEmperorAmbassadorStep[]): NexusEmperorAmbassadorTransition {
|
||||
return { before: structuredClone(g), step: structuredClone(step),
|
||||
after: stepNexusEmperorAmbassador(g, step, actions) };
|
||||
}
|
||||
/** Original component shuffle only, while the five Ambassador tokens are undealt. */
|
||||
function originalRicheseCohort<T>(operation: () => T): T {
|
||||
const source = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const desired = ['richese', ...source.filter(effect => effect !== 'richese')];
|
||||
const current: string[] = [...source], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
/** Reorder only physical cards that have not yet left the original Spice deck. */
|
||||
function originalBlow(g: Game, worm: boolean, land: string): void {
|
||||
const desired = [...(worm ? ['worm'] : []), land, ...(g.advanced ? ['other'] : [])];
|
||||
for (const [position, face] of desired.entries()) {
|
||||
const index = g.spiceDeck.findIndex((c, i) => i >= position && (face === 'worm'
|
||||
? 'worm' in c && !c.greatMaker && !c.suppressed && !c.thumper
|
||||
: 'territory' in c && !c.discovery && (face === 'other'
|
||||
? !['hagga_basin', 'red_chasm', 'rock_outcroppings'].includes(c.territory)
|
||||
: c.territory === face)));
|
||||
assert.ok(index >= position, `Original undealt Spice card ${face} must remain available`);
|
||||
g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
export function nexusEmperorAmbassadorCards(g: Game): string[] {
|
||||
return [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
|
||||
...(g.auction?.cards.slice(g.auction.index + Number(!!g.currentAuctionSale)) ?? [])]
|
||||
.map(c => c.id).sort();
|
||||
}
|
||||
export function nexusEmperorAmbassadorInventory(g: Game): void {
|
||||
validateNexusCards(g.nexusCards!.cards!, g.players); validateAmbassadors(g.ecazAmbassadors!);
|
||||
assert.deepEqual(nexusEmperorAmbassadorCards(g), treacheryDeck(['ecaz']).map(c => c.id).sort());
|
||||
for (const p of g.players) assert.equal(p.reserves + p.tanks +
|
||||
Object.values(p.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
}
|
||||
/** Original standalone Ecaz Nexus setup, actual other-seat pact and closing draw,
|
||||
* original paid placement, physical entry and committed Richese trigger. Only
|
||||
* undealt deck/token lotteries are selected. No played hand, wallet, alliance,
|
||||
* force, clock, receipt or control frame is installed by this producer. */
|
||||
export function createNexusEmperorAmbassadorFixture(options: {
|
||||
advanced?: boolean; nexusCard?: 'emperor' | 'guild'; truthtrance?: boolean;
|
||||
arrival?: 'shipment' | 'worm'; buyer?: 'ecaz' | 'harkonnen';
|
||||
} = {}): NexusEmperorAmbassadorFixture {
|
||||
const advanced = options.advanced ?? true, owner = 'ecaz', buyer = options.buyer ?? owner;
|
||||
assert.ok(buyer !== 'harkonnen' || options.arrival !== 'worm');
|
||||
const actions: NexusEmperorAmbassadorStep[] = [];
|
||||
let g = createGame('NEXUSEMPERORAMBASSADOR', newPlayer(owner, 'Ecaz', 'ecaz'), advanced, ['ecaz']);
|
||||
for (const faction of ['guild', 'fremen', ...(buyer === 'harkonnen' ? ['harkonnen' as const] : [])] as const)
|
||||
joinGame(g, newPlayer(faction, faction, faction));
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
const initial = structuredClone(g);
|
||||
for (const player of g.players)
|
||||
g = stepNexusEmperorAmbassador(g, { actor: player.id, action: { type: 'ready' } }, actions);
|
||||
g = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(g));
|
||||
assert.ok(g.players.every(p => !p.hand.length));
|
||||
const front: Card[] = [];
|
||||
for (const player of g.players) for (let n = 0; n < (player.faction === 'harkonnen' ? 2 : 1); n++) {
|
||||
const index = g.deck.findIndex(card => options.truthtrance && player.id === 'guild'
|
||||
? card.effect === 'truthtrance' : card.kind === 'worthless' || card.kind === 'shield');
|
||||
assert.ok(index >= 0); front.push(g.deck.splice(index, 1)[0]);
|
||||
}
|
||||
g.deck.unshift(...front);
|
||||
const undealt = structuredClone(g);
|
||||
while (g.status === 'setup') {
|
||||
const step = nextNexusEmperorAmbassadorStep(g);
|
||||
if (step.action.type === 'fremenSetup')
|
||||
step.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
|
||||
const lastTraitor = step.action.type === 'traitor' && g.players.filter(p => p.traitorChoices.length).length === 1;
|
||||
if (lastTraitor) {
|
||||
actions.push(structuredClone(step));
|
||||
g = originalRicheseCohort(() => applyAction(structuredClone(g), step.actor, step.action));
|
||||
} else g = stepNexusEmperorAmbassador(g, step, actions);
|
||||
}
|
||||
const afterSetup = structuredClone(g);
|
||||
assert.ok(g.ecazAmbassadors!.tokens.some(t => t.effect === 'richese' && t.zone === 'supply'));
|
||||
originalBlow(g, false, 'hagga_basin');
|
||||
let firstMentat: NexusEmperorAmbassadorTransition | undefined;
|
||||
while (g.turn === 1) {
|
||||
const t = transition(g, nextNexusEmperorAmbassadorStep(g), actions); g = t.after;
|
||||
if (g.turn === 2) firstMentat = t;
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
g = advanceNexusEmperorAmbassador(g, s => s.turn === 2 && s.phase === 1 && nexusEmperorAmbassadorClean(s), actions);
|
||||
originalBlow(g, true, 'rock_outcroppings');
|
||||
g = advanceNexusEmperorAmbassador(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && nexusEmperorAmbassadorClean(s), actions);
|
||||
const worm = structuredClone(g);
|
||||
for (const [actor, target] of [['guild', 'fremen'], ['fremen', 'guild']])
|
||||
g = stepNexusEmperorAmbassador(g, { actor, action: { type: 'alliance', target } }, actions);
|
||||
const alliance = structuredClone(g);
|
||||
g = advanceNexusEmperorAmbassador(g, s => s.nexusCards?.phase?.stage === 'drawing' && nexusEmperorAmbassadorClean(s), actions);
|
||||
const cards = g.nexusCards!.cards!, at = cards.deck.indexOf(options.nexusCard ?? 'emperor');
|
||||
assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
||||
const draw = transition(g, { actor: owner, action: { type: 'nexusCardChoice', turn: g.turn,
|
||||
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } }, actions); g = draw.after;
|
||||
g = advanceNexusEmperorAmbassador(g, s => s.nexusCards?.phase?.stage !== 'drawing' && nexusEmperorAmbassadorClean(s), actions);
|
||||
const afterClosingDraw = structuredClone(g);
|
||||
g = advanceNexusEmperorAmbassador(g, s => s.decision?.kind === 'ecazPlacement' && !s.response && !s.phaseOpening, actions);
|
||||
const token = g.ecazAmbassadors!.tokens.find(t => t.effect === 'richese')!.id;
|
||||
const placement = transition(g, { actor: owner, action: { type: 'decision', token, territory: 'arrakeen' } }, actions);
|
||||
g = advanceNexusEmperorAmbassador(placement.after, s => s.phase === 5 && nexusEmperorAmbassadorClean(s), actions);
|
||||
assert.equal(g.ecazAmbassadors!.tokens.find(t => t.id === token)!.location, 'arrakeen');
|
||||
if (options.arrival === 'worm') {
|
||||
g = advanceNexusEmperorAmbassador(g, s => s.turn === 2 && s.phase === 5 &&
|
||||
s.active === 'fremen' && nexusEmperorAmbassadorClean(s), actions);
|
||||
g = stepNexusEmperorAmbassador(g, { actor: 'fremen',
|
||||
action: { type: 'ship', territory: 'rock_outcroppings', sector: 13, amount: 1 } }, actions);
|
||||
g = stepNexusEmperorAmbassador(g, { actor: 'fremen', action: { type: 'endMovement' } }, actions);
|
||||
}
|
||||
if (options.arrival === 'worm' || buyer === 'harkonnen') {
|
||||
g = advanceNexusEmperorAmbassador(g, s => s.turn === 3 && s.phase === 1 && nexusEmperorAmbassadorClean(s), actions);
|
||||
originalBlow(g, true, 'red_chasm');
|
||||
}
|
||||
if (buyer === 'harkonnen') {
|
||||
g = advanceNexusEmperorAmbassador(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && nexusEmperorAmbassadorClean(s), actions);
|
||||
for (const [actor, target] of [[owner, buyer], [buyer, owner]])
|
||||
g = stepNexusEmperorAmbassador(g, { actor, action: { type: 'alliance', target } }, actions);
|
||||
}
|
||||
const entrant = options.arrival === 'worm' ? 'fremen' : 'guild';
|
||||
let arrivalStep: NexusEmperorAmbassadorStep;
|
||||
if (options.arrival === 'worm') {
|
||||
g = advanceNexusEmperorAmbassador(g, s => s.decision?.kind === 'wormRide' && !s.response && !s.phaseOpening, actions);
|
||||
assert.ok(g.decision?.kind === 'wormRide');
|
||||
assert.equal(g.decision!.territory, 'rock_outcroppings');
|
||||
const source = Object.keys(nexusEmperorAmbassadorPlayer(g, entrant).forces).find(key => key.startsWith('rock_outcroppings:'));
|
||||
assert.ok(source);
|
||||
arrivalStep = { actor: entrant, action: { type: 'decision', accept: true,
|
||||
territory: 'arrakeen', sector: 10, forces: { [source]: 1 } } };
|
||||
} else {
|
||||
g = advanceNexusEmperorAmbassador(g, s => s.phase === 5 && s.active === entrant && nexusEmperorAmbassadorClean(s), actions);
|
||||
arrivalStep = { actor: entrant, action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 } };
|
||||
}
|
||||
const beforeArrival = structuredClone(g), arrival = transition(g, arrivalStep, actions); g = arrival.after;
|
||||
g = advanceNexusEmperorAmbassador(g, s => !!s.pendingAmbassador && !s.response && !s.phaseOpening, actions);
|
||||
const entry = structuredClone(g);
|
||||
assert.equal(g.pendingAmbassador!.stage, 'offer');
|
||||
const trigger = transition(g, { actor: owner, action: { type: 'decision',
|
||||
event: g.pendingAmbassador!.event, trigger: true, beneficiary: buyer } }, actions); g = trigger.after;
|
||||
assert.ok(g.decision?.kind === 'nexusEmperorAmbassadorPurchase');
|
||||
assert.equal(g.decision!.player, buyer);
|
||||
const event = g.decision!.event, card = g.deck[0]; assert.ok(card);
|
||||
nexusEmperorAmbassadorInventory(g);
|
||||
return { game: g, owner, buyer, entrant, event, token, card, actions,
|
||||
wallets: Object.fromEntries(g.players.map(p => [p.id, p.spice])),
|
||||
chronology: { initial, undealt, afterSetup, firstMentat, worm, alliance, draw,
|
||||
afterClosingDraw, placement, beforeArrival, arrival, entry, trigger } };
|
||||
}
|
||||
/** Deterministic source successor: finish only the original purchase's response
|
||||
* children. Its actual movement/worm suffix is left for the caller to observe. */
|
||||
export function finishNexusEmperorAmbassador(g: Game): Game {
|
||||
return advanceNexusEmperorAmbassador(g, s => !s.pendingAmbassador && !s.response && !s.truthtrance);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
createNexusEmperorAmbassadorFixture, finishNexusEmperorAmbassador,
|
||||
nexusEmperorAmbassadorCards, nexusEmperorAmbassadorInventory, nexusEmperorAmbassadorPlayer,
|
||||
nextNexusEmperorAmbassadorStep, stepNexusEmperorAmbassador,
|
||||
} from './fixture-nexus-emperor-ambassador';
|
||||
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejection must precede spending, drawing and Ambassador continuation');
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const arrival of ['shipment', 'worm'] as const) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} original ${arrival}: Pay 3 versus Use 0 purchases the same physical card exactly once`, () => {
|
||||
const f = createNexusEmperorAmbassadorFixture({ advanced, arrival });
|
||||
const before = structuredClone(f.game), own = nexusEmperorAmbassadorPlayer(before, f.buyer);
|
||||
assert.deepEqual(f.game.deck, f.chronology.entry.deck, 'Offering confirmation cannot reserve or draw a card');
|
||||
assert.deepEqual(f.game.discard, f.chronology.entry.discard);
|
||||
assert.equal(own.hand.length, nexusEmperorAmbassadorPlayer(f.chronology.entry, f.buyer).hand.length);
|
||||
if (arrival === 'shipment') assert.equal(f.game.active, f.entrant, 'The buyer does not replace the original movement actor');
|
||||
else assert.equal(f.game.pendingAmbassador!.resume, 'wormRide');
|
||||
const offer = viewGame(f.game, f.buyer).nexusEmperorAmbassadorPurchase!;
|
||||
assert.equal(offer.canUse, true); assert.equal(offer.blocked, null);
|
||||
for (const rival of f.game.players.filter(p => p.id !== f.buyer)) {
|
||||
const view = viewGame(f.game, rival.id);
|
||||
assert.equal(view.nexusEmperorAmbassadorPurchase, null);
|
||||
assert.equal(view.players.find(p => p.id === f.buyer)!.hand, undefined);
|
||||
assert.equal(JSON.stringify(view.ambassadorEntry).includes(f.card.id), false);
|
||||
}
|
||||
const outcomes = [false, true].map(use => {
|
||||
const action = { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use };
|
||||
let game = stepNexusEmperorAmbassador(f.game, { actor: f.buyer, action });
|
||||
game = finishNexusEmperorAmbassador(game);
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice, own.spice - (use ? 0 : 3));
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.length, own.hand.length + 1,
|
||||
'Ecaz must not receive a fabricated native Harkonnen bonus');
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === f.card.id).length, 1);
|
||||
assert.deepEqual(game.deck, before.deck.slice(1));
|
||||
assert.deepEqual(game.discard, before.discard);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.buyer], use ? null : 'emperor');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length, use ? 1 : 0);
|
||||
assert.equal(game.response?.kind === 'emperorIncome', false);
|
||||
assert.equal(game.auction ?? null, null); assert.equal(game.currentAuctionSale ?? null, null);
|
||||
for (const rival of game.players.filter(p => p.id !== f.buyer))
|
||||
assert.equal(rival.spice, f.wallets[rival.id], 'No seller, subsidy, reimbursement or income is invented');
|
||||
assert.deepEqual(game.players.map(p => [p.id, p.forces, p.reserves, p.shipped, p.moved]),
|
||||
before.players.map(p => [p.id, p.forces, p.reserves, p.shipped, p.moved]));
|
||||
assert.deepEqual(game.movementRemaining, before.movementRemaining);
|
||||
assert.deepEqual(nexusEmperorAmbassadorCards(game), nexusEmperorAmbassadorCards(before));
|
||||
nexusEmperorAmbassadorInventory(game);
|
||||
if (use) {
|
||||
assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1);
|
||||
} else assert.deepEqual(game.nexusEmperorPurchaseHistory ?? [], before.nexusEmperorPurchaseHistory ?? []);
|
||||
rejectsUnchanged(game, f.buyer, action);
|
||||
return game;
|
||||
});
|
||||
assert.deepEqual(f.game, before);
|
||||
assert.deepEqual(outcomes[0].decision, outcomes[1].decision);
|
||||
assert.deepEqual(outcomes[0].wormRides, outcomes[1].wormRides);
|
||||
if (arrival === 'shipment') {
|
||||
for (const game of outcomes) {
|
||||
assert.equal(game.active, f.entrant);
|
||||
const next = stepNexusEmperorAmbassador(game, { actor: f.entrant, action: { type: 'endMovement' } });
|
||||
assert.notEqual(next.active, f.entrant);
|
||||
assert.deepEqual(next.deck, game.deck, 'The original movement successor cannot repeat the purchase');
|
||||
}
|
||||
} else {
|
||||
const steps = outcomes.map(nextNexusEmperorAmbassadorStep);
|
||||
for (const [i, game] of outcomes.entries()) {
|
||||
const next = stepNexusEmperorAmbassador(game, steps[i]);
|
||||
assert.deepEqual(next.deck, game.deck);
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(next, f.buyer).hand.length, own.hand.length + 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
void test('wrong actor, stale event, extra cost fields, coerced input and original-trigger replay are atomic refusals', () => {
|
||||
const f = createNexusEmperorAmbassadorFixture();
|
||||
for (const rival of f.game.players.filter(p => p.id !== f.buyer))
|
||||
rejectsUnchanged(f.game, rival.id, { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true });
|
||||
for (const action of [
|
||||
{ type: 'nexusEmperorAmbassadorPurchase', event: 'stale', use: true },
|
||||
{ type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true, price: 0 },
|
||||
{ type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: false, allyPayment: 3 },
|
||||
{ type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true, card: f.card.id },
|
||||
{ type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true, cost: f.card.id },
|
||||
{ type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: 'true' },
|
||||
{ type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: 1 },
|
||||
{ type: 'nexusEmperorAmbassadorPurchase', event: f.event },
|
||||
{ type: 'decision', event: f.event, use: true },
|
||||
f.chronology.trigger.step.action,
|
||||
]) rejectsUnchanged(f.game, f.buyer, action);
|
||||
nexusEmperorAmbassadorInventory(f.game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} irrelevant original Nexus draw still opens uniform buyer Pay, never Use`, () => {
|
||||
const f = createNexusEmperorAmbassadorFixture({ advanced, nexusCard: 'guild' });
|
||||
const offer = viewGame(f.game, f.buyer).nexusEmperorAmbassadorPurchase!;
|
||||
assert.equal(offer.canUse, false);
|
||||
rejectsUnchanged(f.game, f.buyer, { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true });
|
||||
const game = finishNexusEmperorAmbassador(stepNexusEmperorAmbassador(f.game, { actor: f.buyer,
|
||||
action: { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: false } }));
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice, f.wallets[f.buyer] - 3);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.buyer], 'guild');
|
||||
assert.deepEqual(game.deck, f.game.deck.slice(1));
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === f.card.id).length, 1);
|
||||
nexusEmperorAmbassadorInventory(game);
|
||||
},
|
||||
);
|
||||
|
||||
void test('actual setup-dealt Truthtrance suspends the original Ambassador confirmation and restores the same purchase', () => {
|
||||
const f = createNexusEmperorAmbassadorFixture({ truthtrance: true });
|
||||
const card = nexusEmperorAmbassadorPlayer(f.game, 'guild').hand.find(c => c.effect === 'truthtrance');
|
||||
assert.ok(card);
|
||||
assert.ok(nexusEmperorAmbassadorPlayer(f.chronology.afterSetup, 'guild').hand.some(c => c.id === card.id));
|
||||
let game = stepNexusEmperorAmbassador(f.game, { actor: 'guild', action: { type: 'card', card: card.id } });
|
||||
assert.equal(game.truthtrance!.stage, 'priority');
|
||||
rejectsUnchanged(game, f.buyer, { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
|
||||
game = stepNexusEmperorAmbassador(game, { actor, action: { type: 'truthPass' } });
|
||||
}
|
||||
const name = nexusEmperorAmbassadorPlayer(game, f.buyer).hand[0].name;
|
||||
game = stepNexusEmperorAmbassador(game, { actor: 'guild', action: { type: 'truthAsk',
|
||||
question: { kind: 'fact', target: f.buyer, fact: { kind: 'hand', name } } } });
|
||||
assert.equal(viewGame(game, f.buyer).truthAnswer, 'yes');
|
||||
game = stepNexusEmperorAmbassador(game, { actor: f.buyer, action: { type: 'truthAnswer', answer: 'yes' } });
|
||||
assert.equal(game.truthtrance, null);
|
||||
assert.ok(game.decision?.kind === 'nexusEmperorAmbassadorPurchase');
|
||||
assert.deepEqual(game.deck, f.game.deck);
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice, f.wallets[f.buyer]);
|
||||
game = finishNexusEmperorAmbassador(stepNexusEmperorAmbassador(game, { actor: f.buyer,
|
||||
action: { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true } }));
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === f.card.id).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
|
||||
nexusEmperorAmbassadorInventory(game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} real later alliance gives native Harkonnen buyer Pay-only and one original bonus`, () => {
|
||||
const f = createNexusEmperorAmbassadorFixture({ advanced, buyer: 'harkonnen' });
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(f.game, f.buyer).ally, f.owner);
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(f.game, f.owner).ally, f.buyer);
|
||||
assert.equal(viewGame(f.game, f.buyer).nexusEmperorAmbassadorPurchase!.canUse, false);
|
||||
assert.equal(viewGame(f.game, f.owner).nexusEmperorAmbassadorPurchase, null);
|
||||
for (const actor of [f.owner, f.buyer])
|
||||
rejectsUnchanged(f.game, actor, { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: true });
|
||||
const hand = nexusEmperorAmbassadorPlayer(f.game, f.buyer).hand.length;
|
||||
let game = stepNexusEmperorAmbassador(f.game, { actor: f.buyer,
|
||||
action: { type: 'nexusEmperorAmbassadorPurchase', event: f.event, use: false } });
|
||||
const bonus = f.game.deck[1]; assert.ok(bonus);
|
||||
game = finishNexusEmperorAmbassador(game);
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.length, hand + 2);
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice, f.wallets[f.buyer] - 3);
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === bonus.id).length, 1);
|
||||
assert.deepEqual(game.deck, f.game.deck.slice(2));
|
||||
assert.deepEqual(game.nexusCards!.cards, f.game.nexusCards!.cards,
|
||||
'The original paid purchase must not consume another card after native alliance disposal.');
|
||||
for (const rival of game.players.filter(p => p.id !== f.buyer)) assert.equal(rival.spice, f.wallets[rival.id]);
|
||||
assert.equal(game.auction ?? null, null); assert.equal(game.currentAuctionSale ?? null, null);
|
||||
nexusEmperorAmbassadorInventory(game);
|
||||
},
|
||||
);
|
||||
|
||||
void test('original Easy policy resolves both actual native buyer entitlements without borrowing movement control', () => {
|
||||
for (const nexusCard of ['emperor', 'guild'] as const) {
|
||||
const f = createNexusEmperorAmbassadorFixture({ nexusCard });
|
||||
const view = viewGame(f.game, f.buyer);
|
||||
view.players.find(p => p.id === f.buyer)!.bot = 'Easy';
|
||||
const action = botActions(view)[0]; assert.ok(action);
|
||||
const game = finishNexusEmperorAmbassador(stepNexusEmperorAmbassador(f.game, { actor: f.buyer, action }));
|
||||
assert.equal(game.active, f.entrant);
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).spice,
|
||||
f.wallets[f.buyer] - (nexusCard === 'emperor' ? 0 : 3));
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.buyer], nexusCard === 'emperor' ? null : 'guild');
|
||||
assert.equal(nexusEmperorAmbassadorPlayer(game, f.buyer).hand.filter(c => c.id === f.card.id).length, 1);
|
||||
nexusEmperorAmbassadorInventory(game);
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
emperorNexusPurchaseEvent,
|
||||
signEmperorNexusPurchase,
|
||||
validateEmperorNexusPurchase,
|
||||
type EmperorNexusPurchase,
|
||||
type EmperorNexusBankPurchase,
|
||||
} from '../game/nexus-emperor-purchase';
|
||||
import { emperorNexusEvent } from '../game/nexus-emperor-secret-ally';
|
||||
|
||||
@@ -19,8 +19,8 @@ const context = {
|
||||
],
|
||||
};
|
||||
|
||||
function purchase(): EmperorNexusPurchase {
|
||||
const receipt: EmperorNexusPurchase = {
|
||||
function purchase(): EmperorNexusBankPurchase {
|
||||
const receipt: EmperorNexusBankPurchase = {
|
||||
kind: 'purchase',
|
||||
event: emperorNexusPurchaseEvent(3, 'winner', 2, 1, 4),
|
||||
owner: 'winner',
|
||||
@@ -41,9 +41,6 @@ function purchase(): EmperorNexusPurchase {
|
||||
}
|
||||
|
||||
void test('only a positive whole bid affordable personally and without ally contribution can be quoted', () => {
|
||||
assert.equal(quoteEmperorNexusPurchase(1, 1, 0), undefined);
|
||||
assert.equal(quoteEmperorNexusPurchase(5, 9, 0), undefined);
|
||||
assert.equal(quoteEmperorNexusPurchase(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, 0), undefined);
|
||||
for (const [price, ownSpice, allyPayment] of [
|
||||
[0, 10, 0], [-1, 10, 0], [1.5, 10, 0], [NaN, 10, 0],
|
||||
[Infinity, 10, 0], [Number.MAX_SAFE_INTEGER + 1, Number.MAX_SAFE_INTEGER + 1, 0],
|
||||
@@ -58,26 +55,10 @@ void test('only a positive whole bid affordable personally and without ally cont
|
||||
}
|
||||
});
|
||||
|
||||
void test('typed signatures are stable across property order and JSON reload; earlier turns remain valid', () => {
|
||||
const receipt = purchase();
|
||||
assert.equal(receipt.signature, signEmperorNexusPurchase({ ...receipt, signature: 'ignored' }));
|
||||
assert.equal(receipt.signature, signEmperorNexusPurchase({
|
||||
signature: '', afterSpice: 7, beforeSpice: 7, card: 'treachery-17', price: 4, auctionIndex: 1,
|
||||
advanced: false, faction: 'atreides', sequence: 2, phase: 3,
|
||||
turn: 3, owner: 'winner', event: receipt.event, kind: 'purchase',
|
||||
}));
|
||||
assert.doesNotThrow(() => validateEmperorNexusPurchase(context, JSON.parse(JSON.stringify(receipt))));
|
||||
assert.doesNotThrow(() => validateEmperorNexusPurchase({ ...context, advanced: true }, {
|
||||
...receipt, advanced: true, signature: signEmperorNexusPurchase({ ...receipt, advanced: true }),
|
||||
}));
|
||||
const minimum = { ...receipt, price: 1, event: emperorNexusPurchaseEvent(3, 'winner', 2, 1, 1), beforeSpice: 1, afterSpice: 1 };
|
||||
minimum.signature = signEmperorNexusPurchase(minimum);
|
||||
assert.doesNotThrow(() => validateEmperorNexusPurchase(context, minimum));
|
||||
});
|
||||
|
||||
void test('receipt fields and financial transitions cannot be changed after signing', () => {
|
||||
const receipt = purchase();
|
||||
const invalid = (patch: Partial<EmperorNexusPurchase>) =>
|
||||
const invalid = (patch: Partial<EmperorNexusBankPurchase>) =>
|
||||
assert.throws(
|
||||
() => validateEmperorNexusPurchase(context, { ...receipt, ...patch }),
|
||||
EmperorNexusPurchaseError,
|
||||
@@ -100,7 +81,7 @@ void test('receipt fields and financial transitions cannot be changed after sign
|
||||
|
||||
void test('even re-signed invalid receipts reject future, nonauction, malformed, or non-reimbursed records', () => {
|
||||
const receipt = purchase();
|
||||
const invalid = (patch: Partial<EmperorNexusPurchase>) => {
|
||||
const invalid = (patch: Partial<EmperorNexusBankPurchase>) => {
|
||||
const changed = { ...receipt, ...patch };
|
||||
changed.signature = signEmperorNexusPurchase(changed);
|
||||
assert.throws(() => validateEmperorNexusPurchase(context, changed), EmperorNexusPurchaseError);
|
||||
|
||||
Reference in new issue
Block a user