From 369371671f268c1696879ce5125b4fcbc60f9cab Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:48:37 +1000 Subject: [PATCH] Prototype Sapho first after completed movement turns Allow a physical Sapho holder to go first among remaining unstarted combined shipment/movement turns after earlier players have fully finished. Preserve completed resources, cards, counters and physical order. Keep Advanced Guild priority guarded until Guild leaves the remaining queue. Reuse private controls, all four AI profiles and the existing action/CAS continuation. Add four engine regressions and one independently authored SQLite recovery race; upgrade the existing Sapho Advanced engine fixture to genuine Advanced setup. Update player guidance and the rules decision register. Record the fourteen Leader Skill cards and lifecycle as sourced future work, with unresolved edges. No mode or publication gates are opened. Validation: check (types, lint, 4,369 offline tests) and build passed. All 35 focused Sapho tests passed; independent engine/persistence review found no blocker. One initial focused Advanced test omitted the existing Guild shipment approval; it was corrected to resolve that real decision before ending the turn. No HTTP/auth/schema change; the HTTP suite was not repeated for this checkpoint. Following the reported power outage, the existing localhost server was healthy, SQLite integrity/foreign-key checks passed, and the saved QA seat restored the same private hand, forces, resources and pending Storm decision after refresh. All 291 opening room versions/state hashes and all application seat/recovery/ entry/handover records match the fresh backup. Cloudflare internal metadata changed; application data did not. No restart or database reset was needed. Source fingerprint: de0e0e52ef7b6888792a6e2ec5e8c4745da446ece843bf3092320e915d621266 Private report: /home/quintine/.local/share/dune/checkpoints/20260913-sapho-completed-boundary-checks/report.json Private backup/comparison: /home/quintine/.local/share/dune/checkpoints/20260913-outage-sapho-before/ Check duration 146645 ms; build 6753 ms. Reported aggregate goal counter at this milestone opening was 65037986 and before Git was 65524147 (486161 difference, including outage recovery, parallel source research and coordination; not a billing measurement or a savings claim). --- components/juice-of-sapho.tsx | 2 +- docs/CURRENT_STATUS.md | 14 ++ docs/IMPLEMENTATION_STATUS.md | 26 ++ docs/JUICE_OF_SAPHO_RUNTIME.md | 26 +- docs/LEADER_SKILLS_RULES.md | 196 +++++++++++++++ docs/RULE_DECISIONS.md | 6 + game/engine.ts | 29 ++- game/reference.ts | 6 +- .../juice-of-sapho-boundary-recovery.test.ts | 226 ++++++++++++++++++ tests/juice-of-sapho-engine.test.ts | 164 ++++++++++++- 10 files changed, 670 insertions(+), 25 deletions(-) create mode 100644 docs/LEADER_SKILLS_RULES.md create mode 100644 tests/juice-of-sapho-boundary-recovery.test.ts diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx index ddfc941a..249c75d3 100644 --- a/components/juice-of-sapho.tsx +++ b/components/juice-of-sapho.tsx @@ -20,7 +20,7 @@ function unavailableReason(game: GameView) { return 'No order change is available for you in this lot. You must still be eligible and have not bid; first is available only before bidding begins. A card already committed elsewhere cannot be used.'; } if (game.phase === 5) - return 'Movement order can change only before the current combined shipment and movement turn begins. First also requires that nobody has begun a turn this phase and that Advanced Guild timing is absent. Already-held positions and committed cards are unavailable.'; + 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.'; } diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index dbe44584..1a76abff 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -4,6 +4,20 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid ## Evidence and source of truth +- [Juice of Sapho](JUICE_OF_SAPHO_RUNTIME.md#13-september-first-at-a-later-clean-movement-boundary) + now connects first among remaining unstarted movement turns after earlier + turns finish, including after Advanced Guild completes its turn. Existing + private controls, all four AI profiles and saved continuation use the same + server option. Other Sapho timing questions remain pending. Independent + review and focused checks pass; broad results are in the checkpoint commit. +- The [Leader Skills source contract](LEADER_SKILLS_RULES.md) records all fourteen + cards, setup/lifecycle and printed clarifications for the next prototype batch. + Runtime, controls and AI remain **Missing**; unresolved edge cases stay explicit. +- Following the reported power outage, the existing server responded normally, + the database integrity check passed with 291 rooms, and all 218 rooms in the + preceding handover baseline were unchanged. The saved QA seat restored its + same private hand and pending Storm decision after refresh. A new private + backup protects all 291 rooms. No server restart or database reset was needed. - The current [Discovery prototype](DISCOVERY_PROTOTYPE.md) adds unambiguous Jacurutu battle income, sole-occupant Ecological Testing Station adjustment and Shrine card conversion. They connect engine effects, private controls, legal diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2fc9218b..cdfc46be 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 13 September 2026 — Sapho first after completed movement turns + +Extends the existing clean-boundary movement contract to let Sapho's holder +act first among remaining unstarted turns after earlier players fully finish. +Completed shipment resources, forces, cards and counters stay intact. Advanced +Guild priority remains guarded until Guild finishes. Current private controls, +all four AI profiles and persisted room actions reuse the same legality check; +the card is discarded once. Other Sapho modes and release gates remain pending. +See [runtime scope](JUICE_OF_SAPHO_RUNTIME.md). + +Four new engine tests include genuine Advanced setup and owner-only rendered +controls. One independent SQLite test exercises real completed shipment, +restart, concurrent claims, physical custody and replay rejection. Independent +review found no blocker. Checkpoint-wide results and the final source fingerprint +are recorded in the commit and private report. + +The user reported a power outage during this work. The existing server was +healthy, all 291 rooms were present, SQLite integrity/foreign-key checks passed, +and all 218 rooms in the prior opening baseline matched. QA room `2EMYG4CY` +restored its same private hand, resources and pending Storm choice after refresh. +All 291 rooms were backed up before further work; no reset or restart occurred. + +The independent [Leader Skills source contract](LEADER_SKILLS_RULES.md) supplies +the complete fourteen-card inventory and shared lifecycle for the next batch. +It records unresolved edges and establishes no runtime prototype or mode gate. + ## 13 September 2026 — Discovery location effects prototype Adds automatic unambiguous Jacurutu battle income, an owned Testing Station diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 44dc64af..f88527c6 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -1,13 +1,13 @@ # Juice of Sapho: finite ordering implementation -7 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 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. ## Implemented contract `{type:'card',card:'richese-juice-of-sapho',scope,event,mode}` accepts one of these current opportunities and discards the actual held card once: - `scope:'onceAround'`: first before any bidder acts; last while the holder has not acted. The intervention applies to this lot only. Prior bids, funding, completed participants, physical lot, event and storm tie order remain intact. Richese is not restored to last after Sapho. A holder excluded initially by a full hand gains a slot and eligibility by discarding Sapho, provided this lot is still open. Completed passes never reopen. Every purchase still requires a strict outbid. -- `scope:'movement'`: reorder entire remaining combined shipment/movement turns at a clean boundary. First requires no combined turn yet begun and no Advanced Guild. Last remains after Guild, including later Guild deferrals. No movement counters, Hajr, transport pricing, concealed tokens, resources or completed turns reset. Existing shipment/movement/preparation, reaction, Truthtrance, paid Box and gift commitments block the intervention. The next actor retains an already settled Guild timing choice when their identity is unchanged. An already granted, unstarted early Guild turn is lifted to the front of the local remaining queue before applying last, without changing physical order; this also permits Guild itself to declare last. A player now actually last cannot spend the card on a no-op. +- `scope:'movement'`: reorder entire remaining combined shipment/movement turns at a clean boundary. First requires every remaining turn to be unstarted; previously completed turns may retain their spent shipment/movement counters. In Advanced, Guild must be absent or have fully completed its turn and left the remaining queue. Last remains after Guild, including later Guild deferrals. No movement counters, Hajr, transport pricing, concealed tokens, resources or completed turns reset. Existing shipment/movement/preparation, reaction, Truthtrance, paid Box and gift commitments block the intervention. The next actor retains an already settled Guild timing choice when their identity is unchanged. An already granted, unstarted early Guild turn is lifted to the front of the local remaining queue before applying last, without changing physical order; this also permits Guild itself to declare last. A player now actually last cannot spend the card on a no-op. The reusable immutable `ordered-opportunity` helper checks unique IDs, exact remaining/completed partition, stable event and locked current/protected-last positions. It never defines card scope or cyclic timing. Movement protection persists as `saphoMovementLast:{event,turn,player}`, checked on action, normalization and projection, and cleared when its owner finishes. Normal storm order is never rewritten. Invalid records are rejected, not repaired by replaying turns. @@ -17,7 +17,7 @@ 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. First after an already completed opportunity 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. +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 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. @@ -25,7 +25,25 @@ The battle audit in [the battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) found a New source contribution review covers the pure helper and all four new rules test files (33 test cases including 960 helper reorder cases), plus four real room/SQLite recovery cases. Recovery exercises simultaneous duplicate Sapho requests with one successful CAS and one failed CAS, old/new-version replay, strict outbid sale, exact physical inventory, private projections, actual phase entry and Guild deferral through phase completion. -Browser and final regression outcomes are recorded in the final checkpoint below after completion. The hourly maintenance automation remains removed; the explicitly authorized manual backup and controlled development-server restart continue during active work. +Browser and final regression outcomes from 7 September are recorded in the historical checkpoint below. The hourly maintenance automation remains removed. Reuse the server; restart only when an observed condition or change requires it, preserving saved games and verifying restoration. + +## 13 September: first at a later clean movement boundary + +The [existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast) +already permits reordering the remaining unstarted participants between complete +combined turns. The old global completed/shipped guard was narrower than that +contract. The engine now checks only remaining participants and only retains the +Advanced Guild-first guard while Guild is among them. No new timing ruling, +saved field, player action shape or separate AI legality calculation is added. + +Four new engine tests cover a real completed shipment before first, a genuine +Advanced Guild turn that fully finishes, rejected partly started/queued turns, +and all four private AI profiles. The existing Advanced engine fixtures now use +the shared Advanced initializer instead of flipping a Basic game. A separate +production-room SQLite test races duplicate first requests after a real completed +shipment and proves one discard, private projections and intact completed-player +custody. Focused checks pass; broad results are recorded in the checkpoint commit +and private source-bound report. This is a bounded connected prototype. ## Final checkpoint diff --git a/docs/LEADER_SKILLS_RULES.md b/docs/LEADER_SKILLS_RULES.md new file mode 100644 index 00000000..22d9b3ef --- /dev/null +++ b/docs/LEADER_SKILLS_RULES.md @@ -0,0 +1,196 @@ +# Leader Skills source contract + +Updated 13 September 2026. This is a primary-source contract for the optional +Leader Skills module from *CHOAM & Richese*. It does not record a runtime +prototype, enable the module, or certify its combinations. + +## Sources and local evidence + +The governing source is Gale Force Nine's twelve-page English *CHOAM & +Richese* rulebook, especially pages 8–9 and its Q&A on pages 11–12. The +[original GF9 URL](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf) +now redirects or returns 403 in ordinary clients. A byte-stable copy of the +publisher-authored file was retrieved from a +[readable rulebook mirror](https://lelekan.com.ua/files/rules/2082/pravila-nastilnoyi-gri-dyuna-kooan-ta-richez-dune-choam-amp-amp-richese-dopovnennya-angl-anglijskoyu-movoyu.0.pdf). +The designers' [expansion page](https://futurepastimes.com/dune-choam-richese) +links the same GF9 rules. + +Card names and face text were checked against a photograph of all fourteen +physical cards. The four clearest individual faces also appear on the +[Future Pastimes designer site](https://images.squarespace-cdn.com/content/v1/5627edd5e4b0e42d8c948a6a/1654192152741-787ZBZ91LZP6CG6SZU28/Leader%2Bskill%2BCards%2B3.jpg). +Designer Jack Reda's +[complete card walkthrough](https://www.youtube.com/watch?v=XT_azRVLq_0) +confirms every value and explains how the two effect bands interact. + +The persistent archive is `/home/quintine/.local/share/dune/sources`: + +- `choam-richese-rulebook-en.pdf` and its layout-preserving `.txt` extraction; +- `leader-skills-tabletopfinder-all.jpg`, the fourteen physical faces; +- `leader-skills-future-pastimes-4cards.jpg` and + `choam-richese-future-pastimes-components.jpg`, designer-site images; +- `leader-skills-designer-video-page.html` and + `leader-skills-designer-video-auto-transcript.txt`, the timestamped designer + walkthrough evidence; and +- `leader-skills-source-provenance.txt`, with retrieval URLs and SHA-256 + hashes. + +The transcript is auto-generated, so names and numbers below follow the card +faces when its speech recognition is imperfect. The effects are paraphrased +rather than reproduced as card text. + +## Physical inventory and effects + +Each card has a face-up effect above its illustration and a second effect for +the assigned leader when that leader is actually used in battle. “Normal” +below means the first effect, which is publicly available while the skilled +leader and card remain face up. “Skilled battle” means the lower effect. When +the skilled leader is used in battle, both the first effect (when applicable) +and the lower effect may apply, subject to the card's “other leader” wording +and the nonstacking exceptions below. The designer +walkthrough establishes that the +1 “other leader” effects and the skilled +leader's +3 effect do not add together; Suk Graduate's two rescue effects are +also alternatives rather than a cumulative four-force rescue +([designer explanation, 4:34–8:23](https://www.youtube.com/watch?v=XT_azRVLq_0&t=274s)). + +| # | Card | Normal effect | Skilled battle effect | +|---:|---|---|---| +| 1 | **Bureaucrat** | Once in each phase, when another player pays at least 5 spice to a player other than this skill's owner, the owner may redirect 2 of that payment to the Spice Bank. | The opponent reduces their battle total by 1 for each stronghold that opponent occupies; Bene Gesserit advisors do not count as occupation for this penalty. | +| 2 | **Spice Banker** | Once in each phase, when another player pays at least 4 spice to the Spice Bank, place 1 spice in front of this faction's shield for collection in the Mentat Pause. | Commit 1–3 spice with the Battle Plan and add the same amount to this leader's strength. This payment is separate from spice used to make forces full strength. | +| 3 | **Diplomat** | A single Worthless card in the owner's Battle Plan may stand in for the defense used by the opponent in that battle, provided the owner played no defense; discard the Worthless card afterward. | If this faction loses, retreat up to this leader's strength in **undialed** forces to one empty adjacent territory that is not a stronghold. | +| 4 | **Mentat** | Before Battle Plans, name a specific weapon and ask whether the opponent holds it. They reveal that card if held; otherwise they reveal a different Treachery Card. Revealing the named weapon does not commit it to the plan. | Add 2 to this leader's strength. | +| 5 | **Suk Graduate** | After winning a battle in which the winner loses forces, return 1 such force to reserves instead of the Tleilaxu Tanks. | After winning and losing forces, save up to 3 from the Tanks: leave 1 in the battle territory and return the remainder to reserves. | +| 6 | **Rihani Decipherer** | After winning a battle, secretly inspect 2 random cards from the Traitor Deck, then reshuffle them into that deck. | After winning, draw 2 Traitor Cards. Keep 1 by revealing an unused Traitor Card already held, then shuffle that revealed card and the unkept drawn card into the deck. | +| 7 | **Sandmaster** | When moving any forces into or through a territory containing spice, the faction may immediately collect 1 spice there, once for each territory traversed. | After winning a battle in a territory that contains spice, add 3 spice to that territory. | +| 8 | **Smuggler** | When shipping forces from off-planet to an empty territory, one additional force may accompany that shipment for free. | When Battle Plans are revealed, collect spice from the battle territory equal to this leader's strength, limited by the spice present. The general survival rule still applies. | +| 9 | **Planetologist** | For a movement, either add 1 to movement range, capped at 3, or move forces from two different territories in the same movement when both groups have the same destination. | In the weapon slot, reveal a green Special card other than a Cheap Hero and add 2 to this leader's strength. Discard the Special card after the battle. The card is used in place of a weapon. | +| 10 | **Warmaster** | Other leaders gain 1 strength when their Battle Plan contains at least one Worthless card. | Add 3 to this leader's strength when its Battle Plan contains at least one Worthless card. | +| 11 | **Master of Assassins** | Other leaders gain 1 strength when using a Poison Weapon. | Add 3 to this leader's strength when using a Poison Weapon. | +| 12 | **Swordmaster of Ginaz** | Other leaders gain 1 strength when using a Projectile Weapon. | Add 3 to this leader's strength when using a Projectile Weapon. | +| 13 | **Killer Medic** | Other leaders gain 1 strength when using a Poison Defense. | Add 3 to this leader's strength when using a Poison Defense. | +| 14 | **Prana Bindu Adept** | Other leaders gain 1 strength when using a Projectile Defense. | Add 3 to this leader's strength when using a Projectile Defense. | + +The designer explains Planetologist through Rihani at +[9:53–14:08](https://www.youtube.com/watch?v=XT_azRVLq_0&t=593s) and Diplomat +through Mentat at +[14:11–18:56](https://www.youtube.com/watch?v=XT_azRVLq_0&t=851s). Those +segments confirm the less legible photograph details: Diplomat says +“undialed,” Spice Banker spends 1–3, Rihani draws 2, and both Warmaster bands +require at least one Worthless card. + +## Setup, visibility and lifecycle + +Leader Skills is an optional variant. It may be included independently of the +two expansion factions and independently of Advanced Stronghold Cards. + +The module changes setup order. After positions and Bene Gesserit prediction, +deal starting Treachery Cards, including the Ixian start-of-game draw. Deal two +Leader Skill Cards to every faction; each faction keeps one and shuffles the +other into the skill deck. The kept card is placed face up in front of the +shield, and one eligible leader disc is placed face up beside it. Setup then +continues from Traitor selection. The card and assigned leader are public, so +Traitor choices occur with that assignment known. The CHOAM Auditor is +explicitly ineligible for a skill. [GF9 rulebook, pp. 7–9](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf) + +The skill remains available while the assigned leader is alive and both the +leader and card are face up. When choosing a battle leader, the owner may: + +- leave the skilled leader and card face up, preserving the normal effect and + publicly proving that leader is absent from the Battle Plan; or +- move both behind the shield. If the skilled leader is selected, put both its + disc and skill card into the battle wheel; both effect bands remain + available when applicable. If another leader is selected, neither part of + the concealed skill is available in that battle. + +After battle, a surviving skilled leader and its card return face up in front +of the shield. If the leader dies, its skill does not apply in that battle and +the skill card is shuffled back into the skill deck. Later, whenever a faction +revives one of its own leaders while it has no Leader Skill Card, it may draw +two skills, keep one, shuffle the other back, and assign the kept skill to that +newly revived leader. The rules give no voluntary reassignment between living +leaders. [GF9 rulebook, p. 9](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf) + +Skills that mention a card type require the physical card in the Battle Plan +to be played in that role. A multi-role card does not qualify merely because +it could have had the named role: the rulebook's example requires Chemistry to +be committed as a Poison Weapon for Master of Assassins. Skill resolution +precedes faction abilities; the printed example orders Mentat before Atreides +Prescience. + +## Capture and official clarifications + +When Harkonnen captures a skilled leader, the skill card travels with that +leader. The captor may use only the lower battle effect and only when using +that captured leader in battle. Thus a captured Spice Banker cannot collect +the normal payment trigger, but may spend 1–3 spice for its battle bonus; a +captured Mentat receives its +2 battle bonus but not the pre-plan question. +[GF9 Q&A, p. 12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf) + +The same official Q&A settles these boundaries: + +- Mentat may ask its question and then move that same leader behind the shield + for possible use in the battle. +- Zoal copies the leader disc's value, without skill or Kwisatz Haderach + additions. +- Tleilaxu taking a foreign Ghola does not draw a new skill; the revival draw + belongs only to a faction reviving its own leader. +- A Richese Smuggler still supplies its extra free force with a No-Field + shipment if the destination is empty. +- Spice Banker triggers when another player pays at least 4 spice to the Bank + during Revival. +- A Traitor obtained by Tleilaxu through Rihani counts as a Face Dancer and may + replace only an unrevealed Face Dancer. + +## Material boundaries not settled by the sources + +These questions should remain explicit rather than be answered by a generic +card framework: + +1. **Captured-card replacement:** the rules say a captured leader keeps the + skill, while revival permits a draw when a faction “has no” skill card. They + do not say whether the original faction still counts as having the captured + card, or can create a second active assignment after reviving another + leader. +2. **Spice Banker escrow:** the gained spice waits in front of the shield until + Mentat Pause. The sources do not say where it goes if the skilled leader + dies or is captured before collection, nor whether capture transfers that + waiting spice. +3. **Smuggler timing:** the face says to collect when plans are revealed, while + the common rule and designer explanation require the skilled leader to + survive the battle. A digital implementation needs a pending receipt or an + equivalent post-resolution settlement; the source does not describe + rollback of already collected spice. +4. **Mentat empty-hand case:** an opponent lacking the named weapon must show a + different Treachery Card, but no instruction covers a hand with no other + card to reveal. Later death also cannot undo information already disclosed + before plans. +5. **Diplomat strength and sequence:** the card does not specify whether its + retreat cap uses printed disc value or all surviving modifiers, or place + the retreat precisely among weapon resolution, leader death and ordinary + loser force removal. +6. **Multiple timing opportunities:** once-per-phase economic triggers and + skill-before-faction ordering are clear individually. The sources give no + priority protocol for simultaneous optional skills, payments and faction + reactions. + +Planetologist retains ordinary movement legality. Its +1 branch alone states a +maximum of 3; the two-origin branch instead requires a shared destination. The +designer expressly presents the latter as useful to Fremen, but does not +publish a general stacking order for every expansion movement modifier +([designer example, 19:13](https://www.youtube.com/watch?v=XT_azRVLq_0&t=1153s)). + +## Implementation readiness contract + +A bounded prototype can now use the exact fourteen-card inventory, modified +setup order, public assignment, battle conceal/reveal custody, death return, +own-leader revival draw, named card-role checks, eight direct strength modifiers, +the settled Q&A interactions and saved pending choices. Every human action +needs the same legal AI route, and private Mentat/Rihani information must remain +seat-scoped across save and recovery. + +Keep the six unresolved boundaries above gated or represented as explicit +pending decisions. In particular, do not pay Smuggler spice irreversibly at +plan reveal, transfer Spice Banker escrow on capture, or issue a replacement +skill while the original card is captured without a recorded ruling. This +source contract is ready for breadth-first implementation; it is not evidence +that Leader Skills is already prototyped or that the optional module can be +enabled publicly. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 9eb09b34..98f528df 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -15,6 +15,8 @@ Updated 13 September 2026. This is the authoritative navigation index for existi | Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) | | Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) | | CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) | +| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) | +| Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. This is a contract, not an implemented module. | [Source contract](LEADER_SKILLS_RULES.md) | | Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine have bounded prototypes; Orgiz and contested/mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | @@ -26,6 +28,10 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Boundary | Still unresolved | Existing record | | --- | --- | --- | +| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and remaining battle timing stay guarded. Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) | +| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered; arithmetic helpers do not admit the full battle card. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) | +| Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) | +| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler survival settlement, Mentat empty-hand/death disclosure, Diplomat retreat ordering and simultaneous optional timing need resolution before those cases are enabled. These are newly recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources) | | Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) | | Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) | | Guild Secret Ally/Homeworlds | Whether the reserve-return grant overrides the Homeworld return restriction. The question was already sent; that route stays guarded. | [Homeworld return](NEXUS_GUILD_SECRET_ALLY_RULES.md#homeworld-prices-and-the-unresolved-return), [checkpoint](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | diff --git a/game/engine.ts b/game/engine.ts index f89c8f77..52a6de28 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -10726,6 +10726,17 @@ function saphoCleanWindow(g: Game) { !g.currentAuctionSale ); } +function saphoMovementStarted(g: Game, p: Player): boolean { + // A paid/reserved movement preparation also starts the combined turn. + return !!( + p.shipped || + p.moved > 0 || + g.hajr.includes(p.id) || + g.karamaShipping || + g.ornithopter || + (g.choamMovement?.turn === g.turn && p.faction === 'choam') + ); +} function saphoMovementQueue(g: Game): OrderedOpportunity | null { let remaining = g.movementRemaining; if ( @@ -10743,21 +10754,13 @@ function saphoMovementQueue(g: Game): OrderedOpportunity | null { return null; remaining = [g.active, ...remaining.filter((id) => id !== g.active)]; } - // A paid/reserved movement preparation also starts the combined turn. - const started = - active.shipped || - active.moved > 0 || - g.hajr.includes(active.id) || - !!g.karamaShipping || - !!g.ornithopter || - (g.choamMovement?.turn === g.turn && active.faction === 'choam'); const state: OrderedOpportunity = { event: `movement:${g.turn}`, eligible: g.order, completed: g.order.filter((id) => !remaining.includes(id)), remaining, current: g.active, - currentStarted: !!started, + currentStarted: saphoMovementStarted(g, active), protectedLast: g.saphoMovementLast?.player ?? null, }; validateOrderedOpportunity(state); @@ -10800,9 +10803,11 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] { !g.saphoMovementLast ) { if ( - !queue.completed.length && - !g.players.some((other) => other.shipped || other.moved > 0) && - !(g.advanced && byFaction(g, 'guild')) && + !queue.remaining.some((id) => saphoMovementStarted(g, getPlayer(g, id))) && + !( + g.advanced && + queue.remaining.some((id) => getPlayer(g, id).faction === 'guild') + ) && queue.current !== p.id ) options.push({ scope: 'movement', event: queue.event, mode: 'first' }); diff --git a/game/reference.ts b/game/reference.ts index bc710ce8..31b08cc3 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -325,12 +325,13 @@ export const RULE_TOPICS: RuleTopic[] = [ title: 'Juice of Sapho: changing order', category: 'Cards', coverage: 'Partial', + developmentStage: 'Prototyped', summary: 'Choose one available first or last position, 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 requires that nobody has begun a combined turn this phase and that Advanced Guild timing is absent. Last stays after the Guild even if the Guild later chooses to wait.', + '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.', '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.', @@ -348,7 +349,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Finite Once Around and clean combined-turn ordering, physical discard, full-hand eligibility and persisted last-over-Guild protection. Remaining timing modes are guarded.', + '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.', }, { area: 'Player controls', @@ -379,6 +380,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/juice-of-sapho-engine-review.test.ts', 'tests/juice-of-sapho-bots.test.ts', 'tests/juice-of-sapho-recovery.test.ts', + 'tests/juice-of-sapho-boundary-recovery.test.ts', ], }, ], diff --git a/tests/juice-of-sapho-boundary-recovery.test.ts b/tests/juice-of-sapho-boundary-recovery.test.ts new file mode 100644 index 00000000..2e2766b4 --- /dev/null +++ b/tests/juice-of-sapho-boundary-recovery.test.ts @@ -0,0 +1,226 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import * as engine from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import type * as Rooms from '../db/rooms'; +import { unitStore } from './fixture-nexus-room-store'; + +const SAPHO = 'richese-juice-of-sapho'; +const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} }; + +function barrier() { + let release!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + let arrivals = 0; + return async () => { + if (++arrivals === 2) release(); + await waiting; + }; +} + +function physical(g: engine.Game) { + return [ + ...g.deck, + ...g.discard, + ...(g.richeseCache ?? []), + ...(g.richeseRemoved ?? []), + ...g.players.flatMap((p) => p.hand), + ...(g.auction?.cards.slice(g.auction.index) ?? []), + ...(g.ixAuction?.cards ?? []), + ] + .map((card) => card.id) + .sort(); +} + +function persist(sqlite: DatabaseSync, code: string, g: engine.Game) { + sqlite + .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') + .run(JSON.stringify(g), g.version, code); +} + +void test('a completed combined turn survives a concurrent persisted Sapho first claim at the next clean boundary', async () => { + const store = unitStore(); + try { + const made = await store.rooms.createRoom( + 'Completed A', + 'atreides', + false, + [], + ); + const code = made.view.code; + const joinedB = await store.rooms.joinRoom(code, 'Fresh B', 'emperor'); + const joinedC = await store.rooms.joinRoom(code, 'Sapho C', 'guild'); + const seats = await Promise.all( + [made.token, joinedB.token!, joinedC.token!].map((token) => + store.rooms.authenticate(code, token), + ), + ); + const [a, b, c] = seats.map((seat) => seat.playerId); + + let staged = await store.rooms.readRoom(code); + for (const player of staged.players) player.ready = true; + staged = engine.applyAction(staged, a, { type: 'start' }); + for (const player of staged.players) + if (player.traitorChoices.length) + staged = engine.applyAction(staged, player.id, { + type: 'traitor', + leader: player.traitorChoices[0], + }); + + Object.assign(staged, { + turn: 2, + phase: 4, + storm: 18, + order: [a, b, c], + ready: [], + active: null, + response: null, + decision: null, + phaseOpening: null, + advanced: false, + deck: baseDeck(), + discard: [], + richeseCache: richeseCards(), + richeseRemoved: [], + }); + for (const player of staged.players) { + player.hand = []; + player.spice = 20; + player.forces = {}; + player.reserves = 20; + player.shipped = false; + player.moved = 0; + } + const saphoAt = staged.richeseCache!.findIndex( + (card) => card.id === SAPHO, + ); + assert.ok(saphoAt >= 0); + staged.players.find((player) => player.id === c)!.hand.push( + staged.richeseCache!.splice(saphoAt, 1)[0], + ); + const shieldAt = staged.deck.findIndex((card) => card.name === 'Shield'); + assert.ok(shieldAt >= 0); + staged.players.find((player) => player.id === a)!.hand.push( + staged.deck.splice(shieldAt, 1)[0], + ); + persist(store.sqlite, code, staged); + + async function act(index: number, action: engine.Action) { + const current = await store.restart().readRoom(code); + await store + .restart() + .act(code, seats[index], current.version, action, clock); + return store.restart().readRoom(code); + } + + for (const index of [0, 1, 2]) await act(index, { type: 'ready' }); + let game = await store.restart().readRoom(code); + assert.equal(game.phase, 5); + assert.equal(game.active, a); + assert.deepEqual(game.movementRemaining, [a, b, c]); + + game = await act(0, { + type: 'ship', + territory: 'polar_sink', + sector: 0, + amount: 2, + }); + assert.equal(game.players.find((player) => player.id === a)!.shipped, true); + game = await act(0, { type: 'endMovement' }); + assert.equal(game.active, b); + assert.deepEqual(game.movementRemaining, [b, c]); + + const completedA = structuredClone( + game.players.find((player) => player.id === a)!, + ); + assert.equal(completedA.spice, 16); + assert.equal(completedA.reserves, 18); + assert.equal(completedA.forces['polar_sink:0'], 2); + assert.equal(completedA.hand.length, 1); + assert.equal(game.players.find((player) => player.id === b)!.shipped, false); + assert.equal(game.players.find((player) => player.id === b)!.moved, 0); + const physicalBefore = physical(game); + assert.equal(physicalBefore.filter((id) => id === SAPHO).length, 1); + const event = `movement:${game.turn}`; + for (const [index, seat] of seats.entries()) { + const view = await store.restart().readSeatView(code, seat); + assert.deepEqual( + view.saphoOptions, + index === 2 ? [{ scope: 'movement', event, mode: 'first' }] : [], + ); + for (const opponent of view.players.filter( + (player) => player.id !== seat.playerId, + )) + for (const key of ['hand', 'spice', 'traitors', 'traitorChoices']) + assert.equal(key in opponent, false, `Other seat disclosed ${key}`); + if (index !== 2) + assert.equal(JSON.stringify(view).includes(SAPHO), false); + } + + const request: engine.Action = { + type: 'card', + card: SAPHO, + scope: 'movement', + event, + mode: 'first', + }; + store.writes.length = 0; + store.hooks.beforeWrite = barrier(); + const attempts = await Promise.allSettled([ + store.rooms.act(code, seats[2], game.version, request, clock), + store.restart().act(code, seats[2], game.version, request, clock), + ]); + store.hooks.beforeWrite = undefined; + assert.equal( + attempts.filter((result) => result.status === 'fulfilled').length, + 1, + ); + assert.equal( + attempts.filter((result) => result.status === 'rejected').length, + 1, + ); + assert.deepEqual( + store.writes.map((write) => write.changes).sort((x, y) => x - y), + [0, 1], + ); + + const after = await store.restart().readRoom(code); + assert.equal(after.version, game.version + 1); + assert.equal(after.active, c); + assert.deepEqual(after.movementRemaining, [c, b]); + assert.deepEqual(after.order, [a, b, c]); + assert.deepEqual( + after.players.find((player) => player.id === a), + completedA, + 'Sapho must not replay or undo A\'s completed shipment and custody.', + ); + assert.equal( + after.players + .find((player) => player.id === c)! + .hand.some((card) => card.id === SAPHO), + false, + ); + assert.equal(after.discard.filter((card) => card.id === SAPHO).length, 1); + assert.deepEqual(physical(after), physicalBefore); + assert.equal( + after.log.filter((entry) => + entry.text.includes('discarded Juice of Sapho'), + ).length, + 1, + ); + + const writes = store.writes.length; + await assert.rejects( + store.restart().act(code, seats[2], after.version, request, clock), + ); + assert.equal(store.writes.length, writes); + assert.deepEqual(await store.restart().readRoom(code), after); + } finally { + store.hooks.beforeWrite = undefined; + store.sqlite.close(); + } +}); diff --git a/tests/juice-of-sapho-engine.test.ts b/tests/juice-of-sapho-engine.test.ts index 55275bbd..24b3c675 100644 --- a/tests/juice-of-sapho-engine.test.ts +++ b/tests/juice-of-sapho-engine.test.ts @@ -1,8 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { applyAction, createGame, + initializeBaseGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, @@ -12,17 +15,26 @@ import { } from '../game/engine'; import { baseDeck, leaders } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { JuiceOfSapho } from '../components/juice-of-sapho'; const card = 'richese-juice-of-sapho'; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); -function initial() { - let g = createGame('SAPHOENGINE', newPlayer('a', 'Atreides', 'atreides')); +function initial(advanced = false) { + let g = createGame( + 'SAPHOENGINE', + newPlayer('a', 'Atreides', 'atreides'), + advanced, + ); joinGame(g, newPlayer('e', 'Emperor', 'emperor')); joinGame(g, newPlayer('g', 'Guild', 'guild')); g.players.forEach((p) => { p.ready = true; }); - g = applyAction(g, 'a', { type: 'start' }); + g = advanced + ? initializeBaseGameForAudit(g) + : applyAction(g, 'a', { type: 'start' }); for (const p of g.players) if (p.traitorChoices.length) g = applyAction(g, p.id, { @@ -58,8 +70,7 @@ function hold(g: Game, id: string) { .hand.push(g.richeseCache!.splice(at, 1)[0]); } function movement(owner = 'e', advanced = false) { - let g = initial(); - g.advanced = advanced; + let g = initial(advanced); g.phase = 4; hold(g, owner); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); @@ -206,7 +217,7 @@ void test('Basic movement first/last preserve physical order and give each facti } }); -void test('Movement first is unavailable after another combined turn finishes, while last still works at the next clean boundary', () => { +void test('The already-current player cannot spend Sapho on first, while last works at the next clean boundary', () => { let g = end(movement()); reject(g, 'e', action(g, 'first')); g = applyAction(g, 'e', action(g, 'last')); @@ -214,6 +225,147 @@ void test('Movement first is unavailable after another combined turn finishes, w assert.equal(g.active, 'g'); }); +void test('Sapho first reorders only the unstarted movement suffix after a real completed shipment', () => { + let g = movement('g'); + g = applyAction(g, 'a', { + type: 'ship', + territory: 'polar_sink', + sector: 0, + amount: 2, + }); + g = end(g); + assert.equal(g.active, 'e'); + const completed = reload(g).players[0]; + assert.equal(completed.shipped, true); + assert.equal(completed.reserves, 18); + const before = reload(g); + const controls = renderToStaticMarkup( + createElement(JuiceOfSapho, { + game: viewGame(g, 'g'), + act: () => {}, + busy: false, + }), + ); + assert.match( + controls, + /Take your movement turn first and discard Juice of Sapho/, + ); + assert.match(controls, /Completed turns stay completed/); + assert.equal( + renderToStaticMarkup( + createElement(JuiceOfSapho, { + game: viewGame(g, 'e'), + act: () => {}, + busy: false, + }), + ), + '', + ); + const request = action(g, 'first'); + g = applyAction(reload(g), 'g', request); + assert.deepEqual(g.movementRemaining, ['g', 'e']); + assert.equal(g.active, 'g'); + assert.deepEqual(g.order, before.order); + assert.deepEqual(g.players[0], completed); + assert.deepEqual(g.players[1], before.players[1]); + assert.equal(g.discard.filter((c) => c.id === card).length, 1); + assert.equal( + g.players[2].hand.some((c) => c.id === card), + false, + ); + reject(g, 'g', request); + g = end(reload(g)); + assert.equal(g.active, 'e'); + g = end(reload(g)); + assert.notEqual(g.phase, 5); + assert.deepEqual(g.movementRemaining, []); + assert.deepEqual(g.players[0], completed); + + const startedNext = applyAction(before, 'e', { + type: 'ship', + territory: 'polar_sink', + sector: 0, + amount: 1, + }); + assert.deepEqual(viewGame(startedNext, 'g').saphoOptions, []); + reject(startedNext, 'g', request); +}); + +void test('Genuine Advanced setup permits Sapho first after Guild finishes, retaining unspent Guild priority beforehand', () => { + let g = movement('e', true); + assert.equal(g.players[1].elites!.reserves, 5); + assert.equal(g.decision?.kind, 'guildTiming'); + reject(g, 'e', action(g, 'first')); + const deferred = applyAction(reload(g), 'g', { + type: 'decision', + take: false, + }); + assert.equal(deferred.active, 'a'); + reject(deferred, 'e', action(deferred, 'first')); + + g = applyAction(g, 'g', { type: 'decision', take: true }); + assert.equal(g.active, 'g'); + reject(g, 'e', action(g, 'first')); + g = applyAction(g, 'g', { + type: 'ship', + territory: 'polar_sink', + sector: 0, + amount: 2, + }); + assert.equal(g.decision?.kind, 'guildShipment'); + g = applyAction(g, 'g', { type: 'decision', allow: true }); + g = end(g); + const completed = reload(g).players[2]; + assert.equal(g.active, 'a'); + assert.deepEqual(g.movementRemaining, ['a', 'e']); + g = applyAction(reload(g), 'e', action(g, 'first')); + assert.equal(g.active, 'e'); + assert.deepEqual(g.movementRemaining, ['e', 'a']); + assert.deepEqual(g.players[2], completed); + g = end(reload(g)); + assert.equal(g.active, 'a'); + g = end(reload(g)); + assert.notEqual(g.phase, 5); + assert.deepEqual(g.players[2], completed); + assert.equal(g.discard.filter((c) => c.id === card).length, 1); +}); + +void test('Sapho first cannot move a queued participant with a spent shipment, movement or preparation', () => { + for (const kind of ['shipment', 'movement', 'hajr'] as const) { + const g = end(movement('g')); + if (kind === 'shipment') g.players[2].shipped = true; + if (kind === 'movement') g.players[2].moved = 1; + if (kind === 'hajr') g.hajr.push('g'); + assert.deepEqual(viewGame(g, 'g').saphoOptions, []); + reject(g, 'g', action(g, 'first')); + } +}); + +void test('All four AI profiles use the private Sapho first option after a completed movement prefix', () => { + for (const difficulty of DIFFICULTIES) { + let g = movement('g'); + g = applyAction(g, 'a', { + type: 'ship', + territory: 'polar_sink', + sector: 0, + amount: 2, + }); + g = end(g); + const view = viewGame(g, 'g'); + view.players.find((p) => p.id === 'g')!.bot = difficulty; + const untouched = structuredClone(view); + const proposals = botActions(view); + assert.deepEqual(view, untouched); + assert.deepEqual(proposals, [action(g, 'first')]); + for (const id of ['a', 'e']) + assert.deepEqual(viewGame(g, id).saphoOptions, []); + const next = applyAction(reload(g), 'g', proposals[0]); + assert.equal(next.active, 'g'); + assert.deepEqual(next.players[0], g.players[0]); + assert.equal(next.discard.filter((c) => c.id === card).length, 1); + } +}); + void test('Shipment and movement commitments both prevent splitting the active combined turn', () => { let g = movement(); g = applyAction(g, 'a', {