Offer Emperor gifts before allied auction payment
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
93b0146de9
commit
6cec4ed78b
11 files changed
+528
-2
No files matched your search
@@ -4359,6 +4359,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.emperorAuctionGiftPending && !g.response) {
|
||||
const me = g.players.find(player => player.id === g.me);
|
||||
return (me?.bot || me?.autopilot) && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending &&
|
||||
g.emperorAuctionGift
|
||||
? [{type: 'emperorAuctionGiftContinue', event: g.emperorAuctionGift.event}] : [];
|
||||
}
|
||||
if (g.victoryContinuation)
|
||||
return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
|
||||
? policyActions(g) : [];
|
||||
|
||||
+110
@@ -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),
|
||||
|
||||
@@ -91,6 +91,7 @@ export const FACTION_RULES: Record<
|
||||
],
|
||||
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.',
|
||||
'Before a supported ally’s ordinary auction payment, the Emperor may give spice or continue to that payment. The already declared buyer and allied funding shares remain fixed; the original payment follows after the gift response.',
|
||||
'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.',
|
||||
|
||||
Reference in new issue
Block a user