Files
Dune/tests/richese-acquisition-review.test.ts

322 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
prepareSpecialKaramaIntent,
executeSpecialKaramaIntent,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, leaders } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { createRicheseAuction } from '../game/richese-auction';
function fixture(emperor = true) {
let g = createGame('RICHACQUIRE', newPlayer('r', 'Richese', 'guild'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
if (emperor) joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
g.players.forEach((p) => {
p.ready = true;
});
g = applyAction(g, 'r', { type: 'start' });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.players[0].faction = 'richese';
g.players[0].leaders = leaders('richese');
g.players.forEach((p) => {
p.hand = [];
p.spice = 20;
});
Object.assign(g, {
advanced: true,
phase: 3,
turn: 2,
response: null,
decision: null,
phaseOpening: null,
pendingKarama: null,
deck: baseDeck(),
discard: [],
richeseCache: richeseCards(),
richeseRemoved: [],
order: g.players.map((p) => p.id),
});
const karama = g.deck.find((c) => c.effect === 'karama')!;
g.deck = g.deck.filter((c) => c.id !== karama.id);
g.players[0].hand = [karama];
return g;
}
function command(g: Game): Action {
return {
type: 'card',
mode: 'special',
card: g.players[0].hand.find((c) => c.effect === 'karama')!.id,
acquire: g.richeseCache!.find((c) => c.effect !== 'karama')!.id,
};
}
function giveKarama(g: Game, id: string) {
let card = g.deck.find((c) => c.effect === 'karama');
if (card) g.deck = g.deck.filter((c) => c.id !== card!.id);
else {
card = g.richeseCache!.find((c) => c.effect === 'karama')!;
g.richeseCache = g.richeseCache!.filter((c) => c.id !== card!.id);
}
g.players.find((p) => p.id === id)!.hand.push(card);
return card.id;
}
function inventory(g: Game) {
return [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.richeseCache ?? []),
...(g.richeseRemoved ?? []),
...(g.auction?.cards.slice(g.auction.index) ?? []),
]
.map((c) => c.id)
.sort();
}
function rejected(g: Game, action: Action, pattern: RegExp) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'r', action), pattern);
assert.deepEqual(g, before);
}
void test('special purchase preparation is pure and a bank purchase commits exact card, cost and once marker once', () => {
const g = fixture(false);
g.players[0].ally = 'h';
g.players[1].ally = 'r';
const action = command(g);
const before = structuredClone(g);
const intent = prepareSpecialKaramaIntent(g, 'r', action);
assert.equal(intent.kind, 'richese');
assert.deepEqual(g, before);
const done = applyAction(g, 'r', action);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].specialKaramaUsed, true);
assert.equal(done.players[0].hand.length, 1);
assert.equal(done.players[0].hand[0].id, action.acquire);
assert.equal(done.discard.filter((c) => c.id === action.card).length, 1);
assert.equal(done.richeseCache!.length, 9);
assert.equal(done.pendingIxAlly ?? null, null);
assert.equal(done.players[1].hand.length, 0);
assert.deepEqual(inventory(done), inventory(before));
rejected(done, action, /already been used/);
});
void test('Emperor automatically receives the three-spice purchase when no cancellation choice exists', () => {
const g = fixture();
const action = command(g);
const done = applyAction(g, 'r', action);
assert.equal(done.players.find((p) => p.id === 'e')!.spice, 23);
assert.equal(done.players[0].spice, 17);
assert.equal(done.pendingRichesePurchaseIncome, null);
assert.equal(done.response, null);
assert.equal(done.players[0].hand[0].id, action.acquire);
assert.deepEqual(inventory(done), inventory(g));
});
void test('canceling only Emperor income preserves the private purchase and exactly restores an interrupted auction response', () => {
const g = fixture();
const cancel = giveKarama(g, 'h');
giveKarama(g, 'e');
g.auction = {
cards: [g.deck.shift()!],
index: 0,
bid: 0,
bidder: null,
active: 'h',
passed: [],
opener: 1,
};
g.active = 'h';
g.response = { kind: 'atreidesAuction', owner: 'a', passed: ['r'] };
const action = command(g);
const originalResponse = structuredClone(g.response);
const originalAuction = structuredClone(g.auction);
const held = applyAction(g, 'r', action);
assert.equal(held.response!.kind, 'richesePurchaseIncome');
assert.equal(held.players[0].hand[0].id, action.acquire);
assert.equal(held.players[0].specialKaramaUsed, true);
assert.equal(held.players.find((p) => p.id === 'e')!.spice, 20);
assert.deepEqual(
held.pendingRichesePurchaseIncome!.resume.response,
originalResponse,
);
const selected = g.richeseCache!.find((c) => c.id === action.acquire)!;
for (const viewer of ['h', 'a', 'e']) {
const view = viewGame(held, viewer);
assert.equal(view.players[0].hand, undefined);
assert.equal('pendingRichesePurchaseIncome' in view, false);
assert.equal(JSON.stringify(view.response).includes(selected.id), false);
assert.equal(
view.log.some((entry) => entry.text.includes(selected.name)),
false,
);
}
const done = applyAction(JSON.parse(JSON.stringify(held)), 'h', {
type: 'card',
card: cancel,
mode: 'cancel',
});
assert.equal(done.players.find((p) => p.id === 'e')!.spice, 20);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[0].hand[0].id, action.acquire);
assert.deepEqual(done.response, originalResponse);
assert.deepEqual(done.auction, originalAuction);
assert.equal(done.pendingRichesePurchaseIncome, null);
assert.deepEqual(inventory(done), inventory(g));
});
void test('purchase income resumes an existing cache decision without rebuilding its pool or revealing the selected acquisition', () => {
const g = fixture();
giveKarama(g, 'h');
g.richeseBidding = {
owner: 'r',
event: 'round-1',
turn: g.turn,
stage: 'cacheOffer',
position: 'first',
normalCount: 3,
blackMarketSold: false,
cacheCanceled: false,
};
g.decision = { kind: 'richeseCache', player: 'r' };
const decision = structuredClone(g.decision);
const round = structuredClone(g.richeseBidding);
let done = applyAction(g, 'r', command(g));
assert.equal(done.decision, null);
assert.equal(done.response!.kind, 'richesePurchaseIncome');
done = applyAction(JSON.parse(JSON.stringify(done)), 'h', {
type: 'passResponse',
});
assert.deepEqual(done.decision, decision);
assert.deepEqual(done.richeseBidding, round);
assert.equal(done.auction, null);
assert.equal(done.players.find((p) => p.id === 'e')!.spice, 23);
assert.equal(done.richeseCache!.length, 9);
});
void test('an interrupted Worthless-Karama conversion and its original response survive the independent income window', () => {
const g = fixture();
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
bg.hand = [];
g.players.push(bg);
g.order.push('b');
giveKarama(g, 'h');
giveKarama(g, 'e');
const original = {
kind: 'atreidesAuction' as const,
owner: 'a',
passed: ['r'],
};
g.auction = {
cards: [g.deck.shift()!],
index: 0,
bid: 0,
bidder: null,
active: 'h',
passed: [],
opener: 1,
};
const worthless = g.deck.find((c) => c.kind === 'worthless')!;
g.deck = g.deck.filter((c) => c.id !== worthless.id);
g.discard.push(worthless);
g.pendingKarama = { owner: 'b', use: { kind: 'cancel', response: original } };
g.response = {
kind: 'worthlessKarama',
owner: 'b',
passed: [],
intent: 'Cancel the inspection.',
};
const pending = structuredClone(g.pendingKarama);
const response = structuredClone(g.response);
let done = applyAction(g, 'r', command(g));
assert.equal(done.pendingKarama, null);
assert.deepEqual(
done.pendingRichesePurchaseIncome!.resume.pendingKarama,
pending,
);
done = applyAction(JSON.parse(JSON.stringify(done)), 'h', {
type: 'passResponse',
});
assert.deepEqual(done.pendingKarama, pending);
assert.deepEqual(done.response, response);
assert.equal(done.players.find((p) => p.id === 'e')!.spice, 23);
assert.deepEqual(inventory(done), inventory(g));
});
void test('special purchase cannot spend sealed bid funds or take the already offered cache card', () => {
const g = fixture();
const action = command(g);
const offered = g.richeseCache![1].id;
g.richeseAuction = createRicheseAuction({
event: 'lot',
cardId: offered,
owner: 'r',
source: 'cache',
method: 'silent',
eligible: g.order,
order: g.order,
tieOrder: g.order,
});
g.richeseAuction.sealed = { r: 18 };
g.richeseAuction.acted = ['r'];
g.richeseFunding = { r: { amount: 18, allyPayment: 0, donor: null } };
rejected(g, action, /uncommitted/);
g.richeseAuction.sealed = {};
g.richeseAuction.acted = [];
g.richeseFunding = {};
rejected(g, { ...action, acquire: offered }, /offered for auction/);
});
void test('noncanonical cache selections remain invalid before any purchase cost', () => {
const g = fixture();
const fake = command(g);
g.richeseCache!.find((c) => c.id === fake.acquire)!.name =
'Invented replacement';
rejected(g, fake, /verified card/);
});
void test('a delayed normalized acquisition intent revalidates exact card custody before any cost or once marker', () => {
const g = fixture(false);
const action = command(g);
const intent = prepareSpecialKaramaIntent(g, 'r', action);
const removed = g.richeseCache!.find((c) => c.id === action.acquire)!;
g.richeseCache = g.richeseCache!.filter((c) => c.id !== removed.id);
g.players[1].hand.push(removed);
const before = structuredClone(g);
assert.throws(() => executeSpecialKaramaIntent(g, intent), /verified card/);
assert.deepEqual(g, before);
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[0].specialKaramaUsed ?? false, false);
});
void test('a special cache purchase with an Ixian ally does not create a bidding replacement or bonus draw', () => {
const g = fixture();
g.players[1].faction = 'ixians';
g.players[1].leaders = leaders('ixians');
g.players[0].ally = 'h';
g.players[1].ally = 'r';
g.players[1].hand = [g.deck.shift()!];
const deck = structuredClone(g.deck);
const allyHand = structuredClone(g.players[1].hand);
const action = command(g);
const done = applyAction(g, 'r', action);
assert.equal(done.players[0].hand[0].id, action.acquire);
assert.equal(done.pendingIxAlly ?? null, null);
assert.equal(done.decision, null);
assert.deepEqual(done.players[1].hand, allyHand);
assert.deepEqual(done.deck, deck);
assert.deepEqual(inventory(done), inventory(g));
});