Allow Emperor gifts during Basic Ix phase openings
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
538247e777
commit
d4abc6bb83
9 files changed
+294
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user