From a6be17fc5d049ab18b188cfd2611392fca63ec0b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:35:55 +1100 Subject: [PATCH] Protect Sapho priority at the Testing Station --- components/game-table.tsx | 9 +- components/sapho-station-priority.tsx | 38 +++++ docs/CURRENT_STATUS.md | 10 ++ docs/DISCOVERY_PROTOTYPE.md | 11 +- docs/RULE_DECISIONS.md | 8 ++ game/bots.ts | 6 + game/engine.ts | 45 +++++- game/reference.ts | 2 +- game/table-turn.ts | 3 +- .../sapho-station-priority-controls.test.tsx | 73 ++++++++++ tests/sapho-station-priority-fixture.ts | 60 ++++++++ tests/sapho-station-priority.test.ts | 135 ++++++++++++++++++ 12 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 components/sapho-station-priority.tsx create mode 100644 tests/sapho-station-priority-controls.test.tsx create mode 100644 tests/sapho-station-priority-fixture.ts create mode 100644 tests/sapho-station-priority.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 4ee9029c..db9d68ab 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,5 +1,6 @@ 'use client'; import { SaphoArrivalOrder } from './sapho-arrival-order'; +import { SaphoStationPriority } from './sapho-station-priority'; import { RicheseBidPromisePanel } from './richese-bid-promise-panel'; import { EarlyRicheseBidPromises } from './early-richese-bid-promises'; import { TravelPromises } from './travel-promises'; @@ -391,7 +392,7 @@ export function GameTable({ const harkonnenBetrayalOwnsControls = !!g.nexusHarkonnenBetrayalReaction && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending; - const busy = reactionBusy || !!g.saphoArrivalOrder || !!g.victoryContinuation || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; + const busy = reactionBusy || !!g.saphoStationPriority || !!g.saphoArrivalOrder || !!g.victoryContinuation || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const reactionOwnsControls = !!g.saphoArrivalOrder || !!g.nexusFremenRideBetrayal || !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const canSpendKarama = (card: Card, purpose: KullPurpose) => !kullPurposeBlock(g, purpose) && canUseAsKaramaRole(g, me, card); @@ -1535,6 +1536,8 @@ export function GameTable({ + {g.saphoStationPriority && } {g.saphoArrivalOrder && g.players.find(player => player.id === id)?.name ?? id} act={act} />} @@ -6116,7 +6119,9 @@ export function GameTable({ - + diff --git a/components/sapho-station-priority.tsx b/components/sapho-station-priority.tsx new file mode 100644 index 00000000..874d9fb9 --- /dev/null +++ b/components/sapho-station-priority.tsx @@ -0,0 +1,38 @@ +'use client'; + +import type { Action } from '@/game/engine'; +import { Button } from './ui/button'; + +/** This is the public opportunity projected for this viewer, independent of + * whether any player holds a particular card. */ +export type SaphoStationPriorityView = { + event: string; + canPass: boolean; + waiting: boolean; +}; + +export type SaphoStationPriorityProps = { + game: { saphoStationPriority: SaphoStationPriorityView | null }; + act: (action: Action) => void; + busy: boolean; +}; + +/** Keep the opportunity visible until the server advances the Station order. + * Actual order-changing choices remain in the player's own card controls. */ +export function SaphoStationPriority({ game, act, busy }: SaphoStationPriorityProps) { + const view = game.saphoStationPriority; + if (!view) return null; + const event = view.event; + const canAct = () => !busy && game.saphoStationPriority === view && + view.event === event && typeof event === 'string' && event.length > 0 && view.canPass; + + return
+

Testing Station priority

+

Before the next Testing Station choice, players may use an available order change from their own hand. Already completed choices stay finished.

+ {view.canPass ? :

{view.waiting ? 'Waiting for the other players to continue.' : 'Your response has been recorded.'}

} +
; +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 18a18b69..3b3fc423 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Testing Station Sapho priority — 30 October. Prototyped / Partial.** New +two-occupant Station choices with Richese seated give both occupants a neutral, +persistent opportunity before the first adjustment. A bot passes without +preempting a human's Sapho, and a used card retains the original Semuta and +storm continuation. Nine related files pass 61 cases, including public starts, +bot scheduling and rendered controls; Station placement, forces and card +custody are staged in the new journeys. Broader interactions and deployed +verification remain open. +[Scope](DISCOVERY_PROTOTYPE.md#sapho-at-ecological-testing-station--29-october-2026). + **Basic early leaderless announcement for expansion factions — 29 October. Prototyped / Partial.** Eligible Basic selected-faction battles announce before plans, preserving neutral Ghola preparation, Richese's shared preparation and diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index f04df2da..30687a44 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -64,10 +64,13 @@ In the supported Semuta/Discovery profile, the committed reorder pauses for the neutral discard reaction; claim or pass resumes the same next station choice. The ordering is a provisional online interpretation of Sapho's printed first/last effect; no combined publisher clarification is claimed. -The current occupant may make its choice before the other occupant plays Sapho, -including when an automated seat acts first. There is no neutral pre-choice -priority window yet, so first-position intervention is not guaranteed against -an automatically acting current occupant. +New two-occupant Station decisions with Richese seated open a neutral shared +priority before either adjustment. Each occupant may continue or use its own +eligible Sapho; an automated occupant passes without consuming the original +station choice. The window is independent of private card custody and closes +when both pass or Sapho is used. Already-open unstamped choices retain their +existing timing. Replay of a recovered Sapho and broader combinations remain +unfinished. 13 September 2026. The connected Discovery batches are **Prototyped**, with partial rules coverage. It connects physical components, real actions, private diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 388ded1c..ed25882b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Testing Station Sapho priority — 30 October 2026 + +A new two-occupant Station choice with Richese seated opens a neutral shared +order opportunity before the first adjustment. Both occupants can pass or +use their eligible Sapho; a bot passes without preempting the other player. +This is an online sequencing interpretation, not a combined publisher ruling. +[Scope](DISCOVERY_PROTOTYPE.md#sapho-at-ecological-testing-station--29-october-2026). + ## Selected Nexus with Leader Skills — 29 October 2026 Selecting Nexus and Leader Skills with expansion decks does not require every diff --git a/game/bots.ts b/game/bots.ts index 7bc6baa3..42f69f10 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1250,6 +1250,12 @@ 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 }]; diff --git a/game/engine.ts b/game/engine.ts index e16f2311..7fdf3268 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1674,6 +1674,8 @@ export type Game = { } | null; stormMovementSource?: StormMovementSource; ecologicalStorm?: DiscoveryStorm; + /** Neutral opportunity before either of two Station occupants chooses. */ + saphoStationPriority?: { event: string; passed: string[] }; discoveryEnabled?: boolean; discoveries?: DiscoveryState; discoveryStash?: DiscoveryStash; @@ -14119,6 +14121,9 @@ function startStormMovement(g: Game, n: number) { if (offer && !offer.blocked) { g.ecologicalStorm = nexusRule(() => createDiscoveryStorm(g,crypto.randomUUID())); g.decision = {kind:'ecologicalStorm',player:g.ecologicalStorm.owner,event:g.ecologicalStorm.event}; + if (g.ecologicalStorm.queue?.order.length === 2 && byFaction(g, 'richese') && + !g.semutaAdvancedOrdinaryPreview && !g.semutaRecruitsPreview) + g.saphoStationPriority = {event:g.ecologicalStorm.event, passed:[]}; } else moveStorm(g,n); } function choamStormOptions(g: Game) { @@ -14653,6 +14658,16 @@ function discoveryIntegrity(g: Game) { } function ecologicalStormIntegrity(g: Game) { const frame = g.ecologicalStorm, continuation = g.pendingTreacheryDiscard?.continuation; + if (g.saphoStationPriority) { + const priority = g.saphoStationPriority; + requireRule(frame?.stage === 'choose' && frame.event === priority.event && + frame.queue?.order.length === 2 && frame.queue.choices.length === 0 && + g.decision?.kind === 'ecologicalStorm' && g.decision.event === priority.event && + Array.isArray(priority.passed) && priority.passed.length < 2 && + new Set(priority.passed).size === priority.passed.length && + priority.passed.every(id => frame.queue!.order.includes(id)), + 'The Testing Station order opportunity has lost its original occupants.'); + } requireRule(!(g.status === 'playing' && g.phase === 0 && g.advanced && g.turn > 1 && g.stormDialers.length === 0 && g.stormPending !== null) || !!g.stormMovementSource, 'The revealed Advanced storm has lost its original movement source.'); @@ -29849,6 +29864,8 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] { const options: SaphoOption[] = []; if (saphoTestingStationDecision(g)) { const frame = g.ecologicalStorm!; + if (g.saphoStationPriority && + (!frame.queue?.order.includes(p.id) || g.saphoStationPriority.passed.includes(p.id))) return options; for (const mode of nexusRule(() => discoveryStormSaphoModes(g, frame, p.id))) options.push({scope: 'testingStation', event: frame.event, mode}); return options; @@ -29966,6 +29983,7 @@ function playSapho(g: Game, p: Player, action: Action) { holder: p.id, event: option.event, mode: option.mode, nextEvent: crypto.randomUUID(), })); const used = discard(g, p, 'richese-juice-of-sapho'); + delete g.saphoStationPriority; g.ecologicalStorm = frame; g.decision = {kind:'ecologicalStorm', player:frame.owner, event:frame.event}; log(g, `${p.name} discarded Juice of Sapho to take the Testing Station choice ${option.mode}. Both occupants retain one adjustment and the original storm movement remains.`, @@ -43504,6 +43522,27 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi decideSaphoArrivalOrder(game, getPlayer(game, id), action); return game.pendingTreacheryDiscard?.reaction ? game : normalizeAutomaticGame(game); } + if (state.saphoStationPriority) { + const priority = state.saphoStationPriority, station = state.ecologicalStorm; + ecologicalStormIntegrity(state); + getPlayer(state, id); + if (action?.type === 'advanceBots') return state; + requireRule(station?.stage === 'choose' && station.event === priority.event && + station.queue?.order.includes(id) && !priority.passed.includes(id), + 'This Testing Station priority is not available to you.'); + if (action.type === 'saphoStationPass') { + requireRule(Object.keys(action).sort().join(',') === 'event,type' && action.event === priority.event, + 'Continue only the current Testing Station order opportunity.'); + const game = structuredClone(state); + game.saphoStationPriority!.passed.push(id); + if (game.saphoStationPriority!.passed.length === station.queue!.order.length) + delete game.saphoStationPriority; + return game; + } + requireRule(action.type === 'card' && action.card === 'richese-juice-of-sapho' && + action.scope === 'testingStation' && action.event === priority.event, + 'Finish the Testing Station priority before choosing storm movement.'); + } if (action.alliancePromiseEvent !== undefined) { const guide = projectedAlliancePromiseGuide(state, id); requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && @@ -49391,7 +49430,11 @@ export function viewGame(state: Game, id: string, now?: number) { ? {event:g.battle.jacurutuDial.event,owner:g.battle.jacurutuDial.owner,winner:g.battle.jacurutuDial.winner, choices:structuredClone(g.battle.jacurutuDial.choices)} : null, ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose' - ? {...nexusRule(() => discoveryStormOffer(g,g.ecologicalStorm))!,event:g.ecologicalStorm.event} : null, + ? {...nexusRule(() => discoveryStormOffer(g,g.ecologicalStorm))!,event:g.ecologicalStorm.event, + ...(g.saphoStationPriority ? {blocked:'Finish the Testing Station order opportunity first.'} : {})} : null, + saphoStationPriority: g.saphoStationPriority ? {event:g.saphoStationPriority.event, + canPass:!!g.ecologicalStorm?.queue?.order.includes(id) && !g.saphoStationPriority.passed.includes(id), + waiting:g.saphoStationPriority.passed.includes(id) || !g.ecologicalStorm?.queue?.order.includes(id)} : null, discoveryEntry: g.discoveryEntry?.stage === 'choose' && g.decision?.kind === 'discoveryEntry' && g.decision.player === id ? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null, greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null, diff --git a/game/reference.ts b/game/reference.ts index 4d6ca893..3e42d1c1 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -512,7 +512,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.', 'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Semuta claim/pass restores the same question queue, including the selected Homeworld, Leader Skills and Discovery profiles. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions and pending-parent combinations remain unfinished.', 'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.', - 'Before either of two Ecological Testing Station occupants chooses, use Sapho to take your adjustment first or last if that changes the order. Each occupant still makes one choice against the cumulative storm distance. Physical storm order and its original source stay unchanged. In supported Semuta games, claim or pass resumes the same next choice. The current occupant can act before another player intervenes, including an automated seat. Replay of a recovered Sapho remains unfinished; this online priority is provisional.', + 'Before either of two Ecological Testing Station occupants chooses, use Sapho to take your adjustment first or last if that changes the order. Fresh choices with Richese seated give both occupants a shared opportunity to use an available order change or continue; an automated occupant cannot advance the station choice while another player is deciding. Each occupant still adjusts the cumulative storm distance once. Physical storm order and its original source stay unchanged. In supported Semuta games, claim or pass resumes the same next choice. Replay of a recovered Sapho remains unfinished; this online priority is provisional.', 'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. At a clean boundary, the holder may pause its own claimed turn and resume last without repeating the claim or payment. Completed turns stay closed and physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.', 'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.', 'When two or more players declare simultaneous Truthtrances in a quiet unrevealed battle, their public question-order window can use Sapho to move the holder’s group first or last. The current battle and committed plans remain in place. In the supported Basic Semuta preview, the discard and any exchange finish before the reordered questions resume.', diff --git a/game/table-turn.ts b/game/table-turn.ts index f92160fe..2945e35e 100644 --- a/game/table-turn.ts +++ b/game/table-turn.ts @@ -12,12 +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.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/sapho-station-priority-controls.test.tsx b/tests/sapho-station-priority-controls.test.tsx new file mode 100644 index 00000000..48c0e43c --- /dev/null +++ b/tests/sapho-station-priority-controls.test.tsx @@ -0,0 +1,73 @@ +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 { SaphoStationPriority, type SaphoStationPriorityProps } from '../components/sapho-station-priority'; + +function props(): SaphoStationPriorityProps & { sent: Action[] } { + const sent: Action[] = []; + return {game: {saphoStationPriority: {event:'station-event', canPass:true, waiting:false}}, + busy:false, sent, act(action) { sent.push(action); }}; +} +const render = (input: SaphoStationPriorityProps) => + renderToStaticMarkup(createElement(SaphoStationPriority, input)); + +/** Invoke the actual rendered Button callback, without mounting a browser. */ +function pass(input: SaphoStationPriorityProps) { + const section = SaphoStationPriority(input); assert.ok(section); + const button = Children.toArray(section.props.children).find(child => + isValidElement(child) && (child.props as {'data-testid'?: string})['data-testid'] === 'sapho-station-priority-pass'); + assert.ok(isValidElement<{onClick: () => void; disabled: boolean}>(button)); + return button; +} + +void test('a neutral Testing Station opportunity stays visible and passes its exact event', () => { + const input = props(), html = render(input); + assert.match(html, /Testing Station priority/); + assert.match(html, /Continue without changing the order/); + assert.match(html, /Already completed choices stay finished/); + assert.equal(pass(input).props.disabled, false); + pass(input).props.onClick(); + assert.deepEqual(input.sent, [{type:'saphoStationPass', event:'station-event'}]); +}); + +void test('busy and invalid events disable Continue without sending an action', () => { + for (const change of ['busy', 'empty-event'] as const) { + const input = props(); + if (change === 'busy') input.busy = true; + else input.game.saphoStationPriority!.event = ''; + assert.match(render(input), /disabled=""/); + const button = pass(input); assert.equal(button.props.disabled, true); + button.props.onClick(); + assert.deepEqual(input.sent, []); + } +}); + +void test('waiting and completed viewers see no Continue button or hidden card data', () => { + for (const waiting of [true, false]) { + const input = props(); + input.game.saphoStationPriority = {event:'station-event', canPass:false, waiting}; + // Only the server-supplied public opportunity is rendered, even when a + // surrounding object contains unrelated private fields. + Object.assign(input.game, {hiddenCard:'private-sapho-card-id'}); + const html = render(input); + assert.doesNotMatch(html, /sapho-station-priority-pass|private-sapho-card-id/); + assert.match(html, waiting ? /Waiting for the other players/ : /Your response has been recorded/); + assert.deepEqual(input.sent, []); + } + const input = props(); input.game.saphoStationPriority = null; + assert.equal(render(input), ''); +}); + +void test('stale event, consumed response and replaced priority callbacks do not send', () => { + for (const change of ['event', 'pass', 'replace', 'close'] as const) { + const input = props(), button = pass(input), view = input.game.saphoStationPriority!; + if (change === 'event') view.event = 'new-event'; + else if (change === 'pass') view.canPass = false; + else if (change === 'replace') input.game.saphoStationPriority = {...view}; + else input.game.saphoStationPriority = null; + button.props.onClick(); + assert.deepEqual(input.sent, []); + } +}); diff --git a/tests/sapho-station-priority-fixture.ts b/tests/sapho-station-priority-fixture.ts new file mode 100644 index 00000000..93198fce --- /dev/null +++ b/tests/sapho-station-priority-fixture.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, type Game } from '../game/engine'; +import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; +import { advanceTestingStationContest } from './fixture-testing-station-contest'; + +export const STATION = 'ecological-testing-station'; +export const SAPHO = 'richese-juice-of-sapho'; + +export function stationSeat(game: Game, id: string) { + const player = game.players.find(seat => seat.id === id); + assert.ok(player); + return player; +} + +export function stationCards(game: Game) { + return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)]; +} + +/** Public Richese/Discovery lobby and genuine first Storm progression. The + * rare revealed Station, its two physical occupants, and Sapho's custody are + * staged after real setup; no priority frame or station choice is fabricated. */ +export function createSaphoStationPriorityFixture(advanced: boolean, holderHasSapho = true) { + let game = createGame('SAPHOSTATIONPRIORITY', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); + for (const id of ['atreides', 'emperor'] as const) joinGame(game, newPlayer(id, id, id)); + game = applyAction(game, game.host, {type:'moduleOption', module:'discoveries', enabled:true}); + for (const player of game.players) game = applyAction(game, player.id, {type:'ready'}); + game = applyAction(game, game.host, {type:'start', ...(advanced ? {advancedPreview:true} : {})}); + game = advanceTestingStationContest(game, state => state.status === 'playing'); + assert.equal(game.phase, 0); + const token = game.discoveries!.tokens.find(candidate => candidate.face === STATION); + assert.ok(token && token.status === 'supply'); + const placement = Object.values(DISCOVERY_CARD_PLACEMENTS).find(candidate => candidate.type === token.type); + assert.ok(placement); + Object.assign(token, {status:'placed', territory:placement.territory, sector:placement.sector, revealedTurn:game.turn}); + validateDiscoveryState(game.discoveries!); + for (const id of ['atreides', 'emperor']) { + const player = stationSeat(game, id); + assert.ok(player.reserves > 0); + player.reserves--; + player.forces[`${STATION}:0`] = (player.forces[`${STATION}:0`] ?? 0) + 1; + } + const order = game.order.filter(id => id === 'atreides' || id === 'emperor'); + assert.equal(order.length, 2); + const [first, holder] = order; + stationSeat(game, first).bot = 'Easy'; + const beforeCards = stationCards(game).map(card => card.id).sort(); + if (holderHasSapho) { + const index = game.richeseCache!.findIndex(card => card.id === SAPHO); + assert.ok(index >= 0); + const target = stationSeat(game, holder); + if (target.hand.length >= 4) game.deck.push(target.hand.pop()!); + target.hand.push(...game.richeseCache!.splice(index, 1)); + } + assert.deepEqual(stationCards(game).map(card => card.id).sort(), beforeCards); + game = advanceTestingStationContest(game, state => state.decision?.kind === 'ecologicalStorm'); + assert.equal(game.ecologicalStorm?.owner, first); + assert.deepEqual(game.ecologicalStorm?.queue?.order, order); + return {game, first, holder, order, beforeCards}; +} diff --git a/tests/sapho-station-priority.test.ts b/tests/sapho-station-priority.test.ts new file mode 100644 index 00000000..46f1cfc6 --- /dev/null +++ b/tests/sapho-station-priority.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { botActions, runBots } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { discoveryStormActions } from '../game/discovery-storm-options'; +import { tableActionOwner } from '../game/table-turn'; +import { createTestingStationContestFixture } from './fixture-testing-station-contest'; +import { createSaphoStationPriorityFixture, SAPHO, stationCards, stationSeat } from './sapho-station-priority-fixture'; + +function finishDiscard(game: Game): Game { + if (!game.pendingTreacheryDiscard?.reaction) return game; + const event = game.pendingTreacheryDiscard.batch.event; + for (const player of game.players) game = applyAction(game, player.id, {type:'semutaPass', event}); + return game; +} + +function rejectedWithoutMutation(game: Game, player: string, action: {type:string; [key:string]:unknown}) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, player, action)); + assert.deepEqual(game, before); +} + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} Station waits for a human Sapho holder after the earlier AI passes`, () => { + const fixture = createSaphoStationPriorityFixture(advanced); + let game = fixture.game; + const priority = game.saphoStationPriority; + assert.ok(priority, 'A real Station opening creates the neutral priority window'); + assert.equal(priority.event, game.ecologicalStorm!.event); + assert.deepEqual(priority.passed, []); + assert.equal(viewGame(game, fixture.first).saphoStationPriority?.canPass, true); + assert.equal(viewGame(game, fixture.holder).saphoStationPriority?.canPass, true); + for (const player of game.players) assert.equal(tableActionOwner(viewGame(game, player.id)), null); + + const normalChoice = {type:'decision', event:game.ecologicalStorm!.event, delta:0}; + assert.throws(() => applyAction(game, fixture.first, normalChoice), /Sapho|priority/i); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, fixture.first); + view.players.find(player => player.id === fixture.first)!.bot = difficulty; + const actions = botActions(view); + assert.ok(actions.some(action => action.type === 'saphoStationPass' && action.event === priority.event), difficulty); + assert.ok(!actions.some(action => action.type === 'decision' && action.event === game.ecologicalStorm!.event), difficulty); + } + game = applyAction(game, fixture.first, {type:'saphoStationPass', event:priority.event}); + assert.deepEqual(game.saphoStationPriority?.passed, [fixture.first]); + const afterBotView = viewGame(game, fixture.first); + assert.equal(afterBotView.saphoStationPriority?.canPass, false); + assert.equal(afterBotView.saphoStationPriority?.waiting, true); + assert.ok(!botActions(afterBotView).some(action => action.type === 'decision' && action.event === game.ecologicalStorm!.event)); + assert.throws(() => applyAction(game, fixture.first, normalChoice), /Sapho|priority/i); + rejectedWithoutMutation(game, fixture.first, {type:'saphoStationPass', event:priority.event}); + rejectedWithoutMutation(game, fixture.holder, {type:'saphoStationPass', event:'stale-station-event'}); + rejectedWithoutMutation(game, 'richese', {type:'saphoStationPass', event:priority.event}); + for (const player of game.players) assert.equal(tableActionOwner(viewGame(game, player.id)), null); + + const option = viewGame(game, fixture.holder).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first'); + assert.ok(option, 'The human retains their real Sapho option'); + game = applyAction(game, fixture.holder, {type:'card', card:SAPHO, + scope:option.scope, event:option.event, mode:option.mode}); + assert.ok(!game.saphoStationPriority); + assert.deepEqual(game.ecologicalStorm?.queue?.order, [...fixture.order].reverse()); + assert.equal(game.ecologicalStorm?.owner, fixture.holder); + game = finishDiscard(game); + assert.equal(game.discard.filter(card => card.id === SAPHO).length, 1); + const choice = discoveryStormActions(viewGame(game, fixture.holder)).find(action => action.delta === 0); + assert.ok(choice); + game = applyAction(game, fixture.holder, choice); + assert.equal(game.ecologicalStorm?.queue?.choices.length, 1); + assert.deepEqual(stationCards(game).map(card => card.id).sort(), fixture.beforeCards); + }); + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} bot scheduler passes once and waits for the human Station holder`, () => { + const fixture = createSaphoStationPriorityFixture(advanced); + const initial = fixture.game; + assert.ok(stationSeat(initial, fixture.first).bot); + assert.ok(!stationSeat(initial, fixture.holder).bot); + const event = initial.saphoStationPriority!.event; + const game = runBots(initial, 12); + assert.deepEqual(game.saphoStationPriority, {event, passed:[fixture.first]}); + assert.equal(game.botsPending, false); + assert.deepEqual(game.ecologicalStorm?.queue?.choices, []); + assert.deepEqual(game.ecologicalStorm?.queue?.order, fixture.order); + assert.equal(game.decision?.kind, 'ecologicalStorm'); + assert.equal(game.decision?.player, fixture.first); + assert.equal(viewGame(game, fixture.holder).saphoStationPriority?.canPass, true); + for (const player of game.players) assert.equal(tableActionOwner(viewGame(game, player.id)), null); + const repeated = runBots(game, 12); + assert.deepEqual(repeated, game, 'The bot neither passes twice nor takes the protected choice'); + }); + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} Station opens the same neutral pass window when no occupant holds Sapho`, () => { + const fixture = createSaphoStationPriorityFixture(advanced, false); + let game = fixture.game; + assert.equal(stationSeat(game, fixture.first).hand.some(card => card.id === SAPHO), false); + assert.equal(stationSeat(game, fixture.holder).hand.some(card => card.id === SAPHO), false); + const event = game.saphoStationPriority?.event; assert.ok(event); + for (const id of fixture.order) { + const view = viewGame(game, id); + assert.deepEqual(view.saphoStationPriority, {event, canPass:true, waiting:false}); + game = applyAction(game, id, {type:'saphoStationPass', event}); + } + assert.ok(!game.saphoStationPriority); + assert.deepEqual(game.ecologicalStorm?.queue?.order, fixture.order); + const choice = discoveryStormActions(viewGame(game, fixture.first)).find(action => action.delta === 0); + assert.ok(choice); + game = applyAction(game, fixture.first, choice); + assert.equal(game.ecologicalStorm?.queue?.choices.length, 1); + assert.deepEqual(stationCards(game).map(card => card.id).sort(), fixture.beforeCards); + }); + +void test('A previously opened unstamped Station continues without a new priority window', () => { + const fixture = createSaphoStationPriorityFixture(false, false); + const old = structuredClone(fixture.game); + delete old.saphoStationPriority; + const choice = discoveryStormActions(viewGame(old, fixture.first)).find(action => action.delta === 0); + assert.ok(choice); + const game = applyAction(old, fixture.first, choice); + assert.ok(!game.saphoStationPriority); + assert.equal(game.ecologicalStorm?.queue?.choices.length, 1); +}); + +void test('A Station table without Richese keeps its original unblocked choice', () => { + const game = createTestingStationContestFixture().game; + assert.ok(!game.players.some(player => player.faction === 'richese')); + assert.ok(!game.saphoStationPriority); + const owner = game.ecologicalStorm!.owner; + assert.equal(viewGame(game, owner).saphoStationPriority, null); + const choice = discoveryStormActions(viewGame(game, owner)).find(action => action.delta === 0); + assert.ok(choice); + const next = applyAction(game, owner, choice); + assert.equal(next.ecologicalStorm?.queue?.choices.length, 1); +});