Allow Emperor gift during Guild timing choice
Verify and publish checkpoint container / container (push) Successful in 11m8s

This commit is contained in:
Quintine committed 2026-10-29 06:13:25 +11:00
1 parent a9b67a89b3
commit 93b0146de9
9 files changed
+383 -2

No files matched your search

+65
View File
@@ -1232,6 +1232,8 @@ type SpiceBankerIncomePayment = {
};
export type ResponseWindow = {
/** The original public Guild timing decision remains live during this gift. */
emperorGuildTimingGift?: { event: string; signature: string };
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
advisorPreparationSource?: AdvisorPreparationSource;
advisorMatchingSource?: AdvisorMatchingSource;
@@ -19355,6 +19357,7 @@ function validateKaramaUse(
const kullBlock = kullKaramaBlock(g, p, karamaUsePurpose(g, use));
requireRule(!kullBlock, kullBlock ?? 'Kull Wahad prevents this Karama effect.');
if (use.kind === 'cancel') {
validateEmperorGuildTimingGift(g, use.response);
requireRule(
use.response && use.response.owner !== p.id,
'Karama cancels another faction’s power.',
@@ -29278,6 +29281,51 @@ function movementTurn(g: Game) {
};
} else g.active = remaining[0];
}
function emperorGuildTimingSignature(g: Game, owner: string, recipient: string, amount: number): string {
return JSON.stringify([g.turn, g.phase, g.active, g.decision, g.order,
g.movementRemaining, g.guildTimingGranted, g.guildTimingLocked, g.saphoMovementLast ?? null,
owner, recipient, amount]);
}
/** First supported interrupt: the clean Advanced Guild timing choice. */
function emperorGuildTimingGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
const decision = g.decision;
if (!g.advanced || g.status !== 'playing' || g.phase !== 5 ||
g.expansions.length !== 0 || 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.response || g.phaseOpening ||
g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.battle ||
g.pendingRicheseGift || g.pendingNullentropy || g.pendingShipment ||
g.pendingGuildTransport || g.pendingArrivalOverlap || g.pendingAmbassador ||
decision?.kind !== 'guildTiming' || g.active !== null ||
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 validateEmperorGuildTimingGift(g: Game, response: ResponseWindow) {
const parent = response.emperorGuildTimingGift;
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 === 'guildTiming' &&
parent.signature === emperorGuildTimingSignature(g, owner.id, recipient.id, response.amount),
'The Emperor gift lost its original Guild timing decision.');
}
function emperorGuildTimingGiftIntegrity(g: Game) {
if (g.response) validateEmperorGuildTimingGift(g, g.response);
if (g.pendingKarama?.use.kind === 'cancel')
validateEmperorGuildTimingGift(g, g.pendingKarama.use.response);
}
function chooseGuildTiming(g: Game, take: boolean) {
const guild = byFaction(g, 'guild')!;
if (take) g.active = guild.id;
@@ -37045,6 +37093,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
karamaConversionIntegrity(g);
currentFactionPayment(g);
const response = g.response!;
validateEmperorGuildTimingGift(g, response);
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null;
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
@@ -41879,6 +41928,7 @@ 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 {
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
@@ -42623,6 +42673,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
@@ -43357,6 +43408,17 @@ function applyActionInner(
specialKarama(g, p, action);
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',
'This Guild timing 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: [], emperorGuildTimingGift: {
event: crypto.randomUUID(), signature: emperorGuildTimingSignature(g, id, quote.recipient, amount),
} };
return g;
}
if (g.response) {
const response = g.response;
if (response.kind === 'fremenMovement') pendingFremenMovement(g, response);
@@ -47143,6 +47205,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string, now?: number) {
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
@@ -48194,6 +48257,7 @@ export function viewGame(state: Game, id: string, now?: number) {
![g.response.owner, g.response.recipient].includes(id)
? { amount: undefined }
: {}),
...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}),
}
: null,
paymentIncome: currentFactionPayment(g),
@@ -48259,6 +48323,7 @@ export function viewGame(state: Game, id: string, now?: number) {
? g.aid[me.ally].amount
: 0,
},
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
coalitionEmperor: (() => {
const group = coalitionEmperorGroup(g, me);
return group ? {allies: group.members.filter(member => member !== id),