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