Allow Distrans between completed auction bids
Verify and publish checkpoint container / container (push) Successful in 10m44s

This commit is contained in:
Quintine committed 2026-10-29 10:34:00 +11:00
1 parent ab7a947833
commit ccc4c7bb40
16 files changed
+250 -80

No files matched your search

+63 -39
View File
@@ -15,46 +15,70 @@ type DistransAuctionContext = {
currentAuctionSale?: unknown;
};
/** The printed card excludes use during a bid. Provisionally, an ordinary
* lot has not begun bidding until its first bid or pass. Inspection and
* opening the physical lot do not start that bid or change the dealt pool.
* An untouched Richese lot likewise has no bid yet.
* Once any bidder acts, even a pass or a zero sealed bid, it is closed. */
const amount = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const ids = (value: unknown): value is string[] =>
Array.isArray(value) && value.every(id => typeof id === 'string' && id.length > 0) &&
new Set(value).size === value.length;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function validSpecial(s: RicheseAuction, funding: Record<string, unknown> | null | undefined): boolean {
if (!(s.method === 'onceAround' || s.method === 'silent' || s.method === 'normal') ||
!(s.source === 'cache' || s.source === 'blackMarket') ||
(s.source === 'cache' && s.method === 'normal') ||
typeof s.event !== 'string' || !s.event || typeof s.cardId !== 'string' || !s.cardId ||
typeof s.owner !== 'string' || !s.owner || s.outcome !== null ||
!ids(s.eligible) || !s.eligible.length || !ids(s.order) || !ids(s.tieOrder) ||
s.order.length !== s.eligible.length || s.tieOrder.length !== s.eligible.length ||
!s.order.every(id => s.eligible.includes(id)) || !s.tieOrder.every(id => s.eligible.includes(id)) ||
!ids(s.acted) || !ids(s.passed) ||
!s.acted.every(id => s.eligible.includes(id)) || !s.passed.every(id => s.eligible.includes(id)) ||
!amount(s.bid) || (s.bid === 0 ? s.bidder !== null : !s.bidder || !s.eligible.includes(s.bidder)) ||
!record(s.sealed) || (funding != null && !record(funding))) return false;
if (funding && !Object.entries(funding).every(([id, entry]) => {
if (!s.eligible.includes(id) || !record(entry) || !amount(entry.amount) ||
!amount(entry.allyPayment) || entry.allyPayment > entry.amount) return false;
return entry.donor === null || (typeof entry.donor === 'string' && entry.donor.length > 0);
})) return false;
if (s.method === 'silent') {
const sealed = Object.keys(s.sealed);
return s.active === null && s.bid === 0 && s.bidder === null && s.passed.length === 0 &&
sealed.length === s.acted.length && sealed.every(id => s.acted.includes(id) && amount(s.sealed[id]));
}
if (Object.keys(s.sealed).length) return false;
if (s.method === 'onceAround') {
const remaining = s.order.filter(id => !s.acted.includes(id));
return remaining.length > 0 && s.active === remaining[0] &&
(s.bidder !== null || remaining.some(id => id !== s.owner)) &&
s.passed.every(id => s.acted.includes(id)) &&
(!s.bidder || (s.acted.includes(s.bidder) && !s.passed.includes(s.bidder)));
}
if (s.acted.length || !s.active || !s.eligible.includes(s.active) ||
s.passed.includes(s.active) || s.active === s.bidder ||
(s.bidder !== null && s.passed.includes(s.bidder))) return false;
const previous = s.passed.at(-1) ?? s.bidder;
if (!previous) return s.active === s.order[0];
const start = s.order.indexOf(previous);
const next = s.order.slice(start + 1).concat(s.order.slice(0, start + 1))
.find(id => id !== s.bidder && !s.passed.includes(id));
return s.active === next;
}
/** Provisional interpretation: a completed bid or pass ends the interval
* during which Distrans is forbidden. Preserve the existing open lot and all
* accepted bid reservations; transfers must still satisfy their normal rules. */
export function distransAuctionBlock(g: DistransAuctionContext): string | null {
if (g.currentAuctionSale || (!g.auction && !g.richeseAuction)) return null;
const special = g.richeseAuction;
if (g.phase === 3 && !g.auction && special &&
(special.method === 'onceAround' || special.method === 'silent' || special.method === 'normal') &&
(special.source === 'cache' || special.source === 'blackMarket') &&
(special.source !== 'cache' || special.method !== 'normal') &&
typeof special.event === 'string' && special.event.length > 0 &&
typeof special.cardId === 'string' && special.cardId.length > 0 &&
typeof special.owner === 'string' && special.owner.length > 0 &&
special.outcome === null && special.bid === 0 && special.bidder === null &&
Array.isArray(special.eligible) && special.eligible.length > 0 &&
special.eligible.every(id => typeof id === 'string' && id.length > 0) &&
new Set(special.eligible).size === special.eligible.length &&
Array.isArray(special.order) && special.order.length === special.eligible.length &&
new Set(special.order).size === special.order.length &&
special.order.every(id => special.eligible.includes(id)) &&
Array.isArray(special.tieOrder) && special.tieOrder.length === special.eligible.length &&
new Set(special.tieOrder).size === special.tieOrder.length &&
special.tieOrder.every(id => special.eligible.includes(id)) &&
(special.method === 'silent' ? special.active === null : special.active === special.order[0]) &&
(special.method !== 'onceAround' || special.order.some(id => id !== special.owner)) &&
Array.isArray(special.acted) && special.acted.length === 0 &&
Array.isArray(special.passed) && special.passed.length === 0 &&
special.sealed && typeof special.sealed === 'object' && !Array.isArray(special.sealed) &&
Object.keys(special.sealed).length === 0 &&
(!g.richeseFunding || (typeof g.richeseFunding === 'object' && !Array.isArray(g.richeseFunding) &&
Object.keys(g.richeseFunding).length === 0))) return null;
if (g.phase === 3 && !g.auction && g.richeseAuction &&
validSpecial(g.richeseAuction, g.richeseFunding)) return null;
const lot = g.auction;
if (
g.phase === 3 && !g.richeseAuction && lot &&
Number.isSafeInteger(lot.index) && lot.index >= 0 &&
lot.index < lot.cards.length && !!lot.cards[lot.index] &&
lot.bid === 0 && lot.bidder === null && lot.passed.length === 0 &&
(lot.allyPayment ?? 0) === 0
) return null;
return 'Distrans is unavailable after bidding starts on this lot. Finish its auction first; other special-auction timing remains guarded.';
if (g.phase === 3 && !g.richeseAuction && lot && Array.isArray(lot.cards) &&
Number.isSafeInteger(lot.index) && lot.index >= 0 && lot.index < lot.cards.length && !!lot.cards[lot.index] &&
amount(lot.bid) && (lot.bid === 0 ? lot.bidder === null : typeof lot.bidder === 'string' && !!lot.bidder) &&
ids(lot.passed) && (!lot.bidder || !lot.passed.includes(lot.bidder)) &&
amount(lot.allyPayment ?? 0) && (lot.allyPayment ?? 0) <= lot.bid) return null;
return 'Distrans is unavailable during this unresolved auction state; finish the current bid or auction first.';
}
+13 -1
View File
@@ -5476,6 +5476,18 @@ function distransBlock(g: Game, owner: Player, card: Card): string | null {
if (promised) return promised;
const reserved = transferCardBlock(g, owner, card);
if (reserved) return reserved;
// A started lot is only open between complete actions, not while a bid or
// its resulting interaction is still waiting for a decision.
const special = g.richeseAuction;
const started = !g.currentAuctionSale && (g.auction
? g.auction.bid > 0 || g.auction.passed.length > 0
: !!special && (special.bid > 0 || special.passed.length > 0 ||
special.acted.length > 0 || Object.keys(special.sealed).length > 0));
if (started && (g.response || g.decision || g.pendingKarama || g.phaseOpening || g.pendingTreacheryDiscard))
return 'Finish the pending auction interaction before playing Distrans.';
// A special-lot Semuta continuation still needs an auction-bound resume.
if (started && special && (g.semutaPreview || g.semutaAdvancedOrdinaryPreview || g.semutaRecruitsPreview))
return 'Distrans between special-auction bids is unavailable with the Semuta preview.';
return distransAuctionBlock(g);
}
function validateDistrans(
@@ -9681,7 +9693,7 @@ function semutaOfferSupported(
entries[0].card.id === continuation.card;
// These completed public costs retain the original ordinary lot through the
// existing signed continuation. Semuta itself has no during-bid exception;
// native Distrans still cannot produce a transfer after that bid starts.
// Distrans can also produce this discard between completed ordinary bids.
const ordinaryAuctionDiscard = g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
(continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard' ||
continuation.kind === 'saphoTruthOrderDiscard' ||
+5 -5
View File
@@ -763,9 +763,9 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Any faction holding Distrans may use it. Choose another player whose hand has room and a different Treachery Card from your own hand. The recipient need not be your ally. Transfer that card intact, then discard Distrans into the ordinary discard pile.',
'Open the Distrans transfer panel in your hand, select the recipient and card, inspect either card, then press Give and discard Distrans. There is no spice payment, purchase bonus, recipient acknowledgement or faction-power Karama response. Other players see the played Distrans, not the transferred card identity.',
'The printed exception is during a bid. Provisionally, an untouched ordinary, Once Around or normal Black Market lot allows Distrans before its first bid or pass, and a Silent lot allows it before its first sealed submission. Opening or inspecting a lot does not start the bid; its card, eligibility and bidding order stay the same. Started lots remain guarded, while a completed purchase income response may allow use. Giving Distrans itself remains guarded.',
'The printed exception is during a bid. Provisionally, supported ordinary, Once Around, Silent and normal Black Market lots allow Distrans between completed bid, pass or sealed-submission actions. The lot, eligibility, commitments and bidding order stay the same. Pending auction interactions restrict play; started special lots also remain blocked while the Semuta preview is enabled. A completed purchase income response may allow use. The exact scope awaits official clarification; giving Distrans itself remains guarded.',
'Current hand space still controls bidding after a transfer. A full donor can free space; if the active bidder receives its final card, it must pass instead of raising. The existing auction skips full hands without adding a lot or resetting earlier passes.',
'Distrans resolves in one server action and keeps an existing response, decision or phase opening in place. Active Truthtrance retains priority. Both outgoing cards must be uncommitted; a sealed plan, fixed prescience answer, pending Richese gift or accepted replacement cannot lose its reserved card.',
'Distrans resolves in one server action and can keep supported existing responses, decisions or phase openings in place; pending auction interactions restrict the started-lot window. Active Truthtrance retains priority. Both outgoing cards must be uncommitted; a sealed plan, fixed prescience answer, pending Richese gift or accepted replacement cannot lose its reserved card.',
'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.',
'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.',
'All four AI profiles use their own legal projected cards. They may give an allied Bene Gesserit or CHOAM a Worthless card, or share a duplicate ordinary weapon, defense or Karama to free a full hand. They do not inspect the recipient’s private cards and preserve existing decision priority.',
@@ -783,13 +783,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Synchronous two-card custody, private transfer, normal disposal, reservations and prospective checks are integrated. Provisional untouched ordinary and Richese special-lot windows admit use before the first bid, pass or sealed submission and recheck live bid capacity. Started lots, competing transactions and self-transfer retain their separate boundaries.',
'Synchronous two-card custody, private transfer, normal disposal, reservations and prospective checks are integrated. Provisional ordinary and Richese special-lot windows admit use between completed bids, passes and sealed submissions, preserving commitments and rechecking capacity. Competing transactions and self-transfer retain separate boundaries.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Recipient/card selectors, both inspectors, unavailable reasons and explicit give-and-discard controls are integrated. Persistent provisional pre-bid guidance and the actual hook-free submission are connected. Browser/mobile acceptance of this extension remains pending.',
'Recipient/card selectors, both inspectors, unavailable reasons and explicit give-and-discard controls are integrated. Persistent provisional between-bids guidance and the actual hook-free submission are connected. Browser/mobile acceptance of this extension remains pending.',
},
{
area: 'AI',
@@ -1336,7 +1336,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: definition.card.effect === 'karama'
? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity pause on the public cost before settling that response; a clean printed normal-auction purchase or winning-bid payment pauses before its reserved lot settles; and a printed cancellation of any ordinary native CHOAM Worthless or Cunning power pauses on its public cost before the denial completes. Richese starts are unfinished previews; other Karama-to-Semuta reactions remain unavailable.'
: definition.card.effect === 'distrans'
? 'Use the Distrans transfer panel to choose another player and a separate held card. Open-bid timing and self-transfer remain guarded pending clarification.'
? 'Use the Distrans transfer panel to choose another player and a separate held card. Supported lots provisionally allow play between completed bid actions; pending interactions and self-transfer remain guarded.'
: definition.card.effect === 'nullentropyBox'
? 'Use the Nullentropy Box panel to pay two bank spice for a private discard search. Both modes provisionally permit full-hand retrieval as an atomic exchange that discards the Box and leaves the final hand within its current limit. Provisional Guild refund claims remain guarded.'
: definition.card.effect === 'ornithopter'