171 lines
7.9 KiB
TypeScript
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);
|
|
});
|
|
}
|