import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine'; import { advanceSemutaRicheseCacheKarama, createSemutaRicheseCacheKaramaFixture, semutaRicheseCacheKaramaInventory as inventory, semutaRicheseCacheKaramaPlayer as player, } from './fixture-semuta-richese-cache-karama'; import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.pendingNullentropy && !game.truthtrance; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); /** A native Basic roster really buys Semuta in turn one and progresses through * the next Richese declaration. Only the Box and original search candidates * are staged by conserved card moves; lot, bids, wallets and clocks are real. */ function fixture(method: 'onceAround' | 'silent') { const native = createSemutaRicheseCacheKaramaFixture(); let game = structuredClone(native.beforeDeclaration); const box = 'richese-nullentropy-box', owner = native.richese, holder = native.holder; assert.equal(player(game, holder).hand.some(card => card.id === native.semuta), true); moveSemutaEmperorCard(game, box, owner); assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner))); for (let i = 0; i < 2; i++) { const index = game.deck.findIndex(card => card.effect !== 'nullentropyBox'); assert.ok(index >= 0); game.discard.push(game.deck.splice(index, 1)[0]); } const physical = inventory(game); game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, position: 'first' }); game = advanceSemutaRicheseCacheKarama(game, state => state.decision?.kind === 'richeseCache' && !state.response && !state.phaseOpening); const lotCard = game.richeseCache![0]; assert.ok(lotCard && lotCard.id !== box); game = applyAction(game, owner, { type: 'decision', event: game.richeseBidding!.event, card: lotCard.id, method, ...(method === 'onceAround' ? { direction: 'clockwise' } : {}) }); game = advanceSemutaRicheseCacheKarama(game, state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome); const lot = game.richeseAuction!; assert.equal(lot.source, 'cache'); assert.equal(lot.method, method); const bidder = method === 'onceAround' ? lot.active! : lot.eligible[0]; assert.ok(bidder); game = applyAction(game, bidder, { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 }); assert.ok(clean(game) && game.richeseAuction && !game.richeseAuction.outcome); assert.ok(player(game, owner).spice >= 2); return { game, owner, holder, box, physical, lotCard: lotCard.id, bidder }; } function rejectWithoutMutation(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); } for (const method of ['onceAround', 'silent'] as const) for (const claim of [false, true]) { void test(`Semuta ${claim ? 'claims' : 'passes'} a completed Box during a Basic Richese ${method} lot`, () => { const f = fixture(method), before = structuredClone(f.game); const balances = Object.fromEntries(before.players.map(p => [p.id, p.spice])); const openingDiscard = before.discard.map(card => card.id); assert.ok(openingDiscard.length >= 2); const paid = applyAction(before, f.owner, { type: 'card', card: f.box }); assert.equal(player(paid, f.owner).spice, balances[f.owner] - 2); assert.ok(paid.pendingNullentropy); assert.deepEqual(paid.richeseAuction, before.richeseAuction); assert.deepEqual(paid.richeseFunding, before.richeseFunding); const target = paid.discard[0].id, other = paid.discard[1].id; rejectWithoutMutation(paid, f.holder, { type: 'decision', event: paid.pendingNullentropy.event, card: target }); const pending = applyAction(paid, f.owner, { type: 'decision', event: paid.pendingNullentropy.event, card: target }); assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'nullentropyDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.deepEqual(pending.richeseAuction, before.richeseAuction); assert.deepEqual(pending.richeseFunding, before.richeseFunding); assert.equal(player(pending, f.owner).hand.filter(card => card.id === target).length, 1); assert.equal(player(pending, f.owner).hand.some(card => card.id === other), false); assert.equal(pending.discard.filter(card => card.id === f.box).length, 1); assert.deepEqual(viewGame(pending, f.holder).semutaReaction?.candidates, []); assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); assert.equal(viewGame(pending, f.owner).semutaReaction?.canCommit, false); assert.deepEqual(inventory(pending), f.physical); const event = pending.pendingTreacheryDiscard!.batch.event; rejectWithoutMutation(pending, f.holder, { type: 'semutaCommit', event: 'stale' }); let done = reload(pending); if (claim) done = applyAction(done, f.holder, { type: 'semutaCommit', event }); else for (const seat of done.players) done = applyAction(done, seat.id, { type: 'semutaPass', event }); assert.equal(done.pendingTreacheryDiscard, null); assert.deepEqual(done.richeseAuction, before.richeseAuction); assert.deepEqual(done.richeseFunding, before.richeseFunding); assert.equal(player(done, f.owner).spice, balances[f.owner] - 2); assert.equal(player(done, f.owner).hand.filter(card => card.id === target).length, 1); assert.equal(player(done, f.holder).hand.some(card => card.id === f.box), claim); assert.equal(done.discard.some(card => card.id === f.box), !claim); assert.deepEqual(inventory(done), f.physical); const originalLot = done.richeseAuction!, originalEvent = originalLot.event; for (let count = 0; done.richeseAuction?.event === originalEvent && !done.richeseAuction.outcome && count < 12; count++) { const lot = done.richeseAuction; const actor = method === 'onceAround' ? lot.active : lot.eligible.find(id => lot.sealed[id] === undefined); assert.ok(actor, 'The original finite lot has an unacted bidder'); done = applyAction(done, actor, { type: 'richeseBid', event: originalEvent, amount: method === 'onceAround' ? null : 0, allyPayment: 0 }); } assert.ok(!done.richeseAuction || done.richeseAuction.event !== originalEvent || done.richeseAuction.outcome, 'Original bidding reaches its native settlement without replaying the Box'); assert.equal(player(done, f.owner).hand.some(card => card.id === target), true); assert.deepEqual(inventory(done), f.physical); }); }