690 lines
22 KiB
TypeScript
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!);
|
|
}
|
|
});
|