diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 98febed9..7e088989 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,12 +17,21 @@ open mode gates. ## Current checkpoint and work +**Distrans/Semuta special-auction continuation — 29 October. Prototyped / +Partial.** The Semuta claim/pass following a Distrans transfer can now suspend +and resume a supported Once Around, Silent or normal Black Market lot, retaining +its bids and funding. Basic cache and Advanced Black Market tests stage the +physical Semuta card and exercise claim/pass; a recipient can use the gifted +Semuta. Unsupported pending interactions remain guarded. Fifteen related files +pass 96 cases; types/lint and production build pass. Official timing clarification, +other interactions and deployed verification remain open. +[Contract](RICHESE_DISTRANS_ENGINE_AUDIT.md#semuta-continuation-for-a-started-special-lot--29-october-2026). + **Distrans between auction actions — 29 October. Prototyped / Partial.** The provisional interpretation permits transfers between completed ordinary bids and passes while preserving the lot and reserved hand capacity. Timing validation also recognizes completed Once Around, Silent and normal Black -Market actions, but started special lots remain guarded when the Semuta preview -is enabled, pending a safe reaction continuation. Pending bid interactions +Market actions; the subsequent Semuta continuation is described above. Pending bid interactions remain guarded. Thirteen focused files pass 85 cases; types/lint and production build pass. Official timing clarification and deployed verification remain open. [Timing and limits](RICHESE_DISTRANS_ENGINE_AUDIT.md#between-completed-bid-actions--29-october-2026). diff --git a/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md b/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md index 6e087b21..71aa0f7b 100644 --- a/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md +++ b/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md @@ -2,6 +2,25 @@ Audit date: 2026-09-06. Read `docs/RICHESE_ACQUISITION_RULES.md` first. This audit does not activate the card or certify the remaining Richese effects. +## Semuta continuation for a started special lot — 29 October 2026 + +**Prototyped / Partial.** When the supported +Semuta preview observes the completed Distrans discard between special-auction +bid actions, offer the existing neutral claim/pass sequence for that physical +Distrans. The card given privately by Distrans is not a discard and is not a +Semuta target. A claim, including the existing full-hand exchange policy, or +passing all offers must resume the same auction without replaying the transfer, +the discard or any bid. Preserve the physical lot, method, event, active seat, +order, accepted bids and passes, sealed submissions, funding and reserved hand +capacity. Protect the saved continuation against changes to the bound auction; +retain compatibility with earlier continuations without that binding. + +This composes the existing discard and auction rules; it is not a publisher +clarification of the quoted “during a bid” exception. Pending auction actions +and unsupported interaction profiles remain guarded. Focused native-auction tests exercise Basic Once Around and Silent and Advanced +normal Black Market claim/pass, including a recipient using the transferred +Semuta; the Semuta card is staged from the physical deck. Deployed verification and other interaction profiles remain open. + ## Between completed bid actions — 29 October 2026 **Prototyped / Partial.** Provisionally interpret the printed prohibition on @@ -11,9 +30,8 @@ accept a transfer between completed bid, pass or sealed-submission actions. Preserve the current lot, accepted bids, sealed choices, funding commitments, turn order and recipient capacity reserved by a pending bid. Existing other priority and transaction boundaries still apply; started lots remain blocked -during pending auction interactions. Started special lots are also blocked -while the Semuta preview is enabled until their discard continuation is -supported. A transfer does not reopen bidding or create an extra bid. This interpretation supersedes the first-action +during pending auction interactions. Supported Semuta claim/pass preserves the started special lot; unsupported +reaction profiles remain guarded. A transfer does not reopen bidding or create an extra bid. This interpretation supersedes the first-action cutoffs in the historical checkpoints below. It is not a publisher ruling; official clarification of the scope of “during a bid” remains pending. Self-transfer remains separately unsupported. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0356337f..56143f31 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1244,6 +1244,17 @@ added. Earlier stamps on captured or concealed discs do not prove present location. Homeworld, shared Duke, Kwisatz and exceptional custody remain separate; the later Basic interpretation is recorded above. [Source and scope](EFFECTIVE_BATTLE_WEAPONS.md#earlier-leaders-in-an-advanced-explosion--27-october-2026). +## Semuta continuation after Distrans — 29 October 2026 + +The provisional composition offers the existing Semuta claim/pass sequence for the +used Distrans after a completed transfer between supported special-auction +bids. The private gift is not an eligible discard. Claim or pass must continue +the original lot, including its sealed choices and committed funding, without +replaying the transfer or auction action. This is not a publisher ruling about +the scope of “during a bid.” Supported claim/pass preserves the original lot +and funding; unsupported interaction profiles remain guarded. +[Contract](RICHESE_DISTRANS_ENGINE_AUDIT.md#semuta-continuation-for-a-started-special-lot--29-october-2026). + ## Distrans between completed bidding actions — 29 October 2026 The printed card excludes play “during a bid” (publisher E2 p5), without @@ -1251,9 +1262,8 @@ clarifying the interval. Provisionally allow Distrans between completed bid, pass or sealed-submission actions on supported ordinary, Once Around, Silent and normal Black Market lots. Preserve the original lot, bidding commitments, funding, reserved recipient capacity and turn order; unresolved actions and -other priority boundaries remain separately guarded. Started special lots -remain blocked while the Semuta preview is enabled pending a supported discard -continuation. This supersedes the +other priority boundaries remain separately guarded. Supported special-lot Semuta reactions preserve the auction; unsupported +interactions remain guarded. This supersedes the first-action cutoffs in the 26 and 27 October entries below, and remains subject to official clarification. [Source and scope](RICHESE_DISTRANS_ENGINE_AUDIT.md#between-completed-bid-actions--29-october-2026). diff --git a/game/engine.ts b/game/engine.ts index 2f2547f9..891823aa 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1734,6 +1734,8 @@ export type Game = { } | { kind: 'distransDiscard'; + /** Present on new special-auction reactions; older saves omit it. */ + auctionBinding?: string; owner: string; recipient: string; transferred: string; @@ -5485,9 +5487,17 @@ function distransBlock(g: Game, owner: Player, card: Card): string | null { special.acted.length > 0 || Object.keys(special.sealed).length > 0)); if (started && (g.response || g.decision || g.pendingKarama || g.phaseOpening || g.pendingTreacheryDiscard)) return 'Finish the pending auction interaction before playing Distrans.'; - // A special-lot Semuta continuation still needs an auction-bound resume. - if (started && special && (g.semutaPreview || g.semutaAdvancedOrdinaryPreview || g.semutaRecruitsPreview)) - return 'Distrans between special-auction bids is unavailable with the Semuta preview.'; + if (started && special && (g.semutaPreview || g.semutaAdvancedOrdinaryPreview || g.semutaRecruitsPreview)) { + const auctionBinding = distransSpecialAuctionBinding(g); + const continuation: DistransDiscardContinuation = { + kind: 'distransDiscard', owner: owner.id, recipient: owner.id, transferred: '', card: card.id, + auctionBinding, resume: { response: g.response, decision: g.decision, + pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, stateSignature: '', + }; + if (!auctionBinding || !semutaOfferSupported(g, continuation, + [{ card, discardedBy: owner.id, publicFace: true }])) + return 'Finish the special auction before playing Distrans in this Semuta interaction.'; + } return distransAuctionBlock(g); } function validateDistrans( @@ -6443,6 +6453,14 @@ type DistransDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'distransDiscard' } >; +/** Preserve the physical special lot and its committed funds through Semuta. */ +function distransSpecialAuctionBinding(g: Game): string | undefined { + if (g.phase !== 3 || !g.richeseAuction || g.auction || g.ixAuction || g.currentAuctionSale || + distransAuctionBlock(g)) return undefined; + return JSON.stringify({ turn: g.turn, phase: g.phase, active: g.active, order: g.order, + auction: g.richeseAuction, bidding: g.richeseBidding, funding: g.richeseFunding, + aid: g.aid, players: g.players.map(p => ({ id: p.id, spice: p.spice, ally: p.ally })) }); +} function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) { const owner = getPlayer(g, c.owner), recipient = getPlayer(g, c.recipient); return JSON.stringify({ @@ -6451,6 +6469,7 @@ function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) { turn: g.turn, phase: g.phase, active: g.active, ownerHand: owner.hand.map(card => card.id), recipientHand: recipient.hand.map(card => card.id), + ...(c.auctionBinding === undefined ? {} : { auctionBinding: c.auctionBinding }), }); } type SaphoTruthOrderDiscardContinuation = Extract< @@ -8338,6 +8357,7 @@ function treacheryDiscardIntegrity(g: Game) { !g.discard.some(card => card.id === c.transferred) && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && + (c.auctionBinding === undefined || c.auctionBinding === distransSpecialAuctionBinding(g)) && c.stateSignature === distransDiscardSignature(g, c), 'The completed private Distrans transfer no longer matches its fresh discard.', ); @@ -9703,6 +9723,9 @@ function semutaOfferSupported( !!continuation.richeseLotEvent && continuation.richeseLotEvent === g.richeseAuction?.event; const richeseBoxDiscard = supportedRicheseTruthLot(g) && continuation.kind === 'nullentropyDiscard' && continuation.auctionBinding?.kind === 'richese'; + const richeseDistransDiscard = continuation.kind === 'distransDiscard' && + continuation.auctionBinding !== undefined && + continuation.auctionBinding === distransSpecialAuctionBinding(g); if (continuation.kind === 'truthtranceDiscard' && ['bribe', 'bribeExpression', 'mixedBribe'].includes(continuation.record.question.kind) && (!continuation.bribeBinding || !bareBasicSemutaProfile(g) || !bribePromiseModeSupported(g))) return false; @@ -10129,7 +10152,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 || richeseBoxDiscard || !g.richeseAuction) && + continuation.use.response.kind === 'emperorIncome') || richeseTruthDiscard || richeseBoxDiscard || richeseDistransDiscard || !g.richeseAuction) && (richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || occupyDiscard || continuation.kind === 'saphoAggressorDiscard' || (continuation.kind === 'saphoTruthOrderDiscard' && !!continuation.battleEvent && g.phase === 6 && g.battle?.event === continuation.battleEvent && !g.battle.revealed && !g.battle.preparation && @@ -44258,6 +44281,7 @@ function applyActionInner( g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g)) && !g.pendingTreacheryDiscard) { const continuation: DistransDiscardContinuation = { kind: 'distransDiscard', owner: p.id, recipient: recipient.id, + ...(distransSpecialAuctionBinding(g) === undefined ? {} : { auctionBinding: distransSpecialAuctionBinding(g) }), transferred: result.transferred.id, card: result.discarded.id, resume: { response: g.response, decision: g.decision, pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, diff --git a/game/reference.ts b/game/reference.ts index 498ee9c9..8f4f1a40 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -763,7 +763,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'Any faction holding Distrans may use it. Choose another player whose hand has room and a different Treachery Card from your own hand. The recipient need not be your ally. Transfer that card intact, then discard Distrans into the ordinary discard pile.', 'Open the Distrans transfer panel in your hand, select the recipient and card, inspect either card, then press Give and discard Distrans. There is no spice payment, purchase bonus, recipient acknowledgement or faction-power Karama response. Other players see the played Distrans, not the transferred card identity.', - 'The printed exception is during a bid. Provisionally, supported ordinary, Once Around, Silent and normal Black Market lots allow Distrans between completed bid, pass or sealed-submission actions. The lot, eligibility, commitments and bidding order stay the same. Pending auction interactions restrict play; started special lots also remain blocked while the Semuta preview is enabled. A completed purchase income response may allow use. The exact scope awaits official clarification; giving Distrans itself remains guarded.', + 'The printed exception is during a bid. Provisionally, supported ordinary, Once Around, Silent and normal Black Market lots allow Distrans between completed bid, pass or sealed-submission actions. The lot, eligibility, commitments and bidding order stay the same. Pending auction interactions restrict play; supported Semuta claim or pass preserves the special lot and its commitments. A completed purchase income response may allow use. The exact scope awaits official clarification; giving Distrans itself remains guarded.', 'Current hand space still controls bidding after a transfer. A full donor can free space; if the active bidder receives its final card, it must pass instead of raising. The existing auction skips full hands without adding a lot or resetting earlier passes.', 'Distrans resolves in one server action and can keep supported existing responses, decisions or phase openings in place; pending auction interactions restrict the started-lot window. Active Truthtrance retains priority. Both outgoing cards must be uncommitted; a sealed plan, fixed prescience answer, pending Richese gift or accepted replacement cannot lose its reserved card.', 'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.', diff --git a/tests/distrans-black-market-prebid.test.ts b/tests/distrans-black-market-prebid.test.ts index 015da25d..6d279226 100644 --- a/tests/distrans-black-market-prebid.test.ts +++ b/tests/distrans-black-market-prebid.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { viewGame, type Action, type Game } from '../game/engine'; +import { handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; import { actDistransPrebid, advanceDistransPrebid, createDistransPrebidFixture, distransPrebidClean, distransPrebidInventory, distransPrebidPlayer as player, @@ -67,13 +69,54 @@ void test('genuine Advanced Black Market normal lot remains unbid through earned }); for (const amount of [null, 1] as const) { - void test(`genuine Advanced Black Market first ${amount === null ? 'pass' : 'bid'} keeps Distrans guarded with the Semuta preview`, () => { + void test(`genuine Advanced Black Market first ${amount === null ? 'pass' : 'bid'} allows Distrans while preserving the lot`, () => { const f = source(), lot = f.game.richeseAuction!, actor = lot.active!; assert.ok(actor); const game = actDistransPrebid(f.game, actor, { type: 'richeseBid', event: lot.event, amount, allyPayment: 0 }); assert.ok(game.richeseAuction); - assert.match(viewGame(game, f.holder).distrans!.blocked!, /Semuta preview/); - rejected(game, f.holder, f.action); - assert.ok(player(game, f.seller).hand.some(c => c.id === f.physical.id)); + assert.equal(viewGame(game, f.holder).distrans!.blocked, null); + const after = actDistransPrebid(game, f.holder, f.action); + assert.deepEqual(after.richeseAuction, game.richeseAuction); + assert.deepEqual(after.richeseFunding, game.richeseFunding); + assert.ok(player(after, f.seller).hand.some(c => c.id === f.physical.id)); + assert.ok(player(after, f.recipient).hand.some(c => c.id === f.give)); + assert.deepEqual(distransPrebidInventory(after), f.originalInventory); + }); +} + +for (const claim of [false, true]) { + void test(`Advanced Black Market resumes a started lot after Semuta ${claim ? 'claims' : 'passes'} Distrans`, () => { + const f = source(), game = structuredClone(f.game); + // Controlled physical custody supplement: move the one existing Semuta to + // the seller. The lot, prices, bidders and other hands remain native. + assert.notEqual(f.physical.id, SEMUTA_DRUG_ID); + assert.ok(player(game, f.seller).hand.length < handLimit(player(game, f.seller))); + moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, f.seller); assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + const lot = game.richeseAuction!, actor = lot.active!; + const started = actDistransPrebid(game, actor, { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 }); + const originalLot = structuredClone(started.richeseAuction); + const originalFunding = structuredClone(started.richeseFunding); + const pending = actDistransPrebid(started, f.holder, f.action); + const frame = pending.pendingTreacheryDiscard; assert.ok(frame); + assert.equal(frame.continuation.kind, 'distransDiscard'); + assert.equal(frame.reaction?.stage, 'offer'); + assert.equal(viewGame(pending, f.seller).semutaReaction?.canCommit, true); + assert.deepEqual(pending.richeseAuction, originalLot); + assert.deepEqual(pending.richeseFunding, originalFunding); + let resumed = pending; + if (claim) resumed = actDistransPrebid(resumed, f.seller, { type: 'semutaCommit', event: frame.batch.event }); + else for (const seat of resumed.players) + resumed = actDistransPrebid(resumed, seat.id, { type: 'semutaPass', event: frame.batch.event }); + assert.equal(resumed.pendingTreacheryDiscard, null); + assert.deepEqual(resumed.richeseAuction, originalLot); + assert.deepEqual(resumed.richeseFunding, originalFunding); + assert.equal(player(resumed, f.seller).hand.some(c => c.id === f.card), claim); + assert.equal(resumed.discard.some(c => c.id === SEMUTA_DRUG_ID), claim); + assert.equal(resumed.discard.some(c => c.id === f.card), !claim); + assert.ok(player(resumed, f.seller).hand.some(c => c.id === f.physical.id)); + assert.ok(player(resumed, f.recipient).hand.some(c => c.id === f.give)); + assert.deepEqual(distransPrebidInventory(resumed), f.originalInventory); + const complete = advanceDistransPrebid(resumed, state => state.richeseAuction?.event !== lot.event); + assert.deepEqual(distransPrebidInventory(complete), f.originalInventory); }); } diff --git a/tests/distrans-richese-controls.test.ts b/tests/distrans-richese-controls.test.ts index 1159602e..598e0214 100644 --- a/tests/distrans-richese-controls.test.ts +++ b/tests/distrans-richese-controls.test.ts @@ -67,8 +67,8 @@ for (const advanced of [false, true]) for (const method of ['onceAround', 'silen lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id))!; const started = actDistransPrebid(before, actor, { type: 'richeseBid', event: lot.event, amount: method === 'onceAround' ? null : 0, allyPayment: 0 }); - const guarded = viewGame(started, f.holder); - assert.match(guarded.distrans!.blocked!, /Semuta preview/); - assert.match(transferButton(markup(guarded)), /\sdisabled=/); + const available = viewGame(started, f.holder); + assert.equal(available.distrans!.blocked, null); + assert.doesNotMatch(transferButton(markup(available)), /\sdisabled=/); }); } diff --git a/tests/distrans-richese-prebid.test.ts b/tests/distrans-richese-prebid.test.ts index a99e2396..d98a6b0e 100644 --- a/tests/distrans-richese-prebid.test.ts +++ b/tests/distrans-richese-prebid.test.ts @@ -63,16 +63,17 @@ for (const advanced of [false, true]) for (const method of ['onceAround', 'silen assert.deepEqual(distransPrebidInventory(game), f.originalInventory); }); for (const first of method === 'silent' ? [0, 1] : [null, 1]) { - void test(`${prefix} first ${first === null ? 'pass' : first === 0 ? 'sealed zero' : 'bid'} keeps Distrans guarded with the Semuta preview`, () => { + void test(`${prefix} first ${first === null ? 'pass' : first === 0 ? 'sealed zero' : 'bid'} allows Distrans without changing the original special lot`, () => { const f = createDistransRichesePrebidFixture({ advanced, method }); const start = firstSubmission(f.game, first); const game = actDistransPrebid(reload(f.game), start.actor, start.action); assert.ok(game.richeseAuction?.acted.includes(start.actor)); - assert.match(viewGame(game, f.holder).distrans!.blocked!, /Semuta preview/); - rejected(game, f.holder, f.action); - assert.ok(player(game, f.holder).hand.some(c => c.id === f.card)); - assert.ok(player(game, f.holder).hand.some(c => c.id === f.give)); - assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + assert.equal(viewGame(game, f.holder).distrans!.blocked, null); + const after = actDistransPrebid(game, f.holder, f.action); + assert.deepEqual(after.richeseAuction, game.richeseAuction); + assert.deepEqual(after.richeseFunding, game.richeseFunding); + assert.ok(player(after, f.recipient).hand.some(c => c.id === f.give)); + assert.deepEqual(distransPrebidInventory(after), f.originalInventory); }); } void test(`${prefix} rejects a full recipient before spending either physical card`, () => { @@ -118,6 +119,7 @@ for (const advanced of [false, true]) for (const method of ['onceAround', 'silen const choice = viewGame(game, f.holder).distrans!.choices.find(c => c.recipient === recipientId)!; assert.ok(choice.cards.some(c => c.id === f.give)); game = actDistransPrebid(game, f.holder, action); + game = advanceDistransPrebid(game, distransPrebidClean); assert.equal(player(game, recipientId).hand.length, handLimit(recipient)); assert.equal(player(game, f.holder).hand.length, handLimit(donor) - 2); assert.deepEqual(distransPrebidInventory(game), f.originalInventory); diff --git a/tests/distrans-started-richese-auction.test.ts b/tests/distrans-started-richese-auction.test.ts index 0a91d9d7..e88ee3b4 100644 --- a/tests/distrans-started-richese-auction.test.ts +++ b/tests/distrans-started-richese-auction.test.ts @@ -1,36 +1,119 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { viewGame } from '../game/engine'; -import { actDistransPrebid, distransPrebidInventory, distransPrebidPlayer as player } from './fixture-distrans-prebid'; +import { handLimit, viewGame, type Action } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { actDistransPrebid, advanceDistransPrebid, distransPrebidClean, + distransPrebidInventory, distransPrebidPlayer as player } from './fixture-distrans-prebid'; import { createDistransRichesePrebidFixture } from './fixture-distrans-richese-prebid'; -import { distransAuctionBlock } from '../game/distrans-timing'; +import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; for (const method of ['onceAround', 'silent'] as const) { - void test(`a started ${method} cache auction preserves its commitments while Semuta guards Distrans`, () => { - const f = createDistransRichesePrebidFixture({ method }); - const lot = f.game.richeseAuction!; - const bidder = method === 'silent' - ? lot.order.find(id => id !== f.holder && player(f.game, id).spice >= 1)! - : lot.active!; - assert.ok(bidder && player(f.game, bidder).spice >= 1); - const game = actDistransPrebid(f.game, bidder, { - type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0, + for (const claim of [false, true]) { + void test(`Basic ${method} started auction resumes its original lot after Semuta ${claim ? 'claims' : 'passes'} Distrans`, () => { + const f = createDistransRichesePrebidFixture({ method }); + // The game and bidding phase are reached through native actions. Stage + // only the conserved physical Semuta card into another player's hand + // before the real cache declaration; this is not an earned purchase. + let game = structuredClone(f.beforeDeclaration); + const semutaHolder = 'e'; + moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, semutaHolder); + assert.ok(player(game, semutaHolder).hand.length <= handLimit(player(game, semutaHolder))); + const offer = game.richeseCache![0]; assert.ok(offer); + const declaration = { ...f.declaration, card: offer.id } as Action; + game = actDistransPrebid(game, game.decision!.player, declaration); + const lot = game.richeseAuction!; + const bidder = method === 'silent' + ? lot.order.find(id => id !== f.holder && player(game, id).spice >= 1)! + : lot.active!; + assert.ok(bidder && player(game, bidder).spice >= 1); + game = actDistransPrebid(game, bidder, { + type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0, + }); + assert.ok(game.richeseAuction && !game.richeseAuction.outcome); + assert.ok(game.richeseAuction.acted.includes(bidder)); + const recipient = game.players.find(p => p.id !== f.holder && p.id !== bidder && + p.hand.length < handLimit(p)); + assert.ok(recipient); + const transfer = { ...f.action, target: recipient.id } as Action; + const quote = viewGame(game, f.holder).distrans!; + assert.equal(quote.blocked, null); + assert.ok(quote.choices.find(c => c.recipient === recipient.id)!.cards.some(c => c.id === f.give)); + const originalLot = structuredClone(game.richeseAuction); + const originalFunding = structuredClone(game.richeseFunding); + const originalInventory = distransPrebidInventory(game); + const originalHand = structuredClone(player(game, recipient.id).hand); + const transferred = player(game, f.holder).hand.find(c => c.id === f.give)!; + game = actDistransPrebid(game, f.holder, transfer); + const pending = game.pendingTreacheryDiscard; + assert.ok(pending?.reaction, 'The public Distrans discard must offer a Semuta reaction'); + assert.equal(pending.batch.entries[0].card.id, f.card); + const event = pending.batch.event; + if (method === 'silent' && claim) { + const altered = structuredClone(game); + altered.richeseAuction!.sealed[bidder] = 2; + assert.throws(() => actDistransPrebid(altered, semutaHolder, { type: 'semutaCommit', event }), + /completed private Distrans transfer|Semuta/); + } + assert.equal(viewGame(game, semutaHolder).semutaReaction!.canCommit, true); + if (claim) { + game = actDistransPrebid(game, semutaHolder, { type: 'semutaCommit', event }); + } else { + for (const seat of game.players) + game = actDistransPrebid(game, seat.id, { type: 'semutaPass', event }); + } + assert.equal(game.pendingTreacheryDiscard, null); + assert.deepEqual(game.richeseAuction, originalLot); + assert.deepEqual(game.richeseFunding, originalFunding); + assert.deepEqual(player(game, recipient.id).hand, + recipient.id === semutaHolder && claim + ? [...originalHand.filter(c => c.id !== SEMUTA_DRUG_ID), transferred, + pending.batch.entries[0].card] + : [...originalHand, transferred]); + assert.equal(player(game, semutaHolder).hand.some(c => c.id === f.card), claim); + assert.equal(game.discard.some(c => c.id === SEMUTA_DRUG_ID), claim); + assert.equal(game.discard.some(c => c.id === f.card), !claim); + assert.deepEqual(distransPrebidInventory(game), originalInventory); + if (method === 'silent') { + const observer = game.players.find(p => p.id !== bidder)!; + const hidden = viewGame(game, observer.id).richeseAuction!; + assert.equal(hidden.ownBid, null); + assert.equal(hidden.revealedBids, null); + assert.equal('sealed' in hidden, false); + assert.equal(viewGame(game, bidder).richeseAuction!.ownBid, 1); + } + game = advanceDistransPrebid(game, state => distransPrebidClean(state) && + state.richeseAuction?.event !== lot.event); + assert.deepEqual(distransPrebidInventory(game), originalInventory); }); - assert.ok(game.richeseAuction && !game.richeseAuction.outcome); - assert.ok(game.richeseAuction.acted.includes(bidder)); - assert.equal(distransAuctionBlock(game), null, 'Completed special bid is a valid timing interval'); - const before = structuredClone(game), beforeInventory = distransPrebidInventory(game); - assert.match(viewGame(game, f.holder).distrans!.blocked!, /Semuta preview/); - assert.throws(() => actDistransPrebid(game, f.holder, f.action), /Semuta preview/); - assert.deepEqual(game, before); - assert.deepEqual(distransPrebidInventory(game), beforeInventory); - if (method === 'silent') { - const observer = game.players.find(p => p.id !== bidder)!; - const hidden = viewGame(game, observer.id).richeseAuction!; - assert.equal(hidden.ownBid, null); - assert.equal(hidden.revealedBids, null); - assert.equal('sealed' in hidden, false); - assert.equal(viewGame(game, bidder).richeseAuction!.ownBid, 1); - } - }); + } } + +void test('a recipient can use the received Semuta to claim Distrans during a started Silent lot', () => { + const f = createDistransRichesePrebidFixture({ method: 'silent' }); + let game = structuredClone(f.beforeDeclaration); + // Stage the one physical Semuta card in the donor's hand before the real + // lot declaration; the recipient acquires it only through native Distrans. + moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, f.holder); + assert.ok(player(game, f.holder).hand.length <= handLimit(player(game, f.holder))); + game = actDistransPrebid(game, game.decision!.player, + { ...f.declaration, card: game.richeseCache![0].id } as Action); + const lot = game.richeseAuction!; + const bidder = lot.order.find(id => id !== f.holder && id !== f.recipient && + player(game, id).spice >= 1)!; + assert.ok(bidder); + game = actDistransPrebid(game, bidder, + { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 }); + const originalLot = structuredClone(game.richeseAuction), funding = structuredClone(game.richeseFunding); + const inventory = distransPrebidInventory(game); + game = actDistransPrebid(game, f.holder, { ...f.action, give: SEMUTA_DRUG_ID } as Action); + const pending = game.pendingTreacheryDiscard!; + assert.ok(pending?.reaction); + assert.equal(viewGame(game, f.recipient).semutaReaction!.canCommit, true); + game = actDistransPrebid(game, f.recipient, { type: 'semutaCommit', event: pending.batch.event }); + assert.equal(game.pendingTreacheryDiscard, null); + assert.ok(player(game, f.recipient).hand.some(c => c.id === f.card)); + assert.ok(game.discard.some(c => c.id === SEMUTA_DRUG_ID)); + assert.deepEqual(game.richeseAuction, originalLot); + assert.deepEqual(game.richeseFunding, funding); + assert.deepEqual(distransPrebidInventory(game), inventory); +});