diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx index a231369b..9108fccf 100644 --- a/components/juice-of-sapho.tsx +++ b/components/juice-of-sapho.tsx @@ -17,7 +17,7 @@ function unavailableReason(game: GameView) { return 'Silent bids are simultaneous. These controls do not change their tie order.'; if (game.richeseAuction?.method !== 'onceAround') return 'These bidding controls require a Once Around auction.'; - 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.'; + return 'No order change is available for you in this lot. You must not have bid or passed; discarding this card can open a hand slot, but a completed opportunity cannot be repeated. 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 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.'; diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index e36aaea7..f8763d3e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,28 @@ # Dune implementation status +## 29 September 2026 — Sapho finite-bid boundary and pending source questions + +The existing Once Around Juice of Sapho adapter permits an unacted holder to +move first after another bidder's completed bid. New engine and persisted-room +regressions cover the committed bid, remaining single opportunities, exact +discard, stale/duplicate rejection and private projections. The hand-panel +unavailability explanation and [Sapho contract](JUICE_OF_SAPHO_RUNTIME.md) +now describe this bounded behavior rather than a pre-first-bid restriction. +Thirty focused Sapho cases pass. A separate direct engine run after an earlier +bid reported `canFirst:true`, one completed bidder, the holder next, unchanged +standing bid 1 and one physical discard after JSON reload. A React-rendered +owner-panel fixture inspected in Chromium displayed the corrected guidance; +this is not a live-room UI check. `npm run check` passes typecheck, lint and +5,756/5,756 offline cases; the app build also passes. No engine rule or public +start gate changed. + +The [source follow-up](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) records +that the requested publisher/designer search did not settle Tleilaxu Secret +Ally revival accounting, leader eligibility or partial use; the +[decision index](RULE_DECISIONS.md#pending-interpretations-preserve-existing-questions) +also retains the Moritani Atomics alliance-change ambiguity. Neither receives +an inferred rule or an implementation unlock. + ## 29 September 2026 — administrator action history prototype The existing [Action history](ADMIN_ACTION_HISTORY.md) draft now has a bounded diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 535a1a6b..464596d8 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -12,7 +12,7 @@ pre-plan opportunity; later aggressor intervention remains unfinished. `{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:'onceAround'`: first or last while the holder has not acted and the change reorders a still-unacted bidder. Earlier bids remain committed. 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 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. @@ -27,7 +27,7 @@ The [battle-order follow-up](SAPHO_BATTLE_ORDER.md) connects first/last at a cle boundary between actual battles. It does not implement the separate aggressor mode discussed below. -Later battle aggressor intervention, 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. +Later battle aggressor intervention, normal/Black Market cyclic bidding, other ordered actions and partly completed movement remain unfinished. First/Guild competing priority is 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/JUICE_OF_SAPHO_SOURCE_UPDATE.md b/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md index e780b30e..789fca08 100644 --- a/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md +++ b/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md @@ -33,6 +33,8 @@ The narrow last adapter is expressly supported: move an eligible, not-yet-acted Discard frees a hand slot. Determine eligibility for a previously full holder using the actual post-discard state if the current action remains available; a static initial eligible array should not by itself make that legal use impossible. Conversely do not reopen a settled lot, restore a completed pass, or silently undo other bids. These cases deserve explicit adapter tests. +The connected follow-up now offers first after earlier bids whenever the holder is unacted and not already the next bidder. Earlier bid amounts, funding, completed opportunities and storm tie order remain unchanged; this does not resolve ordinary cyclic auction duration. + ### Ordinary cyclic and normal Black Market bidding The source guarantees an order effect exists for ordered actions, but does not specify the cycle anchor or duration in a cyclic auction. Plausible implementations (one upcoming circuit, whole current lot, or full Bidding Phase) have different winners and opener sequences. None was resolved by this review. diff --git a/docs/NEXUS_TLEILAXU_RULES.md b/docs/NEXUS_TLEILAXU_RULES.md index b3f24c92..8c3795ae 100644 --- a/docs/NEXUS_TLEILAXU_RULES.md +++ b/docs/NEXUS_TLEILAXU_RULES.md @@ -57,3 +57,7 @@ Normal leader revival has an unavailable-roster trigger and a separate repeated- Coordinator question recommendation: ask explicitly about independent versus ordinary force/leader allowance, waived leader prerequisites, and whether the leader portion is optional. The related [Ambassador accounting questions](TLEILAXU_AMBASSADOR_RULES.md) are already pending; do not duplicate those or assume an answer covers differently worded Nexus text. No user question was sent by this audit. Future revival quote inputs should retain source `nexusTleilaxuSecretAlly`, chosen leader/Kwisatz identity, exact normal/starred force counts, payment and existing usage histories. Require a resolved accounting policy before mutating ordinary counters. A receipt must preserve original revival, subsequent Homeworld deployment and phase continuation once; no card/force/spice effect should be inferred afresh from a restored hand or current Tanks. + +### Source-search follow-up + +The requested web search found no publisher/designer answer to the three questions above. The [publisher-authored expansion rulebook, pp. 11, 14–16](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) explains Nexus cards and answers other faction interactions, but does not specify whether this grant is additional to ordinary revival, consumes the ordinary leader slot, or permits using only one part. The printed card wording quoted above establishes quantities and prices, not these accounting or partial-use rules. Search-result summaries that conflate Secret Ally with native Tleilaxu revival or Ghola are not rulings. All three questions remain pending; do not implement an inferred answer. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 70f59c8b..83f04eb1 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -86,7 +86,8 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | 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) | | Occupied Homeworlds | Entitlement/expiry after departure, replacement or contest; unique powers and shared income. The first-occupier/current-occupier question was already sent. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) | -| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and the individual faction source/runtime documents above | +| Nexus responses and composition | Betrayal private-response timing, partial revival/payment choices, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations. Tleilaxu Secret Ally force accounting, leader slot/eligibility and force-only use remain unresolved after requested publisher/designer source searches; do not infer answers from native Tleilaxu revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries), [Tleilaxu source search](NEXUS_TLEILAXU_RULES.md#source-search-follow-up) and individual faction source/runtime documents | +| Moritani Atomics after alliance change | The publisher's persistent −1 hand-limit text names Moritani and “your ally,” but not whether the penalty follows a later ally, remains on the original ally or affects both. Requested online source searches found no clarification; the alliance-change case remains gated. | [Terror source boundary](MORITANI_TERROR_RULES.md#material-gaps-and-ordinary-compositions) | | Orgiz collection | The [publisher rulebook, page 13](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) says an occupant steals one spice of each collected blow, but its Q&A and searched designer/community sources do not define stacked board piles. Choose one transfer per positive collected board deposit as the observable first version, from its unique rival collector; this is an inference, not a publisher ruling. Contested Orgiz occupancy and Ecaz shared lots withhold only the uncertain transfer while ordinary collection continues. | [Discovery interpretation](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | | Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | | Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | diff --git a/tests/juice-of-sapho-engine.test.ts b/tests/juice-of-sapho-engine.test.ts index e1911af0..96eb9713 100644 --- a/tests/juice-of-sapho-engine.test.ts +++ b/tests/juice-of-sapho-engine.test.ts @@ -176,7 +176,29 @@ void test('Once Around last preserves prior bids and permits a final outbid afte assert.deepEqual(g.order, ['a', 'e', 'g']); }); -void test('Once Around rejects late first, completed opportunity, no-op and stale event without card loss', () => { +void test('Once Around first after a prior bid moves only unacted bidders and preserves the committed bid', () => { + let g = auction('g'); + const first = g.richeseAuction!.active!; + g = bid(g, first, 1); + const before = reload(g); + assert.ok(viewGame(g, 'g').saphoOptions.some((o) => o.scope === 'onceAround' && o.mode === 'first')); + g = applyAction(reload(g), 'g', action(g, 'first')); + assert.deepEqual(g.richeseAuction!.acted, [first]); + assert.equal(g.richeseAuction!.bid, 1); + assert.equal(g.richeseAuction!.bidder, first); + assert.deepEqual(g.richeseAuction!.tieOrder, before.richeseAuction!.tieOrder); + assert.equal(g.richeseAuction!.active, 'g'); + assert.equal(g.discard.filter((c) => c.id === card).length, 1); + g = bid(reload(g), 'g', 2); + assert.equal(g.richeseAuction!.active, 'e'); + assert.deepEqual(g.richeseAuction!.acted, [first, 'g']); + const sold = g.richeseAuction!.cardId; + g = bid(g, 'e', 3); + assert.ok(g.players[1].hand.some((c) => c.id === sold)); + assert.equal(g.players[1].spice, 17); +}); + +void test('Once Around rejects no-op first, completed opportunity, no-op last and stale event without card loss', () => { let g = auction(); g = bid(g, g.richeseAuction!.active!, 1); reject(g, 'e', action(g, 'first')); diff --git a/tests/juice-of-sapho-recovery.test.ts b/tests/juice-of-sapho-recovery.test.ts index c9773815..53275164 100644 --- a/tests/juice-of-sapho-recovery.test.ts +++ b/tests/juice-of-sapho-recovery.test.ts @@ -96,7 +96,7 @@ const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} }; const SAPHO = 'richese-juice-of-sapho'; type Scope = 'onceAround' | 'movement'; -async function fixture(scope: Scope, advanced = false) { +async function fixture(scope: Scope, advanced = false, lateFirst = false) { const store = unitStore(); const made = await store.rooms.createRoom( 'First player', @@ -107,7 +107,7 @@ async function fixture(scope: Scope, advanced = false) { const code = made.view.code; const holder = await store.rooms.joinRoom(code, 'Sapho holder', 'emperor'); const guild = await store.rooms.joinRoom(code, 'Guild', 'guild'); - const fourth = advanced + const fourth = advanced || lateFirst ? await store.rooms.joinRoom(code, 'Later player', 'harkonnen') : null; const seats = await Promise.all( @@ -165,7 +165,9 @@ async function fixture(scope: Scope, advanced = false) { 'Roster', 'richese', ).leaders; - initial.order = [seats[2].playerId, seats[1].playerId, seats[0].playerId]; + initial.order = lateFirst + ? [seats[2].playerId, seats[3].playerId, seats[1].playerId, seats[0].playerId] + : [seats[2].playerId, seats[1].playerId, seats[0].playerId]; } const save = (g: engine.Game) => store.sqlite @@ -192,11 +194,9 @@ async function fixture(scope: Scope, advanced = false) { direction: 'clockwise', }); g = await f.restart().readRoom(code); - assert.deepEqual(g.richeseAuction!.order, [ - seats[2].playerId, - seats[1].playerId, - seats[0].playerId, - ]); + assert.deepEqual(g.richeseAuction!.order, lateFirst + ? [seats[3].playerId, seats[2].playerId, seats[1].playerId, seats[0].playerId] + : [seats[2].playerId, seats[1].playerId, seats[0].playerId]); } else if (advanced) { const g = await f.restart().readRoom(code); assert.equal(g.decision?.kind, 'guildTiming'); @@ -398,6 +398,37 @@ void test('Once Around last survives duplicate cardplay and restart without repl } }); +void test('a prior persisted bid survives duplicate Sapho first and the remaining one-bid order', async () => { + const f = await fixture('onceAround', false, true); + try { + let g = await stable(f); + const event = g.richeseAuction!.event; + g = await act(f, 3, bid(g, 2)); + const previous = structuredClone(g.richeseAuction!); + await privacy(f, 'onceAround', event, ['first', 'last']); + g = await duplicate(f, action('onceAround', event, 'first')); + assert.deepEqual(g.richeseAuction!.order, [ + f.seats[3].playerId, f.seats[1].playerId, + f.seats[2].playerId, f.seats[0].playerId, + ]); + assert.equal(g.richeseAuction!.active, f.seats[1].playerId); + for (const key of ['acted', 'bid', 'bidder', 'tieOrder', 'sealed'] as const) + assert.deepEqual(g.richeseAuction![key], previous[key]); + await privacy(f, 'onceAround', event, []); + g = await act(f, 1, bid(g, 3)); + assert.equal(g.richeseAuction!.active, f.seats[2].playerId); + g = await act(f, 2, bid(g, null)); + assert.equal(g.richeseAuction!.active, f.seats[0].playerId); + g = await act(f, 0, bid(g, null)); + assert.equal(g.players[1].spice, 17); + assert.equal(g.discard.filter((card) => card.id === SAPHO).length, 1); + await rejectsWithoutWrite(f, 1, action('onceAround', event, 'first')); + } finally { + f.hooks.beforeWrite = undefined; + f.sqlite.close(); + } +}); + void test('Once Around first persists only within its lot and cannot be replayed after its first actual bid', async () => { const f = await fixture('onceAround'); try {