From 7c1f4b4e0946b860ca291383415f182019555554 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:44:00 +1100 Subject: [PATCH] Preserve Emperor gift opportunity before bot Extortion --- components/emperor-extortion-priority.tsx | 41 +++++++++ components/game-table.tsx | 2 + docs/CURRENT_STATUS.md | 11 +++ docs/EMPEROR_ALLIANCE_TIMING.md | 12 ++- docs/RULE_DECISIONS.md | 7 ++ game/bots.ts | 13 +-- game/engine.ts | 37 +++++++- game/reference.ts | 1 + game/table-turn.ts | 4 +- .../emperor-extortion-bot-priority-fixture.ts | 77 ++++++++++++++++ tests/emperor-extortion-bot-priority.test.ts | 89 +++++++++++++++++++ ...peror-extortion-priority-controls.test.tsx | 57 ++++++++++++ tests/sapho-station-bot-priority.test.ts | 32 +++++++ 13 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 components/emperor-extortion-priority.tsx create mode 100644 tests/emperor-extortion-bot-priority-fixture.ts create mode 100644 tests/emperor-extortion-bot-priority.test.ts create mode 100644 tests/emperor-extortion-priority-controls.test.tsx create mode 100644 tests/sapho-station-bot-priority.test.ts diff --git a/components/emperor-extortion-priority.tsx b/components/emperor-extortion-priority.tsx new file mode 100644 index 00000000..eee680d7 --- /dev/null +++ b/components/emperor-extortion-priority.tsx @@ -0,0 +1,41 @@ +'use client'; + +import type { Action } from '@/game/engine'; +import { Button } from './ui/button'; + +/** The same public opportunity is shown regardless of the Emperor's spice. */ +export type EmperorExtortionPriorityView = { + event: string; + recipient: string; + canContinue: boolean; +}; + +export type EmperorExtortionPriorityProps = { + game: { + emperorExtortionPriority: EmperorExtortionPriorityView | null; + players: readonly { id: string; name: string }[]; + }; + act: (action: Action) => void; + busy: boolean; +}; + +/** The payer's normal Extortion choice remains pending until the Emperor + * completes this optional alliance opportunity. */ +export function EmperorExtortionPriority({ game, act, busy }: EmperorExtortionPriorityProps) { + const priority = game.emperorExtortionPriority; + if (!priority) return null; + const event = priority.event; + const recipient = game.players.find(player => player.id === priority.recipient)?.name ?? 'the ally'; + const canAct = () => !busy && game.emperorExtortionPriority === priority && + priority.event === event && typeof event === 'string' && event.length > 0 && priority.canContinue; + + return
+

Emperor alliance opportunity

+

Before {recipient} makes the Extortion choice, the Emperor may share spice with their ally. The existing gift control shows any available amount.

+ {priority.canContinue ? :

Waiting for the Emperor to continue.

} +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index db9d68ab..a6bbb3d7 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,6 +1,7 @@ 'use client'; import { SaphoArrivalOrder } from './sapho-arrival-order'; import { SaphoStationPriority } from './sapho-station-priority'; +import { EmperorExtortionPriority } from './emperor-extortion-priority'; import { RicheseBidPromisePanel } from './richese-bid-promise-panel'; import { EarlyRicheseBidPromises } from './early-richese-bid-promises'; import { TravelPromises } from './travel-promises'; @@ -1536,6 +1537,7 @@ export function GameTable({ + {g.emperorExtortionPriority && } {g.saphoStationPriority && } {g.saphoArrivalOrder && 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' && diff --git a/game/engine.ts b/game/engine.ts index 7fdf3268..1005a56d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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, diff --git a/game/reference.ts b/game/reference.ts index 3e42d1c1..e799a7f5 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', ], diff --git a/game/table-turn.ts b/game/table-turn.ts index 2945e35e..2fbd2113 100644 --- a/game/table-turn.ts +++ b/game/table-turn.ts @@ -12,13 +12,13 @@ type TurnView = Pick< | 'phaseOpening' | 'automaticContinuationPending' > & - Partial>; + Partial>; /** 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) diff --git a/tests/emperor-extortion-bot-priority-fixture.ts b/tests/emperor-extortion-bot-priority-fixture.ts new file mode 100644 index 00000000..ca848186 --- /dev/null +++ b/tests/emperor-extortion-bot-priority-fixture.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { placeTerror } from '../game/moritani-terror'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export const extortionSeat = (game: Game, id: string) => { + const player = game.players.find(row => row.id === id); assert.ok(player); return player; +}; +export const extortionAct = (game: Game, id: string, action: Action) => applyAction(game, id, action); +export function extortionEvent(game: Game): string { + assert.ok(game.decision?.kind === 'moritaniExtortion'); + return game.decision.event; +} +export function extortionPayment(game: Game, pay: boolean): Action { + return {type:'decision', event:extortionEvent(game), pay}; +} +export function finishExtortionGiftResponse(game: Game): Game { + for (let count = 0; game.response && count < 30; count++) { + const responder = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(responder); + game = extortionAct(game, responder.id, {type:'passResponse'}); + } + assert.equal(game.response, null); + return game; +} + +/** A fresh public E3 Basic/Advanced start, genuine setup, shipment and Terror + * revelation. The alliance, movement boundary, token placement and wallets are + * staged; the original Extortion owner and subsequent choices are real. */ +export function createExtortionBotPriority(advanced: boolean, + options: {unallied?: boolean; emperorSpice?: number; autopilot?: boolean} = {}) { + let game = createGame('EXTORTIONBOTPRIORITY', newPlayer('m', 'Moritani', 'moritani'), advanced, ['ecaz']); + for (const [id, name, faction] of [['e','Emperor','emperor'], ['a','Atreides','atreides'], + ['h','Harkonnen','harkonnen']] as const) joinGame(game, newPlayer(id, name, faction)); + for (const player of game.players) game = extortionAct(game, player.id, {type:'ready'}); + game = extortionAct(game, 'm', advanced ? {type:'start', advancedPreview:true} : {type:'start'}); + for (let count = 0; game.status === 'setup' && count < 100; count++) { + if (game.decision?.kind === 'moritaniSetup') game = extortionAct(game, 'm', + {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]}); + else { + const step = nextStrongholdFactionsNativeStep(game); assert.ok(step); + game = extortionAct(game, step.actor, step.action); + } + } + assert.equal(game.status, 'playing'); + if (!options.unallied) { extortionSeat(game, 'e').ally = 'a'; extortionSeat(game, 'a').ally = 'e'; } + const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token); + game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1); + Object.assign(game, {phase:5, turn:2, storm:18, order:['e','a','h','m'], active:'e', + movementRemaining:['e','a','h','m'], response:null, decision:null, phaseOpening:null, ready:[]}); + extortionSeat(game, 'e').spice = 10; extortionSeat(game, 'a').spice = 2; + const harkonnen = extortionSeat(game, 'h'); + let card = harkonnen.hand.find(row => row.effect === 'karama'); + if (!card) { + const sourceOwner = game.players.find(player => player.id !== 'h' && player.hand.some(row => row.effect === 'karama')); + const source = sourceOwner?.hand ?? game.deck, index = source.findIndex(row => row.effect === 'karama'); + assert.ok(index >= 0); [card] = source.splice(index, 1); harkonnen.hand.push(card); + } + const karama = card.id; + game = extortionAct(game, 'e', {type:'ship', territory:'habbanya_ridge_sietch', sector:17, amount:1}); + assert.equal(game.decision?.kind, 'moritaniTerror'); + game = extortionAct(game, 'm', {type:'decision', reveal:true}); + game.phase = 7; game.ready = []; + for (const player of game.players) game = extortionAct(game, player.id, {type:'ready'}); + assert.equal(game.phase, 8); + assert.equal(game.decision?.kind, 'moritaniPlacement'); + game = extortionAct(game, 'm', {type:'decision', decline:true}); + assert.equal(game.decision?.kind, 'moritaniExtortion'); + assert.equal(game.decision?.player, 'e'); + if (options.autopilot) extortionSeat(game, 'a').autopilot = 'Easy'; + else extortionSeat(game, 'a').bot = 'Easy'; + if (options.emperorSpice !== undefined) extortionSeat(game, 'e').spice = options.emperorSpice; + game = extortionAct(game, 'e', extortionPayment(game, false)); + assert.equal(game.decision?.kind, 'moritaniExtortion'); + assert.equal(game.decision?.player, 'a'); + return {game, token:token.id, karama}; +} diff --git a/tests/emperor-extortion-bot-priority.test.ts b/tests/emperor-extortion-bot-priority.test.ts new file mode 100644 index 00000000..953bf85d --- /dev/null +++ b/tests/emperor-extortion-bot-priority.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { botActions, runBots } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createExtortionBotPriority, extortionAct, extortionEvent, extortionPayment, extortionSeat, + finishExtortionGiftResponse } from './emperor-extortion-bot-priority-fixture'; + +for (const advanced of [false, true]) for (const autopilot of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} human Emperor gets Extortion gift priority before ${autopilot ? 'autopilot' : 'bot'} ally`, () => { + const {game: initial} = createExtortionBotPriority(advanced, {autopilot}); + let game = initial; + const event = extortionEvent(game); + assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true}); + for (const id of ['a','h','m']) + assert.deepEqual(viewGame(game, id).emperorExtortionPriority, {event, recipient:'a', canContinue:false}); + const e = extortionSeat(game, 'e').spice, a = extortionSeat(game, 'a').spice; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'a'); + const actor = view.players.find(player => player.id === 'a')!; + if (autopilot) actor.autopilot = difficulty; else actor.bot = difficulty; + assert.ok(!botActions(view).some(action => action.type === 'decision' && action.event === event), difficulty); + } + const waiting = runBots(game, 5); + assert.equal(waiting.decision?.kind, 'moritaniExtortion'); + assert.equal(extortionEvent(waiting), event); + assert.equal(extortionSeat(waiting, 'e').spice, e); + assert.equal(extortionSeat(waiting, 'a').spice, a); + const original = structuredClone(game); + assert.throws(() => extortionAct(game, 'a', extortionPayment(game, false))); + assert.throws(() => extortionAct(game, 'a', {type:'emperorExtortionContinue', event})); + assert.throws(() => extortionAct(game, 'e', {type:'emperorExtortionContinue', event:'stale'})); + assert.deepEqual(game, original); + game = extortionAct(game, 'e', {type:'emperorExtortionContinue', event}); + assert.ok(!viewGame(game, 'e').emperorExtortionPriority); + assert.deepEqual(game.emperorExtortionGiftRelease, {event, emperor:'e', recipient:'a'}); + const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event); + assert.ok(action); assert.equal(action.pay, false); + game = runBots(game, 1); + assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event); + }); + +for (const advanced of [false, true]) for (const canceled of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} ${canceled ? 'canceled' : 'successful'} Emperor gift preserves priority until Continue`, () => { + const fixture = createExtortionBotPriority(advanced); + let game = fixture.game; + const event = extortionEvent(game), emperor = extortionSeat(game, 'e').spice; + game = extortionAct(game, 'e', {type:'emperorGift', amount:1}); + assert.equal(game.response?.kind, 'emperorGift'); + game = canceled ? extortionAct(game, 'h', {type:'card', card:fixture.karama, mode:'cancel'}) : + finishExtortionGiftResponse(game); + assert.equal(game.response, null); + assert.equal(game.decision?.kind, 'moritaniExtortion'); + assert.equal(extortionEvent(game), event); + assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true}); + assert.equal(extortionSeat(game, 'a').spice, canceled ? 2 : 3); + assert.equal(extortionSeat(game, 'e').spice, emperor - (canceled ? 0 : 1)); + const before = runBots(game, 5); + assert.equal(extortionEvent(before), event); + game = extortionAct(game, 'e', {type:'emperorExtortionContinue', event}); + const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event); + // The current minimal bot policy declines even when it can afford to pay. + assert.ok(action); assert.equal(action.pay, false); + game = runBots(game, 1); + assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === fixture.token)?.status, 'extortion'); + }); + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} no-spice Emperor still has neutral Continue priority`, () => { + const {game} = createExtortionBotPriority(advanced, {emperorSpice:0}); + const event = extortionEvent(game); + assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true}); + assert.deepEqual(viewGame(game, 'a').emperorExtortionPriority, {event, recipient:'a', canContinue:false}); + assert.ok(!botActions(viewGame(game, 'a')).some(action => action.type === 'decision')); + const continued = extortionAct(game, 'e', {type:'emperorExtortionContinue', event}); + assert.ok(botActions(viewGame(continued, 'a')).some(action => action.type === 'decision' && action.pay === false)); + }); + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} unaffiliated bot pays or declines without Emperor priority`, () => { + const {game} = createExtortionBotPriority(advanced, {unallied:true}); + const event = extortionEvent(game); + assert.ok(!viewGame(game, 'e').emperorExtortionPriority); + const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event); + assert.ok(action); assert.equal(action.pay, false); + const next = runBots(game, 1); + assert.ok(next.decision?.kind !== 'moritaniExtortion' || next.decision.event !== event); + }); diff --git a/tests/emperor-extortion-priority-controls.test.tsx b/tests/emperor-extortion-priority-controls.test.tsx new file mode 100644 index 00000000..ef9b6133 --- /dev/null +++ b/tests/emperor-extortion-priority-controls.test.tsx @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Children, createElement, isValidElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { Action } from '../game/engine'; +import { EmperorExtortionPriority, type EmperorExtortionPriorityProps } from '../components/emperor-extortion-priority'; + +function props(): EmperorExtortionPriorityProps & { sent: Action[] } { + const sent: Action[] = []; + return {game:{emperorExtortionPriority:{event:'extortion-event',recipient:'ally',canContinue:true}, + players:[{id:'ally',name:'Ally'}]}, busy:false, sent, act(action) {sent.push(action);}}; +} +const render = (input: EmperorExtortionPriorityProps) => renderToStaticMarkup(createElement(EmperorExtortionPriority, input)); +function button(input: EmperorExtortionPriorityProps) { + const section = EmperorExtortionPriority(input); assert.ok(section); + const match = Children.toArray(section.props.children).find(child => isValidElement(child) && + (child.props as {'data-testid'?: string})['data-testid'] === 'emperor-extortion-continue'); + assert.ok(isValidElement<{onClick:() => void; disabled:boolean}>(match)); + return match; +} + +void test('the Emperor sees a persistent, event-bound Extortion opportunity', () => { + const input = props(), html = render(input); + assert.match(html, /Before Ally makes the Extortion choice/); + assert.match(html, /Continue to Extortion/); + assert.equal(button(input).props.disabled, false); + button(input).props.onClick(); + assert.deepEqual(input.sent, [{type:'emperorExtortionContinue',event:'extortion-event'}]); +}); + +void test('other viewers see the same neutral opportunity without a Continue control or private data', () => { + const input = props(); + input.game.emperorExtortionPriority!.canContinue = false; + Object.assign(input.game, {privateSpice:19, privateCard:'secret-card'}); + const html = render(input); + assert.match(html, /Waiting for the Emperor/); + assert.doesNotMatch(html, /emperor-extortion-continue|secret-card|19/); + assert.deepEqual(input.sent, []); + input.game.emperorExtortionPriority = null; + assert.equal(render(input), ''); +}); + +void test('busy, invalid, stale or consumed opportunities cannot submit', () => { + for (const change of ['busy','empty','event','permission','replace','close'] as const) { + const input = props(); + if (change === 'busy') input.busy = true; + if (change === 'empty') input.game.emperorExtortionPriority!.event = ''; + const control = button(input); + if (change === 'busy' || change === 'empty') assert.equal(control.props.disabled, true); + if (change === 'event') input.game.emperorExtortionPriority!.event = 'other'; + if (change === 'permission') input.game.emperorExtortionPriority!.canContinue = false; + if (change === 'replace') input.game.emperorExtortionPriority = {...input.game.emperorExtortionPriority!}; + if (change === 'close') input.game.emperorExtortionPriority = null; + control.props.onClick(); + assert.deepEqual(input.sent, [], change); + } +}); diff --git a/tests/sapho-station-bot-priority.test.ts b/tests/sapho-station-bot-priority.test.ts new file mode 100644 index 00000000..392a716a --- /dev/null +++ b/tests/sapho-station-bot-priority.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import { botActions, runBots } from '../game/bots'; +import { createSaphoStationPriorityFixture } from './sapho-station-priority-fixture'; + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} Station priority wins over a synthetic competing bot guide`, () => { + const fixture = createSaphoStationPriorityFixture(advanced); + const initial = fixture.game; + const event = initial.saphoStationPriority!.event; + const view = viewGame(initial, fixture.first); + + // A supported Station decision normally suppresses turn-action guidance. + // Inject a competing projected guide to exercise defensive dispatch order; + // this does not claim that the combination occurs in an ordinary game. + view.turnActionGuide = {event:'synthetic-guide', action:{type:'ready'}}; + const action = botActions(view)[0]; + assert.deepEqual(action, {type:'saphoStationPass', event}); + const afterPass = applyAction(initial, fixture.first, action); + assert.deepEqual(afterPass.saphoStationPriority?.passed, [fixture.first]); + assert.deepEqual(afterPass.ecologicalStorm?.queue?.choices, []); + assert.equal(viewGame(afterPass, fixture.holder).saphoStationPriority?.canPass, true); + + // Real scheduler behavior is separately checked on the public-start state; + // only the fixture's Station placement and card custody were staged. + const scheduled = runBots(initial, 12); + assert.deepEqual(scheduled.saphoStationPriority?.passed, [fixture.first]); + assert.deepEqual(scheduled.ecologicalStorm?.queue?.choices, []); + assert.equal(viewGame(scheduled, fixture.holder).saphoStationPriority?.canPass, true); + }, +);