From cb691e1b147e9c6d9411763f64b7af336f38eca9 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Mon, 14 Sep 2026 05:14:57 +1000
Subject: [PATCH] Connect Sapho first and last battle-choice order
Juice of Sapho can change the remaining Battle-phase chooser order at clean boundaries. A chooser may occupy the physical defender slot and select the actual other combatant. Physical storm order, attackers, defenders and tie advantage remain intact. Full-roster phase priority survives aftermath, newly eligible Face Dancers and saved continuation. Controls and all four AI profiles consume the same projected choices.
Independent review verified private options, public geometry, exact physical disposal, rejected-action immutability, phase expiry and boundary-bound saved chooser integrity. Actual Basic/Advanced Face Dance regressions verify later replacement priority. Authenticated SQLite tests cover duplicate concurrent card requests, private restore and battle completion. Chooser/aggressor separation and phase duration remain explicit implementation interpretations; the separate aggressor mode and unresolved timing stay gated. Reinforcements remains disabled pending the documented reserve-cost question.
Types, lint, 4792 offline tests, build and 43 HTTP tests pass with unchanged source. Six fixed genuine Basic/Advanced faction samples finish in 3672 accepted actions and 97 periodic private JSON restores. They retain 82 rejected candidates at existing guarded arrival combinations; subsequent legal candidates finish. This is bounded playability evidence, not complete compliance or AI calibration.
The browser used Sapho first from a later physical circle, chose Emperor, sealed and resolved a battle, then refreshed into its next choice. Emperor retained the tie win. All 9 accepted actions replay exactly using the recorded random battle event; physical custody, all private JSON views and the original seat row pass. A private harness initially needed random-event replay binding and SQLite-row prototype normalization; no production fix or game reset was required.
After the reported outage, the server responded and the staged QA game restored exactly. All 970 opening games and their original seat/recovery records remain preserved (1004 after checks), including all 971 rooms present immediately before checks. SQLite integrity and foreign keys pass. No restart, database reset or recurring automation was needed.
Verified source tree: b699f80600f8e297595a8e756edf367a607ddd99707474c03231e6479bd2f389
---
components/game-table.tsx | 7 +
components/juice-of-sapho.tsx | 13 +-
docs/CURRENT_STATUS.md | 32 +-
docs/ECAZ_TREACHERY_RULES.md | 27 ++
docs/IMPLEMENTATION_STATUS.md | 21 +
docs/JUICE_OF_SAPHO_RUNTIME.md | 13 +-
docs/RULE_DECISIONS.md | 2 +
docs/SAPHO_BATTLE_ORDER.md | 78 ++++
game/battle-chooser-order.ts | 227 +++++++++++
game/bot-battle-choices.ts | 7 +-
game/bots.ts | 6 +-
game/card-presentation.ts | 2 +-
game/engine.ts | 92 ++++-
game/reference.ts | 15 +-
tests/fixture-sapho-battle-order.ts | 237 +++++++++++
tests/fixture-sapho-face-dance.ts | 148 +++++++
tests/sapho-battle-order-bots.test.ts | 68 ++++
tests/sapho-battle-order-controls.test.tsx | 67 +++
tests/sapho-battle-order-recovery.test.ts | 284 +++++++++++++
tests/sapho-battle-order.test.ts | 448 +++++++++++++++++++++
20 files changed, 1759 insertions(+), 35 deletions(-)
create mode 100644 docs/SAPHO_BATTLE_ORDER.md
create mode 100644 game/battle-chooser-order.ts
create mode 100644 tests/fixture-sapho-battle-order.ts
create mode 100644 tests/fixture-sapho-face-dance.ts
create mode 100644 tests/sapho-battle-order-bots.test.ts
create mode 100644 tests/sapho-battle-order-controls.test.tsx
create mode 100644 tests/sapho-battle-order-recovery.test.ts
create mode 100644 tests/sapho-battle-order.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 5dfc570a..47b1bdde 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -4254,6 +4254,13 @@ export function GameTable({
to ship and move.
+ Remaining battle-choice order: {g.battleOrder.remaining.map(id =>
+ g.players.find(player => player.id === id)?.name ?? id).join(' → ')}.
+ {' '}Only unresolved battles can be chosen. Each battle keeps its own aggressor and tie rules.
+
diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx
index 249c75d3..8730c57f 100644
--- a/components/juice-of-sapho.tsx
+++ b/components/juice-of-sapho.tsx
@@ -21,7 +21,11 @@ function unavailableReason(game: GameView) {
}
if (game.phase === 5)
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.';
- return 'These controls support Once Around bidding and movement order. Battle aggressor and other phase or auction modes remain unfinished.';
+ if (game.phase === 6)
+ return game.battle
+ ? 'Finish the current battle and its aftermath before changing the order of remaining battle choices. This does not change the current battle’s aggressor.'
+ : '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 bidding, movement order and remaining battle-choice order. Battle aggressor and other phase or auction modes remain unfinished.';
}
export function JuiceOfSapho({
@@ -48,12 +52,13 @@ export function JuiceOfSapho({
Choose one available order change, then discard Juice of Sapho. This
- does not grant an extra bid, shipment or movement.
+ does not grant an extra bid, shipment, movement or battle.
{once
? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.`
+ : battle
+ ? `Choose your remaining battles ${option.mode}. Completed battles stay resolved. This changes who chooses next, not the aggressor or tie advantage of a battle. Other players may still choose battles against you before your turn.`
: `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : ''}.`}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 48b54701..eec5c4e4 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,14 +6,16 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
-Previous verified checkpoint: `c04cb93`, [Recruits](RECRUITS_RUNTIME.md).
-Types, lint, **4,743 offline tests**, build and **43 HTTP tests** passed.
-Eight fixed Basic/Advanced six-seat samples finished in 7,995 actions with no
-rejected candidates and three actual Recruits plays. All opening games and
-seat/recovery records were preserved, with 936 rooms after verification.
+Latest verified checkpoint: `0450156`, [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md).
+Types, lint, **4,776 offline tests**, build and **43 HTTP tests** passed.
+Eight fixed Basic/Advanced six-seat samples finished in 9,956 actions with no
+rejected candidates, three Harass withdrawals and 266 periodic save/restore
+checks. Browser controls, private restoration and the final engine replay passed.
+All 937 games present before verification and their original seat/recovery
+records were preserved, with 970 rooms after HTTP checks.
Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
-Current prototype: [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects either
+The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either
battle-card slot, private category inspections, exact undialed normal/elite
returns, opponent-Traitor cancellation, mandatory disposal, controls and all
four AI profiles. Physical allocation must be unique. Richese card combinations
@@ -22,12 +24,18 @@ card-specific discard precedence are explicitly labeled inferences. The Stone
Burner timing question is pending; no user answer is assumed. Reinforcements
remains unfinished. This does not open variant, mode or publication gates.
-After the latest reported power failure, the existing server responds normally.
-All **936 rooms**, **1,959 seats** and original recovery/entry/handover records
-match the opening backup; SQLite integrity and foreign-key checks pass. Browser
-refresh restores the existing test seat. No restart, database reset or recurring
-automation was needed. Current checkpoint verification and delivery are recorded
-with the final source-bound report and Git message.
+The existing server remained healthy after the reported power failures. Saved
+games and original recovery/entry/handover records survived; SQLite integrity
+and foreign-key checks passed, and refresh restored the test seat. No restart,
+database reset or recurring automation was needed. Exact verification, source
+fingerprints and delivery are recorded in the private report and Git message.
+
+Current work: [Sapho Battle-phase order](SAPHO_BATTLE_ORDER.md) connects first/last
+remaining battle choices without rewriting storm order or tie roles. The browser completed a reordered
+battle and restored its next choice; independent review and an actual Basic/Advanced
+Face Dance continuation passed. Reinforcements
+remains disabled while its reserve-cost question is pending; see the
+[latest source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review).
## Authoritative navigation
diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md
index 005113fd..b4170f3f 100644
--- a/docs/ECAZ_TREACHERY_RULES.md
+++ b/docs/ECAZ_TREACHERY_RULES.md
@@ -90,3 +90,30 @@ No runtime tests were added or claimed for this source task. At this source audi
now have connected development prototypes. The linked contracts supersede the
historical runtime-absence statements above within their explicit boundaries.
Reinforcements and complete combined-module acceptance remain unfinished.
+
+## 14 September Reinforcements cost review
+
+The printed close-up at [4:24](https://www.youtube.com/watch?v=P9ZTV1uZqsM&t=264s)
+and designer explanation at [8:55](https://www.youtube.com/watch?v=iF5E3-LOfhY&t=535s)
+place its effect during Revealing Wheels: increase the calculated dial by two,
+then place three reserve forces in the Tanks. A separate score modifier, without
+extra support or on-board casualties, is the strongest implementation inference.
+Three physical counters pay the cost; an available ordinary/elite mix needs its
+owner's explicit choice.
+
+The [base Traitor outcome](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=10)
+can reasonably be read to waive the sole caller's reserve cost. The later card's
+mandatory transfer can also reasonably override that general protection, using
+the same specificity argument as mandatory after-use card disposal. Neither
+reading is an authoritative Reinforcements ruling. Opposing/mutual Traitors and
+explosions have no directly retrieved combined clarification either. The card
+remains disabled: an eligibility gate based on concealed matching Traitors would
+expose private information or fail after plans were sealed.
+
+The user has been asked whether to retain that gate, always charge three on all
+outcomes, or waive only the sole traitor-call winner's cost. No answer is assumed.
+Separately, the sources do not establish whether these reserve counters count
+toward the Advanced Atreides seven-loss threshold, or the relative order when
+the same plan also plays Harass & Withdraw. Those combinations remain explicit
+boundaries rather than inferred rules. No disconnected quote is recorded as a
+completed card effect.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 2a020345..dcd604ee 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,26 @@
# Dune implementation status
+## 14 September 2026 — Sapho remaining battle-choice order
+
+[The Sapho battle-order prototype](SAPHO_BATTLE_ORDER.md) adds first/last remaining
+Battle-phase choice through the existing physical card, hand controls, legal AI
+and saved state. The public chooser is separate from physical attacker/defender;
+old battle outcomes, storm order and tie rules remain intact. Accepted priority
+persists through complete aftermath and later changed board eligibility. This
+separation and phase duration are explicit implementation interpretations.
+
+A fresh [Reinforcements cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review)
+found no authoritative resolution of its reserve payment versus a traitor win.
+The question has been sent to the user; the card remains disabled while independent
+Sapho work proceeds. Advanced Atreides accounting and same-plan Harass ordering
+remain separate unresolved combinations.
+
+The checkpoint uses a 970-room preservation baseline, a separate browser lobby,
+focused rules/controls/AI/SQLite checks and independent review. Final complete-game,
+browser, broad-check and verified Git results belong to the source-bound private
+report and commit message. Public mode and publication gates remain unchanged.
+
+
## 14 September 2026 — Harass & Withdraw battle prototype
[Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) now connects either physical card
diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md
index f88527c6..c7d5056b 100644
--- a/docs/JUICE_OF_SAPHO_RUNTIME.md
+++ b/docs/JUICE_OF_SAPHO_RUNTIME.md
@@ -1,6 +1,11 @@
# Juice of Sapho: finite ordering implementation
-Updated 13 September 2026. **Partial card implementation; complete Richese/Advanced starts remain disabled.** This document distinguishes implemented boundaries from printed rules. Sources and unresolved questions remain in [the source audit](JUICE_OF_SAPHO_ENGINE_AUDIT.md). The publisher CHOAM/Richese rulebook p11 confirms Once Around last; the physical face describes first, last (including after Guild), and battle aggressor. No newly discovered official ruling resolves normal cyclic auction duration, partly completed combined turns, or first versus Guild. These three material questions have been sent to the user; no answer has been assumed.
+Updated 14 September 2026. **Partial card implementation; complete Richese/Advanced starts remain disabled.** This document distinguishes implemented boundaries from printed rules. Sources and unresolved questions remain in [the source audit](JUICE_OF_SAPHO_ENGINE_AUDIT.md). The publisher CHOAM/Richese rulebook p11 confirms Once Around last; the physical face describes first, last (including after Guild), and battle aggressor. No newly discovered official ruling resolves normal cyclic auction duration, partly completed combined turns, or first versus Guild. These three material questions have been sent to the user; no answer has been assumed.
+
+The [Battle-phase ordering prototype](SAPHO_BATTLE_ORDER.md) adds first/last
+remaining battle choices. Its separate chooser, phase priority and saved-boundary
+contract supersede the earlier absence of that mode below. Aggressor remains
+unimplemented.
## Implemented contract
@@ -17,7 +22,11 @@ All four AI levels consume the same legal owner projection. They prefer last in
## Remaining scope
-Battle aggressor, battle-phase first/last, normal/Black Market cyclic bidding, other ordered actions and partly completed movement remain unfinished. Once Around first after an already completed bid and first/Guild competing priority are guarded. Reacquiring and replaying Sapho while an existing movement protection remains is also guarded. Silent submission is simultaneous and receives no fabricated order or tie change.
+The [battle-order follow-up](SAPHO_BATTLE_ORDER.md) connects first/last at a clean
+boundary between actual battles. It does not implement the separate aggressor
+mode discussed below.
+
+Battle aggressor, normal/Black Market cyclic bidding, other ordered actions and partly completed movement remain unfinished. Once Around first after an already completed bid and first/Guild competing priority are guarded. Reacquiring and replaying Sapho while an existing movement protection remains is also guarded. Silent submission is simultaneous and receives no fabricated order or tie change.
The battle audit in [the battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) found a real Stone Burner counterexample: a plan admitted with original aggressor tie priority can become allocation-ambiguous after late Sapho. A complete implementation needs the actual allocation continuation (and a usable, possession-independent late intervention boundary), not rejection based on an opponent's hidden plan. No aggressor handler has been enabled around that defect.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index ac7c6409..cf85986a 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -6,6 +6,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
| Topic | Decision to preserve | Detailed authority and evidence |
| --- | --- | --- |
+| Sapho battle order | First/last reorders remaining Battle-phase choosers at clean boundaries. Phase priority persists through aftermath; actual legal pairs determine eligibility. Separating the chooser from physical attacker/tie advantage is an explicit implementation interpretation of the card's distinct alternatives, not a dedicated FAQ ruling. Last does not prevent others from choosing the holder as an opponent. | [Runtime scope and source interpretation](SAPHO_BATTLE_ORDER.md) |
| Harass & Withdraw | Either slot but neither category; private Prescience answers null. Return uniquely determined own undialed physical forces before weapon losses, canceled by an opposing successful Traitor. Mandatory after-use discard overrides winner retention. Explosion ordering and own-Traitor disposal are explicit timing/specificity inferences, consistent with the recorded Planetologist precedent. | [Runtime and sources](HARASS_WITHDRAW_RUNTIME.md) |
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
| Ixian Technology on Richese lots | Replacement-card ownership, income and unsold disposition remain unresolved. A saved explicit per-lot decline permits an unchanged offer to proceed and leaves Technology available later. This development continuation does not implement the exchange or settle its custody. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline) |
@@ -42,6 +43,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Boundary | Still unresolved | Existing record |
| --- | --- | --- |
+| Reinforcements cost | A user question asks whether to keep the card disabled, always transfer three reserve forces on every outcome, or waive only a sole traitor-call winner's cost. No answer is assumed. Advanced Atreides loss accounting and same-plan Harass ordering are separate unresolved combinations. Eligibility must not depend on concealed matching Traitors. | [Fresh cost review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) |
| Harass / Stone Burner | A user question has been sent: keep the combination gated or compare forces remaining after withdrawal. No answer is assumed. The Richese card-family configuration is guarded publicly; hidden Stone Burner custody never controls eligibility. Ambiguous physical allocations and other optional modules remain unfinished. | [Current boundaries](HARASS_WITHDRAW_RUNTIME.md#current-boundaries) |
| Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) |
| Ambassador/Terror arrival priority | Both optionally react to the same entry, but no publisher/designer priority was found. The order can change Fremen relocation versus Atomics losses. A user question has been sent: keep the overlap gated pending an official ruling, or use explicitly provisional storm order. No answer is assumed; Baliset remains a separate pre-entry prevention effect. | [Reproduction and source boundary](DEFERRED_MOVEMENT_ARRIVAL.md) |
diff --git a/docs/SAPHO_BATTLE_ORDER.md b/docs/SAPHO_BATTLE_ORDER.md
new file mode 100644
index 00000000..3e88d8aa
--- /dev/null
+++ b/docs/SAPHO_BATTLE_ORDER.md
@@ -0,0 +1,78 @@
+# Juice of Sapho: remaining battle-choice order
+
+Updated 14 September 2026. This connects first/last Battle-phase ordering through
+controls, AI and saved continuation. The separate aggressor mode and full
+Richese/Advanced publication gates remain unfinished.
+
+## Authority and interpretation
+
+The [physical-card audit](JUICE_OF_SAPHO_ENGINE_AUDIT.md#verified-instructions-and-sources)
+and [source follow-up](JUICE_OF_SAPHO_SOURCE_UPDATE.md#aggressor-and-battle-selection-order)
+establish first/last in an ordered phase/action as a separate printed alternative
+from becoming a battle's aggressor. The [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md)
+explains why chooser identity, participants and tie priority must remain separate.
+
+The selected scope is the remaining Battle phase, at clean boundaries between
+battles. Accepted priority lasts for the rest of that phase. Keeping a reordered
+chooser separate from physical attacker and tie advantage is the existing audit's
+implementation interpretation of those distinct alternatives, not an explicit
+publisher FAQ ruling. The base game normally combines those identities through
+storm order. No new official timing ruling is claimed.
+
+First and last reorder future choices; completed battles stay resolved. Other
+players can still select battles against a last-position holder. Sapho grants no
+immunity or additional battle and does not move forces or alter physical storm
+order. Aggressor mode needs its separate late-intervention protocol and Stone
+Burner allocation work. No new confirmation-only window is introduced here.
+
+## Connected behavior
+
+The existing Richese development configuration supplies the physical card. Its
+holder receives private `battleOrder` first/last options at a clean boundary.
+The command binds the current opportunity and discards the exact unreserved card
+once. A held position, stale event, absent unresolved battle or suspended
+interaction cannot consume it. Earlier completed battles do not prevent a later
+order change; a current battle and its aftermath must finish first.
+
+The stored priority is separate from `g.order`. Legal pair geometry comes from
+the public combat board. Each choice has a chooser and stable attacker/defender
+identities. A reordered physical defender can choose the actual opposing attacker
+without swapping leaders, plans or faction powers. UI and bots use the same
+authoritative choices and owner-relative target.
+
+Priority includes all seats, even those currently without a battle, so a later
+Face Dancer replacement does not move a newly relevant seat behind an accepted
+last position. Actual eligibility is recomputed after all aftermath. A battle
+start or Sapho use expires the old opportunity event. Legally recovering the
+physical card permits a new use at a fresh opportunity; there is no invented
+once-per-phase restriction.
+
+All four AI profiles use their private view and public board. Their initial
+ordering policy favors first when an unresolved stronghold battle is available,
+otherwise last. They can then choose an opponent from either stable battle slot.
+This is a legal strategy path, not difficulty calibration.
+
+## Evidence and boundaries
+
+Focused engine, controls, bot and authenticated SQLite tests exercise ordering,
+actual opponent choice, saved continuation, duplicate-request custody and rejected
+action immutability. Fixtures distinguish genuine Basic/Advanced CHOAM/Richese
+setup from later conserved battle staging. A genuine five-seat Ix/CHOAM regression
+resolves a battle, activates Face Dance and gives the newly eligible replacement
+its next choice ahead of a protected last seat, in Basic and Advanced.
+
+The isolated browser table used first from a later physical circle, chose the
+earlier Emperor attacker, completed the battle and restored the next battle
+choice after refresh. Emperor retained the tie win. All nine accepted actions
+replayed exactly with the recorded battle event; private views, physical custody
+and the original seat row passed. Final check results, fingerprints,
+browser and complete-game evidence are retained in the private checkpoint report
+and Git message.
+
+Intervention within an unresolved battle or suspended aftermath is unavailable.
+Aggressor mode, ordinary cyclic-auction scope, first versus an unspent Advanced
+Guild and interruption of partly completed movement turns retain their existing
+boundaries. Prototype evidence does not certify full card or mode compliance.
+
+The opening preservation baseline contains 970 rooms. Keep all saved games and
+seat/recovery records, reuse the healthy server and leave recurring restarts absent.
diff --git a/game/battle-chooser-order.ts b/game/battle-chooser-order.ts
new file mode 100644
index 00000000..453eac18
--- /dev/null
+++ b/game/battle-chooser-order.ts
@@ -0,0 +1,227 @@
+import { reorderOrderedOpportunity } from './ordered-opportunity';
+
+export type PhysicalBattlePair = {
+ territory: string;
+ attacker: string;
+ defender: string;
+};
+export type BattleChooserChoice = PhysicalBattlePair & { chooser: string };
+export type BattleOrderUse = {
+ event: string;
+ afterBattle: string | null;
+ player: string;
+ mode: 'first' | 'last';
+ eligible: string[];
+ signature: string;
+};
+export type BattleChooserOrder = {
+ version: 1;
+ turn: number;
+ physicalOrder: string[];
+ priority: string[];
+ uses: BattleOrderUse[];
+};
+export class BattleChooserOrderError extends Error {}
+function check(value: unknown, message: string): asserts value {
+ if (!value) throw new BattleChooserOrderError(message);
+}
+const ids = (value: unknown): value is string[] =>
+ Array.isArray(value) &&
+ value.every((id) => typeof id === 'string' && id.length > 0) &&
+ new Set(value).size === value.length;
+const same = (a: unknown, b: unknown) =>
+ JSON.stringify(a) === JSON.stringify(b);
+const keys = (value: object, expected: string) =>
+ Object.keys(value).sort().join(',') === expected;
+
+/** Events name an uncommitted boundary, not a particular faction or hidden card. */
+export function battleChooserEvent(
+ turn: number,
+ afterBattle: string | null,
+ revision: number,
+): string {
+ return `battleOrder:${JSON.stringify([turn, afterBattle, revision])}`;
+}
+export function battleOrderUseSignature(
+ use: Omit | BattleOrderUse,
+): string {
+ return JSON.stringify([
+ use.event,
+ use.afterBattle,
+ use.player,
+ use.mode,
+ use.eligible,
+ ]);
+}
+/** Each physical pair has one chooser; its original attacker/defender never swap. */
+export function quoteBattleChoosers(
+ pairs: readonly PhysicalBattlePair[],
+ physicalOrder: readonly string[],
+ priority: readonly string[] = physicalOrder,
+): {
+ choices: BattleChooserChoice[];
+ remaining: string[];
+ current: string | null;
+} {
+ check(
+ ids(physicalOrder) &&
+ ids(priority) &&
+ same([...physicalOrder].sort(), [...priority].sort()),
+ 'Battle chooser priority must preserve every physical player circle.',
+ );
+ check(
+ Array.isArray(pairs) &&
+ pairs.every(
+ (pair) =>
+ pair &&
+ typeof pair.territory === 'string' &&
+ pair.territory.length > 0 &&
+ pair.attacker !== pair.defender &&
+ physicalOrder.includes(pair.attacker) &&
+ physicalOrder.includes(pair.defender),
+ ),
+ 'Choose only distinct, seated physical battle opponents.',
+ );
+ const remaining = priority.filter((id) =>
+ pairs.some((pair) => pair.attacker === id || pair.defender === id),
+ );
+ const choices = pairs
+ .map((pair) => ({
+ ...pair,
+ chooser:
+ priority.indexOf(pair.attacker) < priority.indexOf(pair.defender)
+ ? pair.attacker
+ : pair.defender,
+ }))
+ .sort((a, b) => priority.indexOf(a.chooser) - priority.indexOf(b.chooser));
+ return { choices, remaining, current: remaining[0] ?? null };
+}
+function changedPriority(priority: readonly string[], use: BattleOrderUse) {
+ check(
+ ids(use.eligible) &&
+ use.eligible.length > 1 &&
+ same(
+ priority.filter((id) => use.eligible.includes(id)),
+ use.eligible,
+ ),
+ 'The saved battle ordering needs its exact remaining eligible chooser sequence.',
+ );
+ check(
+ use.eligible.includes(use.player) &&
+ (use.mode === 'first' || use.mode === 'last') &&
+ (use.mode === 'first' ? use.eligible[0] : use.eligible.at(-1)) !==
+ use.player,
+ 'Juice of Sapho must change one remaining battle chooser position.',
+ );
+ const next = reorderOrderedOpportunity(
+ {
+ event: use.event,
+ eligible: priority,
+ remaining: priority,
+ completed: [],
+ current: priority[0],
+ currentStarted: false,
+ },
+ { event: use.event, player: use.player, position: use.mode },
+ );
+ // Keep the accepted phase priority if a previously uninvolved Face Dancer
+ // later gains a battle. Actual eligibility always comes from current pairs.
+ return [...next.remaining];
+}
+export function validateBattleChooserOrder(
+ state: BattleChooserOrder,
+ physicalOrder: readonly string[],
+ turn: number,
+ events: readonly string[],
+): void {
+ check(
+ state &&
+ typeof state === 'object' &&
+ keys(state, 'physicalOrder,priority,turn,uses,version') &&
+ state.version === 1 &&
+ state.turn === turn &&
+ Number.isSafeInteger(turn) &&
+ turn > 0 &&
+ ids(state.physicalOrder) &&
+ same(state.physicalOrder, physicalOrder) &&
+ ids(state.priority) &&
+ Array.isArray(state.uses) &&
+ state.uses.length > 0 &&
+ ids(events) &&
+ same(
+ events,
+ state.uses.map((use) => use?.event),
+ ),
+ 'The saved battle chooser order lost its phase, physical order or independent use events.',
+ );
+ let priority = [...physicalOrder];
+ for (const [index, use] of state.uses.entries()) {
+ check(
+ use &&
+ typeof use === 'object' &&
+ keys(use, 'afterBattle,eligible,event,mode,player,signature') &&
+ (use.afterBattle === null ||
+ (typeof use.afterBattle === 'string' &&
+ use.afterBattle.length > 0)) &&
+ use.event === battleChooserEvent(turn, use.afterBattle, index) &&
+ use.signature === battleOrderUseSignature(use),
+ 'The saved Juice of Sapho use lost its original battle boundary.',
+ );
+ priority = changedPriority(priority, use);
+ }
+ check(
+ same(priority, state.priority),
+ 'The battle chooser priority contradicts its played scheduling history.',
+ );
+}
+export function reorderBattleChoosers(input: {
+ state?: BattleChooserOrder;
+ physicalOrder: readonly string[];
+ turn: number;
+ afterBattle: string | null;
+ pairs: readonly PhysicalBattlePair[];
+ event: string;
+ player: string;
+ mode: 'first' | 'last';
+}): BattleChooserOrder {
+ if (input.state)
+ validateBattleChooserOrder(
+ input.state,
+ input.physicalOrder,
+ input.turn,
+ input.state.uses.map((use) => use.event),
+ );
+ const revision = input.state?.uses.length ?? 0;
+ check(
+ input.event === battleChooserEvent(input.turn, input.afterBattle, revision),
+ 'This battle chooser opportunity has expired.',
+ );
+ const priority = input.state?.priority ?? input.physicalOrder;
+ const use: BattleOrderUse = {
+ event: input.event,
+ afterBattle: input.afterBattle,
+ player: input.player,
+ mode: input.mode,
+ eligible: quoteBattleChoosers(input.pairs, input.physicalOrder, priority)
+ .remaining,
+ signature: '',
+ };
+ use.signature = battleOrderUseSignature(use);
+ const next: BattleChooserOrder = {
+ version: 1,
+ turn: input.turn,
+ physicalOrder: [...input.physicalOrder],
+ priority: changedPriority(priority, use),
+ uses: [
+ ...(input.state?.uses ?? []).map((use) => structuredClone(use)),
+ use,
+ ],
+ };
+ validateBattleChooserOrder(
+ next,
+ input.physicalOrder,
+ input.turn,
+ next.uses.map((use) => use.event),
+ );
+ return next;
+}
diff --git a/game/bot-battle-choices.ts b/game/bot-battle-choices.ts
index 36bba605..a5ca41a9 100644
--- a/game/bot-battle-choices.ts
+++ b/game/bot-battle-choices.ts
@@ -8,17 +8,18 @@ import {
splitLocation,
} from './board';
-/** Public fighter locations and storm order determine which battle may be chosen. */
+/** Use the authoritative chooser, which can differ from the physical attacker. */
export function botBattleChoices(g: GameView): Action[] {
const me = g.players.find((p) => p.id === g.me)!;
if (g.active !== me.id) return [];
if (Array.isArray(g.battleChoices))
return g.battleChoices
- .filter((battle) => battle.attacker === me.id)
+ .filter((battle) => (battle.chooser ?? battle.attacker) === me.id &&
+ [battle.attacker, battle.defender].includes(me.id))
.map((battle) => ({
type: 'chooseBattle',
territory: battle.territory,
- target: battle.defender,
+ target: battle.attacker === me.id ? battle.defender : battle.attacker,
}));
// Legacy component fixtures may predate the authoritative combat frontier.
const ownLocations = Object.keys(presenceByLocation(me));
diff --git a/game/bots.ts b/game/bots.ts
index 1d630533..663656f2 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1079,8 +1079,12 @@ function saphoAction(g: GameView): Action | null {
me.reserves >= 3 &&
(me.faction === 'fremen' || (me.spice ?? 0) >= 3) &&
Object.values(g.spice).some((amount) => amount >= 6);
+ const battleFirst = options.some(option => option.scope === 'battleOrder') &&
+ (g.battleChoices ?? []).some(battle =>
+ [battle.attacker, battle.defender].includes(me.id) &&
+ gameTerritories(g).some(t => t.id === battle.territory && t.type === 'stronghold'));
const preferred =
- options[0].scope === 'movement' && earlySpice ? 'first' : 'last';
+ (options[0].scope === 'movement' && earlySpice) || battleFirst ? 'first' : 'last';
const option =
options.find((candidate) => candidate.mode === preferred) ?? options[0];
return { type: 'card', card: card.id, ...option };
diff --git a/game/card-presentation.ts b/game/card-presentation.ts
index fbfeddf3..c1c604ac 100644
--- a/game/card-presentation.ts
+++ b/game/card-presentation.ts
@@ -34,7 +34,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
if (definition?.card.effect === 'nullentropyBox')
return 'Use the Nullentropy Box panel to pay for a private search.';
if (definition?.card.effect === 'juiceOfSapho')
- return 'Use the Juice of Sapho panel to choose an available Once Around or movement order change.';
+ return 'Use the Juice of Sapho panel to choose an available Once Around, movement or battle-choice order change.';
if (definition?.card.effect === 'distrans')
return 'Choose a recipient and card in the Distrans transfer panel.';
return definition && definition.card.effect !== 'karama'
diff --git a/game/engine.ts b/game/engine.ts
index e871ba69..59f0d8c8 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -58,6 +58,7 @@ import { currentJunctionOffer, junctionOfferEvent, junctionOfferIntegrity, junct
import { homeworldAllianceBlock } from './homeworld-alliance';
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 { quoteHomeworldCombatLoss } from './homeworld-combat-loss';
import { quoteHomeworldBattleRules } from './homeworld-battle-rules';
import type { HomeworldForces } from './homeworld-custody';
@@ -603,6 +604,9 @@ export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
signature: string;
};
export type Battle = {
+ /** Scheduling owner; physical combat slots and aggressor remain independent. */
+ chooser?: string;
+ chooserEvent?: string;
/** Only newly created supported battles enter the pre-plan Mentat question. */
mentatQuestionVersion?: 1;
mentatQuestionEvent?: string;
@@ -1518,6 +1522,9 @@ export type Game = {
movementRemaining: string[] | null;
/** Juice of Sapho's last position lasts only through this combined-turn queue. */
saphoMovementLast?: { event: string; turn: number; player: string } | null;
+ /** Separate Battle-phase scheduling; never rewrites physical storm order. */
+ battleOrder?: BattleChooserOrder;
+ battleOrderUseEvents?: string[];
guildTimingGranted: boolean;
guildTimingLocked: boolean;
ready: string[];
@@ -12032,7 +12039,51 @@ function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
export function battles(g: Game) {
const quote = boardResolution(() => quoteCombatBoard(g));
applyAdvisorReleases(g, quote.released);
- return quote.battles;
+ return battleOrderRule(() => quoteBattleChoosers(quote.battles, g.order, g.battleOrder?.priority)).choices;
+}
+function battleOrderRule(calculate: () => T): T {
+ try { return calculate(); }
+ catch (error) { if (error instanceof BattleChooserOrderError) throw new RuleError(error.message); throw error; }
+}
+function battleOrderIntegrity(g: Game) {
+ requireRule(!!g.battleOrder === !!g.battleOrderUseEvents,
+ 'The saved battle chooser order is missing its independent use events.');
+ if (!g.battleOrder) return;
+ requireRule(g.status === 'playing' && g.phase === 6,
+ 'Juice of Sapho battle scheduling expires when the Battle Phase ends.');
+ battleOrderRule(() => validateBattleChooserOrder(g.battleOrder!, g.order, g.turn, g.battleOrderUseEvents!));
+ const cards = physicalTreacheryCards(g).filter(card => card.id === 'richese-juice-of-sapho');
+ requireRule(cards.length === 1 && !!richeseCardDefinition(cards[0]) && cards[0].effect === 'juiceOfSapho',
+ 'The played battle scheduling card has lost its unique physical custody.');
+ if (g.battle)
+ requireRule(g.battle.chooser && [g.battle.attacker, g.battle.defender].includes(g.battle.chooser) &&
+ g.battle.chooserEvent === battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder.uses.length) &&
+ g.battle.chooser === [g.battle.attacker, g.battle.defender].sort((a, b) =>
+ g.battleOrder!.priority.indexOf(a) - g.battleOrder!.priority.indexOf(b))[0],
+ 'The selected battle lost its original scheduling owner.');
+ else if (saphoBattleBoundary(g))
+ requireRule(g.active === battleOrderQuote(g).current,
+ 'The active battle chooser contradicts the remaining scheduling priority.');
+}
+function battleOrderAfterBattle(g: Game) {
+ return g.lastBattleContext?.turn === g.turn ? g.lastBattleContext.event : null;
+}
+function battleOrderQuote(g: Game) {
+ return battleOrderRule(() => quoteBattleChoosers(quoteCombatBoard(g).battles, g.order, g.battleOrder?.priority));
+}
+function saphoBattleBoundary(g: Game) {
+ return g.phase === 6 && saphoCleanWindow(g) && !g.battle &&
+ !g.pendingTreacheryDiscard && !g.pendingRichesePurchaseIncome && !g.pendingCapture &&
+ !g.pendingAuditor && !g.pendingFaceDance && !g.pendingTech && !g.pendingChoamBattleIncome &&
+ !g.moritaniRetention && !g.choamMarket;
+}
+function projectedBattleOrder(g: Game) {
+ if (g.status !== 'playing' || g.phase !== 6) return null;
+ const quote = battleOrderQuote(g);
+ return {event: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
+ phaseEvent: `battleOrder:${g.turn}`, remaining: quote.remaining,
+ current: saphoBattleBoundary(g) ? quote.current : null,
+ uses: g.battleOrder?.uses.map(({event, player, mode}) => ({event, player, mode})) ?? []};
}
/** Evaluate only at the committed end of movement, using the normal battle geometry. */
function offerMoritaniDuke(g: Game) {
@@ -12076,7 +12127,7 @@ function offerMoritaniDuke(g: Game) {
return true;
}
type SaphoOption = {
- scope: 'onceAround' | 'movement';
+ scope: 'onceAround' | 'movement' | 'battleOrder';
event: string;
mode: 'first' | 'last';
};
@@ -12181,6 +12232,13 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
)
return [];
const options: SaphoOption[] = [];
+ if (saphoBattleBoundary(g)) {
+ const order = projectedBattleOrder(g)!;
+ if (order.remaining.includes(p.id)) {
+ if (order.current !== p.id) options.push({scope: 'battleOrder', event: order.event, mode: 'first'});
+ if (order.remaining.at(-1) !== p.id) options.push({scope: 'battleOrder', event: order.event, mode: 'last'});
+ }
+ }
const lot = g.richeseAuction;
if (
g.phase === 3 &&
@@ -12257,6 +12315,16 @@ function playSapho(g: Game, p: Player, action: Action) {
{ faction: p.faction, name: 'Juice of Sapho' },
);
settleRicheseLot(g);
+ } else if (option.scope === 'battleOrder') {
+ const next = battleOrderRule(() => reorderBattleChoosers({state: g.battleOrder, physicalOrder: g.order,
+ turn: g.turn, afterBattle: battleOrderAfterBattle(g), pairs: quoteCombatBoard(g).battles,
+ event: option.event, player: p.id, mode: option.mode}));
+ discard(g, p, 'richese-juice-of-sapho');
+ g.battleOrder = next;
+ (g.battleOrderUseEvents ??= []).push(option.event);
+ g.active = battleOrderQuote(g).current;
+ log(g, `${p.name} discarded Juice of Sapho to choose their remaining battles ${option.mode} this Battle Phase. Other players may still choose to fight them earlier. Physical storm order and battle aggressor are unchanged.`,
+ {faction: p.faction, name: 'Juice of Sapho'});
} else {
const queue = saphoMovementQueue(g)!;
const next =
@@ -12715,6 +12783,10 @@ function finishEcazPlacement(g: Game) {
}
function completePhase(g: Game) {
if (closeNexusCards(g)) return;
+ if (g.phase === 6) {
+ delete g.battleOrder;
+ delete g.battleOrderUseEvents;
+ }
if (g.phase === 7 && g.grummanCollection?.turn === g.turn && g.grummanCollection.stage === 'waiting') {
g.grummanCollection.stage = 'complete';
g.grummanCollection.outcome = 'expired';
@@ -12779,7 +12851,7 @@ function beginPhase(g: Game) {
}
if (g.phase === 6) {
const b = battles(g);
- if (b.length) g.active = b[0].attacker;
+ if (b.length) g.active = b[0].chooser;
else {
nextPhase(g);
return;
@@ -14157,6 +14229,7 @@ function gholaOptions(g: Game, p: Player) {
};
}
function marketGholaIntegrity(g: Game) {
+ battleOrderIntegrity(g);
nexusCardsIntegrity(g);
nexusEmperorSecretIntegrity(g);
nexusTraitorIntegrity(g);
@@ -16291,7 +16364,7 @@ function finishBattle(g: Game) {
g.decision = { ...next };
} else {
const remaining = battles(g);
- if (remaining.length) g.active = remaining[0].attacker;
+ if (remaining.length) g.active = remaining[0].chooser;
else nextPhase(g);
}
}
@@ -23593,9 +23666,9 @@ function applyActionInner(
);
const choice = battles(g).find(
(b) =>
- b.attacker === id &&
+ b.chooser === id &&
b.territory === action.territory &&
- b.defender === action.target,
+ (b.attacker === id ? b.defender : b.attacker) === action.target,
);
requireRule(choice, 'Choose one of your unresolved battles.');
g.auditorInsight = null;
@@ -23614,6 +23687,7 @@ function applyActionInner(
const battleEvent = crypto.randomUUID();
g.battle = {
...choice,
+ chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
event: battleEvent,
...(g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}),
@@ -23630,7 +23704,7 @@ function applyActionInner(
beginStrongholdBattle(g);
log(
g,
- `${p.name} attacks ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}.`,
+ `${p.name} chose the battle between ${getPlayer(g, choice.attacker).name} and ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}. ${getPlayer(g, choice.attacker).name} remains the aggressor.`,
);
return g;
}
@@ -24051,7 +24125,8 @@ export function viewGame(state: Game, id: string) {
canAct: biddingEndQuiet(g) && g.biddingEnd.owners.includes(id), kaitain: { owner: byFaction(g, 'emperor')!.id,
eligible: homeworldRule(() => highKaitainDiscardsAvailable(g, byFaction(g, 'emperor')!.id)) } } : null,
combatLocations: combatLocations(g),
- battleChoices: g.status === 'playing' && g.phase === 6 ? quoteCombatBoard(g).battles : [],
+ battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
+ battleOrder: projectedBattleOrder(g),
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
truthShipmentAnswers:
g.truthtrance?.stage === 'answer' &&
@@ -24946,6 +25021,7 @@ export function viewGame(state: Game, id: string) {
preLeader: b.preLeader ?? null,
territory: b.territory,
attacker: b.attacker,
+ chooser: b.chooser ?? b.attacker,
defender: b.defender,
revealed: b.revealed,
kwisatzBlocked: b.kwisatzBlocked ?? false,
diff --git a/game/reference.ts b/game/reference.ts
index e8fa3f75..b285f127 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -263,7 +263,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A player whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot. A full-hand owner’s unbid cache card is removed automatically. If every hand is full, preparing the normal pool does not draw and lose unseen cards.',
'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.',
'Some unresolved rules are explicitly blocked: changing the normal card count after cache-auction cancellation, exhausted-cache arithmetic, positive Black Market self-bids, and advanced Ixian Technology substitution on special lots. These are implementation guards while the rules are resolved, not claims of official prohibitions.',
- 'Juice of Sapho now supports bounded first/last Once Around and movement ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon and Semuta Drug remain unfinished, as do the other Sapho timing modes; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
+ 'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon and Semuta Drug remain unfinished, as do the other Sapho timing modes; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
],
related: [
'choam-modules',
@@ -328,11 +328,12 @@ export const RULE_TOPICS: RuleTopic[] = [
coverage: 'Partial',
developmentStage: 'Prototyped',
summary:
- 'Choose one available first or last position, then discard Sapho. Battle aggressor and other timing modes remain unfinished.',
+ 'Choose an available first or last position in Once Around bidding, movement or remaining battle choices, then discard Sapho. Battle aggressor and other timing modes remain unfinished.',
steps: [
'In a current Once Around auction, first is available before anyone bids. Last is available before your own bid, including after Richese. You still get only one bidding opportunity and must outbid the current high bid to win.',
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. A completed bid or pass cannot be repeated, and 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.',
'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.',
'These are the supported development controls, not additional printed restrictions. Battle aggressor, ordinary cyclic auction scope, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.',
@@ -350,13 +351,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Finite Once Around and clean combined-turn ordering, including first after completed movement turns; physical discard, full-hand eligibility and persisted last-over-Guild protection. Remaining timing modes are guarded.',
+ 'Finite Once Around, clean combined-turn and remaining Battle-phase ordering; authoritative chooser/participant separation, phase priority, physical discard, full-hand auction eligibility and persisted last-over-Guild movement protection. Remaining timing modes are guarded.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
- 'Owner-only first/last actions, readable inspector and timing explanations. No aggressor or cyclic-auction controls yet.',
+ 'Owner-only first/last actions, public remaining battle order, owner-relative battle targets, readable inspector and timing explanations. No aggressor or cyclic-auction controls yet.',
},
{
area: 'AI',
@@ -374,7 +375,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
- 'Finite-order, engine, all-profile and real persisted-room tests cover once-only play, funding, full-hand eligibility, privacy and Guild deferral. Full expansion games remain unverified.',
+ 'Finite-order, engine, all-profile and real persisted-room tests cover once-only play, funding, full-hand eligibility, privacy, Guild deferral and battle scheduling through actual outcomes. Full expansion compliance remains unverified.',
evidence: [
'tests/ordered-opportunity.test.ts',
'tests/juice-of-sapho-engine.test.ts',
@@ -382,6 +383,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/juice-of-sapho-bots.test.ts',
'tests/juice-of-sapho-recovery.test.ts',
'tests/juice-of-sapho-boundary-recovery.test.ts',
+ 'tests/sapho-battle-order.test.ts',
+ 'tests/sapho-battle-order-bots.test.ts',
+ 'tests/sapho-battle-order-controls.test.tsx',
+ 'tests/sapho-battle-order-recovery.test.ts',
],
},
],
diff --git a/tests/fixture-sapho-battle-order.ts b/tests/fixture-sapho-battle-order.ts
new file mode 100644
index 00000000..d27de82d
--- /dev/null
+++ b/tests/fixture-sapho-battle-order.ts
@@ -0,0 +1,237 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeFactionExpansionsGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { treacheryDeck } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import { traitorDeck } from '../game/traitors';
+
+export const SAPHO_BATTLE_CARD = 'richese-juice-of-sapho';
+export function takeSaphoBattleCard(
+ g: Game,
+ owner: string,
+ card = SAPHO_BATTLE_CARD,
+) {
+ for (const zone of [
+ g.deck,
+ g.discard,
+ g.richeseCache ?? [],
+ ...g.players.map((p) => p.hand),
+ ]) {
+ const at = zone.findIndex((c) => c.id === card);
+ if (at < 0) continue;
+ g.players.find((p) => p.id === owner)!.hand.push(zone.splice(at, 1)[0]);
+ return;
+ }
+ throw new Error(`Missing physical fixture card ${card}`);
+}
+/** Genuine faction setup, followed by explicitly conserved hands and multi-battle topology. */
+export function saphoBattleOrderGame({
+ advanced = false,
+ holder = 'c',
+ seatIds = ['a', 'b', 'c', 'r'] as readonly [string, string, string, string],
+ geometry = 'shared' as 'shared' | 'separate',
+} = {}): Game {
+ const factions = ['emperor', 'guild', 'atreides', 'richese'] as const;
+ let g = createGame(
+ 'SAPHOBAT',
+ newPlayer(seatIds[0], 'Emperor', factions[0]),
+ advanced,
+ ['choam'],
+ );
+ for (let i = 1; i < seatIds.length; i++)
+ joinGame(g, newPlayer(seatIds[i], factions[i], factions[i]));
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ g = initializeFactionExpansionsGameForAudit(g);
+ for (let step = 0; g.status === 'setup' && step < 100; step++) {
+ const next = nextSaphoBattleAction(g);
+ assert.ok(next, 'Genuine setup must have an owned action.');
+ g = applyAction(g, next.player, next.action);
+ }
+ assert.equal(g.status, 'playing');
+ const shared: Record[] = [
+ { 'arrakeen:10': 3, 'carthag:11': 3 },
+ { 'arrakeen:10': 3, 'carthag:11': 3, 'imperial_basin:9': 3 },
+ { 'arrakeen:10': 3, 'imperial_basin:9': 3 },
+ {},
+ ];
+ const separate: Record[] = [
+ { 'carthag:11': 4 },
+ { 'carthag:11': 4 },
+ { 'arrakeen:10': 4 },
+ { 'arrakeen:10': 4 },
+ ];
+ for (const [index, p] of g.players.entries()) {
+ g.deck.push(...p.hand);
+ p.hand = [];
+ p.forces = { ...(geometry === 'shared' ? shared[index] : separate[index]) };
+ p.reserves = 20 - Object.values(p.forces).reduce((a, b) => a + b, 0);
+ p.tanks = 0;
+ p.spice = 30;
+ p.shipped = false;
+ p.moved = 0;
+ if (p.elites) p.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
+ }
+ const index = ['a', 'b', 'c', 'r'].indexOf(holder);
+ const owner = index >= 0 ? seatIds[index] : holder;
+ takeSaphoBattleCard(g, owner);
+ // Starting from a clean combined-turn boundary avoids inventing completed battles.
+ Object.assign(g, {
+ turn: 2,
+ phase: 5,
+ storm: 18,
+ order: [...seatIds],
+ active: seatIds[0],
+ movementRemaining: [...seatIds],
+ guildTimingLocked: true,
+ guildTimingGranted: false,
+ ready: [],
+ response: null,
+ decision: null,
+ phaseOpening: null,
+ });
+ while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
+ assert.equal(g.phase, 6);
+ saphoBattleCustody(g);
+ return g;
+}
+export function saphoBattleOrderAction(
+ g: Game,
+ owner: string,
+ mode: 'first' | 'last',
+): Action {
+ const option = viewGame(g, owner).saphoOptions.find(
+ (o) => o.scope === 'battleOrder' && o.mode === mode,
+ );
+ assert.ok(option, `Missing ${mode} battle-order option for ${owner}.`);
+ return { type: 'card', card: SAPHO_BATTLE_CARD, ...option };
+}
+export function chooseSaphoBattleAction(g: Game): {
+ player: string;
+ action: Action;
+} {
+ assert.ok(g.active && !g.battle);
+ const choice = viewGame(g, g.active).battleChoices.find(
+ (c) => c.chooser === g.active,
+ );
+ assert.ok(choice);
+ return {
+ player: g.active,
+ action: {
+ type: 'chooseBattle',
+ territory: choice.territory,
+ target: choice.attacker === g.active ? choice.defender : choice.attacker,
+ },
+ };
+}
+/** One real owner action, suitable for either applyAction or authenticated room.act. */
+export function nextSaphoBattleAction(
+ g: Game,
+): { player: string; action: Action } | null {
+ if (
+ !g.response &&
+ !g.decision &&
+ g.battle?.preparation &&
+ (!g.battle.preLeader || g.battle.preLeader.closed) &&
+ ['voice', 'prescience'].includes(g.battle.preparation.kind)
+ )
+ return {
+ player: g.battle.preparation.owner,
+ action: { type: 'declineBattlePower' },
+ };
+ for (const p of g.players) {
+ const view = viewGame(g, p.id);
+ view.players.find((player) => player.id === p.id)!.bot = 'Medium';
+ if (
+ g.battle &&
+ !g.response &&
+ !g.decision &&
+ !g.battle.preparation &&
+ (!g.battle.preLeader || g.battle.preLeader.closed) &&
+ !g.battle.prescience &&
+ !g.battle.nexusInspection &&
+ !g.battle.voice &&
+ [g.battle.attacker, g.battle.defender].includes(p.id) &&
+ !g.battle.plans[p.id]
+ ) {
+ const leader = p.leaders.find(
+ (l) =>
+ !l.dead &&
+ !l.capturedBy &&
+ (!l.usedAt || l.usedAt === g.battle!.territory),
+ );
+ if (leader)
+ return {
+ player: p.id,
+ action: {
+ type: 'battlePlan',
+ dial: 0,
+ support: 0,
+ leader: leader.id,
+ },
+ };
+ }
+ const action = botActions(view)[0];
+ if (action) return { player: p.id, action };
+ }
+ return null;
+}
+export function resolveSaphoBattle(state: Game): Game {
+ let g = state;
+ assert.ok(g.battle);
+ for (let step = 0; step < 120; step++) {
+ if (
+ !g.battle &&
+ (g.phase !== 6 || viewGame(g, g.players[0].id).battleOrder?.current)
+ )
+ return g;
+ const next = nextSaphoBattleAction(g);
+ assert.ok(
+ next,
+ 'The selected battle must retain a finite owned continuation.',
+ );
+ g = applyAction(g, next.player, next.action);
+ }
+ throw new Error('Battle continuation exceeded its fixture limit.');
+}
+export function saphoBattleCustody(g: Game) {
+ const physical = [
+ ...g.deck,
+ ...g.discard,
+ ...(g.richeseCache ?? []),
+ ...(g.richeseRemoved ?? []),
+ ...g.players.flatMap((p) => p.hand),
+ ];
+ assert.deepEqual(
+ physical.map((c) => c.id).sort(),
+ [...treacheryDeck(g.expansions), ...richeseCards()].map((c) => c.id).sort(),
+ );
+ assert.deepEqual(
+ [
+ ...(g.traitorReserve ?? []),
+ ...g.players.flatMap((p) => p.traitors),
+ ].sort(),
+ traitorDeck(g.players, false).sort(),
+ );
+ for (const p of g.players) {
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
+ 20,
+ );
+ if (p.elites)
+ assert.equal(
+ p.elites.reserves +
+ p.elites.tanks +
+ Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
+ 5,
+ );
+ }
+}
diff --git a/tests/fixture-sapho-face-dance.ts b/tests/fixture-sapho-face-dance.ts
new file mode 100644
index 00000000..07a577fa
--- /dev/null
+++ b/tests/fixture-sapho-face-dance.ts
@@ -0,0 +1,148 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeFactionExpansionsGameForAudit,
+ joinGame,
+ newPlayer,
+ type Game,
+} from '../game/engine';
+import { treacheryDeck } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import { traitorDeck } from '../game/traitors';
+import {
+ nextSaphoBattleAction,
+ takeSaphoBattleCard,
+} from './fixture-sapho-battle-order';
+
+/** Real five-faction Ix/CHOAM setup; later hands, forces and one Face Dancer identity are staged by conserved swaps. */
+export function saphoFaceDanceGame(advanced = false): {
+ game: Game;
+ winnerLeader: string;
+ loserLeader: string;
+} {
+ const ids = ['a', 'b', 'c', 'r', 't'];
+ const factions = [
+ 'emperor',
+ 'guild',
+ 'atreides',
+ 'richese',
+ 'tleilaxu',
+ ] as const;
+ let g = createGame(
+ 'SAPHOFD',
+ newPlayer('a', 'Emperor', 'emperor'),
+ advanced,
+ ['ix', 'choam'],
+ );
+ for (let index = 1; index < ids.length; index++)
+ joinGame(g, newPlayer(ids[index], factions[index], factions[index]));
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ g = initializeFactionExpansionsGameForAudit(g);
+ for (let step = 0; g.status === 'setup' && step < 100; step++) {
+ const next = nextSaphoBattleAction(g);
+ assert.ok(next);
+ g = applyAction(g, next.player, next.action);
+ }
+ assert.equal(g.status, 'playing');
+ for (const [index, p] of g.players.entries()) {
+ g.deck.push(...p.hand);
+ p.hand = [];
+ p.forces = index < 3 ? { 'arrakeen:10': 3 } : {};
+ p.reserves = index < 3 ? 17 : 20;
+ p.tanks = 0;
+ p.spice = 20;
+ p.shipped = false;
+ p.moved = 0;
+ if (p.elites) p.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
+ }
+ takeSaphoBattleCard(g, 'a');
+ const winnerLeader = [...g.players[1].leaders].sort(
+ (a, b) => b.strength - a.strength,
+ )[0].id;
+ const loserLeader = [...g.players[2].leaders].sort(
+ (a, b) => a.strength - b.strength,
+ )[0].id;
+ const dancer = g.players[4].faceDancers![0];
+ const old = dancer.leader;
+ const reserve = g.traitorReserve!.indexOf(winnerLeader);
+ if (reserve >= 0) g.traitorReserve![reserve] = old;
+ else {
+ let replaced = false;
+ for (const p of g.players) {
+ const held = p.traitors.indexOf(winnerLeader);
+ if (held >= 0) {
+ p.traitors[held] = old;
+ replaced = true;
+ break;
+ }
+ const face = p.faceDancers?.find((card) => card.leader === winnerLeader);
+ if (face) {
+ face.leader = old;
+ replaced = true;
+ break;
+ }
+ }
+ assert.ok(replaced, 'The genuine physical traitor identity must exist.');
+ }
+ dancer.leader = winnerLeader;
+ Object.assign(g, {
+ turn: 2,
+ phase: 5,
+ storm: 18,
+ order: ids,
+ active: 'a',
+ movementRemaining: [...ids],
+ guildTimingLocked: true,
+ guildTimingGranted: false,
+ ready: [],
+ response: null,
+ decision: null,
+ phaseOpening: null,
+ });
+ while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
+ while (g.phaseOpening) {
+ const next = nextSaphoBattleAction(g)!;
+ g = applyAction(g, next.player, next.action);
+ }
+ assert.equal(g.phase, 6);
+ saphoFaceDanceCustody(g);
+ return { game: g, winnerLeader, loserLeader };
+}
+export function saphoFaceDanceCustody(g: Game) {
+ assert.deepEqual(
+ [
+ ...g.deck,
+ ...g.discard,
+ ...(g.richeseCache ?? []),
+ ...(g.richeseRemoved ?? []),
+ ...g.players.flatMap((p) => p.hand),
+ ]
+ .map((c) => c.id)
+ .sort(),
+ [...treacheryDeck(g.expansions), ...richeseCards()].map((c) => c.id).sort(),
+ );
+ assert.deepEqual(
+ [
+ ...(g.traitorReserve ?? []),
+ ...g.players.flatMap((p) => [
+ ...p.traitors,
+ ...(p.faceDancers ?? []).map((c) => c.leader),
+ ]),
+ ].sort(),
+ traitorDeck(g.players, true).sort(),
+ );
+ for (const p of g.players) {
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
+ 20,
+ );
+ if (p.elites)
+ assert.equal(
+ p.elites.reserves +
+ p.elites.tanks +
+ Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
+ 5,
+ );
+ }
+}
diff --git a/tests/sapho-battle-order-bots.test.ts b/tests/sapho-battle-order-bots.test.ts
new file mode 100644
index 00000000..f9fa22e3
--- /dev/null
+++ b/tests/sapho-battle-order-bots.test.ts
@@ -0,0 +1,68 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { botBattleChoices } from '../game/bot-battle-choices';
+import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
+import { saphoBattleOrderGame } from './fixture-sapho-battle-order';
+
+function choices(game: Game, owner: string, level: Difficulty) {
+ const view = viewGame(game, owner);
+ view.players.find(player => player.id === owner)!.bot = level;
+ const before = JSON.stringify(view);
+ const actions = botActions(view);
+ assert.equal(JSON.stringify(view), before, 'policy leaves its private projection unchanged');
+ return actions;
+}
+
+void test('all four profiles use battle first and choose the actual opponent from either stable slot', () => {
+ for (const advanced of [false, true]) for (const level of DIFFICULTIES) {
+ const game = saphoBattleOrderGame({ advanced, holder: 'c', geometry: 'shared' });
+ const actions = choices(game, 'c', level);
+ const card = actions.find(action => action.card === 'richese-juice-of-sapho');
+ assert.ok(card, `${level}: offered card action`);
+ assert.equal(card.scope, 'battleOrder');
+ assert.equal(card.mode, 'first');
+ const ordered = applyAction(game, 'c', card);
+ assert.equal(ordered.active, 'c');
+ const plans = botBattleChoices(viewGame(ordered, 'c'));
+ assert.ok(plans.some(action => action.target === 'a'));
+ assert.ok(plans.every(action => action.target !== 'c'));
+ for (const action of plans) {
+ const next = applyAction(ordered, 'c', action);
+ assert.equal(next.battle?.territory, action.territory);
+ assert.equal(next.battle?.defender, 'c');
+ assert.equal(next.battle?.attacker, action.target);
+ }
+ const botPlans = choices(ordered, 'c', level).filter(action => action.type === 'chooseBattle');
+ assert.deepEqual(botPlans, plans);
+ }
+});
+
+void test('all profiles can move the first chooser last without changing physical storm order', () => {
+ for (const advanced of [false, true]) for (const level of DIFFICULTIES) {
+ const game = saphoBattleOrderGame({ advanced, holder: 'a', geometry: 'shared' });
+ const action = choices(game, 'a', level).find(action => action.card === 'richese-juice-of-sapho');
+ assert.ok(action);
+ assert.equal(action.scope, 'battleOrder');
+ assert.equal(action.mode, 'last');
+ const next = applyAction(game, 'a', action);
+ assert.deepEqual(next.order, game.order);
+ assert.notEqual(next.active, 'a');
+ assert.deepEqual(botBattleChoices(viewGame(next, 'a')), []);
+ assert.equal(next.discard.filter(card => card.id === 'richese-juice-of-sapho').length, 1);
+ }
+});
+
+void test('Sapho scheduling policy cannot inspect another seat’s concealed cards or resources', () => {
+ const game = saphoBattleOrderGame({ advanced: true, holder: 'c', geometry: 'shared' });
+ for (const level of DIFFICULTIES) {
+ const view = viewGame(game, 'c');
+ view.players.find(player => player.id === 'c')!.bot = level;
+ const expected = botActions(view);
+ for (const other of view.players.filter(player => player.id !== 'c'))
+ for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
+ Object.defineProperty(other, field, { get() { throw new Error(`Unauthorized ${field}`); } });
+ assert.deepEqual(botActions(view), expected);
+ }
+});
diff --git a/tests/sapho-battle-order-controls.test.tsx b/tests/sapho-battle-order-controls.test.tsx
new file mode 100644
index 00000000..559c676b
--- /dev/null
+++ b/tests/sapho-battle-order-controls.test.tsx
@@ -0,0 +1,67 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { applyAction, viewGame, type GameView } from '../game/engine';
+import { JuiceOfSapho } from '../components/juice-of-sapho';
+import { botBattleChoices } from '../game/bot-battle-choices';
+import { saphoBattleOrderGame, saphoBattleOrderAction } from './fixture-sapho-battle-order';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
+ return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
+ },
+});
+const { GameTable } = await import('../components/game-table');
+aliases.deregister();
+const panel = (game: GameView, busy = false) => renderToStaticMarkup(createElement(JuiceOfSapho, { game, act() {}, busy }));
+const table = (game: GameView) => renderToStaticMarkup(createElement(GameTable, { game, send: async () => {}, onExit() {}, busy: false }));
+
+void test('held Sapho exposes the legal battle-order action with its actual timing and limits', () => {
+ for (const [holder, mode] of [['c', 'first'], ['a', 'last']] as const) {
+ const game = saphoBattleOrderGame({ holder, geometry: 'shared' });
+ const view = viewGame(game, holder);
+ const html = panel(view);
+ assert.match(html, new RegExp(`Choose battles ${mode} and`));
+ assert.match(html, /Other players may still choose battles against you/);
+ assert.match(html, /not the aggressor or tie advantage/);
+ assert.match(html, /Battle aggressor[\s\S]*unfinished/);
+ assert.match(panel(view, true), /