diff --git a/components/distrans.tsx b/components/distrans.tsx index 71aac95a..bdfdb3af 100644 --- a/components/distrans.tsx +++ b/components/distrans.tsx @@ -50,10 +50,10 @@ export function Distrans({ without a recipient confirmation or Karama response.

- Provisional bid timing: you may transfer before the first bid or pass - on an ordinary, Once Around or normal Black Market lot, or before the - first sealed submission in a Silent lot. Once that bidding has begun, - the lot stays blocked while unresolved. + Provisional bid timing: you may transfer between completed bid actions + on a supported ordinary, Once Around, Silent or normal Black Market + lot. A pending auction decision or other interaction may still limit + the choices available.

{choice && ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ad3f540f..98febed9 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**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 +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). + **Basic movement bribes and shipment witness — 29 October. Prototyped / Partial.** Ordinary nonallied bribes can interrupt supported clean Fremen and Ixian movement responses and CHOAM movement decisions. The original move and diff --git a/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md b/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md index 452ccfa7..6e087b21 100644 --- a/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md +++ b/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md @@ -2,9 +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. +## Between completed bid actions — 29 October 2026 + +**Prototyped / Partial.** Provisionally interpret the printed prohibition on +playing Distrans “during a bid” as applying to the bid action itself, so a +supported ordinary, Once Around, Silent or normal Black Market lot can also +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 +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. + ## Untouched Richese special lot — 27 October 2026 -**Prototyped / Partial.** Extend the selected pre-bid interpretation to an +**Historical checkpoint; its first-action cutoff is superseded above.** Extend the selected pre-bid interpretation to an actual untouched Once Around or Silent cache or Black Market lot, or a normal Black Market lot. Before its first accepted bid, pass or sealed submission, the existing Distrans transfer @@ -40,6 +56,7 @@ fee, recipient consent, new bid opportunity or new start permission. ## Untouched ordinary lot — 26 October 2026 +**Historical checkpoint; its first-action cutoff is superseded above.** The printed exception excludes use during a bid, without defining its complete span. Under the 14 October choose-recommended authority, allow Distrans on a real untouched ordinary lot before its first bid or pass. This is a provisional @@ -115,7 +132,7 @@ The distinction between an ordinary Treachery effect and a cancellable faction/a ## Explicit unresolved guards 1. **Giving the activating Distrans itself.** The give-then-discard wording does not explain self-selection. Do not put the same physical card in both the recipient's hand and the discard, discard a different card, or silently omit the discard. Until clarified, require a separate transferred card and label self-selection unsupported, not a printed prohibition. -2. **Exact scope of “during a bid.”** The face does not define whether this means one bid declaration or the complete auction of a card. It does not say the entire Bidding phase. The selected policies above permit untouched ordinary, Once Around, Silent and normal Black Market lots before their first bidding action; started lots retain their timing boundary. Pre-bid declaration/selection, between-lot windows and post-purchase income are distinct contexts; do not present a blanket phase-3 ban as the official rule. +2. **Exact scope of “during a bid.”** The face does not define whether this means one bid declaration or the complete auction of a card. It does not say the entire Bidding phase. The provisional policy above also permits supported ordinary, Once Around, Silent and normal Black Market lots between completed bidding actions; unresolved actions and other priority restrictions remain separate. Pre-bid declaration/selection, between-lot windows and post-purchase income are distinct contexts; do not present a blanket phase-3 ban as the official rule. 3. **Active Truthtrance answer priority.** Existing question/priority resolution prevents changing the queried hand mid-question. No retrieved Distrans-specific rule overrides it. Keep this narrow existing priority boundary, rather than disabling Distrans for every unrelated decision or response. 4. **Semuta discard reaction — updated 11 October 2026.** The explicit development preview reacts after a clean completed transfer, including during battle. Only the used Distrans is fresh; the privately transferred card is never a Semuta target. Claim or decline cannot repeat the transfer or change a sealed plan. The recipient may itself hold Semuta and claim the used card while keeping the earlier gift. Competing parents retain their separate boundaries; the neutral response and approved full-hand exchange are documented in [the Semuta source/runtime contract](SEMUTA_SOURCE_UPDATE_20260907.md). @@ -185,7 +202,7 @@ Recommended regressions: atomic distinct-card conservation and fresh-version rep ## Runtime integration checkpoint -The ordinary card action and owner-only projected transfer choices are integrated. Shared exact-card reservations cover saved parent Ix replacements and pending gifts; prospective checks preserve compulsory exchanges and binding donor promises. No new response or recipient confirmation is created. The supported post-payment context uses currentAuctionSale only after the existing code has paid and transferred the purchased card. At this historical checkpoint, only untouched ordinary lots were admitted; the later special-lot prototype is described above. Started bids and self-transfer remain guarded. +The ordinary card action and owner-only projected transfer choices are integrated. Shared exact-card reservations cover saved parent Ix replacements and pending gifts; prospective checks preserve compulsory exchanges and binding donor promises. No new response or recipient confirmation is created. The supported post-payment context uses currentAuctionSale only after the existing code has paid and transferred the purchased card. At this historical checkpoint, only untouched ordinary lots were admitted; the later special-lot prototype is described above. At that checkpoint, started bids and self-transfer remained guarded; later started-lot changes are described above. Six root engine, six independent review, five AI and three real SQLite/API cases pass, in addition to six pure custody cases. Full registered suites pass1,247 rules/client and96 persisted/API tests; TypeScript, lint and production build pass. The real browser gave Shield from a synthetic Harkonnen hand using Distrans while preserving a separate pending Richese gift; fresh-tab recovery did not replay it. Exact logs and fixtures are in IMPLEMENTATION_STATUS.md. Mobile, future Semuta, bid/self-transfer rulings and full Richese games remain incomplete. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 892f0610..0356337f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1244,8 +1244,22 @@ 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). +## Distrans between completed bidding actions — 29 October 2026 + +The printed card excludes play “during a bid” (publisher E2 p5), without +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 +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). + ## Distrans before Richese special bidding — 27 October 2026 +Historical checkpoint: its first-action cutoff is superseded above. Provisionally extend the existing pre-bid interpretation to an untouched Once Around or Silent cache or Black Market lot, or normal Black Market lot. The first bid, pass or sealed submission closes the window. Preserve the physical lot, existing eligibility, @@ -2176,6 +2190,7 @@ future retention promise. [Source and controls](TRUTHTRANCE_CARD_COUNT.md#compou ## Distrans before the first ordinary bid/pass — 26 October 2026 +Historical checkpoint: its first-action cutoff is superseded above. The printed card permits use except during a bid (publisher E2 p5). Under 14 October choose-recommended authority, a physical ordinary lot with no bid, bidder, pass or committed contribution may accept Distrans before its first bid diff --git a/game/distrans-timing.ts b/game/distrans-timing.ts index fe3114fd..a6bcf17c 100644 --- a/game/distrans-timing.ts +++ b/game/distrans-timing.ts @@ -15,46 +15,70 @@ type DistransAuctionContext = { currentAuctionSale?: unknown; }; -/** The printed card excludes use during a bid. Provisionally, an ordinary - * lot has not begun bidding until its first bid or pass. Inspection and - * opening the physical lot do not start that bid or change the dealt pool. - * An untouched Richese lot likewise has no bid yet. - * Once any bidder acts, even a pass or a zero sealed bid, it is closed. */ +const amount = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const ids = (value: unknown): value is string[] => + Array.isArray(value) && value.every(id => typeof id === 'string' && id.length > 0) && + new Set(value).size === value.length; +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +function validSpecial(s: RicheseAuction, funding: Record | null | undefined): boolean { + if (!(s.method === 'onceAround' || s.method === 'silent' || s.method === 'normal') || + !(s.source === 'cache' || s.source === 'blackMarket') || + (s.source === 'cache' && s.method === 'normal') || + typeof s.event !== 'string' || !s.event || typeof s.cardId !== 'string' || !s.cardId || + typeof s.owner !== 'string' || !s.owner || s.outcome !== null || + !ids(s.eligible) || !s.eligible.length || !ids(s.order) || !ids(s.tieOrder) || + s.order.length !== s.eligible.length || s.tieOrder.length !== s.eligible.length || + !s.order.every(id => s.eligible.includes(id)) || !s.tieOrder.every(id => s.eligible.includes(id)) || + !ids(s.acted) || !ids(s.passed) || + !s.acted.every(id => s.eligible.includes(id)) || !s.passed.every(id => s.eligible.includes(id)) || + !amount(s.bid) || (s.bid === 0 ? s.bidder !== null : !s.bidder || !s.eligible.includes(s.bidder)) || + !record(s.sealed) || (funding != null && !record(funding))) return false; + + if (funding && !Object.entries(funding).every(([id, entry]) => { + if (!s.eligible.includes(id) || !record(entry) || !amount(entry.amount) || + !amount(entry.allyPayment) || entry.allyPayment > entry.amount) return false; + return entry.donor === null || (typeof entry.donor === 'string' && entry.donor.length > 0); + })) return false; + + if (s.method === 'silent') { + const sealed = Object.keys(s.sealed); + return s.active === null && s.bid === 0 && s.bidder === null && s.passed.length === 0 && + sealed.length === s.acted.length && sealed.every(id => s.acted.includes(id) && amount(s.sealed[id])); + } + if (Object.keys(s.sealed).length) return false; + if (s.method === 'onceAround') { + const remaining = s.order.filter(id => !s.acted.includes(id)); + return remaining.length > 0 && s.active === remaining[0] && + (s.bidder !== null || remaining.some(id => id !== s.owner)) && + s.passed.every(id => s.acted.includes(id)) && + (!s.bidder || (s.acted.includes(s.bidder) && !s.passed.includes(s.bidder))); + } + if (s.acted.length || !s.active || !s.eligible.includes(s.active) || + s.passed.includes(s.active) || s.active === s.bidder || + (s.bidder !== null && s.passed.includes(s.bidder))) return false; + const previous = s.passed.at(-1) ?? s.bidder; + if (!previous) return s.active === s.order[0]; + const start = s.order.indexOf(previous); + const next = s.order.slice(start + 1).concat(s.order.slice(0, start + 1)) + .find(id => id !== s.bidder && !s.passed.includes(id)); + return s.active === next; +} + +/** Provisional interpretation: a completed bid or pass ends the interval + * during which Distrans is forbidden. Preserve the existing open lot and all + * accepted bid reservations; transfers must still satisfy their normal rules. */ export function distransAuctionBlock(g: DistransAuctionContext): string | null { if (g.currentAuctionSale || (!g.auction && !g.richeseAuction)) return null; - const special = g.richeseAuction; - if (g.phase === 3 && !g.auction && special && - (special.method === 'onceAround' || special.method === 'silent' || special.method === 'normal') && - (special.source === 'cache' || special.source === 'blackMarket') && - (special.source !== 'cache' || special.method !== 'normal') && - typeof special.event === 'string' && special.event.length > 0 && - typeof special.cardId === 'string' && special.cardId.length > 0 && - typeof special.owner === 'string' && special.owner.length > 0 && - special.outcome === null && special.bid === 0 && special.bidder === null && - Array.isArray(special.eligible) && special.eligible.length > 0 && - special.eligible.every(id => typeof id === 'string' && id.length > 0) && - new Set(special.eligible).size === special.eligible.length && - Array.isArray(special.order) && special.order.length === special.eligible.length && - new Set(special.order).size === special.order.length && - special.order.every(id => special.eligible.includes(id)) && - Array.isArray(special.tieOrder) && special.tieOrder.length === special.eligible.length && - new Set(special.tieOrder).size === special.tieOrder.length && - special.tieOrder.every(id => special.eligible.includes(id)) && - (special.method === 'silent' ? special.active === null : special.active === special.order[0]) && - (special.method !== 'onceAround' || special.order.some(id => id !== special.owner)) && - Array.isArray(special.acted) && special.acted.length === 0 && - Array.isArray(special.passed) && special.passed.length === 0 && - special.sealed && typeof special.sealed === 'object' && !Array.isArray(special.sealed) && - Object.keys(special.sealed).length === 0 && - (!g.richeseFunding || (typeof g.richeseFunding === 'object' && !Array.isArray(g.richeseFunding) && - Object.keys(g.richeseFunding).length === 0))) return null; + if (g.phase === 3 && !g.auction && g.richeseAuction && + validSpecial(g.richeseAuction, g.richeseFunding)) return null; const lot = g.auction; - if ( - g.phase === 3 && !g.richeseAuction && lot && - Number.isSafeInteger(lot.index) && lot.index >= 0 && - lot.index < lot.cards.length && !!lot.cards[lot.index] && - lot.bid === 0 && lot.bidder === null && lot.passed.length === 0 && - (lot.allyPayment ?? 0) === 0 - ) return null; - return 'Distrans is unavailable after bidding starts on this lot. Finish its auction first; other special-auction timing remains guarded.'; + if (g.phase === 3 && !g.richeseAuction && lot && Array.isArray(lot.cards) && + Number.isSafeInteger(lot.index) && lot.index >= 0 && lot.index < lot.cards.length && !!lot.cards[lot.index] && + amount(lot.bid) && (lot.bid === 0 ? lot.bidder === null : typeof lot.bidder === 'string' && !!lot.bidder) && + ids(lot.passed) && (!lot.bidder || !lot.passed.includes(lot.bidder)) && + amount(lot.allyPayment ?? 0) && (lot.allyPayment ?? 0) <= lot.bid) return null; + return 'Distrans is unavailable during this unresolved auction state; finish the current bid or auction first.'; } diff --git a/game/engine.ts b/game/engine.ts index 530e5d17..2f2547f9 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -5476,6 +5476,18 @@ function distransBlock(g: Game, owner: Player, card: Card): string | null { if (promised) return promised; const reserved = transferCardBlock(g, owner, card); if (reserved) return reserved; + // A started lot is only open between complete actions, not while a bid or + // its resulting interaction is still waiting for a decision. + const special = g.richeseAuction; + const started = !g.currentAuctionSale && (g.auction + ? g.auction.bid > 0 || g.auction.passed.length > 0 + : !!special && (special.bid > 0 || special.passed.length > 0 || + 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.'; return distransAuctionBlock(g); } function validateDistrans( @@ -9681,7 +9693,7 @@ function semutaOfferSupported( entries[0].card.id === continuation.card; // These completed public costs retain the original ordinary lot through the // existing signed continuation. Semuta itself has no during-bid exception; - // native Distrans still cannot produce a transfer after that bid starts. + // Distrans can also produce this discard between completed ordinary bids. const ordinaryAuctionDiscard = g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale && (continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard' || continuation.kind === 'saphoTruthOrderDiscard' || diff --git a/game/reference.ts b/game/reference.ts index 1c5efaf0..498ee9c9 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -763,9 +763,9 @@ 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, an untouched ordinary, Once Around or normal Black Market lot allows Distrans before its first bid or pass, and a Silent lot allows it before its first sealed submission. Opening or inspecting a lot does not start the bid; its card, eligibility and bidding order stay the same. Started lots remain guarded, while a completed purchase income response may allow use. 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; 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.', '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 keeps an existing response, decision or phase opening in place. 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.', + '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.', 'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.', 'All four AI profiles use their own legal projected cards. They may give an allied Bene Gesserit or CHOAM a Worthless card, or share a duplicate ordinary weapon, defense or Karama to free a full hand. They do not inspect the recipient’s private cards and preserve existing decision priority.', @@ -783,13 +783,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Synchronous two-card custody, private transfer, normal disposal, reservations and prospective checks are integrated. Provisional untouched ordinary and Richese special-lot windows admit use before the first bid, pass or sealed submission and recheck live bid capacity. Started lots, competing transactions and self-transfer retain their separate boundaries.', + 'Synchronous two-card custody, private transfer, normal disposal, reservations and prospective checks are integrated. Provisional ordinary and Richese special-lot windows admit use between completed bids, passes and sealed submissions, preserving commitments and rechecking capacity. Competing transactions and self-transfer retain separate boundaries.', }, { area: 'Player controls', status: 'Partial', detail: - 'Recipient/card selectors, both inspectors, unavailable reasons and explicit give-and-discard controls are integrated. Persistent provisional pre-bid guidance and the actual hook-free submission are connected. Browser/mobile acceptance of this extension remains pending.', + 'Recipient/card selectors, both inspectors, unavailable reasons and explicit give-and-discard controls are integrated. Persistent provisional between-bids guidance and the actual hook-free submission are connected. Browser/mobile acceptance of this extension remains pending.', }, { area: 'AI', @@ -1336,7 +1336,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'karama' ? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity pause on the public cost before settling that response; a clean printed normal-auction purchase or winning-bid payment pauses before its reserved lot settles; and a printed cancellation of any ordinary native CHOAM Worthless or Cunning power pauses on its public cost before the denial completes. Richese starts are unfinished previews; other Karama-to-Semuta reactions remain unavailable.' : definition.card.effect === 'distrans' - ? 'Use the Distrans transfer panel to choose another player and a separate held card. Open-bid timing and self-transfer remain guarded pending clarification.' + ? 'Use the Distrans transfer panel to choose another player and a separate held card. Supported lots provisionally allow play between completed bid actions; pending interactions and self-transfer remain guarded.' : definition.card.effect === 'nullentropyBox' ? 'Use the Nullentropy Box panel to pay two bank spice for a private discard search. Both modes provisionally permit full-hand retrieval as an atomic exchange that discards the Box and leaves the final hand within its current limit. Provisional Guild refund claims remain guarded.' : definition.card.effect === 'ornithopter' diff --git a/tests/distrans-black-market-prebid.test.ts b/tests/distrans-black-market-prebid.test.ts index 8e1cfe5a..015da25d 100644 --- a/tests/distrans-black-market-prebid.test.ts +++ b/tests/distrans-black-market-prebid.test.ts @@ -67,14 +67,12 @@ 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'} closes Distrans before either card leaves custody`, () => { + void test(`genuine Advanced Black Market first ${amount === null ? 'pass' : 'bid'} keeps Distrans guarded with the Semuta preview`, () => { 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.ok(viewGame(game, f.holder).distrans!.blocked); + 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.ok(player(game, f.seller).hand.some(c => c.id === f.physical.id)); assert.deepEqual(distransPrebidInventory(game), f.originalInventory); }); diff --git a/tests/distrans-engine.test.ts b/tests/distrans-engine.test.ts index e1b87b17..c0bce541 100644 --- a/tests/distrans-engine.test.ts +++ b/tests/distrans-engine.test.ts @@ -37,7 +37,7 @@ void test('projection exposes choices only to the holder and public history reve assert.equal(JSON.stringify(viewGame(done,'o')).includes(card.id),false); assert.match(done.log.at(-1)!.text,/Distrans/);assert.equal(done.log.some(l=>l.text.includes(card.name)),false); }); -void test('an untouched ordinary lot permits Distrans but its first bid or pass closes the provisional window',()=>{ +void test('ordinary lots permit Distrans between completed bids and passes',()=>{ const {g,card}=fixture('richese',false,3); assert.deepEqual(play(g,card.id).players[1].hand,[card]); g.auction={cards:[g.deck.shift()!],index:0,bid:0,bidder:null,active:'t',passed:[],opener:0}; @@ -50,8 +50,9 @@ void test('an untouched ordinary lot permits Distrans but its first bid or pass if(progress==='bid'){during.auction!.bid=1;during.auction!.bidder='t';} else during.auction!.passed=['o']; const before=structuredClone(during); - assert.throws(()=>play(during,card.id),/after bidding starts/);assert.deepEqual(during,before); - assert.match(viewGame(during,'d').distrans!.blocked!,/after bidding starts/); + assert.equal(viewGame(during,'d').distrans!.blocked,null); + const after=play(during,card.id); + assert.deepEqual(during,before);assert.deepEqual(after.auction,before.auction); } }); void test('an already-paid purchase response allows Distrans without changing its completed sale or creating a second response',()=>{ diff --git a/tests/distrans-prebid-controls.test.ts b/tests/distrans-prebid-controls.test.ts index 67537e28..a1ee4397 100644 --- a/tests/distrans-prebid-controls.test.ts +++ b/tests/distrans-prebid-controls.test.ts @@ -161,9 +161,9 @@ void test('brief provisional timing guidance stays visible without an extra conf for (let render = 0; render < 3; render++) { const html = markup(game); assert.match(html, /aria-label="Provisional Distrans bid timing"/); - assert.match(html, /Provisional bid timing: you may transfer before the first bid or pass on an ordinary, Once Around or normal Black Market lot/); - assert.match(html, /before the first sealed submission in a Silent lot/); - assert.match(html, /Once that bidding has begun, the lot stays blocked while unresolved/); + assert.match(html, /Provisional bid timing: you may transfer between completed bid actions/); + assert.match(html, /ordinary, Once Around, Silent or normal Black Market lot/); + assert.match(html, /A pending auction decision or other interaction may still limit the choices available/); assert.match(html, /No spice is paid/); assert.match(html, /without a recipient confirmation or Karama response/); assert.doesNotMatch(html, /Confirm transfer|Accept transfer|Acknowledge|countdown/); diff --git a/tests/distrans-prebid-engine.test.ts b/tests/distrans-prebid-engine.test.ts index aa539b83..abc2d43c 100644 --- a/tests/distrans-prebid-engine.test.ts +++ b/tests/distrans-prebid-engine.test.ts @@ -108,21 +108,22 @@ for (const advanced of [false, true]) { }); for (const opening of ['bid', 'passBid'] as const) { - void test(`${mode} first real ${opening} closes Distrans until the next untouched ordinary lot`, () => { + void test(`${mode} first real ${opening} leaves the completed lot available for Distrans`, () => { const f = createDistransPrebidFixture({ advanced }); const actor = f.game.auction!.active; let game = actDistransPrebid(f.game, actor, opening === 'bid' ? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' }); assert.equal(distransPrebidUntouched(game), false); - assert.ok(viewGame(game, f.holder).distrans!.blocked); - rejected(game, f.holder, f.action); + assert.equal(viewGame(game, f.holder).distrans!.blocked, null); + const offered = actDistransPrebid(game, f.holder, f.action); + assert.deepEqual(offered.auction, game.auction); let bidder = actor; if (opening === 'passBid') { assert.equal(game.auction!.bid, 0); assert.deepEqual(game.auction!.passed, [actor]); bidder = game.auction!.active; game = actDistransPrebid(game, bidder, { type: 'bid', amount: 1, allyPayment: 0 }); - rejected(game, f.holder, f.action); + assert.equal(viewGame(game, f.holder).distrans!.blocked, null); } game = completeBid(game, bidder); assert.equal(game.auction!.index, 1); diff --git a/tests/distrans-richese-controls.test.ts b/tests/distrans-richese-controls.test.ts index 9395b985..1159602e 100644 --- a/tests/distrans-richese-controls.test.ts +++ b/tests/distrans-richese-controls.test.ts @@ -38,8 +38,8 @@ for (const advanced of [false, true]) for (const method of ['onceAround', 'silen assert.ok(given); const html = markup(view); assert.doesNotMatch(transferButton(html), /\sdisabled=/); - assert.match(html, /ordinary, Once Around or normal Black Market lot/); - assert.match(html, /first sealed submission in a Silent lot/); + assert.match(html, /ordinary, Once Around, Silent or normal Black Market lot/); + assert.match(html, /between completed bid actions/); // Stateful selectors render above; exercise the real action leaf with the // exact entitled recipient/card choice from the native engine projection. @@ -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 blocked = viewGame(started, f.holder); - assert.ok(blocked.distrans?.blocked); - assert.match(transferButton(markup(blocked)), /\sdisabled=/); + const guarded = viewGame(started, f.holder); + assert.match(guarded.distrans!.blocked!, /Semuta preview/); + assert.match(transferButton(markup(guarded)), /\sdisabled=/); }); } diff --git a/tests/distrans-richese-prebid.test.ts b/tests/distrans-richese-prebid.test.ts index 19b0d4fa..a99e2396 100644 --- a/tests/distrans-richese-prebid.test.ts +++ b/tests/distrans-richese-prebid.test.ts @@ -63,12 +63,12 @@ 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'} closes the Distrans window`, () => { + void test(`${prefix} first ${first === null ? 'pass' : first === 0 ? 'sealed zero' : 'bid'} keeps Distrans guarded with the Semuta preview`, () => { 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.ok(viewGame(game, f.holder).distrans!.blocked); + 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)); @@ -133,6 +133,6 @@ for (const advanced of [false, true]) for (const method of ['onceAround', 'silen assert.ok(next.acted.includes(recipientId)); if (method === 'silent') assert.equal(next.sealed[recipientId], 0); else assert.ok(next.passed.includes(recipientId)); - assert.ok(distransAuctionBlock(game), 'The automatic pass or zero closes the original window'); + assert.equal(distransAuctionBlock(game), null, 'The automatic pass or zero is a completed action'); }); } diff --git a/tests/distrans-started-auction-runtime.test.ts b/tests/distrans-started-auction-runtime.test.ts new file mode 100644 index 00000000..faa2271d --- /dev/null +++ b/tests/distrans-started-auction-runtime.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handLimit, viewGame } from '../game/engine'; +import { + actDistransPrebid, advanceDistransPrebid, createDistransPrebidFixture, + distransPrebidClean, distransPrebidInventory, distransPrebidPlayer as player, + distransPrebidPosition, finishDistransPrebidLot, +} from './fixture-distrans-prebid'; + +for (const advanced of [false, true]) { + const mode = advanced ? 'Advanced' : 'Basic'; + void test(`${mode} Distrans between ordinary bids preserves the standing purchase and resumes that lot`, () => { + const f = createDistransPrebidFixture({ advanced }); + const bidder = f.game.auction!.active; + let game = actDistransPrebid(f.game, bidder, { type: 'bid', amount: 1, allyPayment: 0 }); + assert.equal(game.auction!.bidder, bidder); + assert.equal(game.auction!.bid, 1); + const recipient = game.players.find(seat => seat.id !== f.holder && seat.id !== bidder && + seat.hand.length < handLimit(seat)); + assert.ok(recipient); + const action = { ...f.action, target: recipient.id }; + const offered = viewGame(game, f.holder).distrans!; + assert.equal(offered.blocked, null); + assert.ok(offered.choices.find(choice => choice.recipient === recipient.id)!.cards.some(card => card.id === f.give)); + const original = structuredClone(game), index = game.auction!.index, turn = game.turn; + const soldCard = game.auction!.cards[index].id; + const bidderSpice = player(game, bidder).spice; + game = actDistransPrebid(game, f.holder, action); + assert.deepEqual(distransPrebidPosition(game), distransPrebidPosition(original)); + assert.ok(player(game, recipient.id).hand.some(card => card.id === f.give)); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + game = advanceDistransPrebid(game, distransPrebidClean); + assert.deepEqual(game.auction, original.auction); + game = finishDistransPrebidLot(game, turn, index); + assert.ok(player(game, bidder).hand.some(card => card.id === soldCard)); + assert.equal(player(game, bidder).spice, bidderSpice - 1); + assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + }); + + void test(`${mode} a standing bidder retains its last hand slot through a Distrans attempt`, () => { + const f = createDistransPrebidFixture({ advanced, capacity: 'recipientLastSlot' }); + const bidder = f.game.auction!.active; + assert.equal(bidder, f.recipient); + assert.equal(player(f.game, bidder).hand.length, handLimit(player(f.game, bidder)) - 1); + const game = actDistransPrebid(f.game, bidder, { type: 'bid', amount: 1, allyPayment: 0 }); + const before = structuredClone(game); + const option = viewGame(game, f.holder).distrans!.choices.find(choice => choice.recipient === bidder)!; + assert.ok(!option.cards.some(card => card.id === f.give)); + assert.throws(() => actDistransPrebid(game, f.holder, f.action)); + assert.deepEqual(game, before); + assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + }); +} diff --git a/tests/distrans-started-richese-auction.test.ts b/tests/distrans-started-richese-auction.test.ts new file mode 100644 index 00000000..0a91d9d7 --- /dev/null +++ b/tests/distrans-started-richese-auction.test.ts @@ -0,0 +1,36 @@ +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 { createDistransRichesePrebidFixture } from './fixture-distrans-richese-prebid'; +import { distransAuctionBlock } from '../game/distrans-timing'; + +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, + }); + 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); + } + }); +} diff --git a/tests/distrans-timing.test.ts b/tests/distrans-timing.test.ts index 43c3899c..1a0e8b19 100644 --- a/tests/distrans-timing.test.ts +++ b/tests/distrans-timing.test.ts @@ -15,18 +15,18 @@ void test('Distrans quotes a virgin physical ordinary lot without rewriting its assert.equal(block({ phase: 3, auction: null }), null); }); -void test('Distrans first bid or pass and committed aid retain the ordinary-lot timing boundary', () => { +void test('Distrans permits completed ordinary bids and passes while rejecting invalid lots', () => { const g = fresh(); + for (const auction of [{ ...g.auction, bid: 1, bidder: 'a' }, { ...g.auction, passed: ['a'] }, + { ...g.auction, bid: 2, bidder: 'a', allyPayment: 1 }]) assert.equal(block({ ...g, auction }), null); for (const auction of [ - { ...g.auction, bid: 1, bidder: 'a' }, - { ...g.auction, passed: ['a'] }, { ...g.auction, bidder: 'a' }, { ...g.auction, allyPayment: 1 }, { ...g.auction, index: -1 }, { ...g.auction, index: 1 }, { ...g.auction, index: 0.5 }, { ...g.auction, cards: [] }, - ]) assert.match(block({ ...g, auction })!, /after bidding starts/); + ]) assert.match(block({ ...g, auction })!, /unresolved auction state/); assert.ok(block({ ...g, phase: 4 })); }); @@ -50,14 +50,16 @@ void test('Distrans can be used provisionally before the first decision in a gen assert.deepEqual(normal, before); }); -void test('a first pass, bid, zero sealed submission, funding or invalid special lot closes the pre-bid opportunity', () => { +void test('completed special bids remain available while malformed lots and funding are rejected', () => { const once = special('onceAround'), silent = special('silent'), normal = special('normal', 'blackMarket'); const pass = submitRicheseBid(once, { event: once.event, actor: once.active!, amount: null }, 0); const bid = submitRicheseBid(once, { event: once.event, actor: once.active!, amount: 1 }, 1); const sealed = submitRicheseBid(silent, { event: silent.event, actor: 'a', amount: 0 }, 0); const normalPass = submitRicheseBid(normal, { event: normal.event, actor: normal.active!, amount: null }, 0); const normalBid = submitRicheseBid(normal, { event: normal.event, actor: normal.active!, amount: 1 }, 1); - for (const lot of [pass, bid, sealed, normalPass, normalBid, + for (const lot of [pass, bid, sealed, normalPass, normalBid]) + assert.equal(block({ phase: 3, auction: null, richeseAuction: lot }), null); + for (const lot of [ { ...once, event: '' }, { ...once, active: 'b' }, { ...once, cardId: '' },