Files
Dune/tests/richese-ambassador-interruptions.test.ts
T
2026-09-08 07:37:52 +10:00

375 lines
12 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import {
createAmbassadors,
placeAmbassador,
validateAmbassadors,
} from '../game/ecaz-ambassadors';
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(copy = false, fifth = false) {
const g = createGame('RAMBTEST', newPlayer('ec', 'Ecaz', 'ecaz'), true, [
'ecaz',
]);
g.players.push(
newPlayer('in', 'Entrant', 'guild'),
newPlayer('h', 'Buyer', 'harkonnen'),
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('b', 'BG', 'beneGesserit'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'in',
order: ['in', 'ec', 'h', 'e', 'b'],
movementRemaining: ['in', 'ec', 'h', 'e', 'b'],
deck: baseDeck(),
discard: [],
});
for (const player of g.players)
Object.assign(player, {
hand: [],
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
traitors: [],
traitorChoices: [],
});
// No available BG reserves: competing shipment accompaniment remains gated.
p(g, 'b').reserves = 0;
p(g, 'b').tanks = 20;
p(g, 'ec').ally = 'h';
p(g, 'h').ally = 'ec';
const hold = (id: string, match: (c: Card) => boolean) => {
const at = g.deck.findIndex(match);
assert.ok(at >= 0);
const card = g.deck.splice(at, 1)[0];
p(g, id).hand.push(card);
return card;
};
const printed = hold('ec', (c) => c.effect === 'karama');
const second = hold('in', (c) => c.effect === 'karama');
const worthless = hold('b', (c) => c.kind === 'worthless');
for (let n = 0; n < 6; n++) hold('h', (c) => c.kind !== 'worthless');
let state = createAmbassadors(() => 0.2);
const effect = 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 &&
(!copy || t.effect !== 'richese'),
)
.slice(0, 4)
.map((t) => t.id),
];
for (const t of state.tokens) {
t.zone =
t.effect === 'ecaz' || state.cohort.includes(t.id) ? 'supply' : 'pool';
if (fifth && t.id !== token.id && state.cohort.includes(t.id))
t.zone = 'used';
t.location = null;
}
state = placeAmbassador(state, token.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
g.ecazAmbassadors = state;
return {
g,
token: token.id,
printed: printed.id,
second: second.id,
worthless: worthless.id,
};
}
function trigger(g: Game, buyer = 'h') {
return applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: buyer,
});
}
function allowWindow(g: Game) {
const kind = g.response?.kind;
for (let n = 0; g.response?.kind === kind && g.response && n < 30; n++) {
const actor = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(actor);
g = applyAction(g, actor.id, { type: 'passResponse' });
}
return g;
}
function allResponses(g: Game) {
for (let n = 0; g.response && n < 10; n++) g = allowWindow(g);
assert.equal(g.response, null);
return g;
}
function arrival(g: Game) {
return {
turn: g.turn,
phase: g.phase,
active: g.active,
queue: g.movementRemaining,
forces: g.players.map((p) => ({
id: p.id,
forces: p.forces,
reserves: p.reserves,
shipped: p.shipped,
moved: p.moved,
})),
};
}
import { richeseCards } from '../game/richese-cards';
function moved(g: Game) {
p(g, 'in').forces = { 'imperial_basin:10': 1 };
p(g, 'in').reserves = 19;
p(g, 'in').shipped = true;
return applyAction(g, 'in', {
type: 'move',
forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen',
sector: 10,
});
}
function holdRichese(g: Game, owner: string, effect: string) {
g.richeseCache ??= richeseCards();
const at = g.richeseCache.findIndex((c) => c.effect === effect);
assert.ok(at >= 0);
const card = g.richeseCache.splice(at, 1)[0];
p(g, owner).hand.push(card);
return card.id;
}
function physical(g: Game) {
const cards = [
...g.deck,
...g.discard,
...(g.richeseCache ?? []),
...g.players.flatMap((p) => p.hand),
];
const ids = cards.map((c) => c.id).sort();
assert.equal(
new Set(ids).size,
ids.length,
'one physical instance in all custody zones',
);
validateAmbassadors(g.ecazAmbassadors!);
for (const player of g.players)
assert.equal(
player.reserves +
player.tanks +
Object.values(player.forces).reduce((a, b) => a + b, 0),
20,
);
return ids;
}
function assertPrivate(g: Game, owner: string, card: string) {
const snapshot = reload(g);
for (const viewer of g.players) {
const v = viewGame(g, viewer.id);
assert.equal('pendingAmbassador' in v, false);
assert.equal('pendingKarama' in v, false);
assert.equal('purchaseReceipt' in (v.ambassadorEntry ?? {}), false);
assert.ok(!JSON.stringify(v.ambassadorEntry).includes(card));
assert.ok(!JSON.stringify(v.response).includes(card));
assert.ok(!JSON.stringify(v.log).includes(card));
if (viewer.id !== owner)
assert.equal(v.players.find((p) => p.id === owner)!.hand, undefined);
}
assert.deepEqual(reload(g), snapshot);
}
function search(g: Game, owner: string, box: string) {
const begin = applyAction(g, owner, { type: 'card', card: box });
assert.ok(
begin.pendingNullentropy,
'multiple candidates create actual paid search',
);
const own = viewGame(begin, owner).nullentropy!.search!;
assert.ok(own.cards.length > 1);
for (const viewer of begin.players.filter((p) => p.id !== owner)) {
assert.equal(viewGame(begin, viewer.id).nullentropy?.search ?? null, null);
}
return {
begin,
choose: {
type: 'decision',
event: own.event,
// Keep the spent BG activator in discard for the exact-once assertion.
card: own.cards.find((c) => c.kind !== 'worthless')!.id,
} as Action,
};
}
function completed(g: Game, expected: string[], entry: Game) {
assert.equal(g.pendingAmbassador, null);
assert.equal(g.response, null);
assert.equal(g.decision, null);
assert.equal(g.auction, null);
assert.equal(g.currentAuctionSale ?? null, null);
assert.deepEqual(arrival(g), arrival(entry));
assert.deepEqual(physical(g), expected);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
}
void test('paid Box search restores Ambassador income before a subsequent BG cancellation', () => {
const f = fixture(),
box = holdRichese(f.g, 'b', 'nullentropyBox');
f.g.discard.push(f.g.deck.shift()!, f.g.deck.shift()!);
const expected = physical(f.g),
entry = moved(f.g);
let g = trigger(entry);
const receipt = reload(g).pendingAmbassador!.purchaseReceipt!,
buyerSpice = p(g, 'h').spice;
const paid = search(g, 'b', box);
assert.equal(p(paid.begin, 'b').spice, 18);
assertPrivate(paid.begin, 'h', receipt.card);
g = applyAction(reload(paid.begin), 'b', paid.choose);
assert.deepEqual(g.pendingAmbassador!.purchaseReceipt, receipt);
assert.equal(g.response?.source, 'ambassador');
g = applyAction(g, 'b', { type: 'card', card: f.worthless, mode: 'cancel' });
assert.equal(g.response?.kind, 'worthlessKarama');
assertPrivate(g, 'h', receipt.card);
g = allResponses(reload(g));
assert.equal(p(g, 'e').spice, 20);
assert.equal(p(g, 'h').spice, buyerSpice);
assert.equal(p(g, 'h').hand.length, 8);
assert.equal(p(g, 'b').spice, 18);
assert.equal(g.discard.filter((c) => c.id === box).length, 1);
assert.equal(g.discard.filter((c) => c.id === f.worthless).length, 1);
completed(g, expected, entry);
});
void test('paid Box nested inside BG conversion preserves the paid Ambassador source across reload', () => {
const f = fixture(),
box = holdRichese(f.g, 'b', 'nullentropyBox');
f.g.discard.push(f.g.deck.shift()!, f.g.deck.shift()!);
const expected = physical(f.g),
entry = moved(f.g);
let g = trigger(entry);
const receipt = reload(g).pendingAmbassador!.purchaseReceipt!;
g = applyAction(g, 'b', { type: 'card', card: f.worthless, mode: 'cancel' });
const conversion = reload(g).pendingKarama;
const paid = search(g, 'b', box);
assertPrivate(paid.begin, 'h', receipt.card);
assert.deepEqual(
normalizeAutomaticGame(reload(paid.begin)),
reload(paid.begin),
);
g = applyAction(reload(paid.begin), 'b', paid.choose);
assert.deepEqual(g.pendingKarama, conversion);
assert.equal(g.response?.kind, 'worthlessKarama');
g = allowWindow(reload(g));
assert.equal(g.response?.kind, 'harkonnenBonus');
assert.equal(g.response?.source, 'ambassador');
g = applyAction(reload(g), 'in', {
type: 'card',
card: f.second,
mode: 'cancel',
});
assert.equal(p(g, 'e').spice, 20);
assert.equal(p(g, 'h').spice, 17);
assert.equal(p(g, 'h').hand.length, 7);
assert.ok(p(g, 'h').hand.some((c) => c.id === receipt.card));
assert.equal(g.discard.filter((c) => c.id === box).length, 1);
assert.equal(g.discard.filter((c) => c.id === f.worthless).length, 1);
assert.equal(g.discard.filter((c) => c.id === f.second).length, 1);
completed(g, expected, entry);
});
void test('Distrans can transfer the purchased card while its historical Ambassador receipt waits for income', () => {
const f = fixture();
// Keep six initial buyer cards, replacing one with a uniquely sourced Distrans.
f.g.deck.push(p(f.g, 'h').hand.pop()!);
const distrans = holdRichese(f.g, 'h', 'distrans');
const expected = physical(f.g),
entry = moved(f.g);
let g = trigger(entry);
const receipt = reload(g).pendingAmbassador!.purchaseReceipt!;
g = applyAction(g, 'h', {
type: 'card',
card: distrans,
target: 'ec',
give: receipt.card,
});
assert.deepEqual(g.pendingAmbassador!.purchaseReceipt, receipt);
assert.ok(!p(g, 'h').hand.some((c) => c.id === receipt.card));
assert.ok(p(g, 'ec').hand.some((c) => c.id === receipt.card));
assertPrivate(g, 'ec', receipt.card);
g = allResponses(reload(g));
assert.equal(p(g, 'e').spice, 23);
assert.equal(p(g, 'h').spice, 17);
assert.equal(p(g, 'h').hand.length, 6);
assert.equal(g.discard.filter((c) => c.id === distrans).length, 1);
assert.equal(
g.players.flatMap((p) => p.hand).filter((c) => c.id === receipt.card)
.length,
1,
);
completed(g, expected, entry);
});
void test('incoming Distrans fills Harkonnen last slot during Ambassador bonus without granting a ninth card', () => {
const f = fixture(),
distrans = holdRichese(f.g, 'b', 'distrans');
const expected = physical(f.g),
entry = moved(f.g);
let g = allowWindow(trigger(entry));
assert.equal(g.response?.kind, 'harkonnenBonus');
assert.equal(p(g, 'h').hand.length, 7);
const deck = reload(g).deck;
g = applyAction(reload(g), 'b', {
type: 'card',
card: distrans,
target: 'h',
give: f.worthless,
});
assert.equal(p(g, 'h').hand.length, 8);
assert.ok(p(g, 'h').hand.some((c) => c.id === f.worthless));
assert.deepEqual(g.deck, deck);
assert.equal(p(g, 'e').spice, 23);
assert.equal(p(g, 'h').spice, 17);
assert.equal(g.discard.filter((c) => c.id === distrans).length, 1);
completed(g, expected, entry);
});
void test('a paid BG conversion rejects a substituted same-phase Ambassador purchase receipt', () => {
const f = fixture();
let g = trigger(moved(f.g));
g = applyAction(g, 'b', { type: 'card', card: f.worthless, mode: 'cancel' });
assert.equal(g.response?.kind, 'worthlessKarama');
const valid = reload(g);
// A different real physical card does not belong to this already paid acquisition.
g.pendingAmbassador!.purchaseReceipt!.card = f.printed;
const before = reload(g);
assert.throws(() => normalizeAutomaticGame(g));
assert.throws(() => viewGame(g, 'b'));
assert.throws(() => applyAction(g, 'in', { type: 'passResponse' }));
assert.deepEqual(reload(g), before);
assert.equal(g.discard.filter((c) => c.id === f.worthless).length, 1);
assert.equal(allResponses(valid).pendingAmbassador, null);
});