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); }); }