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

351 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, leaders } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
function fixture(advanced = false): Game {
let g = createGame('RICHREVIEW', newPlayer('r', 'Richese', 'guild'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
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],
});
assert.equal(g.status, 'playing');
g.players[0].faction = 'richese';
g.players[0].leaders = leaders('richese');
Object.assign(g, {
phase: 2,
advanced,
order: ['a', 'e', 'r'],
ready: [],
response: null,
decision: null,
phaseOpening: null,
richeseCache: richeseCards(),
richeseRemoved: [],
deck: baseDeck(),
});
for (const p of g.players) {
p.spice = 20;
p.hand = [];
}
if (advanced) g.players[0].hand = [g.deck.splice(8, 1)[0]];
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.phase, 3);
return g;
}
const decision = (g: Game, extra: Omit<Action, 'type'>) =>
applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
...extra,
});
const bid = (g: Game, actor: string, amount: number | null, allyPayment = 0) =>
applyAction(g, actor, {
type: 'richeseBid',
event: g.richeseAuction!.event,
amount,
allyPayment,
});
function cache(g = fixture(), method = 'silent') {
g = decision(g, { position: 'first' });
return decision(g, {
card: g.richeseCache![0].id,
method,
direction: 'counterclockwise',
});
}
function reject(g: Game, actor: string, action: Action, pattern?: RegExp) {
const original = structuredClone(g);
if (pattern) assert.throws(() => applyAction(g, actor, action), pattern);
else assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, original);
}
void test('cache sale transfers one physical card and exact payment to Richese across JSON restoration', () => {
let g = cache();
const card = g.richeseAuction!.cardId;
g = bid(g, 'a', 5);
g = bid(g, 'r', 0);
g = bid(JSON.parse(JSON.stringify(g)), 'e', 0);
assert.equal(g.players[0].spice, 25);
assert.equal(g.players[1].spice, 15);
assert.equal(g.players[2].spice, 20);
assert.equal(g.players[1].hand.filter((c) => c.id === card).length, 1);
assert.equal(
g.richeseCache!.some((c) => c.id === card),
false,
);
assert.equal(g.richeseCache!.length, 9);
assert.equal(g.richeseAuction, null);
assert.equal(g.richeseBidding!.stage, 'normal');
assert.equal(g.auction!.cards.length, 2);
});
void test('Richese buying its cache card pays Emperor rather than receiving its own auction income', () => {
let g = cache();
g = bid(g, 'r', 4);
g = bid(g, 'a', 0);
g = bid(g, 'e', 0);
assert.equal(g.players[0].spice, 16);
assert.equal(g.players[2].spice, 24);
assert.equal(g.players[0].hand.length, 1);
assert.equal(g.richeseCache!.length, 9);
assert.equal(g.richeseAuction, null);
});
void test('Black Market protects face IDs and sealed bid amounts while Atreides receives permitted inspection', () => {
let g = fixture(true);
const sold = g.players[0].hand[0];
g = decision(g, {
card: sold.id,
method: 'silent',
claim: 'My unverified claim',
});
assert.equal(viewGame(g, 'e').richeseAuction!.card, null);
assert.equal(viewGame(g, 'e').richeseAuction!.cardId, null);
assert.equal(viewGame(g, 'a').richeseAuction!.card!.id, sold.id);
assert.equal(viewGame(g, 'r').richeseAuction!.card!.id, sold.id);
const low = bid(g, 'a', 2),
high = bid(g, 'a', 9);
assert.deepEqual(viewGame(low, 'e'), viewGame(high, 'e'));
assert.equal(viewGame(high, 'a').richeseAuction!.ownBid, 9);
assert.equal(viewGame(high, 'e').richeseAuction!.ownBid, null);
});
void test('sealed own funds and pledged ally funds cannot be spent or withdrawn twice', () => {
let g = cache();
g.players[1].ally = 'e';
g.players[2].ally = 'a';
g = applyAction(g, 'e', { type: 'pledgeAid', amount: 6 });
g = bid(g, 'a', 15, 5);
reject(g, 'a', { type: 'bribe', target: 'r', amount: 11 });
reject(g, 'e', { type: 'pledgeAid', amount: 4 });
const before = structuredClone(g);
reject(
g,
'a',
{ type: 'richeseBid', event: g.richeseAuction!.event, amount: 1 },
/already submitted/,
);
assert.deepEqual(g, before);
g = bid(g, 'r', 0);
g = bid(g, 'e', 0);
assert.equal(g.players[1].spice, 10);
assert.equal(g.players[2].spice, 14);
assert.equal(g.aid.e.amount, 1);
assert.equal(g.players[0].spice, 35);
});
void test('a last cache lot remains scheduled after the normal pool ends without a bidder', () => {
let g = decision(fixture(), { position: 'last' });
const deckBefore = g.deck.length + g.auction!.cards.length;
while (g.auction) g = applyAction(g, g.auction.active, { type: 'passBid' });
assert.equal(g.phase, 3);
assert.equal(g.decision!.kind, 'richeseCache');
assert.equal(g.deck.length, deckBefore);
g = decision(g, { card: g.richeseCache![0].id, method: 'silent' });
for (const actor of ['a', 'e', 'r']) g = bid(g, actor, 0);
assert.equal(g.decision!.kind, 'richeseUnbid');
const selected = g.richeseAuction!.cardId;
g = decision(g, { keep: false });
assert.equal(g.phase, 4);
assert.equal(g.richeseRemoved!.filter((c) => c.id === selected).length, 1);
assert.equal(g.richeseCache!.length, 9);
});
void test('reserved Black Market Truthtrance cannot be queued for a later mandatory discard', () => {
let g = fixture(true);
g.deck.push(...g.players[0].hand);
const at = g.deck.findIndex((c) => c.name === 'Truthtrance');
assert.ok(at >= 0);
const card = g.deck.splice(at, 1)[0];
g.players[0].hand = [card];
g = decision(g, { card: card.id, method: 'silent' });
reject(g, 'r', { type: 'card', card: card.id });
assert.equal(g.truthtrance ?? null, null);
});
void test('reserved Black Market Truthtrance cannot hide in the additional queued cards array', () => {
let g = fixture(true);
g.deck.push(...g.players[0].hand);
const truths = g.deck.filter((c) => c.name === 'Truthtrance');
assert.equal(truths.length, 2);
g.deck = g.deck.filter((c) => c.name !== 'Truthtrance');
g.players[0].hand = truths;
const [main, offered] = truths;
g = decision(g, { card: offered.id, method: 'silent' });
reject(
g,
'r',
{ type: 'card', card: main.id, cards: [offered.id] },
/card offered for Black Market must remain available/,
);
assert.equal(g.truthtrance ?? null, null);
assert.deepEqual(
g.players[0].hand.map((c) => c.id),
[main.id, offered.id],
);
const legal = applyAction(g, 'r', { type: 'card', card: main.id });
assert.equal(
legal.truthtrance!.queue.some((entry) => entry.card === offered.id),
false,
);
assert.equal(
legal.truthtrance!.queue.some((entry) => entry.card === main.id),
true,
);
});
void test('Silent ally funding errors cannot reveal the committed amount through withdrawal or malformed probes', () => {
let initial = cache();
initial.players[1].ally = 'e';
initial.players[2].ally = 'a';
initial = applyAction(initial, 'e', { type: 'pledgeAid', amount: 10 });
const low = bid(initial, 'a', 2, 2),
high = bid(initial, 'a', 9, 9);
assert.deepEqual(viewGame(low, 'e'), viewGame(high, 'e'));
const error = (g: Game, amount: unknown) => {
const before = structuredClone(g);
let message: string | undefined;
try {
applyAction(g, 'e', { type: 'pledgeAid', amount });
} catch (caught) {
message = (caught as Error).message;
}
assert.ok(message, `Expected rejected pledge probe ${String(amount)}`);
assert.deepEqual(g, before);
return message;
};
for (const amount of [
0,
1,
2,
5,
8,
9,
-1,
'0',
null,
undefined,
NaN,
Infinity,
10.5,
21,
]) {
assert.equal(
error(low, amount),
error(high, amount),
`Secret commitment leaked through ${String(amount)}`,
);
}
for (const amount of [10, 11]) {
const lowUpdated = applyAction(low, 'e', { type: 'pledgeAid', amount });
const highUpdated = applyAction(high, 'e', { type: 'pledgeAid', amount });
assert.deepEqual(viewGame(lowUpdated, 'e'), viewGame(highUpdated, 'e'));
assert.equal(lowUpdated.aid.e.amount, amount);
}
});
void test('Black Market Ixian ally replacement resumes seller income exactly once after a restored decision', () => {
for (const accept of [false, true]) {
let g = fixture(true);
g.players[2].faction = 'ixians';
g.players[2].leaders = leaders('ixians');
g.players[1].ally = 'e';
g.players[2].ally = 'a';
const card = g.players[0].hand[0];
g = decision(g, { card: card.id, method: 'silent' });
g = bid(g, 'a', 3);
g = bid(g, 'e', 0);
g = bid(g, 'r', 0);
assert.equal(g.decision!.kind, 'ixAllyCard');
assert.equal(g.players[0].spice, 20);
assert.equal(g.players[1].spice, 17);
assert.equal(viewGame(g, 'e').ixPurchased, null);
g = applyAction(JSON.parse(JSON.stringify(g)), 'a', {
type: 'decision',
accept,
});
assert.equal(g.players[0].spice, 23);
assert.equal(g.players[1].spice, 17);
assert.equal(
g.players[1].hand.some((c) => c.id === card.id),
!accept,
);
assert.equal(
g.discard.some((c) => c.id === card.id),
accept,
);
assert.equal(g.decision!.kind, 'richeseDeclaration');
assert.equal(g.richeseAuction, null);
}
});
void test('normal paid auctions after a cache sale route income to Emperor without repeating Richese income', () => {
let g = cache();
g = bid(g, 'a', 5);
g = bid(g, 'r', 0);
g = bid(g, 'e', 0);
assert.equal(g.auction!.active, 'a');
const ordinaryCard = g.auction!.cards[0].id;
g = applyAction(g, 'a', { type: 'bid', amount: 3 });
while (g.auction?.index === 0)
g = applyAction(g, g.auction.active, { type: 'passBid' });
assert.equal(g.players[0].spice, 25);
assert.equal(g.players[1].spice, 12);
assert.equal(g.players[2].spice, 23);
assert.equal(
g.players[1].hand.filter((c) => c.id === ordinaryCard).length,
1,
);
assert.equal(g.richeseCache!.length, 9);
assert.equal(g.currentAuctionSale, null);
assert.equal(g.auction!.index, 1);
});
void test('advanced unused Ixian Technology does not pause Richese special lots', () => {
let g = fixture(true);
g.players[2].faction = 'ixians';
g.players[2].hand = [g.deck.shift()!];
const blackMarket = applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
card: g.players[0].hand[0].id,
method: 'silent',
});
assert.equal(blackMarket.decision, null);
assert.equal(blackMarket.richeseAuction?.source, 'blackMarket');
assert.equal(blackMarket.ixTechnologyTurn, g.ixTechnologyTurn);
g = decision(g, { decline: true });
g = decision(g, { position: 'first' });
const cache = applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
card: g.richeseCache![0].id,
method: 'silent',
});
assert.equal(cache.decision, null);
assert.equal(cache.richeseAuction?.source, 'cache');
assert.equal(cache.ixTechnologyTurn, g.ixTechnologyTurn);
g.ixTechnologyTurn = g.turn;
g = decision(g, { card: g.richeseCache![0].id, method: 'silent' });
assert.equal(g.richeseAuction!.source, 'cache');
});