diff --git a/components/emperor-auction-gift.tsx b/components/emperor-auction-gift.tsx
new file mode 100644
index 00000000..4c62203e
--- /dev/null
+++ b/components/emperor-auction-gift.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { useId, useState } from 'react';
+import type { Action, GameView } from '@/game/engine';
+
+/** The server exposes the current optional opportunity only to its Emperor. */
+export function EmperorAuctionGift({ game, act, busy }: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const id = useId();
+ const [raw, setRaw] = useState('1');
+ const offer = game.emperorAuctionGift;
+ if (!offer) return null;
+ const amount = /^[1-9]\d*$/.test(raw) ? Number(raw) : NaN;
+ const canShare = !busy && Number.isSafeInteger(amount) && amount >= 1 && amount <= offer.maximum;
+ const recipient = game.players.find(player => player.id === offer.recipient)?.name ?? 'your ally';
+
+ return The winning bid and its committed ally payment split remain unchanged. Your ally chooses how to pay after the gift’s Karama response.Share spice with {recipient}
+
@@ -3258,7 +3260,9 @@ export function GameTable({ . No storm casualties have been applied.
)} - {g.decision.player !== me.id ? ( + {g.emperorAuctionGiftPending ? ( +Waiting for the Emperor to finish the allied gift opportunity.
+ ) : g.decision.player !== me.id ? (Waiting for{' '} {g.players.find((p) => p.id === g.decision?.player)?.name} to diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0b5d1e94..f2f045b5 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Emperor gift before Advanced allied auction settlement — 29 October. +Prototyped / Partial.** A uniform opportunity precedes supported allied winning +lots with or without a buyer's Karama, preserving the committed funding split +through gift, cancellation and original payment. Actual auction journeys, +rendered controls and the normal bot scheduler pass; the related 39-case +batch, TypeScript, lint and production build pass. The private-balance-safe +prototype still requires Continue at zero spice, so the no-confirmation goal +for forced automatic actions remains open, as do other auction and timing +parents and deployed acceptance. +[Scope](EMPEROR_ALLIANCE_TIMING.md#advanced-allied-auction-payment--29-october-2026). + **Emperor gift during Advanced Guild timing — 29 October. Prototyped / Partial.** A clean classic Advanced Guild timing decision now permits the Emperor's allied spice gift, with the original decision intact through the diff --git a/docs/EMPEROR_ALLIANCE_TIMING.md b/docs/EMPEROR_ALLIANCE_TIMING.md index a3e880ab..3beaeb04 100644 --- a/docs/EMPEROR_ALLIANCE_TIMING.md +++ b/docs/EMPEROR_ALLIANCE_TIMING.md @@ -1,5 +1,29 @@ # Emperor alliance gift timing +## Advanced allied auction payment — 29 October 2026 + +For a clean classic Advanced auction won by the Emperor's current mutual ally, +the Emperor receives a uniform opportunity to give spice before the original +payment. The same public waiting window appears whether the buyer holds Karama +or the Emperor has available spice; only the Emperor sees the gift amount +available. Continue leaves payment to the buyer or the existing automatic +settlement. A gift's Karama response retains the original lot, bid and +committed payer split; allowing it credits the buyer once, while cancellation +transfers nothing. Gifted buyer spice follows the buyer's normal payment route; +the previously pledged Emperor contribution remains separately accounted for. + +The uniform priority and explicit Continue are provisional online timing for +the authorized Advanced rules' any-time power, not publisher instructions. +The same Continue is currently required even when the Emperor has no spice; +this protects the private balance but still needs refinement to meet the +project's no-confirmation requirement for forced automatic actions. +Older already-open payments do not acquire a new window. Other auction modes, +optional modules and live commitments needing a combined planner remain +unfinished. [Runtime cases](../tests/emperor-auction-gift.test.ts) use real +setup, alliance and bids with explicitly staged balances and cards; +[rendered controls](../tests/emperor-auction-gift-controls.test.tsx) cover the +table and owner-only opportunity. + ## Advanced Guild timing prototype — 29 October 2026 The user-authorized revised rules, physical p. 31 (printed p. 29), allow the diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 7c5ca404..e417134c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Advanced Emperor gift before allied auction payment — 29 October 2026 + +The supplied Advanced rules permit an allied gift at any time. The first +clean ordinary-auction implementation offers the Emperor a uniform public +opportunity before the original payment, independent of private card holdings; +its priority and Continue are provisional online timing, not a publisher +ruling. The original payer split remains committed. +[Scope](EMPEROR_ALLIANCE_TIMING.md#advanced-allied-auction-payment--29-october-2026). + ## Advanced Emperor gift during Guild timing — 29 October 2026 The authorized revised Emperor sheet, physical p. 31, allows allied spice diff --git a/game/bots.ts b/game/bots.ts index a9b9a2bc..097aa3cf 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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) : []; diff --git a/game/engine.ts b/game/engine.ts index 59c60a35..8e8bf1ef 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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), diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 42f35486..2ff5fc01 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -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.', diff --git a/tests/advanced-emperor-auction-contribution.test.ts b/tests/advanced-emperor-auction-contribution.test.ts index a65437d8..f67baf2c 100644 --- a/tests/advanced-emperor-auction-contribution.test.ts +++ b/tests/advanced-emperor-auction-contribution.test.ts @@ -53,7 +53,11 @@ function bid(game: Game, action: Action): Game { game = act(game, 'harkonnen', action); for (let count = 0; !game.response && !game.decision && game.auction?.bidder === 'harkonnen' && count < 12; count++) game = act(game, game.auction.active, { type: 'passBid' }); - if (game.decision?.kind === 'auctionPayment') game = act(game, 'harkonnen', { type: 'decision', karama: false }); + if (game.pendingEmperorAuctionGift) + game = act(game, game.pendingEmperorAuctionGift.owner, + { type: 'emperorAuctionGiftContinue', event: game.pendingEmperorAuctionGift.event }); + if (!game.response && game.decision?.kind === 'auctionPayment') + game = act(game, 'harkonnen', { type: 'decision', karama: false }); return game; } diff --git a/tests/emperor-auction-gift-controls.test.tsx b/tests/emperor-auction-gift-controls.test.tsx new file mode 100644 index 00000000..0cf24429 --- /dev/null +++ b/tests/emperor-auction-gift-controls.test.tsx @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame, type Game, type GameView } from '../game/engine'; +import { EmperorAuctionGift } from '../components/emperor-auction-gift'; +import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : + specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true}; + }, +}); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +/** Original public Advanced setup, natural worm and actual alliance, followed + * by ordinary bidding actions. Wallet amounts are deliberately staged. */ +function fixture(emperorSpice: number): Game { + const source = createTruthAllianceFixture({advanced:true, consent:'none'}); + let game = source.game; + game = applyAction(game, 'harkonnen', {type:'alliance', target:'emperor'}); + game = applyAction(game, 'emperor', {type:'alliance', target:'harkonnen'}); + game = advanceTruthAlliance(game, state => state.phase === 3 && !!state.auction && truthAllianceClean(state)); + for (let count = 0; game.auction && game.auction.active !== 'harkonnen' && count < game.players.length; count++) + game = applyAction(game, game.auction.active, {type:'passBid'}); + assert.equal(game.auction?.active, 'harkonnen'); + game.players.find(player => player.id === 'harkonnen')!.spice = 6; + game.players.find(player => player.id === 'emperor')!.spice = emperorSpice; + game = applyAction(game, 'harkonnen', {type:'bid', amount:4, allyPayment:0}); + for (let count = 0; game.auction && !game.response && !game.decision && count < 12; count++) + game = applyAction(game, game.auction.active, {type:'passBid'}); + assert.equal(game.decision?.kind, 'auctionPayment'); + assert.deepEqual(viewGame(game, 'emperor').emperorAuctionGiftPending, {owner:'emperor'}); + return game; +} + +const component = (game: GameView, busy = false) => + renderToStaticMarkup(createElement(EmperorAuctionGift, {game, busy, act() {}})); +const table = (game: GameView, busy = false) => + renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}})); +const enabled = (label: string) => new RegExp(`