From cb13a4d6748c3b86d5b472bab8d798b49b45af28 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:28:34 +1100 Subject: [PATCH] Add Sapho first and last for cyclic auction lots Provisionally apply first/last to the current ordinary or native Advanced normal Black Market lot. Preserve bids, funding and passes; skip passed seats until a real raise. Use a local full cycle and actual post-discard hand capacity. Keep the original Black Market opener in both settlement consumers and clear ordinary overrides for the next lot. Connect persistent controls, native disposal and existing minimal AI options. Validation:10-file134/134, types/lint/build and eight original-action/rendered-table continuations. Public setup/cache1, real lobby circle choice, native Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policies are legal. Corrected fixture opener/completion assumptions and external default-history/final-lot assertions; only runtime was rerun against identical source. Bounded consumer review found no concrete defect. Other timing scopes, direct combined Semuta claim/capture, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/reset/restart/deployment operation. --- components/juice-of-sapho.tsx | 13 +- docs/CURRENT_STATUS.md | 29 ++++ docs/IMPLEMENTATION_STATUS.md | 29 ++++ docs/JUICE_OF_SAPHO_RUNTIME.md | 98 +++++++++++- docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md | 8 + docs/RULE_DECISIONS.md | 13 ++ game/auction-continuation-quote.ts | 8 +- game/engine.ts | 138 ++++++++++++++-- game/normal-auction.ts | 2 +- game/reference.ts | 17 +- game/richese-auction.ts | 4 +- game/sapho-cyclic-auction.ts | 77 +++++++++ tests/fixture-sapho-cyclic-auction.ts | 169 ++++++++++++++++++++ tests/sapho-cyclic-auction-engine.test.ts | 161 +++++++++++++++++++ tests/sapho-cyclic-auction.test.ts | 184 ++++++++++++++++++++++ 15 files changed, 920 insertions(+), 30 deletions(-) create mode 100644 game/sapho-cyclic-auction.ts create mode 100644 tests/fixture-sapho-cyclic-auction.ts create mode 100644 tests/sapho-cyclic-auction-engine.test.ts create mode 100644 tests/sapho-cyclic-auction.test.ts diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx index 9108fccf..049f0143 100644 --- a/components/juice-of-sapho.tsx +++ b/components/juice-of-sapho.tsx @@ -15,8 +15,8 @@ function unavailableReason(game: GameView) { if (game.phase === 3) { if (game.richeseAuction?.method === 'silent') 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.'; + if (game.auction || game.richeseAuction?.method === 'normal') + return 'No cyclic order change is available for your pending bid. A passed player waits for a new raise; the current high bidder has no pending bid. Already-held positions and committed cards are unavailable.'; 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) @@ -25,7 +25,7 @@ function unavailableReason(game: GameView) { return game.battle ? 'Become aggressor only during your open pre-plan opportunity, before declaring ready or committing a plan. Already-held priority and committed cards are unavailable. Finish the current battle and its aftermath before changing remaining battle-choice order.' : '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, remaining battle-choice order and pre-plan aggressor. Later battle intervention and other phase or auction modes remain unfinished.'; + return 'These controls support Once Around and cyclic bidding, movement order, remaining battle-choice order and pre-plan aggressor. Later battle intervention and other phase modes remain unfinished.'; } export function JuiceOfSapho({ @@ -58,6 +58,7 @@ export function JuiceOfSapho({ {options.length ? ( options.map((option) => { const once = option.scope === 'onceAround'; + const cyclic = option.scope === 'cyclicAuction'; const battle = option.scope === 'battleOrder'; const aggressor = option.scope === 'battleAggressor'; return ( @@ -68,6 +69,8 @@ export function JuiceOfSapho({

{aggressor ? 'Become this battle’s aggressor before plans. You win ordinary ties; a Habbanya Stronghold advantage still takes precedence. The participants and later battle choices stay unchanged.' + : cyclic + ? `Bid ${option.mode} in the current cyclic lot. Provisional duration: this lot only. Existing bids, funding and passes stay committed. Later circuits use the changed cycle; the next lot returns to its normal opener.` : once ? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.` : battle @@ -82,7 +85,7 @@ export function JuiceOfSapho({ }} > {aggressor ? 'Become aggressor and discard Juice of Sapho' : - `${once ? 'Bid' : battle ? 'Choose battles' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`} + `${once || cyclic ? 'Bid' : battle ? 'Choose battles' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`} ); @@ -93,7 +96,7 @@ export function JuiceOfSapho({

)}

- Later aggressor intervention and other phase or auction modes are still unfinished. + Later aggressor intervention and other phase modes are still unfinished.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f46f980d..7c3ac905 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,35 @@ open mode gates. ## Current checkpoint and work +**Sapho cyclic auctions — 18 October. Prototyped / Partial.** First/last now +works in ordinary cyclic lots and native Advanced normal Black Market under an +explicit provisional current-lot convention. Full local cycles preserve existing +bids, funding and passes; native traversal skips passes until a real raise. +Actual post-discard capacity admits a full holder. Original opener identity stays +with both Black Market settlement consumers; ordinary overrides clear on the +next lot. Persistent controls and existing minimal policies use the same options. + +Two disjoint lanes, bounded source/continuation review and owner integration +pass **10 files134/134**, types/lint/build and eight finite original-action/ +rendered-table continuations. Public host/setup and cache1 purchase use33 Basic/ +38 Advanced source actions; actual next-turn Black Market uses81, including a +real lobby circle5 choice; a full hand from two normal1 purchases uses44. +Continuations use10–18 actions: prior1, actual holder2/rival3/holder4 wrap, native +payment4 and original next opener or final-lot Revival entry. Eight minimal +policy proposals are legal. Original-action passes remain spent until a real +raise; controlled pure-cycle/post-sale quotes are separately labelled. + +Feedback repaired two fixture assumptions: turn2's native opener required the +original lobby circle choice, and Black Market completion must pass its native +cache auction before the following normal pool. The external rendering smoke +was corrected for default history visibility and final-lot phase closure; only +that smoke was rerun against identical source after tests/types/lint/build. +No engine repair followed frozen feedback. Actual offered/busy controls render, +without browser clicks or visual/deployed acceptance. Direct combined Semuta +claim/capture, other timing scopes, full modes and final assurance remain open. +No saved-room/database/reset/restart/deployment operation. +[Source, convention and exact proof](JUICE_OF_SAPHO_RUNTIME.md#cyclic-ordinary-and-black-market-bidding--18-october-2026). + **Guild Cunning / physical Ornithopter — 18 October. Prototyped / Partial.** Either physical card mode may provisionally replace an earned unused Hajr allowance after Cunning's second shipment. No ordinary move is added. Original diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index cdde846f..cd29ae71 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,34 @@ # Dune implementation status +## 18 October 2026 — Sapho current-lot cyclic auctions + +First/last now connects ordinary cyclic lots and native Advanced normal Black +Market under an explicit provisional current-lot convention. Local cycles retain +all seat positions, bids/funding/passes and actual post-discard capacity. Both +advancement loops skip passed seats until a real raise. Native opener identity +survives both Black Market settlement consumers; ordinary overrides clear before +the next lot. Existing minimal policies use the projected choices, with readable +scope/block reasons and compatible old Sapho discard signatures. + +Two disjoint lanes, bounded review and owner integration pass10-file134/134, +types/lint/build and eight original-action/rendered-table continuations. +Public setup/cache1 uses33 Basic/38 Advanced actions; Black Market uses81 with +an actual pre-start circle5 choice; the full hand from two normal1 purchases +uses44. Each10–18-action continuation preserves prior1 and native2/3/4 raises, +one payment4 and original following opener or final-lot Revival. Eight minimal +policy proposals remain legal; an actual pending-pass case requires a real raise +before reentry. Controlled pure quotes stay separately labelled. + +Feedback corrected original Black Market opener/completion fixture assumptions. +Only the external rendering smoke was rerun after repairing default-history and +final-lot assertions against unchanged source; tests/types/lint/build retain +their exact source binding. No engine repair followed frozen feedback. +[Canonical source and exact proof](JUICE_OF_SAPHO_RUNTIME.md#cyclic-ordinary-and-black-market-bidding--18-october-2026). +No browser click, visual/deployed, direct combined Semuta claim/capture, wider +scope or complete-mode acceptance. Final assurance remains due. No saved-room/ +database/reset/restart/deployment operation. + + ## 18 October 2026 — Guild Cunning with the physical Ornithopter Provisional unused-entitlement composition now permits either card mode to diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 464596d8..f460f664 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -1,6 +1,10 @@ # Juice of Sapho: finite ordering implementation -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. +Updated 18 October 2026. **Partial card implementation; full modes and +publication acceptance remain gated.** The earlier source audit remains the +printed-evidence baseline. The current-lot prototype below selects an explicit +provisional cyclic convention under the 14 October choose-recommended direction; +it is not a newly located publisher answer. Other timing questions remain open. The [Battle-phase ordering prototype](SAPHO_BATTLE_ORDER.md) adds first/last remaining battle choices. Its separate chooser, phase priority and saved-boundary @@ -8,6 +12,96 @@ contract supersede the earlier absence of that mode below. The [early aggressor prototype](SAPHO_AGGRESSOR.md) now uses the existing shared pre-plan opportunity; later aggressor intervention remains unfinished. +## Cyclic ordinary and Black Market bidding — 18 October 2026 + +The physical face offers first/last in an ordered action; the publisher's Once +Around FAQ does not determine duration in cyclic bidding. The +[source review](JUICE_OF_SAPHO_SOURCE_UPDATE.md#ordinary-cyclic-and-normal-black-market-bidding) +identifies one upcoming circuit, the current lot and the whole phase as different +possible readings. Provisionally choose **the current lot**, anchored at the +actual next bidder when Sapho is played. This supersedes the old software +exclusion within ordinary normal auctions and native Advanced normal Black +Market. It does not alter Silent bidding, native Black Market eligibility or +extend a choice across the phase. + +A holder with a pending eligible bid can move first/last in the full local cycle. +Keep other seats' relative cyclic order, including high-bid, passed and currently +full placeholders that could become relevant later. The next actor is the first +eligible unpassed seat other than the high bidder. A passed holder waits for a +real raise; a high bidder has no current pending bid to reorder. These are +explicit next-opportunity prototype boundaries, not extra printed restrictions. +Compare full effective cycles for no-op rejection: a last pending bidder may +still move behind a skipped seat that can reenter after a raise. A mere rotation +with the same next actor and effective cycle cannot consume the card. + +Discarding the real card frees a hand slot before determining holder capacity. +A native full-hand Black Market seller retains its existing paid-retention +eligibility through the shared quote. Sapho does not change the physical lot, +standing bid, bidder, allied funding or passed list. Both native advancement +loops skip passes until a genuine raise clears them. Every new bid must still +strictly exceed the standing amount; Sapho supplies no standing-price purchase. + +The ordinary auction retains a per-lot `saphoOrder`; the physical `g.order` and +original `opener` stay unchanged, and the next lot removes the override. A normal +Black Market retains `saphoOriginalOpener` before changing its local cycle, so +both settlement consumers resume the following normal pool from the original +physical source. Existing same-lot unfunded-payment recovery retains the new +cycle without revising its separate provisional payment policy. + +Owner controls expose `scope:'cyclicAuction'` with the server event and first/ +last mode, explain this provisional duration and keep blocked reasons readable. +The existing minimal bot adapter consumes the same options without new strategy +or hidden hand inspection. The optional cyclic tag on the existing Sapho discard +continuation binds its native lot and does not run a bid during disposal. Legacy +Once Around signatures and their normal resume suffix stay unchanged. + +The frozen batch passes **10 files134/134**, types/lint/build and eight finite +original-action/rendered-table continuations from four public setup sources. +Actual cache1 purchase uses33 Basic/38 Advanced source actions; native next-turn +Black Market uses81, including the accepted original Emperor circle5 choice; +a full hand built through two further normal1 purchases uses44. First/last +continuations use10–18 actions: prior1 stays funded, the actual holder raises2, +rival raises3, holder wraps to4, and native purchase pays4 once. Black Market +pays its seller4 and resumes the original next opener; ordinary lots rotate +normally, while the full-hand final lot correctly enters Revival. Eight minimal +policy proposals remain legal. Another original-action case keeps prior passes +through Sapho, rejects the standing-price bid and reopens passes only on a real +raise. Nine controlled pure tests cover full cycles, no-ops, strict funding/ +termination and post-sale opener/override quotes. + +The first batch exposed an original fixture assumption: turn2's native Storm +made the holder already first after its initial raiser. A real pre-start circle +choice supplies the intended independent pending opportunity. Its completion +predicate also had to follow the actual intervening cache-first lot before +asserting the following normal pool. No live order/card/fund/clock injection or +engine repair followed this feedback. Original unopened Spice positions/scalar +lotteries and the explicit lobby circle choice are selected source conditions. + +The external rendered smoke initially assumed default history showed the full +new log and that every completed lot had a next lot. It now checks the actual +accepted event separately, renders offered/busy/passed controls, and permits the +full-hand final lot's native phase close. Only that smoke was rerun after those +external-only repairs; all application source stayed identical to the passing +134/134/types/lint/build checkpoint. The aggregate report records exact child +report provenance rather than claiming those checks were repeated. A bounded +read-only source/consumer review found no concrete implementation defect. + +Actual GameTable and isolated offered controls render with enabled and busy +states. No browser click, visual or deployed acceptance is claimed. Direct +combined Semuta claim/capture, other ordered phases, first-versus-Guild, partly +completed movement, later aggressor intervention, full modes and final assurance +remain open. No saved-room/database/reset/restart/deployment operation. + +Private artifacts: `/home/quintine/.local/share/dune/checkpoints/20261018-sapho-cyclic/`. +`frozen-integrated-final/report.json` combines passing tests/types/lint/build +from `frozen-integrated-2` with `runtime-repaired-2`, all at base `561152d` and tree +`506a6b698ff5c791a1f05a0700450dc6d13837516ecdef6a8f9d3d5eee752bd3`. +`runtime-smoke.json` and private rendered HTML retain actual source/continuation +counts and controls. Final checkpoint prose/reference evidence is a documented +post-proof overlay; `documentation-overlay.json` and `git-result.json` bind that +reviewed overlay and pushed revision. Relevant helpers, engine, controls and +tests stay identical to the frozen proof. + ## 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: @@ -27,7 +121,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. 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. +Later battle aggressor intervention, other ordered actions and partly completed movement remain unfinished. The current-lot cyclic prototype above supersedes the earlier normal/Black Market absence claim. 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 789fca08..1e3be801 100644 --- a/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md +++ b/docs/JUICE_OF_SAPHO_SOURCE_UPDATE.md @@ -37,6 +37,14 @@ The connected follow-up now offers first after earlier bids whenever the holder ### Ordinary cyclic and normal Black Market bidding +**18 October implementation follow-up:** the standing choose-recommended +instruction selects an explicit provisional current-lot cycle, anchored at the +live next bidder. It preserves funding/passes, original opener identity and +native strict-raise/termination. The following source uncertainty remains; +this is not a publisher answer or a user numerical ruling. +[Runtime contract](JUICE_OF_SAPHO_RUNTIME.md#cyclic-ordinary-and-black-market-bidding--18-october-2026). + + 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. Before enabling any such adapter, specify its selected scope and test that it preserves ordinary raise/pass termination. A last bidder is not entitled to buy at the standing price: every acquisition still requires a legal bid and the ordinary closing condition. Simply moving the holder to the end of an array while advancing from a numeric cursor can skip or repeat seats; immediately changing `active` can deprive another seat of an existing opportunity. A first/last array position without a declared cycle anchor has no stable meaning once bidding wraps. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 4c853dae..777fdd1c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,18 @@ # Rule decision index +## Provisional Sapho cyclic lot — 18 October 2026 + +The printed first/last ordered-action effect does not specify cyclic duration. +Under the 14 October choose-recommended direction, use the current ordinary or +native Advanced normal Black Market lot, anchored at the live next bidder. +Keep the full local cycle, standing bid/funding/passes, strict raises and native +next-lot opener. A pending eligible holder may reorder; passed/high-bid holders +wait for another genuine opportunity. Full effective cycle comparison rejects +no-ops. This is a first-version composition, not publisher clarification or a +new human timing answer. Other ordered phases/timing remain open. +[Source and runtime contract](JUICE_OF_SAPHO_RUNTIME.md#cyclic-ordinary-and-black-market-bidding--18-october-2026). + + ## Guild Cunning and physical Ornithopter — 18 October 2026 The printed Nexus grants a second shipment with Hajr required for later movement. diff --git a/game/auction-continuation-quote.ts b/game/auction-continuation-quote.ts index 7a545958..3ddd5054 100644 --- a/game/auction-continuation-quote.ts +++ b/game/auction-continuation-quote.ts @@ -257,6 +257,8 @@ function calculate( ids(lot.order) && lot.order.length > 0 && lot.order.every((id) => input.order.includes(id)) && + (lot.saphoOriginalOpener === undefined || + text(lot.saphoOriginalOpener) && lot.order.includes(lot.saphoOriginalOpener)) && lot.order.includes(s.winner) && ['normal', 'onceAround', 'silent'].includes(lot.method) && !(lot.source === 'cache' && lot.method === 'normal'), @@ -431,7 +433,7 @@ function calculate( ...(lot.method === 'normal' ? { opener: - (input.order.indexOf(lot.order[0]) + 1) % input.order.length, + (input.order.indexOf(lot.saphoOriginalOpener ?? lot.order[0]) + 1) % input.order.length, } : {}), }); @@ -481,10 +483,12 @@ function calculate( : atreides ? { kind: 'atreidesAuction', owner: atreides.id, passed: [] } : null; + const nextAuction = structuredClone(a); + delete nextAuction.saphoOrder; return result({ kind: 'normalLot', auction: { - ...structuredClone(a), + ...nextAuction, index, bid: 0, allyPayment: 0, diff --git a/game/engine.ts b/game/engine.ts index 5922b64e..e33f6ffa 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -261,6 +261,7 @@ import { quoteNormalAuctionBid, quoteNormalAuctionNext, } from './normal-auction'; +import { reorderSaphoCyclicAuction, SaphoCyclicAuctionError } from './sapho-cyclic-auction'; import { quoteNormalAuctionPeek, quoteIxTechnologyCancellation, @@ -828,6 +829,8 @@ export type Battle = { voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean }; }; export type Auction = { + /** Sapho changes this lot's cycle, never physical order or the next opener. */ + saphoOrder?: string[]; peekKnown?: boolean; occupiedEvent?: string; cards: Card[]; @@ -1395,6 +1398,7 @@ export type Game = { } | { kind: 'saphoAuctionDiscard'; + cyclic?: 'normal' | 'blackMarket'; player: string; card: string; event: string; @@ -5441,6 +5445,7 @@ type SaphoAuctionDiscardContinuation = Extract< function saphoAuctionDiscardSignature(g: Game, c: SaphoAuctionDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, event: c.event, mode: c.mode, + ...(c.cyclic === undefined ? {} : { cyclic: c.cyclic, auction: g.auction }), parent: nullentropyParentSignature(g, c.resume, c.event), turn: g.turn, phase: g.phase, active: g.active, order: g.order, lot: g.richeseAuction, bidding: g.richeseBidding, funding: g.richeseFunding, @@ -6456,21 +6461,27 @@ function treacheryDiscardIntegrity(g: Game) { } else if (continuation?.kind === 'saphoAuctionDiscard') { const c = continuation, entry = batch.entries[0], lot = g.richeseAuction; const remaining = lot?.order.filter(id => !lot.acted.includes(id)); + const cyclic = c.cyclic === undefined ? null : saphoCyclicContext(g); requireRule( - g.phase === 3 && lot?.method === 'onceAround' && + g.phase === 3 && (c.cyclic === undefined ? lot?.method === 'onceAround' && lot.event === c.event && !lot.outcome && lot.eligible.includes(c.player) && !lot.acted.includes(c.player) && (c.mode === 'first' ? remaining?.[0] === c.player && lot.active === c.player : c.mode === 'last' && remaining?.at(-1) === c.player) && - g.active === lot.active && batch.cause === 'sapho:auction' && + g.active === lot.active : cyclic && cyclic.source === c.cyclic && + cyclic.event === c.event && cyclic.eligible.includes(c.player) && + !cyclic.passed.includes(c.player) && cyclic.bidder !== c.player && + (c.mode === 'first' ? cyclic.order[0] === c.player && cyclic.active === c.player + : c.mode === 'last' && cyclic.order.at(-1) === c.player) && + g.active === cyclic.active) && batch.cause === 'sapho:auction' && batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && !c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && c.stateSignature === saphoAuctionDiscardSignature(g, c), - 'The used Juice of Sapho no longer matches its committed Once Around order.', + 'The used Juice of Sapho no longer matches its committed auction order.', ); } else if (continuation?.kind === 'saphoBattleOrderDiscard') { const c = continuation, entry = batch.entries[0], order = g.battleOrder; @@ -7673,7 +7684,7 @@ function semutaOfferSupported( continuation.use.response.kind === 'emperorIncome' ? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal' ? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction) - : !g.auction)) && + : (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) && (bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || continuation.kind === 'ixAllyCard' || (continuation.kind === 'karamaIncomeDiscard' && @@ -7913,7 +7924,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; - settleRicheseLot(g); + if (next.cyclic === undefined) settleRicheseLot(g); } else if (next.kind === 'saphoBattleOrderDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -12355,7 +12366,7 @@ function finishRicheseLot(g: Game) { richeseDeclarationCache(g); round.blackMarketSold = lot.outcome?.kind === 'sold'; if (lot.method === 'normal') { - const opener = lot.order[0]; + const opener = lot.saphoOriginalOpener ?? lot.order[0]; round.opener = (g.order.indexOf(opener) + 1) % g.order.length; } } @@ -13593,7 +13604,7 @@ function auctionNext(g: Game) { return p.hand.length < handLimit(p); }); const next = quoteNormalAuctionNext({ - order: g.order, + order: a.saphoOrder ?? g.order, eligible, active: a.active, bid: a.bid, @@ -15464,12 +15475,18 @@ function recoverAuctionPayment(g: Game, p: Player) { a.bidder = null; a.allyPayment = 0; a.passed = []; - while ( - getPlayer(g, g.order[a.opener]).hand.length >= - handLimit(getPlayer(g, g.order[a.opener])) - ) - a.opener = (a.opener + 1) % g.order.length; - a.active = g.order[a.opener]; + if (a.saphoOrder) { + const first = a.saphoOrder.find(id => getPlayer(g,id).hand.length < handLimit(getPlayer(g,id))); + requireRule(first, 'The original Sapho lot has no eligible bidder to resume.'); + a.active = first; + } else { + while ( + getPlayer(g, g.order[a.opener]).hand.length >= + handLimit(getPlayer(g, g.order[a.opener])) + ) + a.opener = (a.opener + 1) % g.order.length; + a.active = g.order[a.opener]; + } g.active = a.active; log( g, @@ -19328,10 +19345,58 @@ function offerMoritaniDuke(g: Game) { return true; } type SaphoOption = { - scope: 'onceAround' | 'movement' | 'battleOrder'; + scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder'; event: string; mode: 'first' | 'last'; } | { scope: 'battleAggressor'; event: string; mode: 'aggressor' }; +function saphoCyclicRule(quote: () => T): T { + try { return quote(); } + catch (error) { + if (error instanceof SaphoCyclicAuctionError) throw new RuleError(error.message); + throw error; + } +} +function saphoCyclicIntegrity(g: Game) { + const cycle = g.auction?.saphoOrder; + if (cycle !== undefined) requireRule(g.phase === 3 && Array.isArray(cycle) && + cycle.length === g.order.length && new Set(cycle).size === cycle.length && + cycle.every(id => g.order.includes(id)), + 'The current lot has lost its original Sapho bidder cycle.'); + const lot = g.richeseAuction; + if (lot?.saphoOriginalOpener !== undefined) requireRule(g.phase === 3 && + lot.source === 'blackMarket' && lot.method === 'normal' && + g.order.includes(lot.saphoOriginalOpener) && lot.order.includes(lot.saphoOriginalOpener), + 'The Black Market has lost its original opener after Sapho.'); +} +/** A concrete current lot only. Post-discard eligibility can admit a full hand; + * neither a past pass nor the standing high bid creates another pending bid. */ +function saphoCyclicContext(g: Game, spending?: Player) { + if (g.status !== 'playing' || g.phase !== 3 || g.currentAuctionSale) return null; + saphoCyclicIntegrity(g); + const a = g.auction, lot = g.richeseAuction; + if (a && !lot && a.cards[a.index] && g.active === a.active) { + const order = a.saphoOrder ?? g.order; + return {source:'normal' as const, event:JSON.stringify(['saphoCyclic',g.turn,'normal',a.index]), + order, active:a.active, bidder:a.bidder, passed:a.passed, + eligible:order.filter(id => { + const player = getPlayer(g,id); + return player.hand.length - Number(spending?.id === id) < handLimit(player); + })}; + } + if (lot?.source === 'blackMarket' && lot.method === 'normal' && !lot.outcome && + lot.active && g.active === lot.active) { + const order = spending && !lot.order.includes(spending.id) ? [...lot.order,spending.id] : lot.order; + return {source:'blackMarket' as const, event:JSON.stringify(['saphoCyclic',g.turn,'blackMarket',lot.event]), + order, active:lot.active, bidder:lot.bidder, passed:lot.passed, + eligible:order.filter(id => { + const player = getPlayer(g,id); + return (lot.eligible.includes(id) || spending?.id === id) && + richeseBidFitsHand(lot.source,lot.owner,id, + player.hand.length - Number(spending?.id === id),handLimit(player)); + })}; + } + return null; +} function saphoMovementIntegrity(g: Game) { const last = g.saphoMovementLast; if (last === undefined || last === null) return; @@ -19433,6 +19498,15 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] { ) return []; const options: SaphoOption[] = []; + const cyclic = saphoCyclicContext(g,p); + if (cyclic) for (const mode of ['first','last'] as const) { + try { + reorderSaphoCyclicAuction(cyclic,{event:cyclic.event,player:p.id,mode}); + options.push({scope:'cyclicAuction',event:cyclic.event,mode}); + } catch (error) { + if (!(error instanceof SaphoCyclicAuctionError)) throw error; + } + } const battle = g.battle; if (g.phase === 6 && battle?.event && battle.preLeader?.event === battle.event && !battle.preLeader.closed && !battle.preLeader.ready.includes(p.id) && @@ -19525,6 +19599,41 @@ function playSapho(g: Game, p: Player, action: Action) { stageTreacheryDiscard(g, 'sapho:aggressor', entries, continuation); } } + } else if (option.scope === 'cyclicAuction') { + const context = saphoCyclicContext(g,p); + requireRule(context, 'The cyclic Sapho auction is no longer open.'); + const next = saphoCyclicRule(() => reorderSaphoCyclicAuction(context, + {event:option.event,player:p.id,mode:option.mode})); + const used = discard(g,p,'richese-juice-of-sapho'); + if (context.source === 'normal') { + g.auction!.saphoOrder = next.order; + g.auction!.active = next.active; + } else { + const lot = g.richeseAuction!; + lot.saphoOriginalOpener ??= lot.order[0]; + if (!lot.eligible.includes(p.id)) { + lot.eligible.push(p.id); + lot.tieOrder = g.order.filter(id => lot.eligible.includes(id)); + } + lot.order = next.order; + lot.active = next.active; + } + g.active = next.active; + log(g, `${p.name} discarded Juice of Sapho to bid ${option.mode} in this cyclic auction. Provisional scope: the current lot only. Existing bids, funding and passes remain committed; every purchase still requires a raised bid.`, + {faction:p.faction,name:'Juice of Sapho'}); + if (g.semutaPreview) { + const continuation: SaphoAuctionDiscardContinuation = { + kind:'saphoAuctionDiscard',cyclic:context.source,player:p.id,card:used.id, + event:option.event,mode:option.mode, + resume:{response:g.response,decision:g.decision,pendingKarama:g.pendingKarama,phaseOpening:g.phaseOpening}, + stateSignature:'', + }; + const entries = [{card:used,discardedBy:p.id,publicFace:true}]; + if (semutaOfferSupported(g,continuation,entries)) { + continuation.stateSignature = saphoAuctionDiscardSignature(g,continuation); + stageTreacheryDiscard(g,'sapho:auction',entries,continuation); + } + } } else if (option.scope === 'onceAround') { const lot = g.richeseAuction!; // Discarding this card frees a slot even when a full hand excluded its holder @@ -22012,6 +22121,7 @@ function atomicsIntegrity(g: Game) { 'Atomics Aftermath has lost its revealed token, original targets or hand-limit custody.'); } function marketGholaIntegrity(g: Game) { + saphoCyclicIntegrity(g); battleOrderIntegrity(g); saphoAggressorIntegrity(g); nexusCardsIntegrity(g); diff --git a/game/normal-auction.ts b/game/normal-auction.ts index 4e3a8b04..b282a8c3 100644 --- a/game/normal-auction.ts +++ b/game/normal-auction.ts @@ -30,7 +30,7 @@ export function quoteNormalAuctionNext(context: { for (let offset = 1; offset <= context.order.length; offset++) { const index = (Math.max(activeIndex, -1) + offset) % context.order.length; const player = context.order[index]; - if (eligible.has(player) && player !== context.bidder) + if (eligible.has(player) && player !== context.bidder && !context.passed.includes(player)) return { kind: 'bid', player }; } diff --git a/game/reference.ts b/game/reference.ts index aca266c0..2f2ee9ed 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -494,14 +494,15 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Choose an available order change or become battle aggressor before plans, then discard Sapho. Later intervention and other timing modes remain unfinished.', steps: [ 'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.', - '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.', + 'In ordinary cyclic bidding or native Advanced normal Black Market, provisional first/last changes the current lot only, anchored at the live next bidder. Later circuits use that local cycle. Keep standing bids, allied funding and passes; a passed holder waits for a real raise, and the high bidder has no pending bid to reorder. Every purchase still requires a strict raise. Full effective-cycle no-ops cannot consume the card. The next lot uses its normal physical opener.', + 'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.', 'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, the Guild must have finished or be absent. Last stays after the Guild even if the Guild later chooses to wait.', 'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.', 'During your open pre-plan preparation, use Sapho to become the current battle’s aggressor before declaring ready. You win ordinary ties, while the Habbanya Stronghold advantage still takes precedence. Physical participants, plans and later battle-choice order do not swap. The accepted priority survives refresh.', 'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.', 'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.', 'In the explicitly opted-in Semuta preview, clean Once Around and movement-order discards pause after their bidder/turn queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; a clean pre-plan aggressor discard pauses after changing ordinary tie priority. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unsealed battle then continues once.', - 'These are the supported development controls, not additional printed restrictions. Later aggressor intervention, 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.', + 'These are the supported development controls, not additional printed restrictions. Cyclic lot duration and next-opportunity eligibility are explicit provisional conventions. Later aggressor intervention, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.', ], example: 'Atreides bids 2. Before taking its bid, the Emperor uses Sapho to go last. Richese bids 3, then the Emperor must bid at least 4 to win. Atreides does not receive another bid.', @@ -516,13 +517,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - '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.', + 'Finite Once Around, provisional current-lot cyclic ordinary/normal Black Market, clean combined-turn and remaining Battle-phase ordering. Local cycles preserve passes/funding; both native consumers retain the original following opener. Authoritative chooser/participant separation, full-hand eligibility and last-over-Guild protection remain. Other timing modes stay unfinished.', }, { area: 'Player controls', status: 'Partial', detail: - 'Owner-only first/last and pre-plan aggressor actions, public aggressor/tie priority and remaining battle order, owner-relative targets, readable inspector and timing explanations. Later aggressor and cyclic-auction controls remain unfinished.', + 'Owner-only first/last and pre-plan aggressor actions, public aggressor/tie priority and remaining battle order, owner-relative targets, readable inspector and timing explanations. Cyclic first/last explains current-lot duration and pending-bid limits; later aggressor controls remain unfinished.', }, { area: 'AI', @@ -540,8 +541,14 @@ 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, Guild deferral and battle scheduling through actual outcomes. Full expansion compliance remains unverified.', + '18 October cyclic scope:10-file134/134, types/lint/build and eight original-action/rendered-table continuations pass. Actual cache1, Basic/Advanced ordinary bidding, native Advanced Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policy proposals are legal; actual passes need a real raise. Controlled pure-cycle/post-sale quotes are separate. Fixture opener/completion and external smoke assumptions were repaired; only runtime was rerun against identical source after types/lint/build. No browser click, visual/deployed, direct combined Semuta claim/capture, full-mode or final-assurance acceptance.', evidence: [ + 'game/sapho-cyclic-auction.ts', + 'components/juice-of-sapho.tsx', + 'tests/sapho-cyclic-auction.test.ts', + 'tests/fixture-sapho-cyclic-auction.ts', + 'tests/sapho-cyclic-auction-engine.test.ts', + 'docs/JUICE_OF_SAPHO_RUNTIME.md', 'tests/ordered-opportunity.test.ts', 'tests/juice-of-sapho-engine.test.ts', 'tests/juice-of-sapho-engine-review.test.ts', diff --git a/game/richese-auction.ts b/game/richese-auction.ts index 764821bd..6c53c67d 100644 --- a/game/richese-auction.ts +++ b/game/richese-auction.ts @@ -13,6 +13,8 @@ export type RicheseAuction = { method: RicheseAuctionMethod; eligible: string[]; order: string[]; + /** Original native normal-lot opener; Sapho changes this lot's cycle only. */ + saphoOriginalOpener?: string; tieOrder: string[]; active: string | null; bid: number; @@ -215,7 +217,7 @@ export function submitRicheseBid( const at = state.order.indexOf(input.actor); for (let distance = 1; distance <= state.order.length; distance++) { const next = state.order[(at + distance) % state.order.length]; - if (next !== state.bidder) { + if (state.eligible.includes(next) && next !== state.bidder && !state.passed.includes(next)) { state.active = next; break; } diff --git a/game/sapho-cyclic-auction.ts b/game/sapho-cyclic-auction.ts new file mode 100644 index 00000000..fa511e77 --- /dev/null +++ b/game/sapho-cyclic-auction.ts @@ -0,0 +1,77 @@ +export type SaphoCyclicAuctionContext = { + event: string; + order: readonly string[]; + eligible: readonly string[]; + active: string; + bidder: string | null; + passed: readonly string[]; +}; +export type SaphoCyclicAuctionRequest = { + event: string; + player: string; + mode: 'first' | 'last'; +}; +export class SaphoCyclicAuctionError extends Error { + constructor(message: string) { + super(message); + this.name = 'SaphoCyclicAuctionError'; + } +} +function requireAuction(value: unknown, message: string): asserts value { + if (!value) throw new SaphoCyclicAuctionError(message); +} +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +const ids = (value: unknown): value is readonly string[] => + Array.isArray(value) && value.every(identifier) && new Set(value).size === value.length; +function rotate(order: readonly string[], first: string): string[] { + const at = order.indexOf(first); + return [...order.slice(at), ...order.slice(0, at)]; +} + +/** Current-lot/next-opportunity composition, not a publisher timing ruling. + * Keep the complete local cycle: a passed, full-hand or high-bid placeholder + * may matter after a later raise. Eligibility and card custody belong to the + * engine; this quote changes neither bids nor the physical storm-order opener. */ +export function reorderSaphoCyclicAuction( + context: SaphoCyclicAuctionContext, + request: SaphoCyclicAuctionRequest, +): { order: string[]; active: string } { + requireAuction(context && typeof context === 'object', 'Choose a valid current auction lot.'); + requireAuction(identifier(context.event), 'The auction requires its current lot event.'); + requireAuction( + ids(context.order) && context.order.length > 0 && ids(context.eligible) && ids(context.passed) && + context.eligible.every(player => context.order.includes(player)) && + context.passed.every(player => context.order.includes(player)) && + (context.bidder === null || identifier(context.bidder) && context.order.includes(context.bidder)) && + (context.bidder === null || !context.passed.includes(context.bidder)), + 'Auction order, eligible seats, passes and high bidder must form a valid unique lot roster.', + ); + const pending = (player: string) => context.eligible.includes(player) && + !context.passed.includes(player) && player !== context.bidder; + requireAuction( + identifier(context.active) && pending(context.active), + 'The current auction actor must have a pending eligible bid opportunity.', + ); + requireAuction( + request && typeof request === 'object' && request.event === context.event, + 'This auction lot event has expired.', + ); + requireAuction(request.mode === 'first' || request.mode === 'last', 'Choose first or last.'); + requireAuction( + identifier(request.player) && pending(request.player), + 'Only an eligible unpassed player other than the current high bidder can change position.', + ); + const original = rotate(context.order, context.active); + const others = original.filter(player => player !== request.player); + const order = request.mode === 'first' ? [request.player, ...others] : [...others, request.player]; + const active = order.find(pending)!; + // Compare complete effective cycles, including their next active actor. + // Merely rotating a single pending actor around skipped placeholders is a + // no-op, while moving behind a placeholder that can reenter is meaningful. + requireAuction( + JSON.stringify(original) !== JSON.stringify(rotate(order, active)), + 'This player is already in that effective auction position.', + ); + return { order, active }; +} diff --git a/tests/fixture-sapho-cyclic-auction.ts b/tests/fixture-sapho-cyclic-auction.ts new file mode 100644 index 00000000..3ceee294 --- /dev/null +++ b/tests/fixture-sapho-cyclic-auction.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { richeseCards } from '../game/richese-cards'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +const canonicalCard = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(canonicalCard); +export const CYCLIC_SAPHO = canonicalCard.id; +export type SaphoCyclicStep = { actor: string; action: Action }; +export const saphoCyclicPlayer = (game: Game, actor = 'emperor') => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const saphoCyclicClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard && !game.currentAuctionSale; +export function actSaphoCyclic(game: Game, actor: string, action: Action, actions: SaphoCyclicStep[] = []): Game { + const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action)); + actions.push({ actor, action: structuredClone(action) }); return next; +} +export function nextSaphoCyclicStep(game: Game, fillHand = false): SaphoCyclicStep | null { + if (!game.response && !game.phaseOpening) { + if (game.decision?.kind === 'richeseCache' && game.richeseCache?.some(card => card.id === CYCLIC_SAPHO)) + return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, + card: CYCLIC_SAPHO, method: 'onceAround', direction: 'clockwise' } }; + if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player, + action: { type: 'decision', event: game.richeseBidding!.event, keep: false } }; + } + if (saphoCyclicClean(game) && game.richeseAuction?.cardId === CYCLIC_SAPHO && + !game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0) + return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } }; + if (fillHand && saphoCyclicClean(game) && game.auction?.active === 'emperor' && game.auction.bid === 0 && + saphoCyclicPlayer(game).hand.length < 4) + return { actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 } }; + return nextRicheseStrongholdsNativeStep(game); +} +export function advanceSaphoCyclic(game: Game, until: (state: Game) => boolean, + actions: SaphoCyclicStep[] = [], fillHand = false): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Sapho cyclic programme ended before its next owned lot'); + const step = nextSaphoCyclicStep(game, fillHand); assert.ok(step, 'Complete the original private plan before continuing'); + game = actSaphoCyclic(game, step.actor, step.action, actions); + } + throw Error('The original Sapho cyclic programme did not reach its finite boundary'); +} +function orderLand(game: Game) { + const front: Game['spiceDeck'] = []; + for (let count = 0; count < (game.advanced ? 2 : 1); count++) { + const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0); + front.push(game.spiceDeck.splice(at, 1)[0]); + } + game.spiceDeck.unshift(...front); +} + +/** Original public host/setup and native cache1 purchase. Full-hand source + * buys two further real normal lots; Black Market keeps the bought Sapho until + * the next turn and offers Richese's genuinely dealt card using normal bidding. + * Its holder chooses a vacant printed circle before readiness, leaving another + * bidder before and after it at the original second-turn Storm boundary. + * Only original scalar lotteries and unopened Spice positions are selected. */ +export function createSaphoCyclicAuctionFixture(options: { + advanced?: boolean; blackMarket?: boolean; fullHand?: boolean; +} = {}) { + const advanced = options.advanced ?? false; + assert.ok(!options.blackMarket || advanced, 'Native Black Market is Advanced-only'); + assert.ok(!options.blackMarket || !options.fullHand); + let game = createGame('SAPHOCYCLICAUCTION', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); + for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const actions: SaphoCyclicStep[] = []; + const act = (actor: string, action: Action) => { game = actSaphoCyclic(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSaphoCyclic(game, until, actions, options.fullHand); }; + if (options.blackMarket) act('emperor', { type: 'seatPosition', position: 5 }); + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + orderLand(game); + reach(state => saphoCyclicPlayer(state).hand.some(card => card.id === CYCLIC_SAPHO)); + const acquired = structuredClone(game); + let beforeBlackMarket: Game | null = null, blackMarketStep: SaphoCyclicStep | null = null; + if (options.blackMarket) { + reach(state => state.turn === 2 && state.phase === 1 && saphoCyclicClean(state)); + orderLand(game); + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseBlackMarket'); + const offered = saphoCyclicPlayer(game, 'richese').hand[0]; assert.ok(offered); + assert.ok(saphoCyclicPlayer(afterSetup, 'richese').hand.some(card => card.id === offered.id), + 'The native seller offers its actual original starting card'); + beforeBlackMarket = structuredClone(game); + blackMarketStep = { actor: 'richese', action: { type: 'decision', event: game.richeseBidding!.event, + card: offered.id, method: 'normal' } }; + act(blackMarketStep.actor, blackMarketStep.action); + reach(state => state.richeseAuction?.method === 'normal' && !state.richeseAuction.outcome && saphoCyclicClean(state)); + } else reach(state => !!state.auction && saphoCyclicClean(state) && + (!options.fullHand || saphoCyclicPlayer(state).hand.length === 4)); + const lot = saphoCyclicLot(game); + return { game, lobby, setup, afterSetup, acquired, beforeBlackMarket, blackMarketStep, + owner: 'emperor', challenger: 'atreides', seller: 'richese', card: CYCLIC_SAPHO, target: lot.card, + kind: lot.kind, originalOpener: lot.opener, fullHand: options.fullHand ?? false, + sourceActionCount: actions.length, actions, + staging: ['Native host/setup and real cache1 purchase supply Sapho. Optional full-hand holdings are two actual normal1 purchases; Advanced Black Market uses an accepted Emperor circle5 lobby choice and offers the original seller-held card next turn. The native second Storm leaves meaningful first/last choices after a funded prior raise. Only unopened Spice positions/scalar lotteries are selected, with no postdeal custody, funds, phase, order or grant installed.'] }; +} +export type SaphoCyclicAuctionFixture = ReturnType; + +/** Current public cyclic mechanics plus exact private source identity for the + * original owner fixture; normal and Black Market retain their native engines. */ +export function saphoCyclicLot(game: Game) { + const black = game.richeseAuction; + if (black?.method === 'normal') return { kind: 'blackMarket' as const, card: black.cardId, + active: black.active!, bid: black.bid, bidder: black.bidder, passed: [...black.passed], + opener: game.order.indexOf(black.order[0]), event: black.event, funding: structuredClone(game.richeseFunding) }; + const normal = game.auction; assert.ok(normal, 'An original cyclic lot must be open'); + return { kind: 'normal' as const, card: normal.cards[normal.index].id, active: normal.active, + bid: normal.bid, bidder: normal.bidder, passed: [...normal.passed], opener: normal.opener, + event: null, funding: normal.allyPayment ?? 0 }; +} +export function saphoCyclicBid(game: Game, amount: number | null): SaphoCyclicStep { + const lot = saphoCyclicLot(game); + return { actor: lot.active, action: lot.kind === 'blackMarket' + ? { type: 'richeseBid', event: lot.event!, amount, allyPayment: 0 } + : amount === null ? { type: 'passBid' } : { type: 'bid', amount, allyPayment: 0 } }; +} +export function saphoCyclicAction(game: Game, mode: 'first' | 'last'): Action { + const option = viewGame(game, 'emperor').saphoOptions.find(option => option.scope === 'cyclicAuction' && option.mode === mode); + assert.ok(option, `The actual owned cyclic lot needs a meaningful Sapho-${mode} option`); + return { type: 'card', card: CYCLIC_SAPHO, ...option }; +} + +/** A funded prior raise survives Sapho. The holder raises2, a genuine rival + * raises3 and the holder gets a normal cyclic wrap to raise4. Native payment + * and the next original lot/control then complete without another Sapho use. */ +export function completeSaphoCyclicAuction(fixture: SaphoCyclicAuctionFixture, mode: 'first' | 'last') { + let game = structuredClone(fixture.game); + const actions: SaphoCyclicStep[] = [], bids: { before: Game; step: SaphoCyclicStep; after: Game }[] = []; + const bid = (amount: number | null) => { + const before = structuredClone(game), step = saphoCyclicBid(game, amount); + game = actSaphoCyclic(game, step.actor, step.action, actions); + bids.push({ before, step, after: structuredClone(game) }); + }; + bid(1); + const beforeSapho = structuredClone(game), action = saphoCyclicAction(game, mode); + game = actSaphoCyclic(game, fixture.owner, action, actions); + const ordered = structuredClone(game); + let firstRaise = false, rivalRaise = false, lastRaise = false; + for (let count = 0; count < 240; count++) { + // A Black Market delivery precedes the original declaration/cache-first + // programme. Its clean cache-bidding boundary is not the following normal + // pool whose opener this programme must observe. + const followingNormal = fixture.kind !== 'blackMarket' || + !!game.auction && !game.richeseAuction && game.richeseBidding?.stage === 'normal'; + if (followingNormal && saphoCyclicPlayer(game).hand.some(card => card.id === fixture.target) && saphoCyclicClean(game)) { + assert.ok(firstRaise && rivalRaise && lastRaise, 'Original cyclic bidding must include the rival and holder wrap'); + return { game, beforeSapho, action, ordered, bids, actions, mode }; + } + const live = game.auction?.cards[game.auction.index]?.id === fixture.target || + game.richeseAuction?.cardId === fixture.target && !game.richeseAuction.outcome; + if (live && saphoCyclicClean(game)) { + const lot = saphoCyclicLot(game); + if (!firstRaise && lot.active === fixture.owner) { bid(2); firstRaise = true; } + else if (firstRaise && !rivalRaise && lot.active === fixture.challenger) { bid(3); rivalRaise = true; } + else if (rivalRaise && !lastRaise && lot.active === fixture.owner) { bid(4); lastRaise = true; } + else bid(null); + } else { + const step = nextSaphoCyclicStep(game); assert.ok(step, 'Finish the real purchase continuation before its next lot'); + game = actSaphoCyclic(game, step.actor, step.action, actions); + } + } + throw Error('The genuine Sapho cyclic lot did not settle its finite raise/pass programme'); +} diff --git a/tests/sapho-cyclic-auction-engine.test.ts b/tests/sapho-cyclic-auction-engine.test.ts new file mode 100644 index 00000000..c6e9ff58 --- /dev/null +++ b/tests/sapho-cyclic-auction-engine.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { + actSaphoCyclic, completeSaphoCyclicAuction, createSaphoCyclicAuctionFixture, saphoCyclicAction, + saphoCyclicBid, saphoCyclicLot, saphoCyclicPlayer as player, type SaphoCyclicAuctionFixture, +} from './fixture-sapho-cyclic-auction'; + +function source(f: SaphoCyclicAuctionFixture) { + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.ok(player(f.acquired).hand.some(card => card.id === f.card)); + assert.equal(f.sourceActionCount, f.actions.length); + assert.equal(saphoCyclicLot(f.game).bid, 0); + assert.equal(player(f.game).hand.some(card => card.id === f.card), true); + if (f.kind === 'blackMarket') { + assert.equal(f.game.advanced, true); + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'seatPosition' && step.action.position === 5)); + assert.equal(f.lobby.playerPositions![f.owner], 5, + 'The actual pre-start circle choice leaves genuine forward first/last options after the native second Storm'); + assert.equal(f.blackMarketStep!.action.method, 'normal'); + assert.ok(player(f.afterSetup, f.seller).hand.some(card => card.id === f.target)); + assert.ok(player(f.game, f.seller).hand.some(card => card.id === f.target)); + } +} +function finished(f: SaphoCyclicAuctionFixture, result: ReturnType) { + const before = saphoCyclicLot(result.beforeSapho), ordered = saphoCyclicLot(result.ordered); + assert.equal(before.bid, 1); + assert.equal(ordered.bid, before.bid); + assert.equal(ordered.bidder, before.bidder); + assert.deepEqual(ordered.passed, before.passed); + assert.deepEqual(ordered.funding, before.funding); + assert.deepEqual(result.ordered.order, result.beforeSapho.order); + assert.deepEqual(result.ordered.players.map(player => player.spice), result.beforeSapho.players.map(player => player.spice)); + assert.equal(result.ordered.discard.filter(card => card.id === f.card).length, 1); + assert.equal(player(result.ordered).hand.some(card => card.id === f.card), false); + assert.equal(player(result.beforeSapho).spice - player(result.game).spice, 4, + 'Native sale debits the final strict winning bid once'); + assert.equal(player(result.game).hand.filter(card => card.id === f.target).length, 1); + assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(result.game.order, f.game.order); + const raises = result.bids.filter(bid => bid.step.actor === f.owner && bid.step.action.amount !== undefined && bid.step.action.amount !== null); + assert.deepEqual(raises.map(bid => bid.step.action.amount), [2, 4], 'Going first/last does not remove ordinary cyclic bidding on a later wrap'); + assert.throws(() => actSaphoCyclic(result.game, f.owner, result.action), /Sapho|card|hand|opportunity|expired/i); +} + +void test('genuine paid Sapho first and last preserve a prior funded raise and ordinary next-lot rotation in Basic and Advanced', () => { + for (const advanced of [false, true]) { + const f = createSaphoCyclicAuctionFixture({ advanced }); + source(f); + for (const mode of ['first', 'last'] as const) { + const result = completeSaphoCyclicAuction(f, mode); + finished(f, result); + assert.equal(result.action.scope, 'cyclicAuction'); + assert.equal(saphoCyclicLot(result.ordered).active === f.owner, mode === 'first'); + assert.equal(result.ordered.auction!.opener, f.originalOpener); + assert.equal(result.game.auction!.index, f.game.auction!.index + 1); + assert.equal(result.game.auction!.opener, (f.originalOpener + 1) % f.game.order.length); + assert.equal(result.game.auction!.active, f.game.order[(f.originalOpener + 1) % f.game.order.length]); + assert.equal(result.game.auction!.bid, 0); + assert.equal(result.game.auction!.bidder, null); + assert.deepEqual(result.game.auction!.passed, []); + } + } +}); + +void test('genuine Advanced Black Market Sapho keeps the original sale price, seller payment and following normal opener', () => { + const f = createSaphoCyclicAuctionFixture({ advanced: true, blackMarket: true }); + source(f); + for (const mode of ['first', 'last'] as const) { + const result = completeSaphoCyclicAuction(f, mode); + finished(f, result); + assert.equal(player(result.game, f.seller).hand.some(card => card.id === f.target), false); + assert.equal(player(result.game, f.seller).spice - player(result.beforeSapho, f.seller).spice, 4, + 'The native original seller receives the full sale once'); + assert.equal(result.game.richeseBidding!.blackMarketSold, true); + assert.equal(result.game.auction!.opener, (f.originalOpener + 1) % f.game.order.length); + assert.equal(result.game.auction!.active, f.game.order[(f.originalOpener + 1) % f.game.order.length]); + assert.equal(result.game.auction!.index, 0); + } +}); + +void test('actual current-first, highest-bidder and passed-holder positions cannot spend Sapho for a no-op or renewed chance', () => { + const f = createSaphoCyclicAuctionFixture(); + const initial = saphoCyclicAction(f.game, 'first'); + let current = structuredClone(f.game); + for (let count = 0; saphoCyclicLot(current).active !== f.owner && count < 8; count++) { + const step = saphoCyclicBid(current, null); current = actSaphoCyclic(current, step.actor, step.action); + } + assert.equal(saphoCyclicLot(current).active, f.owner); + assert.equal(viewGame(current, f.owner).saphoOptions.some(option => option.scope === 'cyclicAuction' && option.mode === 'first'), false); + assert.throws(() => actSaphoCyclic(current, f.owner, initial), /Sapho|opportunity|expired|no.op/i); + const highStep = saphoCyclicBid(current, 1), highest = actSaphoCyclic(current, highStep.actor, highStep.action); + assert.equal(saphoCyclicLot(highest).bidder, f.owner); + assert.equal(viewGame(highest, f.owner).saphoOptions.some(option => option.scope === 'cyclicAuction'), false); + assert.throws(() => actSaphoCyclic(highest, f.owner, initial), /Sapho|opportunity|expired|bid/i); + const passStep = saphoCyclicBid(current, null), passed = actSaphoCyclic(current, passStep.actor, passStep.action); + assert.ok(saphoCyclicLot(passed).passed.includes(f.owner)); + assert.equal(viewGame(passed, f.owner).saphoOptions.some(option => option.scope === 'cyclicAuction'), false); + assert.throws(() => actSaphoCyclic(passed, f.owner, initial), /Sapho|opportunity|expired|pass/i); + for (const game of [current, highest, passed]) { + assert.ok(player(game).hand.some(card => card.id === f.card)); + assert.equal(game.discard.some(card => card.id === f.card), false); + } +}); + +void test('a genuinely full hand from paid purchases gains one current-lot slot by discarding Sapho', () => { + const f = createSaphoCyclicAuctionFixture({ fullHand: true }); + source(f); + assert.equal(player(f.game).hand.length, 4); + assert.equal(f.actions.filter(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1).length, 2); + const result = completeSaphoCyclicAuction(f, 'first'); + finished(f, result); + assert.equal(player(result.ordered).hand.length, 3); + assert.equal(saphoCyclicLot(result.ordered).active, f.owner); + assert.equal(player(result.game).hand.length, 4); +}); + +void test('all four minimal policies use legal original ordinary and Black Market cyclic Sapho options', () => { + for (const blackMarket of [false, true]) { + const f = createSaphoCyclicAuctionFixture({ advanced: blackMarket, blackMarket }); + const raise = saphoCyclicBid(f.game, 1), game = actSaphoCyclic(f.game, raise.actor, raise.action); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(game, f.owner); + view.players.find(player => player.id === f.owner)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'card' && action.card === f.card && action.scope === 'cyclicAuction'); + assert.ok(action, `${difficulty} retains the owned original cyclic Sapho option`); + const next = actSaphoCyclic(structuredClone(game), f.owner, action); + assert.deepEqual(next.order, game.order); + assert.equal(saphoCyclicLot(next).bid, 1); + assert.equal(saphoCyclicLot(next).bidder, saphoCyclicLot(game).bidder); + assert.equal(next.discard.filter(card => card.id === f.card).length, 1); + } + } +}); + +void test('genuine pending passes survive Sapho and cannot receive a repeat turn before a real raise', () => { + for (const blackMarket of [false,true]) { + const f = createSaphoCyclicAuctionFixture({advanced:blackMarket,blackMarket}); + const raised = saphoCyclicBid(f.game,1); + let game = actSaphoCyclic(f.game,raised.actor,raised.action); + const earlier = saphoCyclicBid(game,null); + game = actSaphoCyclic(game,earlier.actor,earlier.action); + assert.equal(saphoCyclicLot(game).active,f.owner); + const before = saphoCyclicLot(game); + game = actSaphoCyclic(game,f.owner,saphoCyclicAction(game,'last')); + assert.deepEqual(saphoCyclicLot(game).passed,before.passed); + const nextPass = saphoCyclicBid(game,null); + game = actSaphoCyclic(game,nextPass.actor,nextPass.action); + assert.equal(saphoCyclicLot(game).active,f.owner, + 'The earlier passed seat is skipped in the changed cycle'); + const standingPrice = saphoCyclicBid(game,1), snapshot = structuredClone(game); + assert.throws(() => actSaphoCyclic(game,standingPrice.actor,standingPrice.action),/bid|raise/i); + assert.deepEqual(game,snapshot); + const newRaise = saphoCyclicBid(game,2); + game = actSaphoCyclic(game,newRaise.actor,newRaise.action); + assert.deepEqual(saphoCyclicLot(game).passed,[], + 'A genuine raise reopens the previously passed native opportunity'); + assert.deepEqual(game.order,f.game.order); + } +}); diff --git a/tests/sapho-cyclic-auction.test.ts b/tests/sapho-cyclic-auction.test.ts new file mode 100644 index 00000000..d0a49504 --- /dev/null +++ b/tests/sapho-cyclic-auction.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + reorderSaphoCyclicAuction, + SaphoCyclicAuctionError, + type SaphoCyclicAuctionContext, + type SaphoCyclicAuctionRequest, +} from '../game/sapho-cyclic-auction'; +import { quoteNormalAuctionBid, quoteNormalAuctionNext } from '../game/normal-auction'; +import { createRicheseAuction, submitRicheseBid, type RicheseAuction } from '../game/richese-auction'; +import { quoteAuctionContinuation, type AuctionContinuationInput } from '../game/auction-continuation-quote'; +import { baseDeck } from '../game/cards'; + +const context = (): SaphoCyclicAuctionContext => ({ + event: 'ordinary-lot-2', order: ['a', 'full', 'b', 'c', 'd', 'p'], + eligible: ['a', 'b', 'c', 'd', 'p'], active: 'c', bidder: 'a', passed: ['b', 'p'], +}); +const reorder = (state: SaphoCyclicAuctionContext, player: string, mode: 'first' | 'last') => + reorderSaphoCyclicAuction(state, { event: state.event, player, mode }); +const submit = (state: RicheseAuction, actor: string, amount: number | null, maximum = 20) => + submitRicheseBid(state, { event: state.event, actor, amount }, maximum); + +void test('Sapho first rotates the current lot and preserves all high, passed and full-hand placeholders', () => { + const state = context(), before = structuredClone(state); + const result = reorder(state, 'd', 'first'); + assert.deepEqual(result, { order: ['d', 'c', 'p', 'a', 'full', 'b'], active: 'd' }); + assert.deepEqual([...result.order].sort(), [...state.order].sort()); + assert.deepEqual(state, before); + assert.deepEqual(quoteNormalAuctionNext({ ...state, ...result, bid: 4, passed: [...state.passed, 'd'] }), + { kind: 'bid', player: 'c' }); + assert.deepEqual(quoteNormalAuctionNext({ + ...state, ...result, active: 'c', bid: 4, passed: [...state.passed, 'd', 'c'], + }), { kind: 'payment', player: 'a' }); +}); + +void test('Sapho last lets the current pending actor yield without repeating earlier passes when the cycle wraps', () => { + const state = context(), result = reorder(state, 'c', 'last'); + assert.deepEqual(result, { order: ['d', 'p', 'a', 'full', 'b', 'c'], active: 'd' }); + assert.deepEqual(quoteNormalAuctionNext({ ...state, ...result, bid: 4, passed: [...state.passed, 'd'] }), + { kind: 'bid', player: 'c' }); +}); + +void test('a currently last pending bidder can meaningfully move behind placeholders that reenter after a raise', () => { + const state = context(), result = reorder(state, 'd', 'last'); + assert.deepEqual(result, { order: ['c', 'p', 'a', 'full', 'b', 'd'], active: 'c' }); + assert.deepEqual(quoteNormalAuctionNext({ ...state, ...result, bid: 5, bidder: 'c', passed: [] }), + { kind: 'bid', player: 'p' }, 'An actual raise restores passes in the altered full cycle'); + assert.deepEqual(quoteNormalAuctionNext({ ...state, order: ['c', 'd', 'p', 'a', 'full', 'b'], + bid: 5, bidder: 'c', passed: [] }), { kind: 'bid', player: 'd' }); +}); + +void test('true full-cycle no-ops are unavailable but changing the next actor in a two-seat cycle is meaningful', () => { + assert.throws(() => reorder(context(), 'c', 'first'), /already.*position/); + const simple = { event: 'lot', order: ['a', 'b', 'c'], eligible: ['a', 'b', 'c'], + active: 'a', bidder: null, passed: [] }; + assert.throws(() => reorder(simple, 'c', 'last'), SaphoCyclicAuctionError); + const single = { event: 'lot', order: ['a', 'high', 'passed', 'full'], eligible: ['a', 'high', 'passed'], + active: 'a', bidder: 'high', passed: ['passed'] }; + assert.throws(() => reorder(single, 'a', 'first'), SaphoCyclicAuctionError); + assert.throws(() => reorder(single, 'a', 'last'), SaphoCyclicAuctionError, + 'Moving the only pending actor around skipped seats merely rotates the same effective cycle'); + const pair = { ...simple, order: ['a', 'b'], eligible: ['a', 'b'] }; + assert.deepEqual(reorder(pair, 'b', 'first'), { order: ['b', 'a'], active: 'b' }); + assert.deepEqual(reorder(pair, 'a', 'last'), { order: ['b', 'a'], active: 'b' }); +}); + +void test('stale events, invalid permutations, nonpending actors and unavailable holders reject without repairing the lot', () => { + const state = context(), before = structuredClone(state); + for (const request of [ + { event: 'old-lot', player: 'd', mode: 'first' }, + { event: state.event, player: 'full', mode: 'first' }, + { event: state.event, player: 'b', mode: 'first' }, + { event: state.event, player: 'a', mode: 'first' }, + { event: state.event, player: 'missing', mode: 'last' }, + { event: state.event, player: 'd', mode: 'middle' }, + ]) assert.throws(() => reorderSaphoCyclicAuction(state, request as SaphoCyclicAuctionRequest), SaphoCyclicAuctionError); + for (const invalid of [ + { ...state, event: '' }, { ...state, order: [] }, + { ...state, order: ['a', 'b', 'c', 'd', 'p', 'p'] }, + { ...state, order: ['a', 'b', 'c', 'p'] }, + { ...state, eligible: [...state.eligible, 'c'] }, + { ...state, eligible: [...state.eligible, 'outsider'] }, + { ...state, passed: ['p', 'p'] }, { ...state, passed: ['outsider'] }, + { ...state, bidder: 'outsider' }, { ...state, bidder: 'p' }, + { ...state, active: 'a' }, { ...state, active: 'b' }, { ...state, active: 'full' }, + ]) assert.throws(() => reorder(invalid, 'd', 'first'), SaphoCyclicAuctionError); + assert.deepEqual(state, before); +}); + +void test('ordinary cyclic traversal skips prior passes on wrap and preserves strict raises and terminal outcomes', () => { + const state = { order: ['a', 'b', 'c', 'd'], eligible: ['a', 'b', 'c', 'd'], + active: 'd', bid: 4, bidder: 'c', passed: ['a', 'd'] }; + assert.deepEqual(quoteNormalAuctionNext(state), { kind: 'bid', player: 'b' }); + assert.deepEqual(quoteNormalAuctionNext({ ...state, active: 'b', passed: ['a', 'b', 'd'] }), + { kind: 'payment', player: 'c' }); + assert.deepEqual(quoteNormalAuctionNext({ ...state, bid: 0, bidder: null, passed: ['a', 'b', 'c', 'd'] }), + { kind: 'unbid' }); + assert.deepEqual(quoteNormalAuctionBid({ currentBid: 4, amount: 4, maximum: 10 }), + { ok: false, reason: 'Bid must be an integer from 5 to 10.' }); + assert.deepEqual(quoteNormalAuctionBid({ currentBid: 4, amount: 5, maximum: 10 }), { ok: true, amount: 5 }); +}); + +void test('normal Richese bidding skips an earlier pass after Sapho and a real raise restores that seat', () => { + let lot = createRicheseAuction({ + event: 'black-market-2', cardId: 'physical-card', source: 'blackMarket', owner: 'r', method: 'normal', + eligible: ['a', 'b', 'c', 'r'], order: ['a', 'b', 'c', 'r'], tieOrder: ['a', 'b', 'c', 'r'], + }); + lot = submit(lot, 'a', 3); + lot = submit(lot, 'b', null); + const moved = reorder({ ...lot, active: lot.active! }, 'c', 'last'); + lot = { ...lot, ...moved, saphoOriginalOpener: lot.order[0] }; + assert.deepEqual(lot.order, ['r', 'a', 'b', 'c']); + assert.equal(lot.active, 'r'); + lot = submit(lot, 'r', null); + assert.equal(lot.active, 'c', 'Neither the high bidder nor the already passed b repeats an opportunity'); + assert.throws(() => submit(lot, 'c', 3), /Raise/); + assert.throws(() => submit(lot, 'c', 4, 3), /funding/); + lot = submit(lot, 'c', 4); + assert.deepEqual(lot.passed, []); + assert.equal(lot.active, 'r'); + lot = submit(lot, 'r', null); + assert.equal(lot.active, 'a'); + lot = submit(lot, 'a', null); + assert.equal(lot.active, 'b', 'The raise made the original passer eligible again'); + lot = submit(lot, 'b', null); + assert.deepEqual(lot.outcome, { kind: 'sold', winner: 'c', amount: 4 }); + assert.equal(lot.active, null); + assert.equal(lot.saphoOriginalOpener, 'a'); +}); + +/** Controlled post-sale quote inputs only; these do not claim an original + * action-only game history, card acquisition or payment programme. */ +function continuation(): AuctionContinuationInput { + const cards = baseDeck().slice(0, 2); + return { + status: 'playing', phase: 3, turn: 2, advanced: true, order: ['a', 'b', 'r'], physicalCards: cards, + players: [ + { id: 'a', faction: 'atreides', spice: 10, hand: [cards[0]], handLimit: 4 }, + { id: 'b', faction: 'fremen', spice: 10, hand: [], handLimit: 4 }, + { id: 'r', faction: 'richese', spice: 10, hand: [], handLimit: 4 }, + ], + auction: { cards, index: 0, bid: 4, bidder: 'a', active: 'a', passed: ['b', 'r'], opener: 0, + saphoOrder: ['r', 'b', 'a'] }, + sale: { winner: 'a', amount: 4, free: false, origin: 'normal', seller: null }, + }; +} + +void test('the next normal lot drops its local Sapho override and retains the original physical opener rotation', () => { + const input = continuation(), before = structuredClone(input); + const result = quoteAuctionContinuation(input, { kind: 'next' }); + assert.equal(result.next.kind, 'normalLot'); + assert.ok(result.next.kind === 'normalLot'); + assert.equal(result.next.active, 'b'); + assert.equal(result.next.auction.opener, 1); + assert.equal(result.next.auction.index, 1); + assert.equal(Object.hasOwn(result.next.auction, 'saphoOrder'), false); + assert.deepEqual(input, before); +}); + +void test('a reordered normal Black Market sale rotates the next opener from its original native first bidder', () => { + const input = continuation(); + let lot = createRicheseAuction({ event: 'black-market-2', cardId: input.auction!.cards[0].id, + source: 'blackMarket', owner: 'r', method: 'normal', eligible: ['a', 'b', 'r'], + order: ['a', 'b', 'r'], tieOrder: ['a', 'b', 'r'] }); + lot = submit(lot, 'a', 4); + const moved = reorder({ ...lot, active: lot.active! }, 'r', 'first'); + lot = { ...lot, ...moved, saphoOriginalOpener: lot.order[0] }; + lot = submit(lot, 'r', null); + lot = submit(lot, 'b', null); + assert.equal(lot.order[0], 'r'); + input.auction = null; + input.richeseAuction = lot; + input.sale = { winner: 'a', amount: 4, free: false, origin: 'blackMarket', seller: 'r' }; + input.richeseBidding = { owner: 'r', event: 'declaration-2', turn: 2, stage: 'lot', position: null, + normalCount: null, blackMarketSold: false, cacheCanceled: false }; + input.richeseCacheCount = 8; + const result = quoteAuctionContinuation(input, { kind: 'next' }); + assert.ok(result.next.kind === 'richeseEnd'); + assert.equal(result.next.opener, 1); + const legacy = structuredClone(input); delete legacy.richeseAuction!.saphoOriginalOpener; + const old = quoteAuctionContinuation(legacy, { kind: 'next' }); + assert.ok(old.next.kind === 'richeseEnd'); + assert.equal(old.next.opener, 0, 'Unstamped lots retain their old first-position opener'); +});