Preserve Emperor gift opportunity before bot Extortion
Verify and publish checkpoint container / container (push) Successful in 10m54s

This commit is contained in:
Quintine committed 2026-10-30 17:44:00 +11:00
1 parent a6be17fc5d
commit 7c1f4b4e09
13 files changed
+370 -13

No files matched your search

+7 -6
View File
@@ -1250,12 +1250,6 @@ function policyActions(g: GameView): Action[] {
? [{ type: 'victoryContinuation', event: g.victoryContinuation.event, mode: 'finish' }]
: [];
if (g.homeworldTupileBlocked) return [];
if (g.saphoStationPriority) {
const own = g.players.find(player => player.id === g.me), priority = g.saphoStationPriority;
return (own?.bot || own?.autopilot) && g.status === 'playing' && priority.canPass &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
? [{type:'saphoStationPass', event:priority.event}] : [];
}
const dukeGrant = g.homeworldEcazDuke;
if (dukeGrant && dukeGrant.occupier === me.id && !dukeGrant.blocked && dukeGrant.event && dukeGrant.recipients.length)
return [{ type: 'homeworldDuke', event: dukeGrant.event, recipient: dukeGrant.recipients[0].id }];
@@ -4377,6 +4371,13 @@ function botAdvisorMatchingAllowed(g:GameView,action:Action) {
} catch(error) { if(error instanceof AdvisorMatchingError) return false; throw error; }
}
export function botActions(g: GameView): Action[] {
if (g.saphoStationPriority) {
const own = g.players.find(player => player.id === g.me), priority = g.saphoStationPriority;
return (own?.bot || own?.autopilot) && g.status === 'playing' && priority.canPass &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
? [{type:'saphoStationPass', event:priority.event}] : [];
}
if (g.emperorExtortionPriority) return [];
if (g.emperorAuctionGiftPending && !g.response) {
const me = g.players.find(player => player.id === g.me);
return (me?.bot || me?.autopilot) && g.status === 'playing' &&
+35 -2
View File
@@ -2379,6 +2379,8 @@ export type Game = {
moritaniTerror?: TerrorState;
moritaniAtomics?: AtomicsAftermath | null;
moritaniExtortion?: ExtortionState;
/** Public release of the human Emperor's optional gift opportunity. */
emperorExtortionGiftRelease?: { event: string; emperor: string; recipient: string };
grummanCollection?: GrummanCollection;
pendingMoritaniPlacement?: {
token: string;
@@ -30345,7 +30347,7 @@ function emperorExtortionGiftSignature(g: Game, owner: string, recipient: string
return JSON.stringify([g.turn, g.phase, g.active, g.order, g.decision,
g.moritaniExtortion, g.moritaniTerror?.placementTurn, owner, recipient, amount]);
}
function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
function emperorExtortionGiftPublicEligible(g: Game, p: Player): boolean {
const decision = g.decision, extortion = g.moritaniExtortion;
if (g.status !== 'playing' || g.phase !== 8 || g.expansions.length !== 1 ||
g.expansions[0] !== 'ecaz' || g.players.some(seat =>
@@ -30357,7 +30359,11 @@ function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maxi
!extortion || extortion.stage !== 'payment' || decision?.kind !== 'moritaniExtortion' ||
decision.event !== extortionEvent(extortion) || decision.player !== extortion.queue[extortion.cursor] ||
p.faction !== 'emperor' || !p.ally || decision.player !== p.ally ||
!g.players.some(seat => seat.id === p.ally && seat.ally === p.id) ||
!g.players.some(seat => seat.id === p.ally && seat.ally === p.id)) return false;
return true;
}
function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
if (!emperorExtortionGiftPublicEligible(g, p) || !p.ally ||
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) ||
@@ -30368,6 +30374,18 @@ function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maxi
const maximum = Math.max(0, uncommittedSpice(g, p));
return maximum > 0 ? {recipient: p.ally, maximum} : null;
}
/** A neutral window for a human Emperor before an automated ally answers.
* The visible wait never depends on either player's private spice or promises. */
function emperorExtortionPriority(g: Game): {event: string; emperor: string; recipient: string} | null {
const emperor = byFaction(g, 'emperor'), decision = g.decision;
if (!emperor || (emperor.bot ?? emperor.autopilot) || !emperorExtortionGiftPublicEligible(g, emperor) ||
decision?.kind !== 'moritaniExtortion') return null;
const recipient = g.players.find(player => player.id === decision.player);
if (!recipient || !(recipient.bot ?? recipient.autopilot)) return null;
const release = g.emperorExtortionGiftRelease;
if (release?.event === decision.event && release.emperor === emperor.id && release.recipient === recipient.id) return null;
return {event:decision.event, emperor:emperor.id, recipient:recipient.id};
}
function validateEmperorExtortionGift(g: Game, response: ResponseWindow) {
const parent = response.emperorExtortionGift;
if (!parent) return;
@@ -44943,6 +44961,14 @@ function applyActionInner(
specialKarama(g, p, action);
return g;
}
if (t === 'emperorExtortionContinue') {
const priority = emperorExtortionPriority(g);
requireRule(priority && priority.emperor === id && action.event === priority.event &&
Object.keys(action).sort().join(',') === 'event,type',
'Continue only your current Emperor Extortion gift opportunity.');
g.emperorExtortionGiftRelease = priority;
return g;
}
if (t === 'emperorGift' && g.decision?.kind === 'moritaniExtortion' && !g.response) {
const quote = emperorExtortionGiftQuote(g, p);
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
@@ -45081,6 +45107,9 @@ function applyActionInner(
t === 'decision' && decision.player === id,
'Waiting for the player with the pending decision.',
);
if (decision.kind === 'moritaniExtortion')
requireRule(!emperorExtortionPriority(g),
'Wait for the Emperor to finish the allied gift opportunity.');
g.decision = null;
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
if (decision.kind === 'moritaniAssassinate') {actMoritaniAssassinate(g,decision,action);return g;}
@@ -49913,6 +49942,10 @@ export function viewGame(state: Game, id: string, now?: number) {
},
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
emperorExtortionGift: emperorExtortionGiftQuote(g, me),
emperorExtortionPriority: (() => {
const priority = emperorExtortionPriority(g);
return priority ? {event:priority.event, recipient:priority.recipient, canContinue:priority.emperor === id} : null;
})(),
emperorPhaseOpeningGift: emperorPhaseOpeningGiftQuote(g, me),
emperorAuctionGiftPending: g.pendingEmperorAuctionGift
? { owner: g.pendingEmperorAuctionGift.owner } : null,
+1
View File
@@ -4284,6 +4284,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'For this table’s selected timing, Moritani’s normal Mentat Terror placement or relocation opportunity resolves first, including any Karama response. Only afterward does Moritani collect the five spice and offer the Extortion payment opportunity. A token recovered afterward cannot be placed again during that same Mentat Pause.',
'If Extortion is revealed during Mentat after placement has already finished, resolve that committed entry and its interruptions first. Then collect the bank award and offer payment before any player can end the turn.',
'After collection, each non-Moritani player in storm order may pay Moritani three spice or decline. Sapho may provisionally reorder only unanswered choices; earlier declines cannot repeat. A player without three spice cannot pay but may decline. The first payment permanently removes Extortion from play and ends the offers; it does not undo the bank award.',
'In supported games, a human Emperor allied to an automated payer gets a shared opportunity to give spice or Continue before that payer decides. Gifts retain their normal Karama response; the opportunity remains until Continue. This online priority is provisional.',
'If every eligible player declines, rotate Extortion and return it face down to Moritani’s hidden supply for a future placement. Other revealed Terror tokens have no corresponding recovery provision.',
'These controls cover Extortion collection, payer ordering, the payment choice and native return/removal. Supported native Moritani starts remain labelled previews; complete-mode acceptance is unfinished.',
],
+2 -2
View File
@@ -12,13 +12,13 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
Partial<Pick<GameView, 'saphoStationPriority' | 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement' | 'nexusIxianBetrayalReaction' | 'nexusHarkonnenBetrayalReaction'>>;
Partial<Pick<GameView, 'emperorExtortionPriority' | 'saphoStationPriority' | 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement' | 'nexusIxianBetrayalReaction' | 'nexusHarkonnenBetrayalReaction'>>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
* participate; this does not decide which gameplay actions are legal. */
export function tableActionOwner(g: TurnView): string | null {
if (g.saphoStationPriority) return null;
if (g.saphoStationPriority || g.emperorExtortionPriority) return null;
if (g.richeseBetrayalReaction || g.guildBetrayalReaction) return null;
if (g.kullReaction && g.status === 'playing')
return g.players.some(p => p.id === g.kullReaction!.player)