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

690 lines
22 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
normalizeAutomaticGame,
handLimit,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import { FACTIONS, type FactionId } from '../game/catalog';
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 enter(g: Game) {
let entered = applyAction(g, 'in', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 1,
});
if (entered.decision?.kind === 'guildShipment')
entered = applyAction(entered, 'in', { type: 'decision', allow: true });
assert.ok(
entered.pendingAmbassador,
JSON.stringify({
response: entered.response,
decision: entered.decision,
pendingShipment: entered.pendingShipment,
}),
);
return entered;
}
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 reject(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function inventory(g: Game) {
validateAmbassadors(g.ecazAmbassadors!);
const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
assert.equal(cards.length, baseDeck().length);
assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
for (const player of g.players)
assert.equal(
player.reserves +
player.tanks +
Object.values(player.forces).reduce((a, b) => a + b, 0),
20,
);
}
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,
})),
};
}
function privatePurchase(g: Game, buyer: string, card: string) {
const before = structuredClone(g);
for (const viewer of g.players) {
const view = viewGame(g, viewer.id);
assert.equal('pendingAmbassador' in view, false);
assert.equal('purchaseReceipt' in (view.ambassadorEntry ?? {}), false);
if (viewer.id === buyer)
assert.ok(
view.players
.find((p) => p.id === buyer)!
.hand!.some((c) => c.id === card),
);
else
assert.equal(view.players.find((p) => p.id === buyer)!.hand, undefined);
assert.ok(!JSON.stringify(view.ambassadorEntry).includes(card));
}
assert.deepEqual(g, before);
}
void test('direct Richese Ambassador purchase is mandatory, private and resumes its paid shipment after Emperor income', () => {
const f = fixture();
const entry = enter(f.g),
card = entry.deck[0];
const g = trigger(entry, 'ec');
assert.equal(p(g, 'ec').spice, p(entry, 'ec').spice - 3);
assert.equal(p(g, 'ec').hand.length, p(entry, 'ec').hand.length + 1);
assert.ok(p(g, 'ec').hand.some((c) => c.id === card.id));
assert.equal(g.response?.kind, 'emperorIncome');
assert.equal(g.response?.source, 'ambassador');
assert.equal(g.pendingAmbassador?.stage, 'income');
assert.equal(g.pendingAmbassador?.purchaseReceipt?.card, card.id);
assert.equal(g.auction, null);
assert.equal(g.currentAuctionSale ?? null, null);
privatePurchase(g, 'ec', card.id);
reject(g, 'ec', {
type: 'decision',
event: entry.pendingAmbassador!.event,
trigger: true,
beneficiary: 'ec',
});
const done = allResponses(reload(g));
assert.equal(p(done, 'e').spice, p(entry, 'e').spice + 3);
assert.equal(done.pendingAmbassador, null);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.id === f.token)!.zone,
'used',
);
assert.deepEqual(arrival(done), arrival(entry));
assert.deepEqual(normalizeAutomaticGame(reload(done)), reload(done));
inventory(done);
});
void test('Harkonnen allied buyer pays once, Emperor receives three, then one private bonus fills the eighth slot', () => {
const f = fixture(),
entry = enter(f.g),
primary = entry.deck[0],
bonus = entry.deck[1];
let g = trigger(entry);
assert.equal(p(g, 'h').spice, 17);
assert.equal(p(g, 'h').hand.length, 7);
assert.equal(g.response?.kind, 'emperorIncome');
privatePurchase(g, 'h', primary.id);
g = allowWindow(reload(g));
assert.equal(p(g, 'e').spice, 23);
assert.equal(g.response?.kind, 'harkonnenBonus');
assert.equal(g.response?.source, 'ambassador');
assert.equal(g.pendingAmbassador?.stage, 'bonus');
assert.equal(p(g, 'h').hand.length, 7);
g = allResponses(reload(g));
assert.equal(p(g, 'h').hand.length, 8);
assert.ok(p(g, 'h').hand.some((c) => c.id === bonus.id));
assert.equal(p(g, 'h').spice, 17);
assert.equal(p(g, 'e').spice, 23);
assert.equal(g.pendingAmbassador, null);
assert.equal(g.auction, null);
assert.deepEqual(arrival(g), arrival(entry));
privatePurchase(g, 'h', bonus.id);
inventory(g);
});
void test('printed and BG cancellations preserve the paid primary card while suppressing the appropriate child benefit', () => {
for (const bg of [false, true]) {
const f = fixture(),
entry = enter(f.g),
primary = entry.deck[0];
let g = trigger(entry);
g = applyAction(g, bg ? 'b' : 'ec', {
type: 'card',
card: bg ? f.worthless : f.printed,
mode: 'cancel',
});
if (bg) {
assert.equal(g.response?.kind, 'worthlessKarama');
g = allowWindow(reload(g));
}
assert.equal(p(g, 'e').spice, 20);
assert.equal(p(g, 'h').spice, 17);
assert.equal(g.response?.kind, 'harkonnenBonus');
g = applyAction(reload(g), 'in', {
type: 'card',
card: f.second,
mode: 'cancel',
});
assert.equal(g.response, null);
assert.equal(g.pendingAmbassador, null);
assert.equal(p(g, 'h').hand.length, 7);
assert.ok(p(g, 'h').hand.some((c) => c.id === primary.id));
assert.equal(
g.discard.filter((c) => c.id === (bg ? f.worthless : f.printed)).length,
1,
);
assert.equal(g.discard.filter((c) => c.id === f.second).length, 1);
assert.deepEqual(arrival(g), arrival(entry));
inventory(g);
}
});
void test('Richese copy through the fifth BG marker delays cohort replacement and restores a real worm ride once', () => {
const f = fixture(true, true);
f.g.players[f.g.players.findIndex((p) => p.id === 'in')] = newPlayer(
'in',
'Fremen',
'fremen',
);
// Preserve the physical Karama moved to the former entrant's hand.
p(f.g, 'in').hand = [baseDeck().find((c) => c.id === f.second)!];
p(f.g, 'in').spice = 20;
Object.assign(f.g, {
phase: 1,
active: null,
nexus: false,
wormRides: ['hagga_basin'],
decision: { kind: 'wormRide', player: 'in', territory: 'imperial_basin' },
});
p(f.g, 'in').forces = { 'imperial_basin:10': 3, 'hagga_basin:11': 1 };
p(f.g, 'in').reserves = 16;
let g = applyAction(f.g, 'in', {
type: 'decision',
accept: true,
territory: 'arrakeen',
sector: 10,
forces: { 'imperial_basin:10': 2 },
});
const entry = reload(g),
cohort = [...g.ecazAmbassadors!.cohort];
g = trigger(g);
assert.equal(g.pendingAmbassador?.stage, 'copy');
assert.ok(g.pendingAmbassador!.copyChoices.includes('richese'));
const card = g.deck[0];
g = applyAction(g, 'h', {
type: 'decision',
event: g.pendingAmbassador!.event,
effect: 'richese',
});
assert.equal(g.response?.kind, 'emperorIncome');
assert.deepEqual(g.ecazAmbassadors!.cohort, cohort);
assert.equal(
g.ecazAmbassadors!.tokens.find((t) => t.id === f.token)!.zone,
'removed',
);
assert.equal(g.pendingAmbassador?.resume, 'wormRide');
privatePurchase(g, 'h', card.id);
const done = allResponses(reload(g));
assert.equal(done.pendingAmbassador, null);
assert.deepEqual(done.decision, {
kind: 'wormRide',
player: 'in',
territory: 'hagga_basin',
});
assert.deepEqual(done.wormRides, []);
assert.equal(
done.log.filter((l) => l.text.includes('drew a new supply')).length,
1,
);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.id === f.token)!.zone,
'removed',
);
assert.deepEqual(
done.players.map((p) => p.forces),
entry.players.map((p) => p.forces),
);
reject(done, 'h', {
type: 'decision',
event: entry.pendingAmbassador!.event,
effect: 'richese',
});
inventory(done);
});
void test('an Ixian ally never creates a Bidding-only replacement for this actual movement purchase', () => {
const f = fixture();
f.g.players[f.g.players.findIndex((p) => p.id === 'h')].faction = 'ixians';
const entry = enter(f.g),
card = entry.deck[0];
const done = allResponses(trigger(entry, 'ec'));
assert.ok(p(done, 'ec').hand.some((c) => c.id === card.id));
assert.equal(done.pendingIxAlly ?? null, null);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.decision, null);
assert.equal(done.discard.length, entry.discard.length);
assert.equal(done.deck.length, entry.deck.length - 1);
inventory(done);
});
void test('recycling a depleted deck persists the paid card across the pending income response', () => {
const f = fixture();
f.g.discard = f.g.deck;
f.g.deck = [];
const entry = enter(f.g),
possible = new Set(entry.discard.map((c) => c.id));
const g = trigger(entry, 'ec'),
bought = g.pendingAmbassador!.purchaseReceipt!.card;
assert.ok(possible.has(bought));
assert.equal(g.discard.length, 0);
assert.equal(g.deck.length, possible.size - 1);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
for (let n = 0; n < 3; n++) privatePurchase(reload(g), 'ec', bought);
const done = allResponses(reload(g));
assert.ok(p(done, 'ec').hand.some((c) => c.id === bought));
assert.deepEqual(done.deck, g.deck);
inventory(done);
});
void test('forged paid Ambassador receipts reject before cancellation cost and never change the input', () => {
const f = fixture(),
source = trigger(enter(f.g));
for (const change of [
(g: Game) => {
g.pendingAmbassador!.purchaseReceipt!.amount = 99 as 3;
},
(g: Game) => {
g.pendingAmbassador!.purchaseReceipt!.buyer = 'missing';
},
(g: Game) => {
g.pendingAmbassador!.purchaseReceipt!.stage = 'bonus';
},
(g: Game) => {
g.pendingAmbassador!.phase = 3;
},
(g: Game) => {
g.response!.owner = 'h';
},
(g: Game) => {
delete g.pendingAmbassador!.purchaseReceipt;
},
]) {
const bad = reload(source);
change(bad);
reject(bad, 'b', { type: 'card', card: f.worthless, mode: 'cancel' });
reject(bad, 'ec', { type: 'card', card: f.printed, mode: 'cancel' });
}
});
void test('private allied incapacity still commits the optional trigger with the same generic public result', () => {
const base = fixture();
const funded = enter(base.g);
const expectedOffer = viewGame(funded, 'ec').ambassadorEntry;
const failedLogs: string[][] = [];
for (const reason of ['spice', 'capacity'] as const) {
const initial = reload(base.g);
if (reason === 'spice') p(initial, 'h').spice = 2;
else
p(initial, 'h').hand.push(initial.deck.shift()!, initial.deck.shift()!);
const entry = enter(initial);
assert.deepEqual(viewGame(entry, 'ec').ambassadorEntry, expectedOffer);
const done = trigger(entry);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.response, null);
assert.equal(done.decision, null);
assert.deepEqual(done.players, entry.players);
assert.deepEqual(done.deck, entry.deck);
assert.deepEqual(done.discard, entry.discard);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.id === base.token)!.zone,
'used',
);
failedLogs.push(done.log.slice(entry.log.length).map((l) => l.text));
assert.deepEqual(arrival(done), arrival(entry));
inventory(done);
}
assert.deepEqual(failedLogs[0], failedLogs[1]);
assert.ok(
!failedLogs[0].some((text) =>
/insufficient|full hand|cannot afford/i.test(text),
),
);
});
void test('a Harkonnen buyer at seven cards purchases the eighth without an extra bonus or Ix choice', () => {
const f = fixture();
p(f.g, 'h').hand.push(f.g.deck.shift()!);
const entry = enter(f.g),
primary = entry.deck[0];
let g = trigger(entry);
assert.equal(p(g, 'h').hand.length, 8);
assert.equal(g.response?.kind, 'emperorIncome');
g = allResponses(g);
assert.equal(p(g, 'h').hand.length, 8);
assert.ok(p(g, 'h').hand.some((c) => c.id === primary.id));
assert.equal(g.deck.length, entry.deck.length - 1);
assert.equal(g.pendingAmbassador, null);
assert.equal(g.pendingIxAlly ?? null, null);
inventory(g);
});
function beneficiaryFixture(buyerFaction: FactionId, emperorPresent = true) {
const g = createGame('RAMBMATR', newPlayer('ec', 'Ecaz', 'ecaz'), true, [
'ecaz',
]);
const buyer = buyerFaction === 'ecaz' ? 'ec' : 'buyer';
if (buyer !== 'ec') {
g.players.push(newPlayer(buyer, 'Beneficiary', buyerFaction));
p(g, 'ec').ally = buyer;
p(g, buyer).ally = 'ec';
}
const entrant: FactionId =
emperorPresent && buyerFaction !== 'emperor' ? 'emperor' : 'guild';
g.players.push(newPlayer('in', 'Entrant', entrant));
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'in',
order: g.players.map((p) => p.id),
movementRemaining: [
'in',
...g.players.filter((p) => p.id !== 'in').map((p) => p.id),
],
deck: baseDeck(),
discard: [],
});
for (const player of g.players)
Object.assign(player, {
hand: [],
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
traitors: [],
traitorChoices: [],
});
p(g, 'in').forces = { 'imperial_basin:10': 2 };
p(g, 'in').reserves = 18;
for (let n = 0; n < handLimit(p(g, buyer)) - 1; n++)
p(g, buyer).hand.push(g.deck.shift()!);
const ambassadors = createAmbassadors(() => 0.2);
const token = ambassadors.tokens.find((t) => t.effect === 'richese')!;
ambassadors.cohort = [
token.id,
...ambassadors.tokens
.filter((t) => t.effect !== 'ecaz' && t.id !== token.id)
.slice(0, 4)
.map((t) => t.id),
];
for (const t of ambassadors.tokens) {
t.zone =
t.effect === 'ecaz' || ambassadors.cohort.includes(t.id)
? 'supply'
: 'pool';
t.location = null;
}
g.ecazAmbassadors = placeAmbassador(ambassadors, token.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
const entry = applyAction(g, 'in', {
type: 'move',
from: 'imperial_basin:10',
territory: 'arrakeen',
sector: 10,
amount: 1,
});
assert.equal(entry.pendingAmbassador?.stage, 'offer');
return { g, entry, buyer, token: token.id };
}
void test('all twelve distinct factions can receive the mandatory Richese effect at their actual hand limit', () => {
assert.equal(FACTIONS.length, 12);
for (const faction of FACTIONS) {
const f = beneficiaryFixture(faction.id);
assert.equal(
new Set(f.entry.players.map((p) => p.faction)).size,
f.entry.players.length,
);
const card = f.entry.deck[0],
beforeBuyer = p(f.entry, f.buyer);
assert.equal(beforeBuyer.hand.length, handLimit(beforeBuyer) - 1);
const done = allResponses(trigger(f.entry, f.buyer));
assert.equal(p(done, f.buyer).spice, 17, faction.id);
assert.equal(
p(done, f.buyer).hand.length,
handLimit(beforeBuyer),
faction.id,
);
assert.ok(
p(done, f.buyer).hand.some((c) => c.id === card.id),
faction.id,
);
const emperor = done.players.find((p) => p.faction === 'emperor');
assert.ok(emperor);
assert.equal(emperor.spice, emperor.id === f.buyer ? 17 : 23, faction.id);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.pendingIxAlly ?? null, null);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.id === f.token)!.zone,
'used',
);
assert.deepEqual(arrival(done), arrival(f.entry));
privatePurchase(done, f.buyer, card.id);
inventory(done);
}
});
void test('without Emperor the paid acquisition completes directly and no auction income is invented', () => {
const f = beneficiaryFixture('atreides', false),
card = f.entry.deck[0];
assert.ok(!f.entry.players.some((p) => p.faction === 'emperor'));
const done = trigger(f.entry, f.buyer);
assert.equal(p(done, f.buyer).spice, 17);
assert.ok(p(done, f.buyer).hand.some((c) => c.id === card.id));
assert.equal(done.response, null);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.auction, null);
assert.deepEqual(arrival(done), arrival(f.entry));
inventory(done);
});
const stagedCards = (g: Game) =>
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort();
void test('a genuinely triggered empty-pile effect uses its token with no payment, card, or private failure detail', () => {
const f = fixture();
// Bounded exhausted-pile component fixture: no fake pending purchase is made.
// Conservation below covers the cards present in this explicitly reduced inventory.
f.g.deck = [];
f.g.discard = [];
const before = stagedCards(f.g),
entry = enter(f.g);
const done = trigger(entry);
assert.deepEqual(done.players, entry.players);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.response, null);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.id === f.token)!.zone,
'used',
);
assert.deepEqual(stagedCards(done), before);
assert.ok(done.log.at(-1)!.text.includes('received no card'));
assert.ok(!/empty|funds|full|capacity/i.test(done.log.at(-1)!.text));
validateAmbassadors(done.ecazAmbassadors!);
});
void test('a Hark bonus can recycle the actual income-canceling card, or complete with no bonus from an empty pool', () => {
for (const cancelIncome of [false, true]) {
const f = fixture();
// One physical purchase card remains in this bounded inventory. All payment,
// response, cost disposal and recycling below are genuine dispatcher actions.
f.g.deck = [f.g.deck[0]];
f.g.discard = [];
const before = stagedCards(f.g),
entry = enter(f.g),
primary = entry.deck[0];
let g = trigger(entry);
assert.equal(g.deck.length, 0);
assert.equal(g.discard.length, 0);
if (cancelIncome) {
g = applyAction(g, 'ec', {
type: 'card',
card: f.printed,
mode: 'cancel',
});
assert.equal(g.discard.length, 1);
assert.equal(g.discard[0].id, f.printed);
assert.equal(p(g, 'e').spice, 20);
} else {
g = allowWindow(g);
assert.equal(p(g, 'e').spice, 23);
}
assert.equal(g.response?.kind, 'harkonnenBonus');
assert.equal(g.response?.source, 'ambassador');
g = allResponses(reload(g));
assert.equal(g.pendingAmbassador, null);
assert.equal(p(g, 'h').spice, 17);
assert.ok(p(g, 'h').hand.some((c) => c.id === primary.id));
assert.equal(p(g, 'h').hand.length, cancelIncome ? 8 : 7);
assert.equal(
p(g, 'h').hand.some((c) => c.id === f.printed),
cancelIncome,
);
assert.equal(g.discard.length, 0);
assert.deepEqual(stagedCards(g), before);
assert.deepEqual(arrival(g), arrival(entry));
validateAmbassadors(g.ecazAmbassadors!);
}
});