Support Semuta after Box searches during bidding

This commit is contained in:
Quintine committed 2026-10-28 20:40:07 +11:00
1 parent 7dfdf8d593
commit 6bf165a6aa
8 files changed
+312 -2

No files matched your search

+25 -2
View File
@@ -1647,6 +1647,8 @@ export type Game = {
finalDiscardSignature: string;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
parentSignature: string;
/** New Basic bidding reactions bind the existing lot and funding. */
auctionBinding?: { kind: 'ordinary' | 'richese'; signature: string };
}
| {
kind: 'ordinaryCardDiscard';
@@ -5115,6 +5117,7 @@ function finishNullentropy(
`${owner.name} recovered one private card with Nullentropy Box, shuffled the remaining discard pile, and placed the used Box on top. The bank was paid when the search began.`,
{ faction: owner.faction, name: 'Nullentropy Box' },
);
const auctionBinding = nullentropyAuctionBinding(g);
stageTreacheryDiscard(
g,
'nullentropyBox',
@@ -5129,6 +5132,7 @@ function finishNullentropy(
finalDiscardSignature: JSON.stringify(g.discard),
resume: pending.resume,
parentSignature: nullentropyParentSignature(g, pending.resume, event),
...(auctionBinding ? {auctionBinding} : {}),
},
);
}
@@ -6157,6 +6161,18 @@ function nullentropyParentSignature(
);
return JSON.stringify({ searchEvent, resume, parents });
}
/** Clock state is deliberately excluded: it pauses and resumes while decisions
* are pending. The lot and committed resources themselves remain unchanged. */
function nullentropyAuctionBinding(g: Game): {kind:'ordinary'|'richese';signature:string} | undefined {
if (!g.semutaPreview || !bareBasicSemutaProfile(g) || g.phase !== 3 || g.currentAuctionSale || g.ixAuction)
return undefined;
const kind = g.auction && !g.richeseAuction ? 'ordinary' : supportedRicheseTruthLot(g) ? 'richese' : undefined;
if (!kind) return undefined;
return {kind, signature: JSON.stringify({turn:g.turn,phase:g.phase,active:g.active,order:g.order,
auction:g.auction,richeseAuction:g.richeseAuction,richeseBidding:g.richeseBidding,
richeseFunding:g.richeseFunding,aid:g.aid,
players:g.players.map(p => ({id:p.id,spice:p.spice,ally:p.ally}))})};
}
type OrdinaryDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'ordinaryCardDiscard' }
@@ -7929,6 +7945,10 @@ function treacheryDiscardIntegrity(g: Game) {
nullentropyParentSignature(g, c.resume, c.searchEvent),
'The completed Box search does not match its suspended controls and obligations.',
);
if (c.auctionBinding) requireRule(
JSON.stringify(c.auctionBinding) === JSON.stringify(nullentropyAuctionBinding(g)),
'The completed Box search no longer matches its original auction and funding.',
);
suspendedControlsIntegrity(g, c.resume);
} else if (continuation?.kind === 'ordinaryCardDiscard') {
const c = continuation,
@@ -9222,10 +9242,13 @@ function semutaOfferSupported(
// native Distrans still cannot produce a transfer after that bid starts.
const ordinaryAuctionDiscard = g.phase === 3 && !!g.auction && !g.richeseAuction && !g.currentAuctionSale &&
(continuation.kind === 'truthtranceDiscard' || continuation.kind === 'distransDiscard' ||
continuation.kind === 'saphoTruthOrderDiscard');
continuation.kind === 'saphoTruthOrderDiscard' ||
continuation.kind === 'nullentropyDiscard' && continuation.auctionBinding?.kind === 'ordinary');
const richeseTruthDiscard = supportedRicheseTruthLot(g) &&
(continuation.kind === 'truthtranceDiscard' || continuation.kind === 'saphoTruthOrderDiscard') &&
!!continuation.richeseLotEvent && continuation.richeseLotEvent === g.richeseAuction?.event;
const richeseBoxDiscard = supportedRicheseTruthLot(g) && continuation.kind === 'nullentropyDiscard' &&
continuation.auctionBinding?.kind === 'richese';
if (continuation.kind === 'truthtranceDiscard' &&
['bribe', 'bribeExpression', 'mixedBribe'].includes(continuation.record.question.kind) &&
(!continuation.bribeBinding || !bareBasicSemutaProfile(g) || !!g.kullPreview)) return false;
@@ -9562,7 +9585,7 @@ function semutaOfferSupported(
(advisorDiscard || auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' ||
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'emperorIncome') || richeseTruthDiscard || !g.richeseAuction) &&
continuation.use.response.kind === 'emperorIncome') || richeseTruthDiscard || richeseBoxDiscard || !g.richeseAuction) &&
(richeseGiftDiscard || combatDiscard || alliedTraitorDiscard || occupyDiscard || continuation.kind === 'saphoAggressorDiscard' ||
bgDiscard ||
continuation.kind === 'specialKaramaDiscard' ||
+1
View File
@@ -1056,6 +1056,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A pending purchased-shipment cost does not block the search. Under the provisional refund composition, recovering that exact cost preserves its purchased price, creator and beneficiary but ends its later Guild special-stop refund, even if discarded again. Selecting another card leaves the cost’s existing claim status unchanged. Empty or only-Box searches are rejected before payment under the current unsupported-empty-search policy; there is no fallback deck draw.',
'All four AI profiles start only when their own projected availability and spice reserve allow it. They do not inspect unpaid discard contents. Once paid, they choose from their entitled cards, prefer existing functional effects over unfinished Richese faces and complete a legal selection even if only unfinished effects remain. A paid search takes priority over optional actions.',
'Whole Truthtrance questions may name a future printed Box activation. Accepted payment records that play once before private selection; it does not promise the recovered identity. Actual costs share funding with other commitments, while an unpaid unknown recovery cannot supply another effect. Native paid selection and Semuta continuation keep their priority.',
'In supported Basic Semuta games, a completed Box search during an ordinary auction or a Richese Once Around or Silent cache auction can offer the discarded Box to Semuta. The paid search and private selection remain complete; after the reaction, bidding resumes with its original lot, bids and pledged funding. This online ordering is provisional.',
'The paid search and continuations are integrated in development fixtures, with a connected full-hand prototype in both modes. Purchased-shipment cost recovery now uses the provisional nonduplication policy without blocking the search. The explicit Semuta preview can recover the used Box after a clean completed search without replaying payment or shuffle; other discard compositions remain separate. Full Richese starts and complete browser/mobile/full-game acceptance remain gated.',
],
related: [