Files
Dune/tests/bg-richese-cache-cancel.test.ts
T
Quintine 92d5c0ac2e
Verify and publish checkpoint container / container (push) Successful in 11m6s
Clarify Bene Gesserit cache cancellation
2026-10-29 05:04:37 +11:00

171 lines
7.9 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { richeseCards } from '../game/richese-cards';
const player = (game: Game, id: string) => game.players.find((seat) => seat.id === id)!;
function inventory(game: Game) {
const cards = [
...game.deck,
...game.discard,
...game.players.flatMap((seat) => seat.hand),
...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []),
].map((card) => card.id).sort();
assert.equal(new Set(cards).size, cards.length);
return cards;
}
void test('all four minimal bot profiles can respond to the real BG cache conversion', () => {
for (const difficulty of DIFFICULTIES) {
const source = declaration('first');
let game = applyAction(source.game, 'b', {
type: 'card', card: source.worthless, mode: 'cancel',
});
assert.equal(game.response?.kind, 'worthlessKarama');
const view = viewGame(game, 'e');
view.players.find((seat) => seat.id === 'e')!.bot = difficulty;
const before = structuredClone(view);
const action = botActions(view)[0];
assert.ok(action, `${difficulty} provides a response`);
assert.deepEqual(view, before);
game = applyAction(game, 'e', action);
assert.ok(game.response?.kind !== 'worthlessKarama' || game.response.passed.includes('e'));
assert.deepEqual(inventory(game), source.cards);
}
});
/** Set up pre-Bidding stock, then reach the actual declaration through the
* normal ready and Richese decision actions. No pending response is staged. */
function declaration(position: 'first' | 'last') {
let game = createGame('BGRICH', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
Object.assign(game, {
status: 'playing', phase: 2, turn: 2, order: ['r', 'b', 'e'], ready: [],
deck: baseDeck(), richeseCache: richeseCards(), richeseRemoved: [],
});
for (const seat of game.players) {
seat.hand = [];
seat.spice = 20;
}
const take = (id: string, predicate: (card: Game['deck'][number]) => boolean) => {
const index = game.deck.findIndex(predicate);
assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
player(game, id).hand.push(card);
return card.id;
};
const worthless = take('b', (card) => card.kind === 'worthless');
const printed = take('e', (card) => card.effect === 'karama');
// The spare keeps the restored Richese response observable after a counter.
const spare = take('e', (card) => card.effect === 'karama');
const cards = inventory(game);
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
assert.equal(game.decision?.kind, 'richeseDeclaration');
assert.equal(game.richeseBidding?.normalCount, 2);
assert.equal(game.richeseBidding?.normalCountBeforeCache, 3);
game = applyAction(game, 'r', {
type: 'decision', event: game.richeseBidding!.event, position,
});
assert.equal(game.response?.kind, 'richeseAuction');
assert.equal(game.response.owner, 'r');
assert.deepEqual(inventory(game), cards);
return { game, cards, worthless, printed, spare, cache: game.richeseCache!.map((card) => card.id) };
}
function passWindow(game: Game, kind: 'worthlessKarama' | 'richeseAuction') {
for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) {
const seat = game.players.find((candidate) => !game.response!.passed.includes(candidate.id));
assert.ok(seat);
game = applyAction(game, seat.id, { type: 'passResponse' });
}
assert.notEqual(game.response?.kind, kind);
return game;
}
/** Purchase the first ordinary lot using genuine bids and passes. */
function buyOrdinaryLot(game: Game) {
const auction = game.auction;
assert.ok(auction);
const card = auction.cards[auction.index].id;
const buyer = auction.active;
game = applyAction(game, buyer, { type: 'bid', amount: 1 });
for (let count = 0; game.auction?.cards[game.auction.index]?.id === card && count < game.players.length; count++) {
game = applyAction(game, game.auction.active, { type: 'passBid' });
}
assert.ok(player(game, buyer).hand.some((held) => held.id === card));
assert.notEqual(game.auction?.cards[game.auction.index]?.id, card);
return game;
}
for (const position of ['first', 'last'] as const) {
void test(`Advanced BG Worthless cancels the ${position} Richese cache declaration and restores the ordinary lot`, () => {
const source = declaration(position);
let game = source.game;
assert.ok(viewGame(game, 'b').responseControls?.cancelCards.includes(source.worthless));
game = applyAction(game, 'b', { type: 'card', card: source.worthless, mode: 'cancel' });
assert.equal(game.response?.kind, 'worthlessKarama');
assert.equal(game.pendingKarama?.use.kind, 'cancel');
assert.equal(game.richeseBidding?.cacheCanceled, false);
assert.equal(game.discard.filter((card) => card.id === source.worthless).length, 1);
game = passWindow(game, 'worthlessKarama');
assert.equal(game.pendingKarama, null);
assert.equal(game.richeseBidding?.cacheCanceled, true);
assert.equal(game.richeseBidding?.normalCount, 3);
assert.equal(game.richeseBidding?.stage, 'normal');
assert.equal(game.auction?.cards.length, 3);
assert.equal(game.richeseAuction, null);
assert.deepEqual(game.richeseCache?.map((card) => card.id), source.cache);
assert.deepEqual(inventory(game), source.cards);
game = buyOrdinaryLot(game);
assert.equal(game.auction?.index, 1);
assert.deepEqual(game.richeseCache?.map((card) => card.id), source.cache);
assert.deepEqual(inventory(game), source.cards);
});
void test(`A printed Karama counters BG's ${position} Richese conversion and restores the original cache declaration`, () => {
const source = declaration(position);
let game = source.game;
const response = structuredClone(game.response);
game = applyAction(game, 'b', { type: 'card', card: source.worthless, mode: 'cancel' });
assert.equal(game.response?.kind, 'worthlessKarama');
assert.ok(viewGame(game, 'e').responseControls?.cancelCards.includes(source.printed));
game = applyAction(game, 'e', { type: 'card', card: source.printed, mode: 'cancel' });
assert.equal(game.pendingKarama, null);
assert.deepEqual(game.response, response);
assert.equal(game.richeseBidding?.cacheCanceled, false);
assert.equal(game.richeseBidding?.normalCount, 2);
assert.equal(game.discard.filter((card) => card.id === source.worthless).length, 1);
assert.equal(game.discard.filter((card) => card.id === source.printed).length, 1);
assert.ok(player(game, 'e').hand.some((card) => card.id === source.spare));
assert.deepEqual(inventory(game), source.cards);
game = passWindow(game, 'richeseAuction');
if (position === 'first') {
assert.equal(game.decision?.kind, 'richeseCache');
game = applyAction(game, 'r', {
type: 'decision', event: game.richeseBidding!.event,
card: game.richeseCache![0].id, method: 'silent',
});
for (const id of ['r', 'b', 'e'])
game = applyAction(game, id, {
type: 'richeseBid', event: game.richeseAuction!.event, amount: 0, allyPayment: 0,
});
assert.equal(game.decision?.kind, 'richeseUnbid');
game = applyAction(game, 'r', { type: 'decision', event: game.richeseBidding!.event, keep: false });
assert.equal(game.richeseRemoved?.length, 1);
}
assert.equal(game.richeseBidding?.stage, 'normal');
assert.equal(game.richeseBidding?.normalCount, 2);
assert.equal(game.auction?.cards.length, 2);
game = buyOrdinaryLot(game);
assert.equal(game.auction?.index, 1);
assert.deepEqual(inventory(game), source.cards);
});
}