diff --git a/components/battle-outcome-window.tsx b/components/battle-outcome-window.tsx new file mode 100644 index 00000000..cc9af636 --- /dev/null +++ b/components/battle-outcome-window.tsx @@ -0,0 +1,55 @@ +'use client'; + +import type { BattleOutcomeWindow } from '@/game/battle-outcome-window'; +import { Button } from './ui/button'; + +export function BattleOutcomeWindowControls({ + window, + me, + players, + busy, + onReady, +}: { + window: BattleOutcomeWindow; + me: string; + players: readonly { id: string; name: string }[]; + busy: boolean; + onReady: (event: string) => void; +}) { + const combatants = [window.attacker, window.defender]; + const participant = combatants.includes(me); + const ownReady = window.ready.includes(me); + return ( +
+

Final battle actions

+

+ Play any optional final battle card you want to use, then finish. + Battle resolution waits for both players. +

+

+ If Juice of Sapho changes the aggressor, both finished marks are + cleared and both players get a fresh final opportunity. +

+ + {participant && ( + + )} +
+ ); +} diff --git a/components/game-table.tsx b/components/game-table.tsx index 4bb8b0ad..4bd5b9df 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -77,6 +77,7 @@ import { RicheseSpecialKarama } from './richese-special-karama'; import { RicheseGift } from './richese-gift'; import { Distrans } from './distrans'; import { JuiceOfSapho } from './juice-of-sapho'; +import { BattleOutcomeWindowControls } from './battle-outcome-window'; import { LeaderSkillsPanel } from './leader-skills'; import { LeaderSkillBattleGuide } from './leader-skill-battle-guide'; import { smugglerBattlePlanBlock } from '@/game/smuggler-battle'; @@ -5155,6 +5156,11 @@ export function GameTable({ {g.battle.tieWinner !== g.battle.aggressor ? ' (Habbanya Stronghold advantage)' : ''}.

+ {g.battle.outcomeWindow && !g.battle.outcomeWindow.closed && ( + act({type: 'battleOutcomeReady', event})} /> + )} {!g.battle.revealed && g.battle.leaderless.length > 0 && (

{g.battle.leaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')} diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx index 049f0143..3a0ddd80 100644 --- a/components/juice-of-sapho.tsx +++ b/components/juice-of-sapho.tsx @@ -23,9 +23,11 @@ function unavailableReason(game: GameView) { return 'Movement order can change only before the current combined shipment and movement turn begins. First requires all remaining turns to be unstarted and the Advanced Guild to have finished or be absent. Completed turns stay completed. Already-held positions and committed cards are unavailable.'; if (game.phase === 6) return game.battle - ? 'Become aggressor only during your open pre-plan opportunity, before declaring ready or committing a plan. Already-held priority and committed cards are unavailable. Finish the current battle and its aftermath before changing remaining battle-choice order.' + ? game.battle.saphoTimingVersion === 1 + ? 'Become aggressor at a clean boundary before both players finish final battle actions. Already-held priority, a committed card, pending decisions and closed final opportunities are unavailable. Finish the current battle and its aftermath before changing remaining battle-choice order.' + : 'This older battle retains its pre-plan opportunity, before declaring ready or committing a plan. Already-held priority and committed cards are unavailable. Finish the current battle and its aftermath before changing remaining battle-choice order.' : 'You need an unresolved battle and an available first or last position. Completed battles stay resolved, and a card committed elsewhere cannot be used.'; - return 'These controls support Once Around and cyclic bidding, movement order, remaining battle-choice order and pre-plan aggressor. Later battle intervention and other phase modes remain unfinished.'; + return 'These controls support Once Around and cyclic bidding, movement order, remaining battle-choice order and aggressor priority. Other phase modes remain unfinished.'; } export function JuiceOfSapho({ @@ -68,7 +70,7 @@ export function JuiceOfSapho({ >

{aggressor - ? 'Become this battle’s aggressor before plans. You win ordinary ties; a Habbanya Stronghold advantage still takes precedence. The participants and later battle choices stay unchanged.' + ? 'Become this battle’s aggressor. You win ordinary ties; a Habbanya Stronghold advantage still takes precedence. The participants, committed plans and later battle choices stay unchanged.' : cyclic ? `Bid ${option.mode} in the current cyclic lot. Provisional duration: this lot only. Existing bids, funding and passes stay committed. Later circuits use the changed cycle; the next lot returns to its normal opener.` : once @@ -96,7 +98,7 @@ export function JuiceOfSapho({

)}

- Later aggressor intervention and other phase modes are still unfinished. + Late aggressor timing in new battles uses a provisional shared final opportunity. Older battles retain their pre-plan timing. Other phase modes are still unfinished.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 7c3ac905..7c2ddc9b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,36 @@ open mode gates. ## Current checkpoint and work +**Sapho late aggressor — 18 October. Prototyped / Partial.** Fresh public +Richese-roster battles permit clean intervention after commitments/reveal and +retain a fixed final opportunity for both combatants. Both explicitly finish; +an actual Sapho use clears both marks with a fresh event. Priority closes before +Stone counts and outcome settlement. Plans, votes, physical participants and +Habbanya precedence remain; the holder's sealed/inspected card stays reserved. +Old unstamped battles retain early timing and exact old discard-signature bytes. +The final acknowledgement protocol is provisional, not a publisher FAQ. + +Two frozen disjoint lanes, bounded continuation review and owner integration +pass **16 files177/177**, types/lint/build and eight original-action/rendered-table +programmes. Actual cache1 and opposing paid shipments use40 Basic/47 Advanced +source actions; native Stone1 turn1/Sapho1 turn2 uses86, with accepted original +Richese circle4/Emperor circle5. Four-to-seven preparation actions and two-to-four +continuations cover ordinary ties, one opposing sealed plan, own prior final +acknowledgement and real mixed-Sardaukar count2. Sixteen minimal policy proposals +are legal. Pure lifecycle/control cases and controlled reservation probes stay +separately labelled. + +Review repaired a final Harkonnen Nexus vote exit so all final-vote paths retain +native Homeworld Portable defense before the window. Frozen feedback corrected +four historical fixture assumptions: explicit legacy Stone stamping, a human +saving Sapho instead of letting a bot spend it, and Basic/Advanced pre-plan +readiness. All affected checks then passed together. Actual controls render +offered/busy/already-finished/revised states without browser clicks or visual/ +deployed acceptance. Direct late Semuta claim/capture, combined World/Nexus/Sapho, +wider combinations, full modes and final assurance remain open. No saved-room/ +database/reset/restart/deployment operation. +[Canonical source, protocol and proof](SAPHO_AGGRESSOR.md#late-aggressor-and-public-final-opportunity--18-october-2026). + **Sapho cyclic auctions — 18 October. Prototyped / Partial.** First/last now works in ordinary cyclic lots and native Advanced normal Black Market under an explicit provisional current-lot convention. Full local cycles preserve existing diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index cd29ae71..f7cb650d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 18 October 2026 — Sapho late aggressor and final battle actions + +Fresh actual-Richese battles now support clean priority changes after sealed +plans/reveal and a fixed public both-combatant final opportunity. Both explicitly +finish; an actual Sapho use clears both marks with a fresh event. Priority closes +before physical Stone counts or outcome settlement. Existing plans, declarations, +participants and Habbanya priority remain; sealed/inspected physical cards stay +reserved. Legacy unstamped battles retain early timing and signature bytes. +This final online acknowledgement protocol is provisional. + +Two disjoint lanes, bounded review and owner integration pass16-file177/177, +types/lint/build and eight original-action/rendered-table programmes. Public +cache1/paid forces use40 Basic/47 Advanced source actions; actual Stone1 turn1/ +Sapho1 turn2 and original circle4/circle5 use86. Four-to-seven preparation and +two-to-four continuation actions cover ties, one sealed plan, revised readiness +and real mixed count2. Sixteen minimal proposals are legal; controlled reservation +and pure lifecycle/control cases remain separately labelled. + +Review fixed the Harkonnen Nexus final-vote route to retain native Homeworld +Portable defense. Frozen feedback repaired four stale fixture assumptions; all +checks then passed together. [Canonical source and exact proof](SAPHO_AGGRESSOR.md#late-aggressor-and-public-final-opportunity--18-october-2026). +No browser click, visual/deployed, direct late Semuta claim/capture, combined +World/Nexus/Sapho or complete-mode acceptance. Final assurance remains due. +No saved-room/database/reset/restart/deployment operation. + + ## 18 October 2026 — Sapho current-lot cyclic auctions First/last now connects ordinary cyclic lots and native Advanced normal Black diff --git a/docs/JUICE_OF_SAPHO_BATTLE_REVIEW.md b/docs/JUICE_OF_SAPHO_BATTLE_REVIEW.md index d07847a7..c0e0f136 100644 --- a/docs/JUICE_OF_SAPHO_BATTLE_REVIEW.md +++ b/docs/JUICE_OF_SAPHO_BATTLE_REVIEW.md @@ -1,5 +1,13 @@ # Juice of Sapho battle integration review +**18 October implementation follow-up:** the original review below describes +its historical engine and blocker. Fresh Stone physical-total allocation now +permits late aggressor changes before allocation. The current shared outcome +entry owns a fixed final opportunity; all final-vote exits first retain native +Homeworld defense. See the [current contract](SAPHO_AGGRESSOR.md#late-aggressor-and-public-final-opportunity--18-october-2026). +Historical line numbers, absence claims and proposed conservative guards below +are not the current implementation or new publisher restrictions. + 7 September 2026. Read-only audit of saved engine/bots/combat code. No shared source changed. Companion evidence: `/tmp/dune-sapho-stone-probe.ts` and `.jsonl`. The existing `docs/JUICE_OF_SAPHO_ENGINE_AUDIT.md` supplies the verified physical face and official CHOAM FAQ provenance; I did not discover a new timing ruling. The face allows becoming the battle aggressor and separately first/last in an ordered phase/action. It says discard after use. An unresolved current battle is the scope; completed casualties and winner cleanup cannot be reopened. Fine-grained online acknowledgement boundaries below are implementation recommendations, not quoted publisher rules. ## Smallest coherent contract diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 777fdd1c..19e66597 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,17 @@ # Rule decision index +## Provisional Sapho final battle opportunity — 18 October 2026 + +The printed aggressor effect has no before-plans restriction. Fresh battles +with the public Richese physical-cache producer allow clean early/late use and +a fixed both-combatant final opportunity after votes/native late defense, before +Stone counts or outcome settlement. Sapho changes priority without replaying +plans/votes; a public use clears both final acknowledgements. Existing unreserved +physical-card rules and Habbanya priority remain. Old unstamped battles keep their +early protocol. This is an explicit provisional online procedure under the +standing recommended-choice instruction, not publisher clarification. +[Canonical timing, controls and limits](SAPHO_AGGRESSOR.md#late-aggressor-and-public-final-opportunity--18-october-2026). + ## Provisional Sapho cyclic lot — 18 October 2026 The printed first/last ordered-action effect does not specify cyclic duration. @@ -904,7 +916,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; new physical commitments do not remove other profile/module limits. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) | | Stone mixed-force allocation timing | The older timing question remains unanswered by a source. Under 14 October choose-recommended, new original outcome-ambiguous Stone/Mirror battles provisionally commit each ambiguous side's undialed physical total after revealed modes/actual withdrawal and decisive Traitor/explosion branches. Storm-order owners choose privately; every original required value stays sealed until all commit. The eventual winner retains all legal typed casualty choices with the count. Ordinary/invariant and unstamped legacy frames keep their existing path. Publisher physical-count text and supplied Advanced physical12/logical10 casualty timing provide the numerical rules, not this online commitment convention. | [Source, versioned procedure and bounded proof](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026) | | Recruits late accounting and rates | The earlier one/two-further-free question remains unanswered. The prototype preserves paid spice, deducts recorded free usage from the doubled current rate and all returns from the total cap: two-free/one-paid Atreides can take two more free. Clean classic Homeworlds now provisionally recheck native thresholds on separate accepted requests, retaining completed prices/classifications; this is not a numerical user ruling or newly found timing FAQ. Unknown ledgers, pending repricing, later Fremen grants and same-turn replay remain guarded. | [Source alternatives](RECRUITS_RULES.md), [current native runtime](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) | -| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and later battle timing stay guarded. The existing pre-plan opportunity supports [early aggressor](SAPHO_AGGRESSOR.md). Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) | +| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns and other ordered phases remain open. Cyclic current-lot duration and the new shared final battle opportunity are explicit provisional protocols, not publisher rulings. Old battles retain early timing. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [current aggressor contract](SAPHO_AGGRESSOR.md), [historical battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) | | Semuta Drug | Full-hand exchange is user-approved; neutral all-seat acknowledgements remain the explicit application policy. Other discard producers, nested continuation coverage and full-game acceptance remain unfinished. | [Source update and selected exchange](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) | | Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifiers, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Owned multi-pile Smuggler/Sandmaster allocation and printed alternate/hybrid defense roles now have connected, explicitly interpreted paths. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) | | Smuggler collection strength | The older source check/question produced no numerical answer. Under the later choose-recommended instruction, provisionally cap by copied/printed disc plus declared skill bonuses and KH2, retaining reveal piles and delayed survival settlement. Actual Artillery zeroing voids collection; the base Stunner remains a projectile. Sole own Traitor bypasses weapons, so it retains collection. Printed bounty does not change; this is not a publisher ruling. Other exceptional compositions remain separate. | [Source and explicit policy](LEADER_SKILLS_RULES.md#smuggler-modifier-policy--17-october-2026), [runtime contract](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) | diff --git a/docs/SAPHO_AGGRESSOR.md b/docs/SAPHO_AGGRESSOR.md index 130b5c8d..482d6e0d 100644 --- a/docs/SAPHO_AGGRESSOR.md +++ b/docs/SAPHO_AGGRESSOR.md @@ -1,6 +1,90 @@ -# Juice of Sapho: early battle aggressor +# Juice of Sapho: battle aggressor -20 September 2026. Connected partial prototype. The physical face and source +## Late aggressor and public final opportunity — 18 October 2026 + +**Prototyped / Partial.** The physical face permits becoming the current battle's +aggressor without a before-plans qualifier; see the +[primary-source review](JUICE_OF_SAPHO_SOURCE_UPDATE.md#aggressor-and-battle-selection-order). +The former mixed-Stone blocker is addressed by the new +[physical-total allocation](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026). +Under the standing choose-recommended instruction, this checkpoint selects the +following provisional online protocol. The acknowledgement procedure is an +implementation interpretation, not an official timing FAQ or new printed rule. + +Fresh battles with an actual public Richese roster receive +`saphoTimingVersion:1`, alongside their existing public preparation window. +This follows the physical cache/Sapho producer; merely selecting the E2 family +without Richese does not create that cache or a Sapho-dependent prompt. +The canonical unreserved holder may become aggressor at a clean boundary before +the final opportunity closes, including after one/both sealed plans and revealed +weapon/Traitor declarations. Existing nested responses/decisions still finish +first. Eligibility does not inspect an opposing hand or sealed plan. The holder's +own sealed or inspected card slots reserve the physical card: Sapho cannot be +spent while used as a Planetologist Special weapon or another commitment. + +After all original weapon decisions, Traitor votes/responses and any native +Homeworld invader Portable Snooper choice, both physical combatants receive +the same **Final battle actions** control. It opens regardless of actual card +ownership, tie, Traitor/explosion branch or final winner. Each chooses +**Finish final battle actions** using the current public event. This persistent +window has no timer and does not replace a suspended decision. Playing Sapho +changes public priority and creates a fresh event, clearing both finish marks; +both players must finish again. Playing does not itself finish the opportunity. + +The second acknowledgement closes priority before Stone physical counts, +Smuggler/Sandmaster choices, Diplomat retreat, Jacurutu, losses and aftermath. +The closed window cannot reopen there. Plans, participants, chooser, Voice, +Prescience, Truthtrance promises and chosen weapon/Traitor declarations remain +unchanged. Habbanya still precedes ordinary aggressor tie priority on either +physical side. New discard continuations bind the late stage and public revision; +unstamped old battles retain their exact early conditions/signature format. +No existing battle is converted or reset. + +The table exposes only public identity, revision and readiness. Minimal policies +use their existing server-derived Sapho option first, otherwise explicitly finish +their own opportunity. This adds legal participation, not strategy calibration. + +### Checkpoint evidence and remaining limits + +Frozen **16 files177/177**, types/lint/build and eight original-action/ +rendered-table programmes pass. The source-bound report is retained privately at +`/home/quintine/.local/share/dune/checkpoints/20261018-sapho-late/frozen-integrated-2/report.json`: +proof base `cb13a4d6748c3b86d5b472bab8d798b49b45af28`, source tree +`54889569fdfe483bfdeaffdf85c88127a41afdbceafb32ceb7b7d9dc99a7f75f`. +Only checkpoint prose/reference evidence changes after that frozen source. + +The new original-action fixtures buy Sapho for1 through the actual cache and pay +for opposing three-force Arrakeen shipments:40 Basic/47 Advanced source actions. +The Advanced mixed branch buys Stone1 on turn1, Sapho1 on turn2 and ships6/1Sard +against Richese3:86 actions, including actual original circle4/circle5 choices. +Four-to-seven plan/vote/use actions reach the final window; two-to-four actions +finish both acknowledgements, real physical count2 and original typed cleanup. +Baseline ties go to Richese; one-plan/final-window Sapho changes them to Emperor. +A use after the holder's own acknowledgement resets both marks and rejects the +old event. Sixteen minimal proposals are legal in actual held/no-card views. + +Actual GameTable, Sapho and isolated final controls render offered, busy, +already-finished and revised states. Framework aliases/CSS stubs mean no browser +clicks or visual/deployed acceptance. Pure lifecycle/control tests and controlled +own-plan/Prescience reservation probes remain separate evidence; the latter do +not claim a full original Planetologist/Sapho setup. Related existing regressions +include native Stone, Homeworld defense, Harkonnen Nexus and Semuta early disposal. + +Bounded review repaired the Harkonnen Nexus final-vote exit to preserve original +Homeworld invader defense. Frozen feedback corrected four obsolete fixture +assumptions: the historical Stone guard is explicitly unstamped, a human saving +Sapho uses explicit controls instead of an eager bot, and Basic/Advanced readiness +retains late timing only in fresh battles. The exact failed cases pass8/8, then +the entire affected177 pass with types/lint/build/runtime against unchanged source. + +This does not certify full modes, direct late Semuta claim/capture, a combined +World/Nexus/Sapho journey, wider combinations or final assurance. Browser and +exact-revision deployed acceptance remain open. No saved-room/database, reset, +restart or deployment operation was performed. + +## Historical early prototype — 20 September 2026 + +Connected partial prototype at that checkpoint. The physical face and source precedence remain in the [Sapho audit](JUICE_OF_SAPHO_ENGINE_AUDIT.md); the [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) separates scheduling, physical participants and tie priority. This checkpoint implements the printed aggressor diff --git a/game/battle-outcome-window.ts b/game/battle-outcome-window.ts new file mode 100644 index 00000000..7303e0d1 --- /dev/null +++ b/game/battle-outcome-window.ts @@ -0,0 +1,116 @@ +export type BattleOutcomeIdentity = { + event: string; + attacker: string; + defender: string; + turn: number; +}; +export type BattleOutcomeWindow = { + version: 1; + event: string; + battle: string; + turn: number; + attacker: string; + defender: string; + revision: number; + ready: string[]; + closed: boolean; +}; +export class BattleOutcomeWindowError extends Error { + constructor(message: string) { + super(message); + this.name = 'BattleOutcomeWindowError'; + } +} +function requireWindow(value: unknown, message: string): asserts value { + if (!value) throw new BattleOutcomeWindowError(message); +} +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +const count = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const exact = (value: unknown, keys: string): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)) && + Object.keys(value).sort().join(',') === keys; +function validateIdentity(identity: BattleOutcomeIdentity): void { + requireWindow( + exact(identity, 'attacker,defender,event,turn') && identifier(identity.event) && + identifier(identity.attacker) && identifier(identity.defender) && + identity.attacker !== identity.defender && count(identity.turn) && identity.turn > 0, + 'The final battle window needs its original battle, turn and distinct combatants.', + ); +} +const windowEvent = (identity: BattleOutcomeIdentity, revision: number): string => + `battleOutcome:${JSON.stringify([identity.event, identity.turn, revision])}`; + +/** Public identities and acknowledgements only; no outcome or held-card query. */ +export function createBattleOutcomeWindow( + identity: BattleOutcomeIdentity, + revision: number, +): BattleOutcomeWindow { + validateIdentity(identity); + requireWindow(count(revision), 'The final battle window needs a safe nonnegative revision.'); + return { + version: 1, event: windowEvent(identity, revision), battle: identity.event, + turn: identity.turn, attacker: identity.attacker, defender: identity.defender, + revision, ready: [], closed: false, + }; +} + +export function validateBattleOutcomeWindow( + state: BattleOutcomeWindow, + identity: BattleOutcomeIdentity, + revision: number, +): void { + validateIdentity(identity); + requireWindow( + count(revision) && + exact(state, 'attacker,battle,closed,defender,event,ready,revision,turn,version') && + state.version === 1 && state.battle === identity.event && state.turn === identity.turn && + state.attacker === identity.attacker && state.defender === identity.defender && + state.revision === revision && state.event === windowEvent(identity, revision), + 'The final battle window has changed its original battle, combatants or revision.', + ); + requireWindow( + Array.isArray(state.ready) && + state.ready.every(player => player === identity.attacker || player === identity.defender) && + new Set(state.ready).size === state.ready.length && + typeof state.closed === 'boolean' && state.closed === (state.ready.length === 2), + 'The final battle window needs distinct combatant acknowledgements and a matching closed status.', + ); +} + +export function readyBattleOutcomeWindow( + state: BattleOutcomeWindow, + identity: BattleOutcomeIdentity, + revision: number, + player: string, + event: string, +): BattleOutcomeWindow { + validateBattleOutcomeWindow(state, identity, revision); + requireWindow(!state.closed, 'The final battle window is already closed.'); + requireWindow(event === state.event, 'This final battle window event has expired.'); + requireWindow( + player === identity.attacker || player === identity.defender, + 'Only a combatant can finish final battle actions.', + ); + requireWindow(!state.ready.includes(player), 'This combatant already finished final battle actions.'); + const ready = [...state.ready, player]; + return { ...state, ready, closed: ready.length === 2 }; +} + +/** A public Sapho use creates a fresh shared opportunity before resolution. */ +export function reviseBattleOutcomeWindow( + state: BattleOutcomeWindow, + identity: BattleOutcomeIdentity, + previousRevision: number, + nextRevision: number, +): BattleOutcomeWindow { + validateBattleOutcomeWindow(state, identity, previousRevision); + requireWindow(!state.closed, 'The final battle window is already closed.'); + requireWindow( + count(nextRevision) && nextRevision === previousRevision + 1, + 'A final battle window revision must advance exactly once.', + ); + return createBattleOutcomeWindow(identity, nextRevision); +} diff --git a/game/bots.ts b/game/bots.ts index 6dc5084c..1dc0f831 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3786,6 +3786,10 @@ function policyActions(g: GameView): Action[] { } if (g.phase === 6) { const b = g.battle; + if (b?.outcomeWindow && !b.outcomeWindow.closed) { + if (![b.attacker, b.defender].includes(me.id) || b.outcomeWindow.ready.includes(me.id)) return []; + return [{type: 'battleOutcomeReady', event: b.outcomeWindow.event}]; + } const poison = g.residualPoison; const opponent = b?.attacker === me.id diff --git a/game/engine.ts b/game/engine.ts index e33f6ffa..936b4906 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -95,6 +95,7 @@ import { quoteHomeworldSubstitution } from './homeworld-substitution'; import { combatArmy, combatLocations, combatLocationName, homeworldBattleLocation, quoteCombatBoard, quoteCombatBoardContinuation } from './combat-location'; import { battleChooserEvent, quoteBattleChoosers, reorderBattleChoosers, validateBattleChooserOrder, BattleChooserOrderError, type BattleChooserOrder } from './battle-chooser-order'; import { battleTieOwner, changeSaphoAggressor, saphoAggressorEvent, validateSaphoAggressor, SaphoAggressorError, type SaphoAggressorState } from './sapho-aggressor'; +import { createBattleOutcomeWindow, validateBattleOutcomeWindow, readyBattleOutcomeWindow, reviseBattleOutcomeWindow, BattleOutcomeWindowError, type BattleOutcomeWindow } from './battle-outcome-window'; import { quoteHomeworldCombatLoss } from './homeworld-combat-loss'; import { quoteHomeworldBattleRules } from './homeworld-battle-rules'; import type { HomeworldForces } from './homeworld-custody'; @@ -746,6 +747,9 @@ export type Battle = { chooserEvent?: string; saphoAggressor?: SaphoAggressorState; saphoAggressorEvents?: string[]; + /** Fresh Richese-roster battles allow late priority before physical settlement. */ + saphoTimingVersion?: 1; + outcomeWindow?: BattleOutcomeWindow; /** Only newly created supported battles enter the pre-plan Mentat question. */ mentatQuestionVersion?: 1; mentatQuestionEvent?: string; @@ -5494,6 +5498,11 @@ function saphoAggressorDiscardSignature(g: Game, c: SaphoAggressorDiscardContinu voice: battle.voice, prescience: battle.prescience, fullPlan: battle.fullPlan, nexusInspection: battle.nexusInspection, truthPromises: battle.truthPromises, + ...(battle.saphoTimingVersion === 1 ? { + saphoTimingVersion: battle.saphoTimingVersion, outcomeWindow: battle.outcomeWindow, + traitorCalls: battle.traitorCalls, poisonTooth: battle.poisonTooth, + stoneBurner: battle.stoneBurner, lateDefense: battle.lateDefense, + } : {}), }, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally })), }); @@ -6504,10 +6513,8 @@ function treacheryDiscardIntegrity(g: Game) { const c = continuation, entry = batch.entries[0], battle = g.battle; requireRule( g.phase === 6 && battle && battle.event === c.battleEvent && - battle.preLeader?.event === c.battleEvent && !battle.preLeader.closed && - !battle.preLeader.ready.includes(c.player) && + battle.preLeader?.event === c.battleEvent && saphoAggressorTimingOpen(g, c.player) && [battle.attacker, battle.defender].includes(c.player) && - !battle.revealed && Object.keys(battle.plans).length === 0 && battle.saphoAggressor?.uses.at(-1)?.event === c.saphoEvent && battle.saphoAggressor.uses.at(-1)?.player === c.player && battle.saphoAggressorEvents?.includes(c.saphoEvent) && @@ -6518,7 +6525,7 @@ function treacheryDiscardIntegrity(g: Game) { !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoAggressorDiscardSignature(g, c), - 'The used Juice of Sapho no longer matches its pre-plan aggressor choice.', + 'The used Juice of Sapho no longer matches its unresolved aggressor choice.', ); saphoAggressorIntegrity(g); } else if (continuation?.kind === 'residualPoisonDiscard') { @@ -9934,7 +9941,7 @@ function decideHarkonnenNexusBetrayal(g: Game,id: string,action: Action): void { g.nexusHarkonnenBetrayalCursor = closed.cursor; g.pendingNexusHarkonnenBetrayal = null; if (traitorVoters(g,g.battle!).every(voter => g.battle!.traitorCalls[voter] !== undefined)) - advanceBattleOutcome(g); + finishBattleVotes(g); } function drawHarkonnenNexusBetrayalReplacement(g: Game): void { const due = g.pendingNexusHarkonnenReplacement; @@ -13051,10 +13058,36 @@ function battleAggressor(g: Game) { const b = g.battle!; return saphoAggressorRule(() => validateSaphoAggressor(b.saphoAggressor, b.saphoAggressorEvents, b, g.turn)); } +function battleOutcomeRule(calculate: () => T): T { + try { return calculate(); } + catch (error) { if (error instanceof BattleOutcomeWindowError) throw new RuleError(error.message); throw error; } +} +function battleOutcomeIdentity(g: Game) { + const b = g.battle!; + requireRule(b.event, 'The final battle opportunity needs its original battle event.'); + return {event: b.event, attacker: b.attacker, defender: b.defender, turn: g.turn}; +} +/** Stage only. Never infer a public opportunity from held cards or an outcome. */ +function saphoAggressorTimingOpen(g: Game, player: string) { + const b = g.battle; + if (!b?.event || b.preLeader?.event !== b.event) return false; + if (b.saphoTimingVersion === 1) + return !b.outcomeWindow?.closed && !g.pendingAuditor && !g.pendingCapture && + !g.pendingTech && !g.pendingFaceDance && !g.pendingChoamBattleIncome && + !g.pendingWinnerDiscards && !g.moritaniRetention && !g.pendingIxSubstitution; + return !b.preLeader.closed && !b.preLeader.ready.includes(player) && + !b.revealed && Object.keys(b.plans).length === 0 && + !b.voice && !b.prescience && !b.fullPlan && !b.nexusInspection && !b.truthPromises?.length; +} function saphoAggressorIntegrity(g: Game) { const b = g.battle; if (!b) return; battleAggressor(g); + if (b.outcomeWindow) { + requireRule(b.saphoTimingVersion === 1 && b.revealed && g.status === 'playing' && g.phase === 6, + 'The final battle opportunity needs its original revealed battle.'); + battleOutcomeRule(() => validateBattleOutcomeWindow(b.outcomeWindow!, battleOutcomeIdentity(g), b.saphoAggressor?.uses.length ?? 0)); + } if (!b.saphoAggressor) return; requireRule(g.status === 'playing' && g.phase === 6 && b.preLeader?.event === b.event && g.players.some(p => p.id === b.attacker) && g.players.some(p => p.id === b.defender), @@ -19494,7 +19527,9 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] { !richeseCardDefinition(card) || p.hand.filter((c) => c.id === card.id).length !== 1 || transferCardBlock(g, p, card) || - giftReserved(g, p.id, card.id) + giftReserved(g, p.id, card.id) || + (g.battle?.plans[p.id] && [g.battle.plans[p.id].weapon, g.battle.plans[p.id].defense, g.battle.plans[p.id].leader].includes(card.id)) || + (g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id)) ) return []; const options: SaphoOption[] = []; @@ -19508,10 +19543,7 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] { } } const battle = g.battle; - if (g.phase === 6 && battle?.event && battle.preLeader?.event === battle.event && - !battle.preLeader.closed && !battle.preLeader.ready.includes(p.id) && - !battle.revealed && Object.keys(battle.plans).length === 0 && - !battle.voice && !battle.prescience && !battle.fullPlan && !battle.nexusInspection && !battle.truthPromises?.length && + if (g.phase === 6 && battle?.event && saphoAggressorTimingOpen(g, p.id) && !g.pendingTreacheryDiscard && [battle.attacker, battle.defender].includes(p.id) && battleAggressor(g) !== p.id) options.push({scope: 'battleAggressor', @@ -19583,7 +19615,9 @@ function playSapho(g: Game, p: Player, action: Action) { const used = discard(g, p, 'richese-juice-of-sapho'); b.saphoAggressor = next; (b.saphoAggressorEvents ??= []).push(option.event); - log(g, `${p.name} discarded Juice of Sapho to become this battle’s aggressor before plans. They now win ordinary ties; a Habbanya Stronghold advantage still takes precedence. Battle participants and later battle choices stay unchanged.`, + if (b.outcomeWindow) + b.outcomeWindow = battleOutcomeRule(() => reviseBattleOutcomeWindow(b.outcomeWindow!, battleOutcomeIdentity(g), next.uses.length - 1, next.uses.length)); + log(g, `${p.name} discarded Juice of Sapho to become this battle’s aggressor. They now win ordinary ties; a Habbanya Stronghold advantage still takes precedence. Battle participants, committed plans and later battle choices stay unchanged.${b.outcomeWindow ? ' Both players must finish the revised final battle opportunity.' : ''}`, {faction: p.faction, name: 'Juice of Sapho'}); if (g.semutaPreview) { const continuation: SaphoAggressorDiscardContinuation = { @@ -23371,6 +23405,7 @@ function nextRevealedDecision(g: Game) { function homeworldRevealPending(g: Game): boolean { const b = g.battle; if (g.status !== 'playing' || g.phase !== 6 || !b?.revealed || !b.territory.startsWith('homeworld:') || g.decision || g.response || + b.outcomeWindow || g.truthtrance || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingRicheseGift || g.pendingExchange || g.pendingKarama || g.summonedWorm) return false; return traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined); @@ -23387,6 +23422,11 @@ function advanceHomeworldReveal(g: Game): boolean { } else advanceBattleOutcome(g); return true; } +/** All final vote/response exits retain native Homeworld late-defense ordering. */ +function finishBattleVotes(g: Game) { + if (g.battle!.territory.startsWith('homeworld:')) advanceHomeworldReveal(g); + else advanceBattleOutcome(g); +} function traitorVoters(g: Game, b: Battle) { const home = homeworldBattleLocation(g, b.territory); if (home) return [b.attacker, b.defender].filter((id) => id === home.native); @@ -23746,6 +23786,15 @@ function offerStoneAllocation(g: Game): boolean { } function advanceBattleOutcome(g: Game) { const b = g.battle!; + if (b.saphoTimingVersion === 1) { + if (!b.outcomeWindow) { + b.outcomeWindow = battleOutcomeRule(() => createBattleOutcomeWindow(battleOutcomeIdentity(g), b.saphoAggressor?.uses.length ?? 0)); + log(g, 'Both combatants may finish their final battle actions before the outcome and physical losses are applied. This public opportunity does not depend on held cards.', + {faction: getPlayer(g, b.attacker).faction, name: 'Final battle actions'}); + } + battleOutcomeRule(() => validateBattleOutcomeWindow(b.outcomeWindow!, battleOutcomeIdentity(g), b.saphoAggressor?.uses.length ?? 0)); + if (!b.outcomeWindow.closed) return; + } if (offerStoneAllocation(g)) return; const quote = currentBattleResolutionQuote(g); if (quote.smugglerAllocation || quote.sandmasterSelection) { @@ -23821,6 +23870,8 @@ function jacurutuDialIntegrity(g: Game) { } function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { const b = g.battle!; + requireRule(b.saphoTimingVersion !== 1 || b.outcomeWindow?.closed, + 'Both combatants must finish final battle actions before resolving losses.'); requireRule(!!b.diplomatRetreat === !!retreat, 'Resolve the pending Diplomat retreat before battle losses.'); // Quote before any withdrawal: fixedLosses retains the original ceil(E/2) // physical commitment, never one recalculated from Ecaz's diminished board. @@ -27006,7 +27057,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl if ( traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined) ) - advanceBattleOutcome(g); + finishBattleVotes(g); } else if (response.kind === 'richesePurchaseIncome') { const pending = g.pendingRichesePurchaseIncome; requireRule( @@ -31107,6 +31158,20 @@ function applyActionInner( ); return g; } + if (t === 'battleOutcomeReady') { + const b = g.battle; + requireRule(Object.keys(action).sort().join(',') === 'event,type' && + g.phase === 6 && b?.saphoTimingVersion === 1 && b.revealed && b.outcomeWindow && + traitorVoters(g, b).every(player => b.traitorCalls[player] !== undefined), + 'No shared final battle opportunity is waiting for you.'); + requireRule(saphoCleanWindow(g) && !g.pendingTreacheryDiscard, + 'Finish the current interaction before finishing final battle actions.'); + b.outcomeWindow = battleOutcomeRule(() => readyBattleOutcomeWindow(b.outcomeWindow!, battleOutcomeIdentity(g), + b.saphoAggressor?.uses.length ?? 0, id, stringField(action.event))); + log(g, `${p.name} finished final battle actions${b.outcomeWindow.closed ? '; both combatants are ready for the outcome' : ''}.`); + if (b.outcomeWindow.closed) advanceBattleOutcome(g); + return g; + } if ( t === 'card' && p.hand.some((c) => c.id === action.card && c.effect === 'residualPoison') @@ -34479,7 +34544,7 @@ function applyActionInner( ...(g.leaderSkills && ordinaryLeaderSkillModeSupported(g) ? { diplomatRetreatVersion: 1 as const } : {}), ...(g.ecazTreachery ? { harassAllocationVersion: 1 as const } : {}), ...(byFaction(g, 'richese') - ? { preLeader: { event: battleEvent, ready: [], closed: false } } + ? { saphoTimingVersion: 1 as const, preLeader: { event: battleEvent, ready: [], closed: false } } : {}), ...(noFieldPlayers.length ? { noFieldPlayers } : {}), ...(mixedNoFieldPlayers ? { mixedNoFieldPlayers } : {}), @@ -34705,8 +34770,7 @@ function applyActionInner( if ( traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined) ) - if (!b.territory.startsWith('homeworld:')) advanceBattleOutcome(g); - else advanceHomeworldReveal(g); + finishBattleVotes(g); return g; } if (t === 'card') { @@ -36082,6 +36146,8 @@ export function viewGame(state: Game, id: string) { : null, event: b.event ?? null, preLeader: b.preLeader ?? null, + saphoTimingVersion: b.saphoTimingVersion ?? null, + outcomeWindow: b.outcomeWindow ? structuredClone(b.outcomeWindow) : null, territory: b.territory, attacker: b.attacker, aggressor: battleAggressor(g), diff --git a/game/reference.ts b/game/reference.ts index 2f2ee9ed..bc4d9ca3 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -491,18 +491,18 @@ export const RULE_TOPICS: RuleTopic[] = [ coverage: 'Partial', developmentStage: 'Prototyped', summary: - 'Choose an available order change or become battle aggressor before plans, then discard Sapho. Later intervention and other timing modes remain unfinished.', + 'Choose an available order change or become aggressor in an unresolved battle, then discard Sapho. Fresh Richese battles include a provisional shared final opportunity; other timing modes remain unfinished.', steps: [ 'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.', 'In ordinary cyclic bidding or native Advanced normal Black Market, provisional first/last changes the current lot only, anchored at the live next bidder. Later circuits use that local cycle. Keep standing bids, allied funding and passes; a passed holder waits for a real raise, and the high bidder has no pending bid to reorder. Every purchase still requires a strict raise. Full effective-cycle no-ops cannot consume the card. The next lot uses its normal physical opener.', 'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.', 'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, the Guild must have finished or be absent. Last stays after the Guild even if the Guild later chooses to wait.', 'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.', - 'During your open pre-plan preparation, use Sapho to become the current battle’s aggressor before declaring ready. You win ordinary ties, while the Habbanya Stronghold advantage still takes precedence. Physical participants, plans and later battle-choice order do not swap. The accepted priority survives refresh.', + '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.', 'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.', 'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.', - 'In the explicitly opted-in Semuta preview, clean Once Around and movement-order discards pause after their bidder/turn queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; a clean pre-plan aggressor discard pauses after changing ordinary tie priority. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unsealed battle then continues once.', - 'These are the supported development controls, not additional printed restrictions. Cyclic lot duration and next-opportunity eligibility are explicit provisional conventions. Later aggressor intervention, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.', + 'In the explicitly opted-in Semuta preview, clean Once Around and movement-order discards pause after their bidder/turn queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; an aggressor discard pauses after changing ordinary tie priority. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.', + 'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure and cyclic lot duration are provisional conventions. Other phase ordering and partly completed combined-turn intervention remain unfinished. Silent bids retain simultaneous timing and storm tie order.', ], example: 'Atreides bids 2. Before taking its bid, the Emperor uses Sapho to go last. Richese bids 3, then the Emperor must bid at least 4 to win. Atreides does not receive another bid.', @@ -517,13 +517,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Finite Once Around, provisional current-lot cyclic ordinary/normal Black Market, clean combined-turn and remaining Battle-phase ordering. Local cycles preserve passes/funding; both native consumers retain the original following opener. Authoritative chooser/participant separation, full-hand eligibility and last-over-Guild protection remain. Other timing modes stay unfinished.', + 'Finite Once Around, provisional current-lot cyclic ordinary/normal Black Market, clean combined-turn and remaining Battle-phase ordering, and clean late aggressor in fresh Richese battles. Public final readiness closes before physical allocation/settlement; plans, votes, original participants and committed cards remain. Local cycles preserve passes/funding and native following openers. Other timing modes remain unfinished.', }, { area: 'Player controls', status: 'Partial', detail: - 'Owner-only first/last and pre-plan aggressor actions, public aggressor/tie priority and remaining battle order, owner-relative targets, readable inspector and timing explanations. Cyclic first/last explains current-lot duration and pending-bid limits; later aggressor controls remain unfinished.', + 'Owner-only first/last and clean unresolved-battle aggressor actions, public aggressor/tie priority and remaining battle order. Persistent final controls show both combatants and fresh-event readiness; a public Sapho use clears both finish marks. Cyclic controls retain current-lot duration and pending-bid limits. Older battles keep early timing.', }, { area: 'AI', @@ -541,8 +541,15 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - '18 October cyclic scope:10-file134/134, types/lint/build and eight original-action/rendered-table continuations pass. Actual cache1, Basic/Advanced ordinary bidding, native Advanced Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policy proposals are legal; actual passes need a real raise. Controlled pure-cycle/post-sale quotes are separate. Fixture opener/completion and external smoke assumptions were repaired; only runtime was rerun against identical source after types/lint/build. No browser click, visual/deployed, direct combined Semuta claim/capture, full-mode or final-assurance acceptance.', + '18 October late aggressor:16-file177/177, types/lint/build and eight original-action/rendered-table programmes pass. Actual cache1/paid forces use40 Basic/47 Advanced source actions; native Stone1 turn1/Sapho1 turn2 uses86. Sealed-plan and revised-final-window uses preserve plans/votes; both final acknowledgements precede real count2 and settlement. Sixteen minimal proposals are legal. Controlled reservation probes are separate. Related Homeworld final-vote routing is repaired; no direct combined World/Nexus/Sapho or late Semuta claim/capture proof. Earlier cyclic scope:10-file134/134, types/lint/build and eight original-action/rendered-table continuations pass. Actual cache1, Basic/Advanced ordinary bidding, native Advanced Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policy proposals are legal; actual passes need a real raise. Controlled pure-cycle/post-sale quotes are separate. Fixture opener/completion and external smoke assumptions were repaired; only runtime was rerun against identical source after types/lint/build. No browser click, visual/deployed, direct combined Semuta claim/capture, full-mode or final-assurance acceptance.', evidence: [ + 'game/battle-outcome-window.ts', + 'components/battle-outcome-window.tsx', + 'tests/battle-outcome-window.test.ts', + 'tests/battle-outcome-window-controls.test.tsx', + 'tests/fixture-sapho-late-aggressor.ts', + 'tests/sapho-late-aggressor-engine.test.ts', + 'docs/SAPHO_AGGRESSOR.md', 'game/sapho-cyclic-auction.ts', 'components/juice-of-sapho.tsx', 'tests/sapho-cyclic-auction.test.ts', diff --git a/tests/battle-outcome-window-controls.test.tsx b/tests/battle-outcome-window-controls.test.tsx new file mode 100644 index 00000000..78ab5e39 --- /dev/null +++ b/tests/battle-outcome-window-controls.test.tsx @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, type ButtonHTMLAttributes, type MouseEvent, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { BattleOutcomeWindowControls } from '../components/battle-outcome-window'; +import { createBattleOutcomeWindow, readyBattleOutcomeWindow, reviseBattleOutcomeWindow } from '../game/battle-outcome-window'; + +const identity = { event: 'original-public-battle', attacker: 'a', defender: 'd', turn: 2 }; +type Props = Parameters[0]; +function props(onReady: (event: string) => void): Props { + return { window: createBattleOutcomeWindow(identity, 0), me: 'a', busy: false, onReady, + players: [{ id: 'a', name: 'Atreides player' }, { id: 'd', name: 'Richese player' }, { id: 'spectator', name: 'Observer' }] }; +} +const markup = (value: Props) => renderToStaticMarkup(createElement(BattleOutcomeWindowControls, value)); +function readyButton(value: Props): ReactElement> | null { + const panel = BattleOutcomeWindowControls(value); + const children = panel.props.children as (ReactElement> | false)[]; + return children.find(child => child && child.props.children === 'Finish final battle actions') || null; +} +const click = (value: Props) => readyButton(value)?.props.onClick?.({} as MouseEvent); + +void test('final battle controls persist public guidance and status until both players finish, with no automatic acknowledgement', () => { + const sent: string[] = [], value = props(event => sent.push(event)); + const html = markup(value); + assert.match(html, /aria-label="Final battle actions"/); + assert.match(html, /

Final battle actions<\/h3>/); + assert.match(html, /Play any optional final battle card you want to use, then finish/); + assert.match(html, /Battle resolution waits for both players/); + assert.match(html, /Juice of Sapho changes the aggressor, both finished marks are cleared/); + assert.match(html, /Atreides player: Choosing final actions/); + assert.match(html, /Richese player: Choosing final actions/); + assert.doesNotMatch(html, /autofocus|role="dialog"|role="alertdialog"/i); + assert.deepEqual(sent, []); + assert.equal(readyButton(value)!.props.disabled, false); + click(value); + assert.deepEqual(sent, [value.window.event]); +}); + +void test('busy, already finished and closed final battle controls cannot submit through their callback', () => { + const sent: string[] = [], value = props(event => sent.push(event)); + const one = readyBattleOutcomeWindow(value.window, identity, 0, 'a', value.window.event); + const both = readyBattleOutcomeWindow(one, identity, 0, 'd', one.event); + for (const blocked of [{ ...value, busy: true }, { ...value, window: one }, { ...value, window: both }]) { + assert.match(markup(blocked), /]*\sdisabled=""[^>]*>Finish final battle actions/); + click(blocked); + } + assert.deepEqual(sent, []); + const defender = { ...value, me: 'd', window: one }; + assert.match(markup(defender), /Atreides player: Finished/); + assert.equal(readyButton(defender)!.props.disabled, false); + click(defender); + assert.deepEqual(sent, [one.event]); +}); + +void test('a public Sapho revision renders fresh marks and sends only the new event', () => { + const sent: string[] = [], value = props(event => sent.push(event)); + const one = readyBattleOutcomeWindow(value.window, identity, 0, 'a', value.window.event); + const revised = { ...value, window: reviseBattleOutcomeWindow(one, identity, 0, 1) }; + const html = markup(revised); + assert.equal((html.match(/Choosing final actions/g) ?? []).length, 2); + assert.doesNotMatch(html, /: Finished/); + assert.notEqual(revised.window.event, one.event); + click(revised); + assert.deepEqual(sent, [revised.window.event]); +}); + +void test('noncombatants see the same public participant status without a finish action', () => { + const sent: string[] = [], value = { ...props(event => sent.push(event)), me: 'spectator' }; + const html = markup(value); + assert.match(html, /Atreides player: Choosing final actions/); + assert.match(html, /Richese player: Choosing final actions/); + assert.doesNotMatch(html, /