import assert from 'node:assert/strict'; import test from 'node:test'; import { registerHooks } from 'node:module'; import { readFileSync } from 'node:fs'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta'; const aliases = registerHooks({ resolve(specifier, context, next) { return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); }, load(url, context, next) { if (!url.endsWith('.module.css')) return next(url, context); const css = readFileSync(new URL(url), 'utf8'); const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true}; }, }); const {GameTable} = await import('../components/game-table'); aliases.deregister(); /** Public Basic start and six original physical starting cards: only the * unopened deal order and a safe unopened Spice position are selected. The * Truthtrance answer, Richese cache auction, Kull and Semuta are real actions. */ function fixture(answer: 'yes' | 'no') { let game = createGame('KULLRICHBID', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']); for (const id of ['richese', 'emperor', 'harkonnen', 'guild'] as const) joinGame(game, newPlayer(id, id, id)); game = act(game, 'choam', {type:'moduleOption', module:'kullPreview', enabled:true}); for (const p of game.players) game = act(game, p.id, {type:'ready'}); game = act(game, 'choam', {type:'start'}); assert.equal(game.status, 'setup'); assert.ok(game.kullPreview && game.semutaPreview); const originalCards = inventory(game), selected:Game['deck'] = []; // Harkonnen receives two starting cards before the Guild's single draw. for (const kind of ['kull', 'filler', 'karama', 'karama', 'filler', 'truth'] as const) { const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : kind === 'truth' ? card.effect === 'truthtrance' : kind === 'karama' ? card.effect === 'karama' : card.kind === 'worthless' && card.id !== 'ix-kull-wahad'); assert.ok(index >= 0); selected.push(game.deck.splice(index, 1)[0]); } game.deck.unshift(...selected); game = advance(game, state => state.status === 'playing'); const [kull,,karama,counter,,truth] = selected.map(card => card.id); for (const [id, card] of [['choam',kull], ['emperor',karama], ['harkonnen',counter], ['guild',truth]] as const) assert.ok(player(game, id).hand.some(row => row.id === card), `${id} received original ${card}`); const spice = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(spice >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(spice, 1)[0]); game = advance(game, state => state.phase === 2 && clean(state) && state.choamCharity?.turn === state.turn); game = act(game, 'guild', {type:'card', card:truth}); while (game.truthtrance?.stage === 'priority') { const actor = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(actor); game = act(game, actor.id, {type:'truthPass'}); } game = act(game, 'guild', {type:'truthAsk', question:{kind:'phaseBid', target:'emperor', turn:game.turn, minimum:4}}); assert.ok(viewGame(game, 'emperor').truthPhaseBidAnswers?.includes(answer)); const html = renderToStaticMarkup(createElement(GameTable, { game:viewGame(game, 'emperor'), busy:false, send:async () => {}, onExit() {}, })); assert.match(html, /Answer Yes/); assert.match(html, /Answer No/); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'emperor'); view.players.find(seat => seat.id === 'emperor')!.bot = difficulty; const action = botActions(view)[0]; assert.equal(action?.type, 'truthAnswer', difficulty); assert.ok(view.truthPhaseBidAnswers!.includes(action.answer as 'yes' | 'no' | 'unknown'), difficulty); } game = act(game, 'emperor', {type:'truthAnswer', answer}); const event = game.phaseBidPromises!.find(row => row.player === 'emperor')!.event; game = advance(game, state => player(state, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID) && !!state.auction && state.auction.active === 'emperor' && clean(state)); assert.ok(game.phaseBidPromises?.some(row => row.event === event && !row.fulfilled && !row.released), 'The accepted answer survives Richese buying Semuta in its actual cache auction.'); assert.deepEqual(inventory(game), originalCards); return {game, originalCards, event, kull, karama, counter}; } function declare(game:Game, kull:string, counter:string) { const originalLot = game.auction!.cards[game.auction!.index].id; const originalIndex = game.auction!.index; game = act(game, 'harkonnen', {type:'card', mode:'purchase', card:counter}); assert.equal(game.pendingKull?.stage, 'offer'); const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction); assert.ok(reaction.plays.some(row => row.card.id === kull && !row.blocked)); game = act(game, 'choam', {type:'kullDecision', event:reaction.event, source:'printed', card:kull}); assert.equal(game.pendingKull?.stage, 'counter'); return {game, originalLot, originalIndex}; } function semuta(game:Game, card:string, claim:boolean):Game { const pending = game.pendingTreacheryDiscard; assert.ok(pending, 'The original Kull or counter discard offers Semuta.'); assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.entries[0].card.id, card); const event = pending.batch.event; for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'richese'); view.players.find(seat => seat.id === 'richese')!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action, difficulty); assert.ok(['semutaPass', 'semutaCommit'].includes(action.type), difficulty); } if (claim) { assert.equal(viewGame(game, 'richese').semutaReaction?.canCommit, true); game = act(game, 'richese', {type:'semutaCommit', event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game, 'richese', {type:'semutaSelect', event, card}); } else { for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) { const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer'); const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor); game = act(game, actor.id, {type:'semutaPass', event}); } } assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game; } for (const claim of [false, true]) void test(`pre-Bidding phase Yes survives Richese cache and Kull success with Semuta ${claim ? 'claim' : 'pass'}`, () => { const f = fixture('yes'); const declared = declare(f.game, f.kull, f.counter); let game = advance(declared.game, state => !!state.pendingTreacheryDiscard); assert.ok(game.phaseBidPromises?.some(row => row.event === f.event && !row.fulfilled && !row.released)); game = semuta(game, f.kull, claim); game = advance(game, clean); assert.equal(game.auction!.cards[game.auction!.index].id, declared.originalLot); assert.ok(game.phaseBidPromises?.some(row => row.event === f.event && !row.fulfilled && !row.released)); const guide = viewGame(game, 'emperor').phaseBidPromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4); game = act(game, 'emperor', guide.action); assert.ok(game.phaseBidPromises?.some(row => row.event === f.event && row.fulfilled)); assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.counter)); assert.equal(player(game, 'richese').hand.some(card => card.id === f.kull), claim); assert.deepEqual(inventory(game), f.originalCards); }); for (const claim of [false, true]) void test(`countered Kull and Semuta ${claim ? 'claim' : 'pass'} keep a real later lot for phase Yes`, () => { const f = fixture('yes'); const declared = declare(f.game, f.kull, f.counter); assert.ok(declared.game.auction!.cards[declared.originalIndex + 1]); assert.ok(viewGame(declared.game, 'emperor').responseControls?.cancelCards?.includes(f.karama)); let game = act(declared.game, 'emperor', {type:'card', mode:'cancel', card:f.karama}); game = semuta(game, f.karama, claim); game = advance(game, state => clean(state) && !!state.auction && state.auction.index > declared.originalIndex && state.auction.active === 'emperor'); assert.ok(game.phaseBidPromises?.some(row => row.event === f.event && !row.fulfilled && !row.released)); assert.ok(player(game, 'harkonnen').hand.some(card => card.id === declared.originalLot)); const guide = viewGame(game, 'emperor').phaseBidPromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4); game = act(game, 'emperor', guide.action); assert.ok(game.phaseBidPromises?.some(row => row.event === f.event && row.fulfilled)); assert.equal(player(game, 'richese').hand.some(card => card.id === f.karama), claim); assert.deepEqual(inventory(game), f.originalCards); }); void test('phase No remains binding through the real cache and Kull Semuta interruption', () => { const f = fixture('no'); const declared = declare(f.game, f.kull, f.counter); let game = advance(declared.game, state => !!state.pendingTreacheryDiscard); game = semuta(game, f.kull, false); game = advance(game, clean); const before = structuredClone(game); assert.throws(() => act(game, 'emperor', {type:'bid', amount:4, allyPayment:0} as Action)); assert.deepEqual(game, before); game = act(game, 'emperor', {type:'bid', amount:3, allyPayment:0}); assert.equal(game.auction?.bid, 3); assert.deepEqual(inventory(game), f.originalCards); });