322 lines
11 KiB
TypeScript
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));
|
|
});
|