From 6bf165a6aad27348ff7ca503d2ecf4a843e64f10 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:40:07 +1100 Subject: [PATCH] Support Semuta after Box searches during bidding --- docs/CURRENT_STATUS.md | 9 ++ docs/RULE_DECISIONS.md | 9 ++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 24 +++++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 22 ++++ game/engine.ts | 27 ++++- game/reference.ts | 1 + tests/semuta-box-ordinary-auction.test.ts | 119 ++++++++++++++++++++++ tests/semuta-box-richese-auction.test.ts | 103 +++++++++++++++++++ 8 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 tests/semuta-box-ordinary-auction.test.ts create mode 100644 tests/semuta-box-richese-auction.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index b8e94a30..2def57ed 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,15 @@ open mode gates. ## Current checkpoint and work +**Semuta after Nullentropy Box during Basic auctions — 28 October. Prototyped / +Partial.** A completed paid Box search in a clean ordinary, Once Around or +Silent cache lot may offer the discarded Box to Semuta. Claim or pass keeps +the selected card, payment and original lot and funding intact. Six related +offline files pass 45 cases, including real auction actions with staged physical +Box and discard cards. The online ordering is provisional; competing sale +parents, optional modules and deployed acceptance remain open. +[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#completed-box-search-during-basic-bidding--28-october-2026). + **In progress: Gitea migration — 28 October.** The local `origin` now tracks `https://git.juiced.computer/admin/Dune`; its imported `main` is an ancestor of the local checkout. Git push authentication and an Actions runner are not yet diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0332cbac..20fb71d0 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Semuta after a Box search during Basic auctions — 28 October 2026 + +Semuta has no printed general bidding-phase exclusion. A completed Box search +may provisionally offer its discarded Box while an ordinary auction or a +Richese Once Around or Silent cache lot is suspended; payment, private +selection and the original lot resume without replay. The precise online +ordering is a software interpretation rather than a publisher ruling. +[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#completed-box-search-during-basic-bidding--28-october-2026). + ## Semuta before Moritani allied retention — 28 October 2026 The printed powers do not specify their combined discard ordering. In the diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index ca8fa73e..c7f46416 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -2,6 +2,30 @@ Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_AUDIT.md`, then inspected every current `discard(...)` call, direct Treachery-discard mutation and `draw(g)` caller in `game/engine.ts`, plus the Truthtrance discard callback and pure Box/Distrans results. Names below are integration anchors; approximate current lines are navigation aids only. This report adopts no additional source ruling and changes no project file. +## Completed Box search during Basic bidding — 28 October 2026 + +**Prototyped / Partial.** In the supported bare Basic Semuta profile, a +completed paid Nullentropy Box search may offer the discarded physical Box +to Semuta while a clean ordinary auction or a Richese Once Around or Silent +cache lot is active. The owner has already paid two spice, selected the +recovered card and shuffled the remainder. Claim or pass leaves that exchange +and payment committed and resumes the original auction without a second +search or payment. + +The new continuation binds the existing auction, bidding order, public or +sealed bids, funding, pledges and player spice. Its auction clock is excluded +because the existing decision pauses it. The Box is the fresh discard; the +privately recovered card and other cards in the shuffled pile are not fresh +Semuta targets. A saved older Box-discard continuation without the optional +auction binding retains its prior behavior. This scope excludes Black Market, +completed sale and other optional-module compositions. + +The scoped tests use real auction actions with conserved staging of physical +Box and discard cards. They do not constitute full-game or deployed +acceptance. The rule source does not specify an online suspension or priority +protocol for this combination; the preserved continuation is a software +interpretation. [Source scope](SEMUTA_SOURCE_UPDATE_20260907.md#completed-box-search-during-basic-auctions--28-october-2026). + ## Native Moritani retention and initial battle discards — 28 October 2026 In the supported bare Basic Semuta profile, a defeated ally of native Moritani diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 92752059..58d65555 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,28 @@ 7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits. +## Completed Box search during Basic auctions — 28 October 2026 + +**Prototyped / Partial.** The native bare Basic Semuta profile may react to +the physical Nullentropy Box after its completed search during a clean +ordinary lot or a Richese Once Around or Silent cache lot. The actual owner +has paid two spice and privately selected a discard before Semuta can claim +the Box or pass. The selected card stays with the searcher and no search, +shuffle or payment repeats. The original lot, committed bids, pledges and +funding resume from their existing state. + +New auction-bound receipts validate the original live lot and funding. Older +Box receipts without this optional binding retain their previous continuation; +they do not create new auction reactions. The auction clock follows the +existing pause/resume behavior. Controlled tests use original physical cards +and native bidding actions with explicitly staged Box/discard custody; they do +not certify broader optional-module combinations or deployed play. + +Semuta has no printed general bidding-phase exclusion; preserving an auction +through its neutral discard reaction is a software serialization, not a +publisher ruling on the combined timing. +[Continuation scope](SEMUTA_DISCARD_CONTINUATIONS.md#completed-box-search-during-basic-bidding--28-october-2026). + ## Printed-use Truthtrance promises — 27 October 2026 The combined current-turn question can name the actual completed Semuta exchange diff --git a/game/engine.ts b/game/engine.ts index cc47dcce..d7a99033 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1647,6 +1647,8 @@ export type Game = { finalDiscardSignature: string; resume: NonNullable['resume']; parentSignature: string; + /** New Basic bidding reactions bind the existing lot and funding. */ + auctionBinding?: { kind: 'ordinary' | 'richese'; signature: string }; } | { kind: 'ordinaryCardDiscard'; @@ -5115,6 +5117,7 @@ function finishNullentropy( `${owner.name} recovered one private card with Nullentropy Box, shuffled the remaining discard pile, and placed the used Box on top. The bank was paid when the search began.`, { faction: owner.faction, name: 'Nullentropy Box' }, ); + const auctionBinding = nullentropyAuctionBinding(g); stageTreacheryDiscard( g, 'nullentropyBox', @@ -5129,6 +5132,7 @@ function finishNullentropy( finalDiscardSignature: JSON.stringify(g.discard), resume: pending.resume, parentSignature: nullentropyParentSignature(g, pending.resume, event), + ...(auctionBinding ? {auctionBinding} : {}), }, ); } @@ -6157,6 +6161,18 @@ function nullentropyParentSignature( ); return JSON.stringify({ searchEvent, resume, parents }); } +/** Clock state is deliberately excluded: it pauses and resumes while decisions + * are pending. The lot and committed resources themselves remain unchanged. */ +function nullentropyAuctionBinding(g: Game): {kind:'ordinary'|'richese';signature:string} | undefined { + if (!g.semutaPreview || !bareBasicSemutaProfile(g) || g.phase !== 3 || g.currentAuctionSale || g.ixAuction) + return undefined; + const kind = g.auction && !g.richeseAuction ? 'ordinary' : supportedRicheseTruthLot(g) ? 'richese' : undefined; + if (!kind) return undefined; + return {kind, signature: JSON.stringify({turn:g.turn,phase:g.phase,active:g.active,order:g.order, + auction:g.auction,richeseAuction:g.richeseAuction,richeseBidding:g.richeseBidding, + richeseFunding:g.richeseFunding,aid:g.aid, + players:g.players.map(p => ({id:p.id,spice:p.spice,ally:p.ally}))})}; +} type OrdinaryDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'ordinaryCardDiscard' } @@ -7929,6 +7945,10 @@ function treacheryDiscardIntegrity(g: Game) { nullentropyParentSignature(g, c.resume, c.searchEvent), 'The completed Box search does not match its suspended controls and obligations.', ); + if (c.auctionBinding) requireRule( + JSON.stringify(c.auctionBinding) === JSON.stringify(nullentropyAuctionBinding(g)), + 'The completed Box search no longer matches its original auction and funding.', + ); suspendedControlsIntegrity(g, c.resume); } else if (continuation?.kind === 'ordinaryCardDiscard') { const c = continuation, @@ -9222,10 +9242,13 @@ function semutaOfferSupported( // native Distrans still cannot produce a transfer after that bid starts. const ordinaryAuctionDiscard = g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale && (continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard' || - continuation.kind === 'saphoTruthOrderDiscard'); + continuation.kind === 'saphoTruthOrderDiscard' || + continuation.kind === 'nullentropyDiscard' && continuation.auctionBinding?.kind === 'ordinary'); const richeseTruthDiscard = supportedRicheseTruthLot(g) && (continuation.kind === 'truthtranceDiscard' || continuation.kind === 'saphoTruthOrderDiscard') && !!continuation.richeseLotEvent && continuation.richeseLotEvent === g.richeseAuction?.event; + const richeseBoxDiscard = supportedRicheseTruthLot(g) && continuation.kind === 'nullentropyDiscard' && + continuation.auctionBinding?.kind === 'richese'; if (continuation.kind === 'truthtranceDiscard' && ['bribe', 'bribeExpression', 'mixedBribe'].includes(continuation.record.question.kind) && (!continuation.bribeBinding || !bareBasicSemutaProfile(g) || !!g.kullPreview)) return false; @@ -9562,7 +9585,7 @@ function semutaOfferSupported( (advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || continuation.kind === 'ixAllyCard' || (continuation.kind === 'karamaIncomeDiscard' && - continuation.use.response.kind === 'emperorIncome') || richeseTruthDiscard || !g.richeseAuction) && + continuation.use.response.kind === 'emperorIncome') || richeseTruthDiscard || richeseBoxDiscard || !g.richeseAuction) && (richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || occupyDiscard || continuation.kind === 'saphoAggressorDiscard' || bgDiscard || continuation.kind === 'specialKaramaDiscard' || diff --git a/game/reference.ts b/game/reference.ts index 6118e98e..f68c3e7e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1056,6 +1056,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A pending purchased-shipment cost does not block the search. Under the provisional refund composition, recovering that exact cost preserves its purchased price, creator and beneficiary but ends its later Guild special-stop refund, even if discarded again. Selecting another card leaves the cost’s existing claim status unchanged. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.', 'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.', 'Whole Truthtrance questions may name a future printed Box activation. Accepted payment records that play once before private selection; it does not promise the recovered identity. Actual costs share funding with other commitments, while an unpaid unknown recovery cannot supply another effect. Native paid selection and Semuta continuation keep their priority.', + 'In supported Basic Semuta games, a completed Box search during an ordinary auction or a Richese Once Around or Silent cache auction can offer the discarded Box to Semuta. The paid search and private selection remain complete; after the reaction, bidding resumes with its original lot, bids and pledged funding. This online ordering is provisional.', 'The paid search and continuations are integrated in development fixtures, with a connected full-hand prototype in both modes. Purchased-shipment cost recovery now uses the provisional nonduplication policy without blocking the search. The explicit Semuta preview can recover the used Box after a clean completed search without replaying payment or shuffle; other discard compositions remain separate. Full Richese starts and complete browser/mobile/full-game acceptance remain gated.', ], related: [ diff --git a/tests/semuta-box-ordinary-auction.test.ts b/tests/semuta-box-ordinary-auction.test.ts new file mode 100644 index 00000000..03b12b5b --- /dev/null +++ b/tests/semuta-box-ordinary-auction.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { + createSemutaAuctionInspectionFixture, + semutaAuctionInspectionInventory as inventory, + semutaAuctionInspectionPlayer as player, +} from './fixture-semuta-auction-inspection'; + +const BOX = 'richese-nullentropy-box'; +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); + +function rejectWithoutMutation(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +/** Real public Basic setup, paid cache Semuta acquisition, and a later real + * ordinary auction. This supplemental boundary deliberately stages a mutual + * alliance and relocates the physical Box plus two physical discards; no money, + * auction state, counters, phase or saved continuation is manufactured. */ +function fixture() { + const source = createSemutaAuctionInspectionFixture(); + let game = structuredClone(source.game); + while (game.response?.kind === 'atreidesAuction') { + const actor = game.players.find(row => !game.response!.passed.includes(row.id)); + assert.ok(actor); + game = applyAction(game, actor.id, { type: 'passResponse' }); + } + assert.equal(game.response, null); + assert.ok(game.auction && !game.richeseAuction && !game.currentAuctionSale); + const bidder = game.auction.active; + const owner = game.players.find(row => row.id !== bidder && row.id !== source.holder && + row.spice >= 2 && row.hand.length < handLimit(row)); + assert.ok(owner, 'A non-bidding, non-Semuta holder must afford the staged Box.'); + const donor = game.players.find(row => row.id !== bidder && row.id !== owner.id && row.spice >= 1); + assert.ok(donor, 'A distinct player must fund the standing auction bid.'); + assert.ok(!player(game, bidder).ally && !donor.ally, 'The supplemental alliance must not replace a real pact.'); + player(game, bidder).ally = donor.id; + donor.ally = bidder; + game = applyAction(game, donor.id, { type: 'pledgeAid', amount: 1 }); + game = applyAction(game, bidder, { type: 'bid', amount: 1, allyPayment: 1 }); + assert.equal(game.auction?.bidder, bidder); + assert.equal(game.auction?.bid, 1); + assert.equal(game.auction?.allyPayment, 1); + assert.equal(game.aid[donor.id]?.amount, 1); + + const zones = [game.deck, game.discard, game.richeseCache!, game.richeseRemoved!, + ...game.players.map(row => row.hand)].filter(Boolean); + const boxZone = zones.find(zone => zone.some(card => card.id === BOX)); + assert.ok(boxZone, 'The original physical Box must be in a movable zone.'); + const [box] = boxZone.splice(boxZone.findIndex(card => card.id === BOX), 1); + player(game, owner.id).hand.push(box); + const target = game.deck.shift(), other = game.deck.shift(); + assert.ok(target && other, 'The original ordinary deck must supply two discard targets.'); + game.discard.push(target, other); + assert.deepEqual(inventory(game), source.originalInventory); + + return { game, source, owner: owner.id, donor: donor.id, bidder, target: target.id, + lot: structuredClone(game.auction), aid: structuredClone(game.aid), + ownerSpice: player(game, owner.id).spice, originalCards: inventory(game) }; +} + +for (const claim of [false, true]) { + void test(`Semuta ${claim ? 'claims' : 'passes'} the paid Box during a standing Basic ordinary bid`, () => { + const f = fixture(); + const paid = applyAction(f.game, f.owner, { type: 'card', card: BOX }); + assert.equal(player(paid, f.owner).spice, f.ownerSpice - 2); + assert.ok(paid.pendingNullentropy); + assert.deepEqual(paid.auction, f.lot); + assert.deepEqual(paid.aid, f.aid); + const searchEvent = paid.pendingNullentropy.event; + const pending = applyAction(paid, f.owner, { type: 'decision', event: searchEvent, card: f.target }); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'nullentropyDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(entry => + [entry.discardedBy, entry.card.id, entry.publicFace]), [[f.owner, BOX, true]]); + assert.deepEqual(pending.auction, f.lot); + assert.deepEqual(pending.aid, f.aid); + assert.equal(player(pending, f.owner).spice, f.ownerSpice - 2); + assert.equal(player(pending, f.owner).hand.filter(card => card.id === f.target).length, 1); + assert.equal(viewGame(pending, f.source.holder).players.find(row => row.id === f.owner)?.hand, undefined); + assert.deepEqual(viewGame(pending, f.source.holder).semutaReaction?.candidates, []); + const shuffled = pending.discard.filter(card => card.id !== BOX).map(card => card.id); + rejectWithoutMutation(pending, f.source.holder, { type: 'semutaCommit', event: 'stale-event' }); + rejectWithoutMutation(pending, pending.auction!.active, { type: 'passBid' }); + + const alteredLot = reload(pending); + alteredLot.auction!.bid += 1; + rejectWithoutMutation(alteredLot, f.source.holder, { type: 'semutaCommit', event }); + const alteredAid = reload(pending); + alteredAid.aid[f.donor]!.amount += 1; + rejectWithoutMutation(alteredAid, f.source.holder, { type: 'semutaCommit', event }); + + let resumed = reload(pending); + if (claim) resumed = applyAction(resumed, f.source.holder, { type: 'semutaCommit', event }); + else for (const row of resumed.players) + resumed = applyAction(resumed, row.id, { type: 'semutaPass', event }); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.deepEqual(resumed.auction, f.lot); + assert.deepEqual(resumed.aid, f.aid); + assert.equal(player(resumed, f.owner).spice, f.ownerSpice - 2); + assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.target).length, 1); + assert.equal(player(resumed, f.source.holder).hand.some(card => card.id === BOX), claim); + assert.equal(resumed.discard.some(card => card.id === BOX), !claim); + assert.equal(resumed.discard.some(card => card.id === f.source.semuta), claim); + assert.deepEqual(resumed.discard.filter(card => card.id !== BOX && card.id !== f.source.semuta) + .map(card => card.id), shuffled); + assert.deepEqual(inventory(resumed), f.originalCards); + rejectWithoutMutation(resumed, f.source.holder, { type: 'semutaCommit', event }); + rejectWithoutMutation(resumed, f.owner, { type: 'decision', event: searchEvent, card: f.target }); + const active = resumed.auction!.active; + const continued = applyAction(resumed, active, { type: 'passBid' }); + assert.notDeepEqual(continued.auction, f.lot, 'The same original auction continues after the reaction.'); + assert.equal(player(continued, f.owner).spice, f.ownerSpice - 2); + }); +} diff --git a/tests/semuta-box-richese-auction.test.ts b/tests/semuta-box-richese-auction.test.ts new file mode 100644 index 00000000..1deabdc0 --- /dev/null +++ b/tests/semuta-box-richese-auction.test.ts @@ -0,0 +1,103 @@ +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); + }); +}