Allow Emperor gifts during Moritani extortion
Verify and publish checkpoint container / container (push) Successful in 11m51s

This commit is contained in:
Quintine committed 2026-10-29 06:28:09 +11:00
1 parent 6cec4ed78b
commit 538247e777
8 files changed
+307 -4

No files matched your search

+69
View File
@@ -1234,6 +1234,8 @@ type SpiceBankerIncomePayment = {
export type ResponseWindow = {
/** The original public Guild timing decision remains live during this gift. */
emperorGuildTimingGift?: { event: string; signature: string };
/** The original Extortion payment decision remains live during this gift. */
emperorExtortionGift?: { event: string; signature: string };
/** Links an allied auction gift to the still-pending payment opportunity. */
emperorAuctionGiftEvent?: string;
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
@@ -19429,6 +19431,7 @@ function validateKaramaUse(
requireRule(!kullBlock, kullBlock ?? 'Kull Wahad prevents this Karama effect.');
if (use.kind === 'cancel') {
validateEmperorGuildTimingGift(g, use.response);
validateEmperorExtortionGift(g, use.response);
requireRule(
use.response && use.response.owner !== p.id,
'Karama cancels another faction’s power.',
@@ -29398,6 +29401,50 @@ function emperorGuildTimingGiftIntegrity(g: Game) {
if (g.pendingKarama?.use.kind === 'cancel')
validateEmperorGuildTimingGift(g, g.pendingKarama.use.response);
}
function emperorExtortionGiftSignature(g: Game, owner: string, recipient: string, amount: number): string {
return JSON.stringify([g.turn, g.phase, g.active, g.order, g.decision,
g.moritaniExtortion, g.moritaniTerror?.placementTurn, owner, recipient, amount]);
}
function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
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)) ||
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 ||
g.pendingRicheseGift || g.pendingNullentropy || g.pendingShipment || g.pendingArrivalOverlap ||
!extortion || extortion.stage !== 'payment' || decision?.kind !== 'moritaniExtortion' ||
decision.event !== extortionEvent(extortion) || decision.player !== extortion.queue[extortion.cursor] ||
p.faction !== 'emperor' || !p.ally || decision.player !== p.ally ||
!g.players.some(seat => seat.id === p.ally && seat.ally === p.id) ||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
hasMovementPromises(g, p.id, true) || hasBribePromises(g, p.id, true) ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(row => row.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(row => row.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(row => row.answer) ||
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length ||
livePhaseBidPromises(g.phaseBidPromises ?? [], p.id, g.turn).some(row => row.answer)) return null;
const maximum = Math.max(0, uncommittedSpice(g, p));
return maximum > 0 ? {recipient: p.ally, maximum} : null;
}
function validateEmperorExtortionGift(g: Game, response: ResponseWindow) {
const parent = response.emperorExtortionGift;
if (!parent) return;
const owner = g.players.find(player => player.id === response.owner);
const recipient = g.players.find(player => player.id === response.recipient);
requireRule(response.kind === 'emperorGift' && typeof parent.event === 'string' && !!parent.event &&
owner?.faction === 'emperor' && recipient && owner.ally === recipient.id && recipient.ally === owner.id &&
typeof response.amount === 'number' && Number.isSafeInteger(response.amount) && response.amount > 0 &&
response.amount <= uncommittedSpice(g, owner) && g.decision?.kind === 'moritaniExtortion' &&
g.decision.player === recipient.id &&
parent.signature === emperorExtortionGiftSignature(g, owner.id, recipient.id, response.amount),
'The Emperor gift lost its original Extortion payment decision.');
}
function emperorExtortionGiftIntegrity(g: Game) {
if (g.response) validateEmperorExtortionGift(g, g.response);
if (g.pendingKarama?.use.kind === 'cancel') validateEmperorExtortionGift(g, g.pendingKarama.use.response);
}
function chooseGuildTiming(g: Game, take: boolean) {
const guild = byFaction(g, 'guild')!;
if (take) g.active = guild.id;
@@ -37166,6 +37213,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
currentFactionPayment(g);
const response = g.response!;
validateEmperorGuildTimingGift(g, response);
validateEmperorExtortionGift(g, response);
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null;
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
@@ -42005,8 +42053,14 @@ export function applyAction(state: Game, id: string, action: Action): Game {
* automatic deal payment; it is never supplied by a client action. */
function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string,
options?: {insolventDealPayment?: boolean}): Game {
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
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',
'Resolve the Emperor gift response before another action.');
if (state.pendingEmperorAuctionGift)
requireRule(!['dealPay', 'dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type),
'Finish the Emperor auction gift opportunity before changing a public deal.');
@@ -42754,6 +42808,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
@@ -43511,6 +43566,17 @@ function applyActionInner(
specialKarama(g, p, action);
return g;
}
if (t === 'emperorGift' && g.decision?.kind === 'moritaniExtortion' && !g.response) {
const quote = emperorExtortionGiftQuote(g, p);
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
'This Extortion gift is unavailable or no longer current.');
const amount = integer(action.amount, 1, quote.maximum, 'Spice');
g.response = { kind: 'emperorGift', owner: id, recipient: quote.recipient,
amount, elite: 0, passed: [], emperorExtortionGift: {
event: crypto.randomUUID(), signature: emperorExtortionGiftSignature(g, id, quote.recipient, amount),
} };
return g;
}
if (t === 'emperorGift' && g.decision?.kind === 'guildTiming' && !g.response) {
const quote = emperorGuildTimingGiftQuote(g, p);
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
@@ -47308,6 +47374,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string, now?: number) {
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
@@ -48362,6 +48429,7 @@ export function viewGame(state: Game, id: string, now?: number) {
? { amount: undefined }
: {}),
...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}),
...(g.response.emperorExtortionGift ? { emperorExtortionGift: undefined } : {}),
...(g.response.emperorAuctionGiftEvent ? { emperorAuctionGiftEvent: undefined } : {}),
}
: null,
@@ -48429,6 +48497,7 @@ export function viewGame(state: Game, id: string, now?: number) {
: 0,
},
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
emperorExtortionGift: emperorExtortionGiftQuote(g, me),
emperorAuctionGiftPending: g.pendingEmperorAuctionGift
? { owner: g.pendingEmperorAuctionGift.owner } : null,
emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama
+1
View File
@@ -86,6 +86,7 @@ export const FACTION_RULES: Record<
'Start with all twenty forces in reserve and ten spice. One force revival is free each turn.',
'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 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.',
'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.',
],