From f50e4365067b276a260744e2a215f79fe70ee589 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:12:05 +1100 Subject: [PATCH] Allow Distrans before Richese bidding starts --- components/distrans.tsx | 6 +- docs/CURRENT_STATUS.md | 15 +++ docs/IMPLEMENTATION_STATUS.md | 18 +++ docs/RICHESE_DISTRANS_ENGINE_AUDIT.md | 36 +++++- docs/RULE_DECISIONS.md | 9 ++ game/distrans-timing.ts | 36 +++++- game/reference.ts | 10 +- tests/distrans-black-market-prebid.test.ts | 81 ++++++++++++ tests/distrans-prebid-controls.test.ts | 10 +- tests/distrans-richese-controls.test.ts | 74 +++++++++++ tests/distrans-richese-prebid.test.ts | 138 +++++++++++++++++++++ tests/distrans-timing.test.ts | 51 +++++++- tests/fixture-distrans-prebid.ts | 9 ++ tests/fixture-distrans-richese-prebid.ts | 42 +++++++ 14 files changed, 511 insertions(+), 24 deletions(-) create mode 100644 tests/distrans-black-market-prebid.test.ts create mode 100644 tests/distrans-richese-controls.test.ts create mode 100644 tests/distrans-richese-prebid.test.ts create mode 100644 tests/fixture-distrans-richese-prebid.ts diff --git a/components/distrans.tsx b/components/distrans.tsx index 746b77cb..71aac95a 100644 --- a/components/distrans.tsx +++ b/components/distrans.tsx @@ -51,9 +51,9 @@ export function Distrans({

Provisional bid timing: you may transfer before the first bid or pass - on an ordinary auction lot. Once anyone bids or passes, that lot stays - blocked while unresolved. Special auctions keep their existing - restrictions. + 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.

{choice && ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index a5e1d958..8cd642df 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,21 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Distrans before Richese special bidding — 27 October. Prototyped / Partial.** +An untouched Once Around or Silent cache/Black Market lot or normal Black Market +lot provisionally allows the existing transfer before the first bid, pass or sealed submission. Preserve +its original card, eligibility and order; current hand capacity still applies. +Started lots retain their separate timing boundary. +[Source and scope](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-richese-special-lot--27-october-2026). + +Six focused files pass 53/53. Native public R/G/A/E games buy Distrans in an +earlier cache lot, then exercise Basic/Advanced Once Around and Silent and an +Advanced normal Black Market lot through actual actions; four server-rendered +transfer callbacks use the native quote. Four capacity supplements explicitly +stage conserved cards before the real lot and transfer to verify automatic +pass/zero and unchanged original eligibility. Stateful selectors, browser, +mobile, deployment and complete-mode acceptance remain unverified. + **Richese canceled gift retry — 27 October. Prototyped / Partial.** A canceled card may be offered again in the same phase under a provisional one-use interpretation. Each declaration has its own response, and the card, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 7d1de94c..c9675866 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,23 @@ # Dune implementation status +## 27 October 2026 — Distrans before Richese special bidding + +Prototype the existing pre-bid timing interpretation for untouched Once Around +and Silent cache/Black Market lots and normal Black Market lots. The original lot +and eligible order persist; the first bid, pass or sealed submission closes this +window. Started lots remain guarded. This composition is provisional, not a new +publisher clarification. Complete interactions, modes and deployed acceptance +remain open. [Contract](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-richese-special-lot--27-october-2026). + +Six focused files pass 53/53. Public R/G/A/E setups select unopened initial +deals/Spice and scalar lotteries, earn Distrans through a real Guild cache bid, +and open turn-two Basic/Advanced Once Around/Silent and Advanced normal Black +Market lots through actual decisions. Four server-rendered transfer callbacks +apply the native quote. Four additional hand-capacity cases explicitly stage +conserved cards before the real declaration and transfer; automatic pass/zero +and original ineligibility remain. This does not exercise stateful selectors, +browser hydration, mobile, deployment or complete modes. + ## 27 October 2026 — Richese canceled gift retry Prototype the Basic alliance gift retry under the standing selected-interpretation diff --git a/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md b/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md index 3fe6c959..452ccfa7 100644 --- a/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md +++ b/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md @@ -2,6 +2,31 @@ 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. +## Untouched Richese special lot — 27 October 2026 + +**Prototyped / Partial.** 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 +may occur without reopening the auction or changing its event, card, eligible +players, order or commitments. A zero Silent submission and an automatic +full-hand pass count as the first action and close the window. Current hand +limits and the original eligible set continue to govern later bids. Already- +started special lots keep their existing boundary. +This is a provisional application of the printed during-a-bid exception, not a +specific publisher ruling. Self-transfer, existing reservations and other +priority boundaries remain unchanged. + +Native public R/G/A/E test sources earn Guild's Distrans through a real cache +purchase and advance to the original turn-two lot. Basic and Advanced Once +Around/Silent paths and an Advanced normal Black Market path preserve their +original lot across the transfer, while the first accepted bidding action +closes the timing window. Four rendered action-leaf callbacks use real private +quotes. Separate explicitly controlled, card-conserving capacity supplements +stage hands before the native declaration to exercise automatic pass/zero and +preservation of the excluded donor. Stateful selectors, browser/mobile and +deployed play remain unverified. + ## Future printed-use commitment — 27 October 2026 Whole Truthtrance questions can name actual future printed Distrans use this @@ -27,9 +52,10 @@ do not change. The existing completed paid-sale allowance remains. Bidding, an actual current ordinary card, zero bid, no bidder/passes/contribution and no Richese special lot. Current inspection does not consume this window. The first accepted bid or pass closes it for that lot; no action resets the -bid history to permit a transfer. Once Around/Silent unpaid lots, self-transfer, -Truthtrance, exact physical reservations and pending capacity commitments keep -their existing restrictions. This does not certify every auction substep. +bid history to permit a transfer. The Once Around/Silent restriction recorded +at this historical checkpoint is superseded above. Self-transfer, Truthtrance, +exact physical reservations and pending capacity commitments keep their +existing restrictions. This does not certify every auction substep. The existing action, private projection and selectors remain. Persistent human guidance identifies the provisional span. Both outgoing cards and the recipient @@ -89,7 +115,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 26 October selected policy above now permits an untouched ordinary lot before its first bid/pass; live normal/Once Around/Silent lots, including unsettled payment, retain the explicit 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. The earlier open question is superseded only by that selected provisional scope. +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. 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). @@ -159,7 +185,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. The 26 October prototype admits untouched ordinary lots; live bids, unpaid special lots 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. Started bids and self-transfer remain guarded. 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 59b1effb..deeb1b3f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Distrans before Richese special bidding — 27 October 2026 + +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, +order and commitments; live hand capacity still controls subsequent bidding. +This is an interpretation of the printed during-a-bid exception, not a +publisher clarification. [Source and scope](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-richese-special-lot--27-october-2026). + ## Richese canceled gift retry — 27 October 2026 Provisionally allow a fresh offer of the same retained Richese card in the same diff --git a/game/distrans-timing.ts b/game/distrans-timing.ts index 3ba36a23..fe3114fd 100644 --- a/game/distrans-timing.ts +++ b/game/distrans-timing.ts @@ -1,3 +1,5 @@ +import type { RicheseAuction } from './richese-auction'; + type DistransAuctionContext = { phase: number; auction: { @@ -8,16 +10,44 @@ type DistransAuctionContext = { passed: readonly string[]; allyPayment?: number; } | null; - richeseAuction?: unknown; + richeseAuction?: RicheseAuction | null; + richeseFunding?: Record | null; 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. - * Other unpaid auctions retain their separate timing boundary. */ + * An untouched Richese lot likewise has no bid yet. + * Once any bidder acts, even a pass or a zero sealed bid, it is closed. */ 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; const lot = g.auction; if ( g.phase === 3 && !g.richeseAuction && lot && @@ -26,5 +56,5 @@ export function distransAuctionBlock(g: DistransAuctionContext): string | null { 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; special-auction timing remains guarded.'; + return 'Distrans is unavailable after bidding starts on this lot. Finish its auction first; other special-auction timing remains guarded.'; } diff --git a/game/reference.ts b/game/reference.ts index 3882de3c..7f375f58 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -752,7 +752,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, an untouched ordinary auction lot allows Distrans before its first bid or pass. Opening or inspecting that lot does not start the bid; its dealt cards and bidding order stay the same. Active bids and unpaid special auctions remain guarded, while a completed purchase income response may allow use. Giving Distrans itself remains guarded.', + '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.', '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.', '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.', @@ -772,7 +772,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Synchronous two-card custody, private transfer, normal disposal, reservations and prospective checks are integrated. The provisional untouched ordinary-lot window admits use before its first bid/pass and rechecks live bid capacity. Active bids, unpaid special auctions, competing transactions and self-transfer retain their separate boundaries.', + '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.', }, { area: 'Player controls', @@ -796,7 +796,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - 'Pure timing/custody, existing engine/bot regressions, fourteen genuine Basic/Advanced pre-bid cases and seven controlled rendering/callback cases cover the implemented span. Native sources buy the actual cache card, earn capacities through purchases and complete the same ordinary lot. Full Richese games, browser/mobile/deployed acceptance and final assurance remain due.', + 'Pure timing/custody and focused native Basic/Advanced special-lot cases exercise real cache purchases, preserved auction order and action-leaf callbacks. A separate capacity supplement stages conserved cards before a real lot; it is not earned hand history. Full Richese games, stateful selectors, browser/mobile/deployed acceptance and final assurance remain due.', evidence: [ 'tests/distrans.test.ts', 'tests/distrans-engine.test.ts', @@ -805,6 +805,10 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/fixture-distrans-prebid.ts', 'tests/distrans-prebid-engine.test.ts', 'tests/distrans-prebid-controls.test.ts', + 'tests/fixture-distrans-richese-prebid.ts', + 'tests/distrans-richese-prebid.test.ts', + 'tests/distrans-richese-controls.test.ts', + 'tests/distrans-black-market-prebid.test.ts', 'components/distrans-transfer-action.tsx', 'tests/distrans-engine-review.test.ts', 'tests/distrans-bots.test.ts', diff --git a/tests/distrans-black-market-prebid.test.ts b/tests/distrans-black-market-prebid.test.ts new file mode 100644 index 00000000..8e1cfe5a --- /dev/null +++ b/tests/distrans-black-market-prebid.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { + actDistransPrebid, advanceDistransPrebid, createDistransPrebidFixture, + distransPrebidClean, distransPrebidInventory, distransPrebidPlayer as player, + distransPrebidPosition, type DistransPrebidStep, +} from './fixture-distrans-prebid'; + +/** The original Advanced public table actually sells Distrans to Guild for one + * spice in the first cache auction. Continue through real phase actions to the + * next turn; Richese offers its own originally dealt card in the native Black + * Market. Only the underlying fixture's unopened card/Spice order and scalar + * lotteries are selected. No played hand, price, auction or phase is installed. */ +function source() { + const f = createDistransPrebidFixture({ advanced: true }); + const actions: DistransPrebidStep[] = structuredClone(f.actions); + let game = advanceDistransPrebid(f.game, state => state.turn === 2 && state.phase === 3 && + !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard && + state.decision?.kind === 'richeseBlackMarket', actions); + const beforeMarket = structuredClone(game); + const seller = game.decision!.player; + const physical = player(game, seller).hand[0]; assert.ok(physical); + const declaration: Action = { type: 'decision', event: game.richeseBidding!.event, + card: physical.id, method: 'normal', direction: 'clockwise' }; + game = actDistransPrebid(game, seller, declaration, actions); + game = advanceDistransPrebid(game, state => distransPrebidClean(state) && + state.richeseAuction?.source === 'blackMarket' && state.richeseAuction.method === 'normal', actions); + const lot = game.richeseAuction!; + assert.equal(lot.cardId, physical.id); + assert.equal(lot.bid, 0); assert.equal(lot.bidder, null); + assert.deepEqual(lot.passed, []); assert.deepEqual(lot.acted, []); + assert.ok(player(game, seller).hand.some(c => c.id === physical.id)); + 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); + return { ...f, game, actions, beforeMarket, seller, physical, declaration }; +} +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => actDistransPrebid(game, actor, action)); + assert.deepEqual(game, before); +} + +void test('genuine Advanced Black Market normal lot remains unbid through earned Distrans transfer', () => { + const f = source(), before = structuredClone(f.game); + assert.equal(f.beforeMarket.decision?.kind, 'richeseBlackMarket'); + assert.ok(f.acquisition.actions.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.equal(viewGame(before, f.holder).distrans!.blocked, null); + assert.ok(viewGame(before, f.holder).distrans!.choices.find(c => c.recipient === f.recipient)!.cards.some(c => c.id === f.give)); + let game = actDistransPrebid(f.game, f.holder, f.action); + assert.deepEqual(f.game, before); + game = advanceDistransPrebid(game, distransPrebidClean); + assert.deepEqual(distransPrebidPosition(game), distransPrebidPosition(before)); + assert.deepEqual(player(game, f.holder).hand, player(before, f.holder).hand.filter(c => c.id !== f.card && c.id !== f.give)); + assert.deepEqual(player(game, f.recipient).hand, [...player(before, f.recipient).hand, + player(before, f.holder).hand.find(c => c.id === f.give)!]); + assert.ok(player(game, f.seller).hand.some(c => c.id === f.physical.id)); + assert.equal(game.discard.filter(c => c.id === f.card).length, 1); + assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + const lot = game.richeseAuction!, actor = lot.active!; assert.ok(actor); + game = actDistransPrebid(game, actor, { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 }); + assert.equal(game.richeseAuction!.bid, 1); + assert.equal(game.richeseAuction!.bidder, actor); + assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + rejected(game, f.holder, f.action); +}); + +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`, () => { + 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); + 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-prebid-controls.test.ts b/tests/distrans-prebid-controls.test.ts index 9ed793d5..67537e28 100644 --- a/tests/distrans-prebid-controls.test.ts +++ b/tests/distrans-prebid-controls.test.ts @@ -81,8 +81,8 @@ void test('an entitled untouched ordinary lot keeps the transfer enabled in Basi } }); -void test('live bid, pass-only and special-auction blocks come from the authoritative projection', () => { - for (const context of ['live bid', 'pass-only lot', 'special auction']) { +void test('live bid, pass-only and started special-auction blocks come from the authoritative projection', () => { + for (const context of ['live bid', 'pass-only lot', 'started special auction']) { const game = projected(); const reason = `Controlled ${context}: Distrans is unavailable until this lot resolves.`; if (context === 'live bid') { game.auction!.bid = 2; game.auction!.bidder = 't'; } @@ -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 auction lot/); - assert.match(html, /Once anyone bids or passes, that lot stays blocked while unresolved/); - assert.match(html, /Special auctions keep their existing restrictions/); + 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, /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-richese-controls.test.ts b/tests/distrans-richese-controls.test.ts new file mode 100644 index 00000000..9395b985 --- /dev/null +++ b/tests/distrans-richese-controls.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { DistransTransferAction, type DistransTransferActionProps } from '../components/distrans-transfer-action'; +import { viewGame, type Action, type GameView } from '../game/engine'; +import { actDistransPrebid, distransPrebidInventory, distransPrebidPlayer } from './fixture-distrans-prebid'; +import { createDistransRichesePrebidFixture } from './fixture-distrans-richese-prebid'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context); + }, +}); +const { Distrans } = await import('../components/distrans'); +aliases.deregister(); + +function markup(game: GameView) { + return renderToStaticMarkup(createElement(Distrans, { game, busy: false, act() {} })); +} +function transferButton(html: string) { + const button = html.match(/]*data-testid="distrans-transfer")[^>]*>[\s\S]*?<\/button>/)?.[0]; + assert.ok(button, 'The projected transfer action is present'); + return button; +} + +for (const advanced of [false, true]) for (const method of ['onceAround', 'silent'] as const) { + void test(`${advanced ? 'Advanced' : 'Basic'} ${method} native unopened lot projects and executes the Distrans control`, () => { + const f = createDistransRichesePrebidFixture({ advanced, method }); + const before = structuredClone(f.game), view = viewGame(f.game, f.holder), quote = view.distrans; + assert.ok(quote); + assert.equal(quote.blocked, null); + const choice = quote.choices.find(candidate => candidate.recipient === f.recipient); + assert.ok(choice); + assert.equal(choice.blocked, null); + const given = choice.cards.find(card => card.id === f.give); + 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/); + + // Stateful selectors render above; exercise the real action leaf with the + // exact entitled recipient/card choice from the native engine projection. + const actions: Action[] = []; + const props: DistransTransferActionProps = { + card: quote.card, + recipient: { id: choice.recipient, name: view.players.find(player => player.id === choice.recipient)?.name }, + given, reason: null, reasonId: 'native-richese-reason', busy: false, + act(action) { actions.push(action); }, + }; + const button = DistransTransferAction(props); + button.props.onClick(); + assert.deepEqual(actions, [f.action]); + const after = actDistransPrebid(f.game, f.holder, actions[0]); + assert.deepEqual(f.game, before); + assert.ok(!distransPrebidPlayer(after, f.holder).hand.some(card => card.id === f.give || card.id === f.card)); + assert.equal(distransPrebidPlayer(after, f.recipient).hand.filter(card => card.id === f.give).length, 1); + assert.equal(after.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(distransPrebidInventory(after), f.originalInventory); + assert.equal(after.richeseAuction?.event, before.richeseAuction?.event); + assert.equal(after.richeseAuction?.method, method); + + const lot = before.richeseAuction!; + const actor = method === 'onceAround' ? lot.active! : + 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=/); + }); +} diff --git a/tests/distrans-richese-prebid.test.ts b/tests/distrans-richese-prebid.test.ts new file mode 100644 index 00000000..19b0d4fa --- /dev/null +++ b/tests/distrans-richese-prebid.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { distransAuctionBlock } from '../game/distrans-timing'; +import { + actDistransPrebid, advanceDistransPrebid, distransPrebidClean, + distransPrebidInventory, distransPrebidPlayer as player, distransPrebidPosition, +} from './fixture-distrans-prebid'; +import { createDistransRichesePrebidFixture } from './fixture-distrans-richese-prebid'; + +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => actDistransPrebid(game, actor, action)); + assert.deepEqual(game, before); +} +function firstSubmission(game: Game, amount?: number | null) { + const lot = game.richeseAuction!; + const actor = lot.method === 'silent' + ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id))! + : lot.active!; + assert.ok(actor); + return { actor, action: { type: 'richeseBid', event: lot.event, + amount: amount === undefined ? (lot.method === 'silent' ? 0 : null) : amount, + allyPayment: 0 } as Action }; +} + +for (const advanced of [false, true]) for (const method of ['onceAround', 'silent'] as const) { + const prefix = `${advanced ? 'Advanced' : 'Basic'} native ${method}`; + void test(`${prefix} preserves the unbid original cache lot through a genuine Distrans transfer`, () => { + const f = createDistransRichesePrebidFixture({ advanced, method }); + assert.equal(f.initial.status, 'lobby'); + assert.equal(f.acquisition.before.richeseCache!.some(card => card.id === f.card), true); + assert.ok(f.acquisition.actions.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.equal(f.beforeDeclaration.turn, 2); + assert.equal(f.beforeDeclaration.decision?.kind, 'richeseCache'); + const before = structuredClone(f.game); + assert.equal(viewGame(before, f.holder).distrans!.blocked, null); + assert.ok(viewGame(before, f.holder).distrans!.choices.find(c => c.recipient === f.recipient)!.cards.some(c => c.id === f.give)); + let game = actDistransPrebid(f.game, f.holder, f.action); + assert.deepEqual(f.game, before); + game = advanceDistransPrebid(game, distransPrebidClean); + assert.deepEqual(distransPrebidPosition(game), distransPrebidPosition(before)); + assert.deepEqual(player(game, f.holder).hand, player(before, f.holder).hand.filter(c => c.id !== f.card && c.id !== f.give)); + assert.deepEqual(player(game, f.recipient).hand, [...player(before, f.recipient).hand, + player(before, f.holder).hand.find(c => c.id === f.give)!]); + assert.equal(game.discard.filter(c => c.id === f.card).length, 1); + assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + rejected(game, f.holder, f.action); + const start = firstSubmission(game); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, start.actor); + view.players.find(p => p.id === start.actor)!.bot = difficulty; + const action = botActions(view)[0]; + assert.equal(action?.type, 'richeseBid', `${difficulty} can act on the original lot`); + const next = actDistransPrebid(game, start.actor, action); + assert.ok(next.richeseAuction?.acted.includes(start.actor)); + } + game = actDistransPrebid(game, start.actor, start.action); + assert.ok(game.richeseAuction?.acted.includes(start.actor)); + 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`, () => { + 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); + 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); + }); + } + void test(`${prefix} rejects a full recipient before spending either physical card`, () => { + const f = createDistransRichesePrebidFixture({ advanced, method }); + // Controlled custody supplement: move real undealt deck cards into the + // recipient hand. This is not claimed as an earned acquisition sequence. + const game = structuredClone(f.game), recipient = player(game, f.recipient); + while (recipient.hand.length < handLimit(recipient)) recipient.hand.push(game.deck.shift()!); + assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + const choice = viewGame(game, f.holder).distrans!.choices.find(c => c.recipient === f.recipient)!; + assert.ok(choice.blocked); + 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); + }); + void test(`${prefix} fills the active recipient, automatically closes that bid and keeps an originally full donor ineligible`, () => { + const f = createDistransRichesePrebidFixture({ advanced, method }); + // Controlled custody supplement: move only actual undealt deck cards to + // hands. These are not claimed as earned purchases. The declaration and + // transfer still use real actions and preserve the physical inventory. + const staged = structuredClone(f.beforeDeclaration), donor = player(staged, f.holder); + while (donor.hand.length < handLimit(donor)) { + const card = staged.deck.shift(); assert.ok(card); donor.hand.push(card); + } + assert.deepEqual(distransPrebidInventory(staged), f.originalInventory); + let game = actDistransPrebid(staged, staged.decision!.player, f.declaration); + const lot = game.richeseAuction!; + assert.ok(lot); + assert.ok(!lot.eligible.includes(f.holder), 'The full donor was excluded at declaration'); + const recipientId = method === 'silent' + ? lot.order.find(id => lot.eligible.includes(id) && id !== f.holder)! + : lot.active!; + assert.ok(recipientId && recipientId !== f.holder); + const recipient = player(game, recipientId); + while (recipient.hand.length < handLimit(recipient) - 1) { + const card = game.deck.shift(); assert.ok(card); recipient.hand.push(card); + } + assert.equal(recipient.hand.length, handLimit(recipient) - 1); + assert.deepEqual(distransPrebidInventory(game), f.originalInventory); + const originalLot = structuredClone(lot); + const action: Action = { ...f.action, target: recipientId }; + 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); + 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); + const next = game.richeseAuction!; + assert.ok(next); + assert.equal(next.event, originalLot.event); + assert.equal(next.cardId, originalLot.cardId); + assert.equal(next.source, originalLot.source); + assert.deepEqual(next.order, originalLot.order); + assert.deepEqual(next.eligible, originalLot.eligible); + assert.deepEqual(next.tieOrder, originalLot.tieOrder); + assert.ok(!next.eligible.includes(f.holder), 'Freeing space does not add a bidder to this lot'); + 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'); + }); +} diff --git a/tests/distrans-timing.test.ts b/tests/distrans-timing.test.ts index 0647751a..43c3899c 100644 --- a/tests/distrans-timing.test.ts +++ b/tests/distrans-timing.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { distransAuctionBlock as block } from '../game/distrans-timing'; +import { createRicheseAuction, submitRicheseBid } from '../game/richese-auction'; const fresh = () => ({ phase: 3, auction: { cards: ['original-lot'], index: 0, bid: 0, bidder: null as string | null, active: 'a', passed: [] as string[], @@ -14,7 +15,7 @@ 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, committed aid and every special unpaid lot retain their timing boundary', () => { +void test('Distrans first bid or pass and committed aid retain the ordinary-lot timing boundary', () => { const g = fresh(); for (const auction of [ { ...g.auction, bid: 1, bidder: 'a' }, @@ -27,10 +28,50 @@ void test('Distrans first bid or pass, committed aid and every special unpaid lo { ...g.auction, cards: [] }, ]) assert.match(block({ ...g, auction })!, /after bidding starts/); assert.ok(block({ ...g, phase: 4 })); - for (const method of ['onceAround', 'silent']) { - assert.ok(block({ ...g, richeseAuction: { method, bid: 0, acted: [] } })); - assert.ok(block({ phase: 3, auction: null, richeseAuction: { method, bid: 0, acted: [] } })); - } +}); + +function special(method: 'onceAround' | 'silent' | 'normal', source: 'cache' | 'blackMarket' = 'cache') { + return createRicheseAuction({ event: 'lot-event', cardId: 'physical-card', source, + owner: 'r', method, eligible: ['a', 'r', 'b'], order: ['a', 'r', 'b'], + tieOrder: ['r', 'a', 'b'] }); +} + +void test('Distrans can be used provisionally before the first decision in a genuine Richese special lot', () => { + for (const method of ['onceAround', 'silent'] as const) + for (const source of ['cache', 'blackMarket'] as const) { + const lot = special(method, source), before = structuredClone(lot); + assert.equal(block({ phase: 3, auction: null, richeseAuction: lot, richeseFunding: {} }), null); + assert.deepEqual(lot, before); + assert.ok(block({ phase: 4, auction: null, richeseAuction: lot })); + assert.ok(block({ ...fresh(), richeseAuction: lot })); + } + const normal = special('normal', 'blackMarket'), before = structuredClone(normal); + assert.equal(block({ phase: 3, auction: null, richeseAuction: normal, richeseFunding: {} }), null); + assert.deepEqual(normal, before); +}); + +void test('a first pass, bid, zero sealed submission, funding or invalid special lot closes the pre-bid opportunity', () => { + 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, + { ...once, event: '' }, + { ...once, active: 'b' }, + { ...once, cardId: '' }, + { ...once, order: ['a', 'a', 'r'] }, + { ...once, tieOrder: ['a', 'r', 'r'] }, + { ...once, outcome: { kind: 'unbid' as const } }, + { ...normal, source: 'cache' as const }, + { ...normal, active: 'r' }, + { ...silent, active: 'a' }, + { ...silent, passed: ['a'] }, + { ...silent, acted: ['a'] }, + { ...silent, sealed: { a: 0 } }, + ]) assert.ok(block({ phase: 3, auction: null, richeseAuction: lot })); + assert.ok(block({ phase: 3, auction: null, richeseAuction: once, richeseFunding: { a: { amount: 0 } } })); }); void test('Distrans keeps the existing paid-sale allowance independently of the later income response', () => { diff --git a/tests/fixture-distrans-prebid.ts b/tests/fixture-distrans-prebid.ts index 36dc267f..eb66c477 100644 --- a/tests/fixture-distrans-prebid.ts +++ b/tests/fixture-distrans-prebid.ts @@ -58,6 +58,15 @@ export function actDistransPrebid(game: Game, actor: string, action: Action, * auction and phase action retains the existing neutral native policy. */ export function nextDistransPrebidStep(game: Game): DistransPrebidStep { if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + // The real Advanced opening has one current owner at a time. Finish that + // owner's optional Charity/Revival opportunity using the public action; + // never stage or reorder the persisted phase frame. + if (!game.decision && !game.truthtrance && !game.pendingNullentropy) { + if (game.phase === 2 && game.charityOrder?.remaining[0]) + return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } }; + if (game.phase === 4 && game.revivalOrder?.remaining[0]) + return { actor: game.revivalOrder.remaining[0], action: { type: 'ready' } }; + } const decision = game.decision; if (decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === DISTRANS_PREBID_CARD)) diff --git a/tests/fixture-distrans-richese-prebid.ts b/tests/fixture-distrans-richese-prebid.ts new file mode 100644 index 00000000..51904cf8 --- /dev/null +++ b/tests/fixture-distrans-richese-prebid.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import type { Action } from '../game/engine'; +import { + actDistransPrebid, advanceDistransPrebid, createDistransPrebidFixture, + distransPrebidClean, distransPrebidInventory, distransPrebidPlayer, + type DistransPrebidStep, +} from './fixture-distrans-prebid'; + +/** Original public Basic/Advanced table, with Guild's actual one-spice cache + * purchase of Distrans and its original dealt gift card. Continue that native + * table to turn two, then use Richese's real cache declaration; no played hand, + * purse, phase, auction, lot or submission is installed. The underlying fixture + * selects only unopened deal/Spice positions and native scalar lotteries. */ +export function createDistransRichesePrebidFixture(options: { + advanced?: boolean; + method: 'onceAround' | 'silent'; +}) { + const original = createDistransPrebidFixture({ advanced: options.advanced }); + const actions: DistransPrebidStep[] = structuredClone(original.actions); + let game = advanceDistransPrebid(original.game, + state => state.turn === 2 && state.decision?.kind === 'richeseCache' && + !state.response && !state.phaseOpening && !state.pendingTreacheryDiscard, + actions); + const beforeDeclaration = structuredClone(game); + const actor = game.decision!.player; + const declaration: Action = { type: 'decision', event: game.richeseBidding!.event, + card: game.richeseCache![0].id, method: options.method, + ...(options.method === 'onceAround' ? { direction: 'counterclockwise' } : {}) }; + game = actDistransPrebid(game, actor, declaration, actions); + assert.ok(distransPrebidClean(game)); + assert.ok(game.richeseAuction); + assert.equal(game.richeseAuction.method, options.method); + assert.equal(game.richeseAuction.outcome, null); + assert.equal(game.richeseAuction.acted.length, 0); + assert.equal(Object.keys(game.richeseAuction.sealed).length, 0); + assert.ok(distransPrebidPlayer(game, original.holder).hand.some(card => card.id === original.card)); + assert.ok(distransPrebidPlayer(game, original.holder).hand.some(card => card.id === original.give)); + assert.deepEqual(distransPrebidInventory(game), original.originalInventory); + return { ...original, game, actions, beforeDeclaration, declaration, method: options.method, + sourceActionCount: actions.length, + staging: [...original.staging, 'The turn-two cache offer and method use actual native actions after the earned Distrans purchase.'] }; +}