Files
Dune/tests/karama-preflight.test.ts

514 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { submitRicheseBid } from '../game/richese-auction';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function hold(g: Game, id: string, form: 'printed' | 'worthless') {
const index = g.deck.findIndex((c) =>
form === 'printed' ? c.effect === 'karama' : c.kind === 'worthless',
);
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
player(g, id).hand.push(card);
return card.id;
}
function inventory(g: Game) {
const ids = [
...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();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
/** Construct only the pre-Bidding scenario. The count opportunity itself is
* generated through real all-ready and Richese declaration actions. */
function countOpportunity(position: 'first' | 'last' = 'first') {
let g = createGame(
'KARAMAPREFLIGHT',
newPlayer('r', 'Richese', 'richese'),
true,
['choam'],
);
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
Object.assign(g, {
status: 'playing',
phase: 2,
turn: 2,
order: ['r', 'b', 'e'],
ready: [],
deck: baseDeck(),
richeseCache: richeseCards(),
richeseRemoved: [],
});
for (const p of g.players) {
p.hand = [];
p.spice = 20;
}
const worthless = hold(g, 'b', 'worthless');
const printed = hold(g, 'e', 'printed');
const spare = hold(g, 'e', 'printed');
const physical = inventory(g);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.phase, 3);
assert.equal(g.decision?.kind, 'richeseDeclaration');
assert.equal(g.richeseBidding?.normalCount, 2);
g = applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
position,
});
assert.equal(g.response?.kind, 'richeseAuction');
assert.equal(g.response.owner, 'r');
assert.equal(g.auction, null);
assert.deepEqual(inventory(g), physical);
return { g, worthless, printed, spare };
}
void test('printed cancellation of a saved BG conversion restores the original Richese cache opportunity', () => {
const { g: initial, worthless, printed, spare } = countOpportunity();
const physical = inventory(initial);
const original = structuredClone(initial.response!);
const legacy = reload(initial);
const bg = player(legacy, 'b');
// Restore a saved conversion frame with its original declaration and spent
// Worthless card. Countering the conversion restores, rather than cancels,
// the original cache opportunity.
const index = bg.hand.findIndex((c) => c.id === worthless);
legacy.discard.push(bg.hand.splice(index, 1)[0]);
legacy.pendingKarama = {
owner: 'b',
use: { kind: 'cancel', response: original },
};
legacy.response = {
kind: 'worthlessKarama',
owner: 'b',
passed: [],
intent: 'Cancel a power used by Richese.',
};
const saved = reload(legacy);
const before = structuredClone(saved);
for (const p of saved.players)
assert.equal(viewGame(saved, p.id).response?.kind, 'worthlessKarama');
assert.deepEqual(reload(normalizeAutomaticGame(saved)), saved);
let done = applyAction(saved, 'e', {
type: 'card',
card: printed,
mode: 'cancel',
});
assert.deepEqual(saved, before);
assert.equal(done.pendingKarama, null);
assert.deepEqual(done.response, original);
assert.deepEqual(done.richeseBidding, initial.richeseBidding);
assert.equal(done.auction, null);
assert.equal(done.discard.filter((c) => c.id === worthless).length, 1);
assert.equal(done.discard.filter((c) => c.id === printed).length, 1);
assert.ok(player(done, 'e').hand.some((c) => c.id === spare));
assert.deepEqual(inventory(done), physical);
assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
// Actual passes now allow the original count once. The spare printed card
// kept restoration observable instead of allowing automatic advancement.
while (done.response?.kind === 'richeseAuction') {
const next = done.players.find(
(p) => !done.response!.passed.includes(p.id),
);
assert.ok(next);
done = applyAction(done, next.id, { type: 'passResponse' });
}
assert.equal(done.decision?.kind, 'richeseCache');
assert.equal(done.richeseBidding?.normalCount, 2);
assert.equal(done.richeseBidding?.stage, 'cacheOffer');
assert.equal(done.discard.length, 2);
assert.deepEqual(inventory(done), physical);
});
function blackMarketOpportunity(winner: 'a' | 'b' | 'e' = 'a') {
let g = createGame(
'KARAMAMARKET',
newPlayer('r', 'Richese', 'richese'),
true,
['choam'],
);
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
Object.assign(g, {
status: 'playing',
phase: 2,
turn: 2,
order: ['r', 'b', 'e', 'a'],
ready: [],
deck: baseDeck(),
richeseCache: richeseCards(),
richeseRemoved: [],
});
for (const p of g.players) {
p.hand = [];
p.spice = 20;
}
const donor = winner === 'a' ? 'b' : 'a';
player(g, winner).ally = donor;
player(g, donor).ally = winner;
const worthless = hold(g, 'b', 'worthless');
const printed = hold(g, 'e', 'printed');
hold(g, 'e', 'printed');
const offered = g.deck.splice(
g.deck.findIndex((c) => c.kind === 'projectile'),
1,
)[0];
player(g, 'r').hand.push(offered);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'richeseBlackMarket');
g = applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
card: offered.id,
method: 'silent',
direction: 'clockwise',
});
assert.equal(g.response?.kind, 'richeseBlackMarket');
assert.equal(g.richeseAuction?.cardId, offered.id);
return { g, worthless, printed, offered };
}
for (const form of ['printed', 'worthless'] as const)
void test(`${form} Black Market cancellation with an exhausted cache continues and restores the ordinary lot`, () => {
// User ruling 7 October 2026: an exhausted cache is legal, so a canceled
// Black Market settles and the round declares the ordinary pool with the
// cache's one-lot reduction restored.
const counts: number[] = [];
for (const exhausted of [false, true]) {
const { g: initial, worthless, printed } = blackMarketOpportunity();
const g = reload(initial);
// A real offered Black Market keeps its response while its cache is
// exhausted; every physical card ID is preserved.
if (exhausted) {
g.richeseRemoved!.push(...g.richeseCache!);
g.richeseCache = [];
}
const physical = inventory(g);
let canceled = applyAction(g, form === 'printed' ? 'e' : 'b', {
type: 'card',
card: form === 'printed' ? printed : worthless,
mode: 'cancel',
});
// The Worthless card opens its own conversion response before settling.
canceled = passKind(canceled, 'worthlessKarama');
assert.equal(canceled.decision?.kind, 'richeseDeclaration');
assert.ok(!canceled.pendingKarama);
assert.deepEqual(inventory(canceled), physical);
counts.push(canceled.richeseBidding!.normalCount!);
}
assert.equal(
counts[1],
counts[0] + 1,
'The exhausted cache restores exactly one ordinary lot',
);
});
function passKind(initial: Game, kind: NonNullable<Game['response']>['kind']) {
let g = initial;
while (g.response?.kind === kind) {
const next = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(next);
g = applyAction(g, next.id, { type: 'passResponse' });
}
return g;
}
function savedSoldPeek(winner: 'a' | 'b' | 'e' = 'a') {
const f = blackMarketOpportunity(winner);
let g = passKind(f.g, 'richeseBlackMarket');
assert.equal(g.response?.kind, 'atreidesAuction');
const peek = structuredClone(g.response);
g = passKind(g, 'atreidesAuction');
assert.equal(g.response, null);
const donor = winner === 'a' ? 'b' : 'a';
g = applyAction(g, donor, { type: 'pledgeAid', amount: 2 });
// Saved-state coverage, not a claimed reachable public pause: today's final
// bid immediately settles. Derive a coherent terminal outcome using the
// real pure bidding reducer, retaining the earlier genuine peek control.
// The cancellation dispatcher must safely handle or reject this snapshot.
for (const id of g.richeseAuction!.eligible) {
const amount = id === winner ? 5 : 0;
g.richeseAuction = submitRicheseBid(
g.richeseAuction!,
{ event: g.richeseAuction!.event, actor: id, amount },
20,
);
g.richeseFunding![id] = {
amount,
allyPayment: id === winner ? 2 : 0,
donor: id === winner ? donor : null,
};
}
assert.deepEqual(g.richeseAuction!.outcome, {
kind: 'sold',
winner,
amount: 5,
});
g.response = peek;
g.active = g.richeseAuction!.active;
return { ...f, g: reload(g) };
}
const invalidSettlement: [string, (g: Game) => void, RegExp][] = [
[
'reserved card missing from its actual source',
(g) => {
const owner = player(g, 'r');
const i = owner.hand.findIndex((c) => c.id === g.richeseAuction!.cardId);
g.deck.push(owner.hand.splice(i, 1)[0]);
},
/reserved auction card.*available/i,
],
[
'winning hand at capacity',
(g) => {
const winner = player(g, 'a');
while (winner.hand.length < 4) winner.hand.push(g.deck.shift()!);
},
/winning hand.*room/i,
],
[
'missing exact funding',
(g) => delete g.richeseFunding!.a,
/exact funding/i,
],
[
'funding amount differs from the winning bid',
(g) => {
g.richeseFunding!.a.amount = 4;
},
/exact funding/i,
],
[
'ally split exceeds the actual pledged credit',
(g) => {
g.richeseFunding!.a.allyPayment = 3;
},
/payment.*funded/i,
],
[
'own part of the declared split is unfunded',
(g) => {
player(g, 'a').spice = 2;
},
/payment.*funded/i,
],
];
for (const form of ['printed', 'worthless'] as const)
for (const [name, corrupt, error] of invalidSettlement)
void test(`${form} Atreides peek cancellation preflights ${name} before spending`, (t) => {
const { g, worthless, printed } = savedSoldPeek();
corrupt(g);
const before = structuredClone(g);
const physical = inventory(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Rejected settlement preflight must not draw.');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Rejected settlement preflight must not advance its lot.');
});
assert.throws(
() =>
applyAction(g, form === 'printed' ? 'e' : 'b', {
type: 'card',
card: form === 'printed' ? printed : worthless,
mode: 'cancel',
}),
error,
);
assert.deepEqual(g, before);
assert.deepEqual(inventory(g), physical);
assert.equal(g.pendingKarama, undefined);
assert.equal(g.discard.length, 0);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
for (const form of ['printed', 'worthless'] as const)
void test(`${form} Atreides peek cancellation preserves a valid exact split and settles the original card once`, () => {
const { g, worthless, printed, offered } = savedSoldPeek();
const before = structuredClone(g);
const physical = inventory(g);
let done = applyAction(g, form === 'printed' ? 'e' : 'b', {
type: 'card',
card: form === 'printed' ? printed : worthless,
mode: 'cancel',
});
assert.deepEqual(g, before);
if (form === 'worthless') {
assert.equal(done.response?.kind, 'worthlessKarama');
assert.equal(player(done, 'a').spice, 20);
assert.equal(done.aid.b.amount, 2);
assert.ok(player(done, 'r').hand.some((c) => c.id === offered.id));
done = passKind(reload(done), 'worthlessKarama');
}
assert.equal(player(done, 'a').spice, 17);
assert.equal(done.aid.b.amount, 0);
assert.equal(player(done, 'b').spice, 18);
assert.equal(player(done, 'r').spice, 25);
assert.equal(
player(done, 'a').hand.filter((c) => c.id === offered.id).length,
1,
);
assert.ok(!player(done, 'r').hand.some((c) => c.id === offered.id));
assert.equal(done.decision?.kind, 'richeseDeclaration');
assert.equal(done.richeseAuction, null);
assert.equal(done.discard.length, 1);
assert.deepEqual(inventory(done), physical);
assert.deepEqual(
reload(normalizeAutomaticGame(reload(done))),
reload(done),
);
});
for (const form of ['printed', 'worthless'] as const)
void test(`${form} winning buyer at capacity frees its own slot before the saved Atreides settlement quote`, () => {
const winner = form === 'printed' ? 'e' : 'b';
const { g, worthless, printed, offered } = savedSoldPeek(winner);
// The same documented constructed sold-peek boundary, with the winning
// buyer also paying for cancellation. Its pre-cost hand is exactly full.
const buyer = player(g, winner);
while (buyer.hand.length < 4) buyer.hand.push(g.deck.shift()!);
assert.equal(buyer.hand.length, 4);
const spent = form === 'printed' ? printed : worthless;
const before = structuredClone(g);
const physical = inventory(g);
let done = applyAction(g, winner, {
type: 'card',
card: spent,
mode: 'cancel',
});
assert.deepEqual(g, before);
if (form === 'worthless') {
assert.equal(done.response?.kind, 'worthlessKarama');
assert.equal(player(done, winner).hand.length, 3);
assert.equal(player(done, winner).spice, 20);
assert.equal(done.aid.a.amount, 2);
assert.ok(player(done, 'r').hand.some((c) => c.id === offered.id));
done = passKind(reload(done), 'worthlessKarama');
}
assert.equal(player(done, winner).hand.length, 4);
assert.equal(
player(done, winner).hand.filter((c) => c.id === offered.id).length,
1,
);
assert.equal(
player(done, winner).hand.some((c) => c.id === spent),
false,
);
assert.equal(done.discard.filter((c) => c.id === spent).length, 1);
assert.equal(player(done, winner).spice, 17);
assert.equal(player(done, 'a').spice, 18);
assert.equal(done.aid.a.amount, 0);
assert.equal(player(done, 'r').spice, 25);
assert.equal(done.richeseAuction, null);
assert.equal(done.decision?.kind, 'richeseDeclaration');
assert.deepEqual(inventory(done), physical);
assert.deepEqual(
reload(normalizeAutomaticGame(reload(done))),
reload(done),
);
});
for (const form of ['printed', 'worthless'] as const)
void test(`${form} original normal-auction winner can pay from a full saved hand by freeing its Karama slot`, () => {
const winner = form === 'printed' ? 'e' : 'b';
let g = createGame(
'KARAMAFULLPAY',
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
true,
);
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('g', 'Guild', 'guild'));
Object.assign(g, {
status: 'playing',
phase: 3,
turn: 2,
order: [
winner,
...g.players.map((p) => p.id).filter((id) => id !== winner),
],
active: winner,
deck: baseDeck(),
});
for (const p of g.players) {
p.hand = [];
p.spice = 20;
}
const card = hold(g, winner, form);
hold(g, 'g', 'printed');
const sold = g.deck.shift()!;
g.auction = {
cards: [sold],
index: 0,
bid: 0,
bidder: null,
active: winner,
passed: [],
opener: 0,
peekKnown: false,
};
g = applyAction(g, winner, { type: 'bid', amount: 3 });
while (!g.decision)
g = applyAction(g, g.auction!.active, { type: 'passBid' });
assert.equal(g.decision.kind, 'auctionPayment');
assert.equal(g.decision.player, winner);
assert.equal(g.auction!.bidder, winner);
// A saved payment-stage capacity boundary; actual bidding above establishes
// the winner. Keep card custody unique while filling the remaining slots.
while (player(g, winner).hand.length < 4)
player(g, winner).hand.push(g.deck.shift()!);
const before = structuredClone(g);
const physical = inventory(g);
let done = applyAction(reload(g), winner, {
type: 'decision',
karama: true,
card,
});
assert.deepEqual(g, before);
if (form === 'worthless') {
assert.equal(player(done, winner).hand.length, 3);
done = passKind(reload(done), 'worthlessKarama');
}
assert.equal(player(done, winner).hand.length, 4);
assert.equal(
player(done, winner).hand.filter((c) => c.id === sold.id).length,
1,
);
assert.equal(player(done, winner).spice, 20);
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
assert.equal(done.auction, null);
assert.deepEqual(inventory(done), physical);
assert.deepEqual(
reload(normalizeAutomaticGame(reload(done))),
reload(done),
);
});