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),
+1
View File
@@ -90,6 +90,7 @@ export const FACTION_RULES: Record<
'Spice gifts and extra revival uses have Karama response windows. Canceling a use spends neither the Emperor’s spice nor the extra-revival allowance.',
],
advanced: [
'In a clean classic-faction Advanced game, the Emperor can also give an ally spice while the Guild is choosing its turn timing. Resolve the gift’s Karama response before the same Guild choice continues. Other interrupted windows remain unfinished.',
'Five Sardaukar forces have a printed battle strength of two each, except against Fremen, where they count as ordinary forces.',
'Spice support applies per token. A supported Sardaukar contributes two strength; an unsupported Sardaukar contributes one, except when fighting Fremen.',
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit. With Homeworlds, a low Salusa prohibits free Sardaukar returns; normal forces and eligible leaders remain available.',