Allow Distrans before Richese bidding starts

This commit is contained in:
Quintine committed 2026-10-27 17:12:05 +11:00
1 parent 813a19bb1d
commit f50e436506
14 files changed
+511 -24

No files matched your search

+3 -3
View File
@@ -51,9 +51,9 @@ export function Distrans({
</p>
<p className="m-0 text-sm leading-6" aria-label="Provisional Distrans bid timing">
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.
</p>
<CardInspector card={distrans.card} />
{choice && (
+15
View File
@@ -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,
+18
View File
@@ -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
+31 -5
View File
@@ -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.
+9
View File
@@ -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
+33 -3
View File
@@ -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<string, unknown> | 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.';
}
+7 -3
View File
@@ -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',
@@ -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);
});
}
+5 -5
View File
@@ -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/);
+74
View File
@@ -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(/<button\b(?=[^>]*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=/);
});
}
+138
View File
@@ -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');
});
}
+46 -5
View File
@@ -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', () => {
+9
View File
@@ -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))
+42
View File
@@ -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.'] };
}