514 lines
17 KiB
TypeScript
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),
|
|
);
|
|
});
|