diff --git a/components/distrans-transfer-action.tsx b/components/distrans-transfer-action.tsx
new file mode 100644
index 00000000..28fafdc0
--- /dev/null
+++ b/components/distrans-transfer-action.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import type { Card } from '@/game/cards';
+import type { Action } from '@/game/engine';
+import { Button } from './ui/button';
+
+export interface DistransTransferActionProps {
+ card: Pick;
+ recipient: { id: string; name?: string } | undefined;
+ given: Pick | undefined;
+ reason: string | null;
+ reasonId: string;
+ busy: boolean;
+ act: (action: Action) => void;
+}
+
+export function DistransTransferAction({
+ card,
+ recipient,
+ given,
+ reason,
+ reasonId,
+ busy,
+ act,
+}: DistransTransferActionProps) {
+ const disabled = busy || !!reason || !recipient || !given;
+ return (
+
+ );
+}
diff --git a/components/distrans.tsx b/components/distrans.tsx
index 04501a59..746b77cb 100644
--- a/components/distrans.tsx
+++ b/components/distrans.tsx
@@ -2,8 +2,8 @@
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
-import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
+import { DistransTransferAction } from './distrans-transfer-action';
export function Distrans({
game,
@@ -49,6 +49,12 @@ export function Distrans({
discard Distrans. No spice is paid. The transfer happens immediately,
without a recipient confirmation or Karama response.
+
+ 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.
+
Minimum bid: {minimumBid} spice.
+ {normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) &&
+ ' Your Treachery hand is full; pass this bid.'}
{maximumBid < minimumBid &&
' You cannot afford a higher bid; pass this card.'}
@@ -4329,7 +4332,8 @@ export function GameTable({
amount: bidAmount,
...payment,
},
- !Number.isSafeInteger(bidAmount) ||
+ !!normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit) ||
+ !Number.isSafeInteger(bidAmount) ||
bidAmount < minimumBid ||
bidAmount > maximumBid,
)}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index d267144c..4bdd8837 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,41 @@ open mode gates.
## Current checkpoint and work
+**Distrans before the first bid/pass — 26 October. Prototyped / Partial.**
+An untouched ordinary lot now allows the private card transfer before its first
+bid or pass, with the same dealt pool/index/opener/order/active seat. The span is
+explicitly provisional; active bids, unpaid special auctions and self-transfer
+keep their restrictions. Current capacity controls new bids after a gift: a full
+active seat sees Raise disabled, can Pass, and all four minimal profiles do so.
+Existing card action/projection and the paid-sale allowance remain.
+
+Two disjoint native/control lanes and root integration pass **10 files,68/68**,
+types/lint/build and **eight native engine/GameTable SSR/actual leaf-callback
+paths** on unchanged source. Original public R/G/A/E sources buy Distrans for1
+and give the original Guild Shield. Native purchases earn full-donor and final-
+slot recipient positions; real Pass and payment finish the same lot. A genuinely
+full recipient rejects at turn2. All45 original Treachery remain. Source actions
+are Basic33/42/44/87 and Advanced38/47/49/96 for ordinary/donor-full/final-slot/
+already-full paths. Fourteen native cases and seven controlled rendering cases
+cover the new span; existing quote/bot/auction regressions remain green.
+
+Types/lint/build/test proof: private `20261026-distrans-prebid-proof-v2/`;
+runtime companion and composed same-source evidence:
+`/home/quintine/.local/share/dune/checkpoints/20261026-distrans-prebid-runtime-proof-v3/`;
+journeys/HTML: adjacent `20261026-distrans-prebid-runtime/`.
+The private runtime selector was repaired for the button's actual arrow SVG;
+application source stayed unchanged. No saved-room/D1/server, reset/restart,
+deployment or recovery operation occurs. SSR/callbacks do not prove DOM/hydration,
+browser/mobile/deployed acceptance. Complete rules/combinations and final
+assurance remain open. [Source and policy](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026).
+
+Next concrete Basic function gap: Truthtrance's structured-question engine
+accepts two through eight facts, but the authoring panel only exposes two.
+Connect add/remove fact controls to the existing AND/OR action with actual
+aggregate-answer/resumption evidence. See `components/truthtrance.tsx`,
+`game/truthtrance.ts`, `tests/fixture-truth-movement-early.ts` and
+[the source contract](TRUTHTRANCE_CARD_COUNT.md).
+
**Shared Ecaz Ambassador Duke — 26 October. Prototyped / Partial.**
The triggered token can now grant its living Duke to Ecaz's current reciprocal
ally. Per-recipient controls and all four minimal profiles use the server's
@@ -42,13 +77,6 @@ Actual hook-free callbacks and SSR are not DOM/hydration or browser/mobile/
deployed acceptance. Complete rules/combinations, mode gates and final assurance
remain open. [Source and selected tenure](ECAZ_DUKE_ACQUISITION.md#existing-ally-direct-acquisition--26-october-2026).
-Next concrete Basic function gap: Distrans rejects even an untouched ordinary
-auction lot before the first bid/pass. Scope that pre-bid transfer under the
-14 October recommended-policy authority, retaining the actual card, lot/order,
-hand-limit eligibility and active-bid restrictions. See
-[the timing audit](RICHESE_DISTRANS_ENGINE_AUDIT.md), `distransBlock` and
-`tests/distrans-engine.test.ts`; broader special-auction timing remains separate.
-
**Caladan reinforcement before Face Dance — 26 October. Prototyped / Partial.**
The original winning army may now take its optional reinforcement before native
replacement. The later reveal uses the live10/11 Arrakis or3/4 Homeworld force
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 18313889..a72e45b6 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,37 @@
# Dune implementation status
+## 26 October 2026 — Distrans before the first ordinary bid/pass
+
+**Prototyped / Partial.** Select a provisional first-bid/pass span under the
+14 October authority. A real untouched ordinary lot now admits the existing
+private transfer with its dealt pool, index, opener, order and active seat intact.
+First bid/pass, unpaid special-auction and self-transfer restrictions remain;
+completed paid-sale use keeps its existing allowance. Current hand capacity is
+shared by engine/table/minimal bots, so a now-full active bidder cannot raise
+but may Pass, then the original cycle skips ineligible hands. No new saved
+field or response is introduced. Persistent controls explain the interpretation.
+
+Frozen **10-file68/68**, types/lint/build and **eight native engine/GameTable
+SSR/actual hook-free transfer-callback paths** pass. Public R/G/A/E starts buy
+the actual cache Distrans for1 and give the original Guild Shield. Real ordinary
+purchases earn both capacity cases and the full-recipient next-turn refusal.
+Source actions are Basic33/42/44/87 and Advanced38/47/49/96 for ordinary/full-
+donor/final-slot/already-full paths. Actual Pass, payment and native retirement
+finish the same lot; all45 original cards remain. Fourteen native cases, seven
+controlled rendering cases and the related existing quote/bot/auction tests
+cover the slice. Feedback corrected the controls' test expansion identifier,
+the optional own-hand count fallback and a private SSR selector's SVG assumption.
+
+Test/types/lint/build: private `20261026-distrans-prebid-proof-v2/`;
+runtime/composed same-source proof:
+`/home/quintine/.local/share/dune/checkpoints/20261026-distrans-prebid-runtime-proof-v3/`;
+journeys/HTML: adjacent `20261026-distrans-prebid-runtime/`.
+Only the private selector changed before the runtime companion; source stayed
+unchanged. No saved-room/D1/server, reset/restart, deployment or recovery
+operation. Browser/mobile/deployed acceptance, broader auction timing, complete
+rules/combinations and final assurance remain open.
+[Source and selected policy](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026).
+
## 26 October 2026 — Existing-ally Ecaz Ambassador Duke acquisition
**Prototyped / Partial.** Connect the printed benefit-sharing branch to the
diff --git a/docs/RICHESE_ACQUISITION_RULES.md b/docs/RICHESE_ACQUISITION_RULES.md
index 9205d84d..334190e2 100644
--- a/docs/RICHESE_ACQUISITION_RULES.md
+++ b/docs/RICHESE_ACQUISITION_RULES.md
@@ -97,7 +97,7 @@ The updated physical Karama explicitly includes alliance abilities among cancell
**Directly supported by the face.** Its owner gives another player one Treachery Card from the owner's hand, provided the other player's hand is not full. Any other faction can receive it, without requiring an alliance. The effect has no printed spice fee. Distrans is discarded after use; the gifted card transfers without being discarded. The printed exception is “other than during a bid.” The effect is a card effect, not itself a faction/alliance power that generic Karama cancels. No recipient accept/refuse step is printed.
-**Unresolved exact span.** No retrieved primary answer defines whether “a bid” means the complete auction of one card or only an individual bid declaration. Treating the whole Bidding phase as forbidden is broader than the face. A defensible bounded policy blocks Distrans throughout an unresolved lot, including Silent submissions and payment, while allowing it between lots when no bid is pending. Record that as an interpretation until clarified. Never permit it between Silent submissions to probe hidden commitments or invalidate fixed eligibility without a settlement policy.
+**Selected provisional span — 26 October 2026.** No retrieved primary answer defines whether “a bid” means the complete auction of one card or only an individual bid declaration. Treating the whole Bidding phase as forbidden is broader than the face. Under the 14 October recommended-policy authority, an untouched ordinary lot now permits use before its first bid or pass, with its dealt pool and cycle intact. Active bids and unpaid special lots, including Silent submissions, remain guarded; paid-sale use keeps its existing separate allowance. [Current timing and live hand-capacity contract](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026). Never use a transfer between Silent submissions to probe hidden commitments or invalidate fixed eligibility without a settlement policy.
Distrans itself as the gifted card is not resolved by the face's give-then-discard instruction. Do not put the same physical card in both the recipient's hand and discard; reject that target as unsupported pending clarification. Other reserved/committed-card and answer-first constraints are the same continuation concerns as the alliance gift. A successful ordinary transfer consumes exactly the chosen separate card plus Distrans from the donor's hand, adds the chosen card to the recipient, and puts only Distrans in normal discard. This produces a new discard event relevant to future Semuta integration.
diff --git a/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md b/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md
index 6edf89d5..54741fc0 100644
--- a/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md
+++ b/docs/RICHESE_DISTRANS_ENGINE_AUDIT.md
@@ -2,6 +2,56 @@
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 ordinary lot — 26 October 2026
+
+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
+timing composition, not a retrieved publisher clarification. It supersedes only
+that part of the original blanket unpaid-lot boundary below. Later lots may
+qualify independently; already committed lot quantity and physical deck order
+do not change. The existing completed paid-sale allowance remains.
+
+`game/distrans-timing.ts` supplies the authoritative timing block. It requires
+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.
+
+The existing action, private projection and selectors remain. Persistent human
+guidance identifies the provisional span. Both outgoing cards and the recipient
+are validated before the normal private transfer/discard; no recipient consent,
+payment or extra deal is added. The current lot/cards/index/opener/order/active
+seat remain. Full donor hands can free space, while a recipient whose final
+slot is filled cannot buy beyond its limit. Shared `normalAuctionHandBlock`
+is used by the engine, minimal AI and table controls: a now-full active player
+may pass, then the existing cycle skips ineligible hands. No new saved field,
+source kind or transfer response is introduced.
+
+Frozen evidence: **10 files,68/68**, types/lint/build and eight native
+engine/GameTable SSR/actual hook-free transfer-callback paths. The public
+R/G/A/E source buys the physical cache Distrans for1 and retains its original
+starting Shield. Actual ordinary purchases create full-donor, final-slot active
+recipient and already-full recipient positions. The last rejects at turn2;
+successful transfers finish the original lot through native bidding/payment.
+All45 original cards remain. Source actions: Basic33/42/44/87 and
+Advanced38/47/49/96 in that four-path order. Fourteen native engine and seven
+controlled rendering/callback cases supplement the existing related regressions.
+Test/types/lint/build logs are in private `20261026-distrans-prebid-proof-v2/`;
+runtime/composed same-source evidence:
+`/home/quintine/.local/share/dune/checkpoints/20261026-distrans-prebid-runtime-proof-v3/`;
+journeys/HTML: adjacent `20261026-distrans-prebid-runtime/`.
+The private smoke selector was corrected for the rendered arrow SVG without
+changing application source. No saved-room/D1/server or recovery operation;
+SSR/callbacks do not prove DOM/hydration or browser/mobile/deployed acceptance.
+
+The official publisher URL still appears in indexed search, but a fresh direct
+fetch returned403; this change uses the recorded face text and selected policy,
+not a claim of a newly published ruling. Full Richese games, wider auction
+timing, combinations and final assurance remain open.
+
## Verified printed effect
Distrans is a Special Treachery Card. Its holder gives **one other player one Treachery Card from the holder's hand**, provided the recipient has room. It can be played at any time except during a bid. Distrans is discarded after use.
@@ -28,7 +78,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. A temporary guard over unresolved normal/Once Around/Silent lots, including unsettled payment, is an explicit conservative implementation boundary. Pre-bid declaration/selection, between-lot windows and post-purchase income are distinct contexts; do not silently present a blanket phase-3 ban as the official rule. The user has already been asked about this boundary by root; this audit adds no question or assumed answer.
+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.
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).
@@ -98,7 +148,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. Open unpaid lots and self-transfer remain explicit unresolved guards.
+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.
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 22801207..1fc4fcd9 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,18 @@
# Rule decision index
+## Distrans before the first ordinary bid/pass — 26 October 2026
+
+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
+or pass. This selects a provisional span; inspection and opening the lot do not
+themselves begin that bid. Keep the original dealt pool, lot index, opener,
+cycle and active seat. Live bids, unpaid special auctions and self-transfer keep
+their existing separate restrictions; completed paid-sale use stays available.
+After a transfer, current hand capacity controls new ordinary bids: a now-full
+active seat can pass and the existing cycle recalculates eligibility.
+[Source and execution](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026).
+
## Existing-ally Ecaz Ambassador Duke acquisition — 26 October 2026
Connect printed Ambassador benefit sharing (publisher E3 p8, authorized
diff --git a/game/bots.ts b/game/bots.ts
index ef1f8b0d..7909e062 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1,4 +1,5 @@
import { discoveryBotActions } from './discovery-options';
+import { normalAuctionHandBlock } from './normal-auction';
import { atreidesFullPlanAction } from './atreides-full-plan';
import { liveMovementPromises } from './movement-promises';
import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options';
@@ -3153,6 +3154,8 @@ function policyActions(g: GameView): Action[] {
];
}
if (g.auction?.active !== me.id) return [];
+ if (normalAuctionHandBlock(me.hand?.length ?? me.handCount ?? me.handLimit, me.handLimit))
+ return [{ type: 'passBid' }];
const budget = Math.min(
(me.spice ?? 0) + g.aid.available,
[1, 3, 5, 7][level] + ((me.hand?.length ?? 0) === 0 ? 2 : 0),
diff --git a/game/distrans-timing.ts b/game/distrans-timing.ts
new file mode 100644
index 00000000..3ba36a23
--- /dev/null
+++ b/game/distrans-timing.ts
@@ -0,0 +1,30 @@
+type DistransAuctionContext = {
+ phase: number;
+ auction: {
+ cards: readonly unknown[];
+ index: number;
+ bid: number;
+ bidder: string | null;
+ passed: readonly string[];
+ allyPayment?: number;
+ } | null;
+ richeseAuction?: unknown;
+ 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. */
+export function distransAuctionBlock(g: DistransAuctionContext): string | null {
+ if (g.currentAuctionSale || (!g.auction && !g.richeseAuction)) 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; special-auction timing remains guarded.';
+}
diff --git a/game/engine.ts b/game/engine.ts
index eb7e4ea1..aa6a08d9 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -284,6 +284,7 @@ import {
type AuctionContinuationQuote,
} from './auction-continuation-quote';
import {
+ normalAuctionHandBlock,
quoteNormalAuctionBid,
quoteNormalAuctionNext,
} from './normal-auction';
@@ -438,6 +439,7 @@ import {
resolveNullentropyBox,
} from './nullentropy-box';
import { transferDistrans } from './distrans';
+import { distransAuctionBlock } from './distrans-timing';
import { SEMUTA_DRUG_ID, committedSemutaCandidates, resolveSemutaDrug, type FreshDiscardBatch, type SemutaContext } from './semuta-drug';
import {
prepareRicheseGift,
@@ -5028,11 +5030,7 @@ function distransBlock(g: Game, owner: Player, card: Card): string | null {
return 'Resolve the active Truthtrance question before changing hands.';
const reserved = transferCardBlock(g, owner, card);
if (reserved) return reserved;
- // The face excludes a bid, not the complete Bidding phase. A live unpaid lot
- // remains guarded pending the requested ruling; already-paid income is separate.
- if ((g.auction || g.richeseAuction) && !g.currentAuctionSale)
- return 'Distrans during an unresolved auction lot awaits a ruling. It remains available outside that bid.';
- return null;
+ return distransAuctionBlock(g);
}
function validateDistrans(
g: Game,
@@ -37543,6 +37541,8 @@ function applyActionInner(
);
const a = g.auction;
if (t === 'bid') {
+ const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
+ requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
const quotedBid = quoteNormalAuctionBid({
currentBid: a.bid,
amount: action.amount,
diff --git a/game/normal-auction.ts b/game/normal-auction.ts
index b282a8c3..0388878b 100644
--- a/game/normal-auction.ts
+++ b/game/normal-auction.ts
@@ -7,6 +7,12 @@ export type NormalAuctionBid =
| { ok: true; amount: number }
| { ok: false; reason: string };
+/** Capacity can change after a lot opens, for example through Distrans. The
+ * existing active player may still pass; only a new purchase bid is barred. */
+export function normalAuctionHandBlock(held: number, limit: number): string | null {
+ return held >= limit ? 'Your Treachery hand is full; pass this bid.' : null;
+}
+
/**
* Quote the next ordinary auction step. The engine owns card custody, payment,
* phase progression and the hand-limit eligibility calculation.
diff --git a/game/reference.ts b/game/reference.ts
index 4d984792..39703109 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -738,7 +738,8 @@ 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. Its exact span remains unresolved: an unpaid open auction lot is currently guarded, while a pre-bid context or completed purchase income response may allow the effect. This is not a prohibition on the entire Bidding phase. Giving Distrans itself also remains guarded pending clarification.',
+ '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.',
+ '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.',
'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.',
@@ -757,13 +758,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Synchronous two-card custody, private transfer, normal Distrans disposal, both-card reservations and prospective transaction/promise checks are integrated. The explicit Semuta preview offers a neutral reaction only after a clean completed transfer; open-lot timing, competing transactions and self-transfer remain guarded.',
+ '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.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
- 'Recipient and given-card selectors, both inspectors, unavailable reasons and an explicit give-and-discard action are integrated. Desktop selection, real transfer and fresh-tab recovery passed in a synthetic table, including a preserved pending gift. Mobile acceptance remains pending.',
+ '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.',
},
{
area: 'AI',
@@ -781,10 +782,16 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
- 'Six pure custody, six engine matrix, six independent review, five all-profile AI and three persisted/API cases cover the implemented boundaries. The integrated suites pass 1,258 registered rules/client/component and 96 persisted/API tests. Desktop transfer and fresh-tab recovery passed in a synthetic table. Mobile and full Richese games remain unverified.',
+ '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.',
evidence: [
'tests/distrans.test.ts',
'tests/distrans-engine.test.ts',
+ 'game/distrans-timing.ts',
+ 'tests/distrans-timing.test.ts',
+ 'tests/fixture-distrans-prebid.ts',
+ 'tests/distrans-prebid-engine.test.ts',
+ 'tests/distrans-prebid-controls.test.ts',
+ 'components/distrans-transfer-action.tsx',
'tests/distrans-engine-review.test.ts',
'tests/distrans-bots.test.ts',
'tests/distrans-recovery.test.ts',
diff --git a/tests/distrans-engine.test.ts b/tests/distrans-engine.test.ts
index 9449bea6..e1b87b17 100644
--- a/tests/distrans-engine.test.ts
+++ b/tests/distrans-engine.test.ts
@@ -37,13 +37,22 @@ 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 unresolved ordinary lot is an explicit timing guard while a phase-three pre-bid context remains playable',()=>{
+void test('an untouched ordinary lot permits Distrans but its first bid or pass closes the provisional window',()=>{
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};
- const before=structuredClone(g);
- assert.throws(()=>play(g,card.id),/unresolved auction lot awaits a ruling/);assert.deepEqual(g,before);
- assert.match(viewGame(g,'d').distrans!.blocked!,/awaits a ruling/);
+ assert.equal(viewGame(g,'d').distrans!.blocked,null);
+ const transferred=play(g,card.id);
+ assert.deepEqual(transferred.auction,g.auction);
+ assert.deepEqual(transferred.players[1].hand,[card]);
+ for(const progress of ['bid','pass'] as const){
+ const during=reload(g);
+ 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/);
+ }
});
void test('an already-paid purchase response allows Distrans without changing its completed sale or creating a second response',()=>{
const {g,card}=fixture('richese',true,3);
diff --git a/tests/distrans-prebid-controls.test.ts b/tests/distrans-prebid-controls.test.ts
new file mode 100644
index 00000000..9ed793d5
--- /dev/null
+++ b/tests/distrans-prebid-controls.test.ts
@@ -0,0 +1,182 @@
+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 { baseDeck } from '../game/cards';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import { richeseCards } from '../game/richese-cards';
+
+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();
+
+// Controlled entitled projections supplement the separate native pre-bid sources.
+// No authoritative hand, auction, payment or rule result is installed in a Game.
+// React renders the stateful wrapper; only the hook-free action leaf is called
+// directly to exercise its actual callback.
+function projected(advanced = false): GameView {
+ const game = createGame('DISTRANSPREBIDCONTROLS', newPlayer('d', 'Donor', 'richese'), advanced, ['choam']);
+ joinGame(game, newPlayer('t', 'Target', 'atreides'));
+ joinGame(game, newPlayer('o', 'Other player', 'emperor'));
+ const view = viewGame(game, 'd');
+ const card = richeseCards().find(candidate => candidate.effect === 'distrans');
+ const given = baseDeck().find(candidate => candidate.kind === 'shield');
+ const other = baseDeck().find(candidate => candidate.kind === 'projectile');
+ assert.ok(card && given && other);
+ view.status = 'playing'; view.phase = 3; view.active = 't';
+ view.auction = { bid: 0, bidder: null, active: 't', remaining: 2, card: null, inspectionBlocked: null };
+ view.distrans = {
+ card, blocked: null,
+ choices: [
+ { recipient: 't', blocked: null, cards: [given, other], unavailable: [] },
+ { recipient: 'o', blocked: null, cards: [other, given], unavailable: [] },
+ ],
+ };
+ return view;
+}
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(Distrans, { game, busy, act() {} }));
+}
+function transferButton(html: string) {
+ const button = html.match(/