Enable Basic mixed-deck Emperor Extortion gifts
Verify and publish checkpoint container / container (push) Successful in 12m11s

This commit is contained in:
Quintine committed 2026-10-30 19:13:54 +11:00
1 parent dac1b432db
commit 3bfcd10140
7 files changed
+374 -9

No files matched your search

+30 -8
View File
@@ -6727,6 +6727,16 @@ function emperorAuctionGiftResumeMatches(g: Game, response: ResponseWindow, deci
: decision?.kind === 'auctionPayment' && decision.player === pending.recipient &&
!!g.auction && g.auction.bidder === pending.recipient && !g.richeseAuction);
}
/** A suspended Extortion gift must resume the very same payer and queue. */
function emperorExtortionGiftResumeMatches(g: Game, response: ResponseWindow, decision: Game['decision']): boolean {
const parent = response.emperorExtortionGift, extortion = g.moritaniExtortion;
return !!parent && response.kind === 'emperorGift' && g.phase === 8 &&
!!extortion && extortion.stage === 'payment' && decision?.kind === 'moritaniExtortion' &&
decision.player === response.recipient && decision.player === extortion.queue[extortion.cursor] &&
decision.event === extortionEvent(extortion) && Number.isSafeInteger(response.amount) &&
typeof response.amount === 'number' && response.amount > 0 &&
parent.signature === emperorExtortionGiftSignature({...g, decision}, response.owner, decision.player, response.amount);
}
/** Preserve physical identity: a Shrine authorizes the role, not a manufactured card. */
function semutaKaramaRoleCost(g: Game, owner: string, card: Card): boolean {
return card.effect === 'karama' || (card.effect === 'truthtrance' &&
@@ -8737,7 +8747,8 @@ function treacheryDiscardIntegrity(g: Game) {
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) &&
(c.kind === 'karamaBgConversionCancelDiscard' ||
((c.kind === 'karamaTerminalDiscard' || c.kind === 'karamaCombatDiscard' || !c.resume.decision ||
c.kind === 'karamaEmperorShareDiscard' && emperorAuctionGiftResumeMatches(g, c.use.response, c.resume.decision)) &&
c.kind === 'karamaEmperorShareDiscard' && (emperorAuctionGiftResumeMatches(g, c.use.response, c.resume.decision) ||
emperorExtortionGiftResumeMatches(g, c.use.response, c.resume.decision))) &&
!c.resume.pendingKarama && !c.resume.phaseOpening)) &&
batch.cause === `karama:${c.use.response.kind}` &&
batch.entries.length === 1 &&
@@ -10187,7 +10198,8 @@ function semutaOfferSupported(
: ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) &&
(terminalDiscard || combatDiscard || !continuation.resume.decision ||
continuation.kind === 'karamaEmperorShareDiscard' &&
emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision)) && !continuation.resume.pendingKarama &&
(emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision) ||
emperorExtortionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision))) && !continuation.resume.pendingKarama &&
!continuation.resume.phaseOpening && !g.pendingKarama &&
(!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response))
: true) &&
@@ -14159,6 +14171,12 @@ function moritaniExtortionIntegrity(g: Game) {
const savedDecisions = pending.stage === 'payment'
? homeworldSavedDecisions(g).filter(decision => decision.kind === 'moritaniExtortion')
: [];
const continuation = g.pendingTreacheryDiscard?.continuation;
const frame = g.pendingKull ?? (continuation?.kind === 'kullCounterDiscard' ? continuation.frame : null);
if (!savedDecisions.length && frame?.intent.kind === 'ordinary' && frame.intent.use.kind === 'cancel' &&
frame.resume.decision?.kind === 'moritaniExtortion' &&
emperorExtortionGiftResumeMatches(g, frame.intent.use.response, frame.resume.decision))
savedDecisions.push(frame.resume.decision);
requireRule(pending.stage === 'reserved'
? g.phase <= 8
: pending.stage === 'payment' && g.phase === 8 &&
@@ -20251,7 +20269,7 @@ function kullSettledRestriction(g: Game, frame: KullFrame): KullPhaseRestriction
// Independent printed descriptors, assembled once rather than trusting a saved
// hand to define its own identity or rebuilding the physical deck per quote.
const kullCanonicalCards: Readonly<Record<string, Card>> =
Object.fromEntries(treacheryDeck(['choam', 'ix']).map(card => [card.id, card]));
Object.fromEntries([...treacheryDeck(['choam', 'ix']), ...richeseCards()].map(card => [card.id, card]));
function canonicalKullCard(card: Card): boolean {
const expected = Object.hasOwn(kullCanonicalCards, card.id) ? kullCanonicalCards[card.id] : undefined;
return !!expected && card.name === expected.name && card.kind === expected.kind &&
@@ -20861,7 +20879,8 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered
g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
} else if (continuation.kind === 'karamaTerminalDiscard' || continuation.kind === 'karamaCombatDiscard' ||
continuation.kind === 'karamaEmperorShareDiscard' &&
emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision)) g.decision = null;
(emperorAuctionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision) ||
emperorExtortionGiftResumeMatches(g, continuation.use.response, continuation.resume.decision))) g.decision = null;
continuation.stateSignature = karamaResponseDiscardSignature(g, continuation);
stageTreacheryDiscard(g, `karama:${use.response.kind}`, entries, continuation);
return;
@@ -30550,9 +30569,10 @@ function emperorExtortionGiftSignature(g: Game, owner: string, recipient: string
}
function emperorExtortionGiftPublicEligible(g: Game, p: Player): boolean {
const decision = g.decision, extortion = g.moritaniExtortion;
if (g.status !== 'playing' || g.phase !== 8 || g.expansions.length !== 1 ||
g.expansions[0] !== 'ecaz' || g.players.some(seat =>
!['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', 'moritani'].includes(seat.faction)) ||
if (g.status !== 'playing' || g.phase !== 8 ||
(g.advanced ? g.expansions.length !== 1 || g.expansions[0] !== 'ecaz' || g.players.some(seat =>
!['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', 'moritani'].includes(seat.faction))
: !g.expansions.includes('ecaz') || !selectedPhaseModuleRoster(g)) ||
g.ecazTreachery || g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.response || g.phaseOpening ||
g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.battle ||
@@ -43432,7 +43452,9 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
const extortionGiftResponse = state.response?.emperorExtortionGift ||
(state.pendingKarama?.use.kind === 'cancel' && state.pendingKarama.use.response.emperorExtortionGift);
if (extortionGiftResponse && action?.type !== 'advanceBots')
requireRule(action?.type === 'passResponse' || action?.type === 'card' && action.mode === 'cancel',
requireRule(action?.type === 'passResponse' || action?.type === 'card' && action.mode === 'cancel' ||
action?.type === 'kullDecision' && !!state.pendingKull &&
state.pendingKull.resume.response?.emperorExtortionGift?.event === extortionGiftResponse.event,
'Resolve the Emperor gift response before another action.');
if (state.pendingEmperorAuctionGift)
requireRule(!['dealPay', 'dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type),