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),
+1 -1
View File
@@ -87,7 +87,7 @@ export const FACTION_RULES: Record<
'Receive payments for other players’ auction purchases. Your own purchases pay the bank; a Karama purchase pays no spice. A cancellation sends one auction payment to the bank.',
'Give spice directly to your ally. After the response window it is immediately spendable, unlike a deferred bribe between non-allies.',
'In supported Basic ordinary auctions, including Nexus games, and Richese cache sales, you may give your mutual ally spice after they win and before their original payment. Continue preserves the bid and pledged split; a canceled gift transfers nothing.',
'In supported Moritani games, you may also give spice while your ally is choosing whether to pay Extortion. The original choice resumes after the gift’s Karama response.',
'In supported Moritani games, including Basic games with other selected faction decks, you may also give spice while your ally is choosing whether to pay Extortion. The original choice resumes after the gift’s Karama response.',
'In supported Basic Ix-deck games with classic factions, Ixians or Tleilaxu, you may give an ally spice during the Amal phase opening, even after you have passed. The original opening resumes after the gift response.',
'During Revival, pay two spice per extra allied force, up to three extra forces per turn. These are separate from the ally’s normal three-force allowance and may be purchased before or after ordinary revivals.',
'Spice gifts and extra revival uses have Karama response windows. Canceling a use spends neither the Emperor’s spice nor the extra-revival allowance.',