Allow Emperor gifts during Basic Ix phase openings
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 06:33:41 +11:00
1 parent 538247e777
commit d4abc6bb83
9 files changed
+294 -4

No files matched your search

+76
View File
@@ -1236,6 +1236,8 @@ export type ResponseWindow = {
emperorGuildTimingGift?: { event: string; signature: string };
/** The original Extortion payment decision remains live during this gift. */
emperorExtortionGift?: { event: string; signature: string };
/** Suspends the original Ix opening until the gift response finishes. */
emperorPhaseOpeningGift?: { event: string; opening: { passed: string[]; initialize: boolean }; signature: string };
/** Links an allied auction gift to the still-pending payment opportunity. */
emperorAuctionGiftEvent?: string;
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
@@ -19432,6 +19434,7 @@ function validateKaramaUse(
if (use.kind === 'cancel') {
validateEmperorGuildTimingGift(g, use.response);
validateEmperorExtortionGift(g, use.response);
validateEmperorPhaseOpeningGift(g, use.response);
requireRule(
use.response && use.response.owner !== p.id,
'Karama cancels another faction’s power.',
@@ -29401,6 +29404,53 @@ function emperorGuildTimingGiftIntegrity(g: Game) {
if (g.pendingKarama?.use.kind === 'cancel')
validateEmperorGuildTimingGift(g, g.pendingKarama.use.response);
}
function emperorPhaseOpeningGiftSignature(g: Game, opening: {passed: string[]; initialize: boolean}, owner: string, recipient: string, amount: number): string {
return JSON.stringify([g.turn, g.phase, g.active, g.order, g.ready, g.amalWindowOpen ?? false,
opening, owner, recipient, amount]);
}
/** Initial Basic Ix opening support, with no independent competing windows. */
function emperorPhaseOpeningGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
if (g.advanced || g.status !== 'playing' || g.expansions.length !== 1 || g.expansions[0] !== 'ix' ||
g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) ||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.semutaPreview || g.semutaRecruitsPreview ||
!g.phaseOpening || !g.amalWindowOpen ||
g.response || g.decision || g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.battle ||
g.pendingRicheseGift || g.pendingNullentropy || g.pendingShipment || g.pendingGuildTransport ||
g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingEmperorAuctionGift ||
p.faction !== 'emperor' || !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 validateEmperorPhaseOpeningGift(g: Game, response: ResponseWindow) {
const parent = response.emperorPhaseOpeningGift;
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 &&
parent.opening && Array.isArray(parent.opening.passed) && typeof parent.opening.initialize === 'boolean' &&
parent.opening.passed.length < g.players.length &&
new Set(parent.opening.passed).size === parent.opening.passed.length &&
parent.opening.passed.every(id => g.players.some(player => player.id === id)) &&
!g.phaseOpening && !g.decision && 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) &&
parent.signature === emperorPhaseOpeningGiftSignature(g, parent.opening, owner.id, recipient.id, response.amount),
'The Emperor gift lost its original phase opening.');
}
function emperorPhaseOpeningGiftIntegrity(g: Game) {
if (g.response) validateEmperorPhaseOpeningGift(g, g.response);
if (g.pendingKarama?.use.kind === 'cancel') validateEmperorPhaseOpeningGift(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]);
@@ -37214,6 +37264,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
const response = g.response!;
validateEmperorGuildTimingGift(g, response);
validateEmperorExtortionGift(g, response);
validateEmperorPhaseOpeningGift(g, response);
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null;
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
@@ -38375,6 +38426,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
`${emperor.name} ${response.kind === 'emperorGift' ? 'shared spice with' : `offered ${amount} extra force revivals to`} ${recipient.name}.`,
);
}
if (response.emperorPhaseOpeningGift) g.phaseOpening = structuredClone(response.emperorPhaseOpeningGift.opening);
if (response.emperorAuctionGiftEvent) {
requireRule(g.pendingEmperorAuctionGift?.event === response.emperorAuctionGiftEvent,
'The Emperor gift lost its original auction opportunity.');
@@ -42053,9 +42105,15 @@ 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 {
emperorPhaseOpeningGiftIntegrity(state);
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
const phaseGiftResponse = state.response?.emperorPhaseOpeningGift ||
(state.pendingKarama?.use.kind === 'cancel' && state.pendingKarama.use.response.emperorPhaseOpeningGift);
if (phaseGiftResponse && action?.type !== 'advanceBots')
requireRule(action?.type === 'passResponse' || action?.type === 'card' && action.mode === 'cancel',
'Resolve the Emperor gift response before another action.');
const extortionGiftResponse = state.response?.emperorExtortionGift ||
(state.pendingKarama?.use.kind === 'cancel' && state.pendingKarama.use.response.emperorExtortionGift);
if (extortionGiftResponse && action?.type !== 'advanceBots')
@@ -42808,6 +42866,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
emperorPhaseOpeningGiftIntegrity(state);
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
@@ -43494,6 +43553,20 @@ function applyActionInner(
decideBiddingEnd(g, p, action);
return g;
}
if (t === 'emperorGift' && g.phaseOpening && !g.response) {
const quote = emperorPhaseOpeningGiftQuote(g, p);
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
'This phase opening gift is unavailable or no longer current.');
const amount = integer(action.amount, 1, quote.maximum, 'Spice');
const opening = structuredClone(g.phaseOpening);
g.phaseOpening = null;
g.response = { kind: 'emperorGift', owner: id, recipient: quote.recipient,
amount, elite: 0, passed: [], emperorPhaseOpeningGift: {
event: crypto.randomUUID(), opening,
signature: emperorPhaseOpeningGiftSignature(g, opening, id, quote.recipient, amount),
} };
return g;
}
if (g.phaseOpening) {
requireRule(
g.status === 'playing',
@@ -47374,6 +47447,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string, now?: number) {
emperorPhaseOpeningGiftIntegrity(state);
emperorExtortionGiftIntegrity(state);
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
@@ -48430,6 +48504,7 @@ export function viewGame(state: Game, id: string, now?: number) {
: {}),
...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}),
...(g.response.emperorExtortionGift ? { emperorExtortionGift: undefined } : {}),
...(g.response.emperorPhaseOpeningGift ? { emperorPhaseOpeningGift: undefined } : {}),
...(g.response.emperorAuctionGiftEvent ? { emperorAuctionGiftEvent: undefined } : {}),
}
: null,
@@ -48498,6 +48573,7 @@ export function viewGame(state: Game, id: string, now?: number) {
},
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
emperorExtortionGift: emperorExtortionGiftQuote(g, me),
emperorPhaseOpeningGift: emperorPhaseOpeningGiftQuote(g, me),
emperorAuctionGiftPending: g.pendingEmperorAuctionGift
? { owner: g.pendingEmperorAuctionGift.owner } : null,
emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama
+1
View File
@@ -87,6 +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 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 Basic Ix-deck games, 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.',
],