Offer Emperor gifts before allied auction payment
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 06:21:49 +11:00
1 parent 93b0146de9
commit 6cec4ed78b
11 files changed
+528 -2

No files matched your search

+110
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 };
/** Links an allied auction gift to the still-pending payment opportunity. */
emperorAuctionGiftEvent?: string;
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
advisorPreparationSource?: AdvisorPreparationSource;
advisorMatchingSource?: AdvisorMatchingSource;
@@ -2071,6 +2073,14 @@ export type Game = {
phaseOpening: Game['phaseOpening'];
};
} | null;
pendingEmperorAuctionGift?: {
event: string;
owner: string;
recipient: string;
turn: number;
signature: string;
giftAmount?: number;
} | null;
richeseGiftBlocked?: { turn: number; phase: number; cards: string[] };
richeseAllyOffer?: RicheseAllyOffer | null;
richeseAllyOpportunity?: { turn: number; recipient: string };
@@ -18386,11 +18396,72 @@ function auctionNext(g: Game) {
g.decision = { kind: 'auctionPayment', player: next.player };
g.active = next.player;
recoverAuctionPayment(g, getPlayer(g, next.player));
beginEmperorAuctionGift(g);
return;
}
a.active = next.player;
g.active = a.active;
}
function emperorAuctionGiftSignature(g: Game): string {
return JSON.stringify([g.turn, g.phase, g.active, g.decision, g.order, g.auction, g.aid]);
}
/** The public alliance and winning seat alone determine this first refusal.
* Only auctionNext creates it: a restored payment never reopens the offer. */
function beginEmperorAuctionGift(g: Game) {
if (!g.advanced || g.status !== 'playing' || g.phase !== 3 ||
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.pendingEmperorAuctionGift ||
g.response || g.phaseOpening || g.truthtrance || g.pendingKarama ||
g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingNullentropy ||
g.currentAuctionSale || g.richeseAuction || g.ixAuction ||
g.decision?.kind !== 'auctionPayment' || !g.auction ||
g.auction.bidder !== g.decision.player) return;
const emperor = byFaction(g, 'emperor');
const buyer = g.players.find(player => player.id === g.decision!.player);
if (!emperor || !buyer || emperor.id === buyer.id ||
emperor.ally !== buyer.id || buyer.ally !== emperor.id) return;
g.pendingEmperorAuctionGift = { event: crypto.randomUUID(), owner: emperor.id,
recipient: buyer.id, turn: g.turn, signature: emperorAuctionGiftSignature(g) };
}
function emperorAuctionGiftIntegrity(g: Game) {
const pending = g.pendingEmperorAuctionGift;
if (!pending) {
requireRule(!g.response?.emperorAuctionGiftEvent &&
!(g.pendingKarama?.use.kind === 'cancel' && g.pendingKarama.use.response.emperorAuctionGiftEvent),
'The Emperor auction gift opportunity is missing.');
return;
}
const owner = g.players.find(player => player.id === pending.owner);
const recipient = g.players.find(player => player.id === pending.recipient);
requireRule(g.status === 'playing' && g.advanced && g.phase === 3 && g.turn === pending.turn &&
typeof pending.event === 'string' && !!pending.event && owner?.faction === 'emperor' && recipient &&
owner.ally === recipient.id && recipient.ally === owner.id &&
g.decision?.kind === 'auctionPayment' && g.decision.player === recipient.id &&
g.auction?.bidder === recipient.id && pending.signature === emperorAuctionGiftSignature(g),
'The Emperor auction gift lost its original payment opportunity.');
const response = g.pendingKarama?.use.kind === 'cancel'
? g.pendingKarama.use.response : g.response;
if (response)
requireRule(response.kind === 'emperorGift' && response.owner === owner.id &&
response.recipient === recipient.id && response.emperorAuctionGiftEvent === pending.event &&
typeof response.amount === 'number' && Number.isSafeInteger(response.amount) &&
response.amount > 0 && response.amount === pending.giftAmount &&
response.amount <= uncommittedSpice(g, owner),
'The pending Emperor gift differs from its original auction opportunity.');
else requireRule(pending.giftAmount === undefined,
'The Emperor auction gift lost its original response.');
}
function emperorAuctionGiftMaximum(g: Game, p: Player): number {
if (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 0;
return Math.max(0, uncommittedSpice(g, p));
}
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) {
const a = g.auction!;
const winner = getPlayer(g, a.bidder!);
@@ -20438,6 +20509,7 @@ function finishHarkonnenExchange(
{ faction: 'harkonnen', name: 'Harkonnen exchange' });
}
function finishAutomaticDecision(g: Game): boolean {
if (g.pendingEmperorAuctionGift) return false;
if (g.biddingEnd && biddingEndQuiet(g)) return finishBiddingEnd(g);
if (advanceHomeworldReveal(g)) return true;
const decision = g.decision;
@@ -38255,6 +38327,11 @@ 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.emperorAuctionGiftEvent) {
requireRule(g.pendingEmperorAuctionGift?.event === response.emperorAuctionGiftEvent,
'The Emperor gift lost its original auction opportunity.');
delete g.pendingEmperorAuctionGift.giftAmount;
}
} else if (response.kind === 'guildIncome') {
const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!-(bureaucratDiversion ?? 0)) : null;
awardFinalFactionBankerIncome(g, response.spiceBankerIncomePayment,
@@ -41928,7 +42005,11 @@ 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 {
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
if (state.pendingEmperorAuctionGift)
requireRule(!['dealPay', 'dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type),
'Finish the Emperor auction gift opportunity before changing a public deal.');
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
@@ -42673,6 +42754,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
@@ -42946,6 +43028,27 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
if (g.pendingEmperorAuctionGift && !g.response && !g.pendingKarama) {
const pending = g.pendingEmperorAuctionGift;
if (t === 'advanceBots') return g;
requireRule(id === pending.owner, 'Wait for the Emperor to finish the allied gift opportunity.');
if (t === 'emperorAuctionGiftContinue') {
requireRule(Object.keys(action).sort().join(',') === 'event,type' && action.event === pending.event,
'This Emperor auction opportunity is no longer current.');
g.pendingEmperorAuctionGift = null;
return g;
}
requireRule(t === 'emperorGift' && Object.keys(action).sort().join(',') === 'amount,type',
'Choose an allied spice gift or continue to the original payment.');
const amount = integer(action.amount, 1, emperorAuctionGiftMaximum(g, p), 'Spice');
pending.giftAmount = amount;
g.response = { kind: 'emperorGift', owner: id, recipient: pending.recipient,
amount, elite: 0, passed: [], emperorAuctionGiftEvent: pending.event };
return g;
}
if (g.pendingEmperorAuctionGift && (g.response || g.pendingKarama) && t !== 'advanceBots')
requireRule(t === 'passResponse' || t === 'card' && action.mode === 'cancel',
'Resolve the Emperor gift response before another action.');
if (g.nexusFremenRideBetrayal?.stage === 'choose') {
if (t === 'advanceBots') return g;
requireRule(t === 'decision', 'Finish the current before-ride Betrayal opportunity first.');
@@ -47205,6 +47308,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string, now?: number) {
emperorAuctionGiftIntegrity(state);
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
@@ -48258,6 +48362,7 @@ export function viewGame(state: Game, id: string, now?: number) {
? { amount: undefined }
: {}),
...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}),
...(g.response.emperorAuctionGiftEvent ? { emperorAuctionGiftEvent: undefined } : {}),
}
: null,
paymentIncome: currentFactionPayment(g),
@@ -48324,6 +48429,11 @@ export function viewGame(state: Game, id: string, now?: number) {
: 0,
},
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
emperorAuctionGiftPending: g.pendingEmperorAuctionGift
? { owner: g.pendingEmperorAuctionGift.owner } : null,
emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama
? { event: g.pendingEmperorAuctionGift.event, recipient: g.pendingEmperorAuctionGift.recipient,
maximum: emperorAuctionGiftMaximum(g, me) } : null,
coalitionEmperor: (() => {
const group = coalitionEmperorGroup(g, me);
return group ? {allies: group.members.filter(member => member !== id),