From 1972622c11de660ea0f928c158cc7c14bb5d0c72 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:24:16 +1100 Subject: [PATCH] Support Sapho ordering at Testing Station --- components/juice-of-sapho.tsx | 5 +- docs/CURRENT_STATUS.md | 14 ++ docs/DISCOVERY_PROTOTYPE.md | 17 +++ docs/RULE_DECISIONS.md | 12 ++ game/discovery-storm.ts | 65 ++++++++- game/engine.ts | 32 ++++- game/reference.ts | 5 +- tests/discovery-storm-sapho-quote.test.ts | 74 ++++++++++ tests/fixture-testing-station-contest.ts | 8 ++ ...sapho-testing-station-public-start.test.ts | 90 ++++++++++++ tests/sapho-testing-station-runtime.test.ts | 132 ++++++++++++++++++ 11 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 tests/discovery-storm-sapho-quote.test.ts create mode 100644 tests/sapho-testing-station-public-start.test.ts create mode 100644 tests/sapho-testing-station-runtime.test.ts diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx index 9d739186..be437860 100644 --- a/components/juice-of-sapho.tsx +++ b/components/juice-of-sapho.tsx @@ -68,7 +68,7 @@ export function SaphoOrderButton({ > {option.scope === 'battleAggressor' ? 'Become aggressor and discard Juice of Sapho' : (option.scope === 'movement' || option.scope === 'revival' || option.scope === 'charity') && option.resumeOwn ? `Resume your ${option.scope} last and discard Juice of Sapho` : - `${bidding ? 'Bid' : option.scope === 'battleOrder' ? 'Choose battles' : option.scope === 'extortion' ? 'Take your Extortion choice' : option.scope === 'revival' ? 'Take your Revival turn' : option.scope === 'charity' ? 'Take your Charity turn' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`} + `${bidding ? 'Bid' : option.scope === 'battleOrder' ? 'Choose battles' : option.scope === 'extortion' ? 'Take your Extortion choice' : option.scope === 'testingStation' ? 'Take your Testing Station choice' : option.scope === 'revival' ? 'Take your Revival turn' : option.scope === 'charity' ? 'Take your Charity turn' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`} ); } @@ -112,6 +112,7 @@ export function JuiceOfSapho({ const battle = option.scope === 'battleOrder'; const aggressor = option.scope === 'battleAggressor'; const extortion = option.scope === 'extortion'; + const testingStation = option.scope === 'testingStation'; const revival = option.scope === 'revival'; const charity = option.scope === 'charity'; const resumeOwn = (option.scope === 'movement' || revival || charity) && option.resumeOwn; @@ -163,6 +164,8 @@ export function JuiceOfSapho({ ? 'Become this battle’s aggressor. You win ordinary ties; a Habbanya Stronghold advantage still takes precedence. The participants, committed plans and later battle choices stay unchanged.' : extortion ? `Take your Extortion choice ${option.mode} among unanswered players. Earlier declines and Moritani’s five-spice bank collection remain. You may decline instead of paying; the first actual three-spice payment ends all offers.` + : testingStation + ? `Take your Testing Station choice ${option.mode}. Each occupant still chooses once; their adjustments apply in the selected order to the original storm movement. This priority is provisional.` : 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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 65c4535c..b87499dc 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,20 @@ open mode gates. ## Current checkpoint and work +**Sapho at Ecological Testing Station — 29 October. Prototyped / Partial.** +Before either of two station occupants chooses, a holder can discard Sapho to +change its own position first or last. The original storm source, physical +storm order and both cumulative choices remain; fresh event and signed receipt +preserve the selected order. Basic and Advanced real Discovery journeys use a +staged printed card; separate genuine public Richese/Discovery starts stage +conserved station pieces and move the original Sapho from the cache. Seven +related files pass 40 cases, including 12 new cases; type checking, lint and +the production build pass. The ordering is provisional: an automated current +occupant may act before another player intervenes. Semuta reaction profiles, +recovered Sapho replay and deployed acceptance remain open. Two stale Richese +Nexus guide statements were also corrected against existing runtime evidence. +[Scope](DISCOVERY_PROTOTYPE.md#sapho-at-ecological-testing-station--29-october-2026). + **Advanced Fremen allied revival scheduling — 29 October. Prototyped / Partial.** During ordered Revival, an automated current ally waits for a useful human Fremen grant. Fremen can release each eligible ally before or during its diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index 159acbdf..99fdc540 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -1,5 +1,22 @@ # Discovery functional prototype +## Sapho at Ecological Testing Station — 29 October 2026 + +**Prototyped / Partial.** Before either of two original occupants chooses, +an eligible holder can discard Juice of Sapho to change its own choice to +first or last. The original physical storm order, source and token are retained; +both occupants still adjust the cumulative distance once. The reordered frame +has a new event and a signed record of the original order. Unchanged frames +retain their original signatures. A position already held, a completed choice, +and another use after a recovered Sapho are not offered in this first version. +Semuta reaction profiles are guarded until that separate discard continuation +is connected. The ordering is a provisional online interpretation of Sapho's +printed first/last effect; no combined publisher clarification is claimed. +The current occupant may make its choice before the other occupant plays Sapho, +including when an automated seat acts first. There is no neutral pre-choice +priority window yet, so first-position intervention is not guaranteed against +an automatically acting current occupant. + 13 September 2026. The connected Discovery batches are **Prototyped**, with partial rules coverage. It connects physical components, real actions, private controls, AI choices and saved continuation. Normal public Discovery and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 586c3669..3369a44a 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,17 @@ # Rule decision index +## Sapho and Ecological Testing Station — 29 October 2026 + +The printed Juice of Sapho allows first or last in an ordered action. In a +two-occupant Ecological Testing Station queue, an occupant can provisionally +change its position before either choice. Both original adjustments still +apply once to the original revealed storm source; physical storm order is not +changed. Already-held positions, completed choices and a second recovered +Sapho use remain outside this first version. Semuta reaction profiles remain +guarded until their discard continuation is connected. This is an online +priority interpretation, not an explicit combined publisher ruling. +[Scope](DISCOVERY_PROTOTYPE.md#sapho-at-ecological-testing-station--29-october-2026). + ## Ordered Fremen allied revival and automated seats — 29 October 2026 The authorized revision, physical pp. 9 and 32, gives ordered Force Revival diff --git a/game/discovery-storm.ts b/game/discovery-storm.ts index 390fef04..62a716a2 100644 --- a/game/discovery-storm.ts +++ b/game/discovery-storm.ts @@ -30,6 +30,14 @@ export type DiscoveryStorm = { order: string[]; choices: { owner: string; delta: -1 | 0 | 1 }[]; }; + /** One provisional Sapho reorder, bound to the original physical order. */ + sapho?: { + event: string; + nextEvent: string; + originalOrder: string[]; + holder: string; + mode: 'first' | 'last'; + }; }; export type DiscoveryStormContext = Pick< Game, @@ -85,6 +93,7 @@ export function discoveryStormSignature( frame.stage, ]; if (frame.queue) values.push(frame.queue.order, frame.queue.choices); + if (frame.sapho) values.push(frame.sapho); return JSON.stringify(values); } export function discoveryStormDistance(frame: DiscoveryStorm): number { @@ -164,7 +173,9 @@ export function validateDiscoveryStorm( need( record(frame) && Object.keys(frame).sort().join(',') === (frame.queue - ? 'base,delta,event,from,owner,queue,signature,stage,token,turn' + ? (frame.sapho + ? 'base,delta,event,from,owner,queue,sapho,signature,stage,token,turn' + : 'base,delta,event,from,owner,queue,signature,stage,token,turn') : 'base,delta,event,from,owner,signature,stage,token,turn') && typeof frame.event === 'string' && frame.event.length > 0 && @@ -211,6 +222,20 @@ export function validateDiscoveryStorm( distance += choice.delta; need(distance >= 0 && distance <= 40, 'Every station choice must retain a legal current distance.'); } + if (frame.sapho) { + const sapho = frame.sapho; + need(record(sapho) && Object.keys(sapho).sort().join(',') === 'event,holder,mode,nextEvent,originalOrder' && + typeof sapho.event === 'string' && sapho.event.length > 0 && + typeof sapho.nextEvent === 'string' && sapho.nextEvent.length > 0 && sapho.nextEvent !== sapho.event && + Array.isArray(sapho.originalOrder) && sapho.originalOrder.length === 2 && + new Set(sapho.originalOrder).size === 2 && + sapho.originalOrder.every(id => typeof id === 'string' && g.players.some(p => p.id === id)) && + sapho.originalOrder.includes(sapho.holder) && ['first', 'last'].includes(sapho.mode) && + (sapho.mode === 'first' ? sapho.originalOrder[1] === sapho.holder : sapho.originalOrder[0] === sapho.holder) && + JSON.stringify(queue.order) === JSON.stringify([...sapho.originalOrder].reverse()) && + frame.event === (queue.choices.length ? `${sapho.nextEvent}:1` : sapho.nextEvent), + 'The station must retain the original Sapho ordering and event.'); + } } need( discoveryStormDistance(frame) >= 0 && @@ -250,7 +275,7 @@ export function validateDiscoveryStorm( if (frame.stage === 'choose') { const occupants = stationOccupants(g); need( - JSON.stringify(occupants) === JSON.stringify(frame.queue?.order ?? [frame.owner]) && + JSON.stringify(occupants) === JSON.stringify(frame.sapho?.originalOrder ?? frame.queue?.order ?? [frame.owner]) && g.stormPending === discoveryStormDistance(frame), 'The station choice no longer belongs to its original occupant and storm.', ); @@ -260,6 +285,42 @@ export function validateDiscoveryStorm( 'The adjusted storm must retain the chosen distance.', ); } + +/** Available position changes before either original station occupant chooses. + * Card ownership, disposal and any external interrupt belong to the caller. */ +export function discoveryStormSaphoModes( + g: DiscoveryStormContext, + frame: DiscoveryStorm, + holder: string, +): Array<'first' | 'last'> { + validateDiscoveryStorm(g, frame); + if (frame.stage !== 'choose' || !frame.queue || frame.queue.choices.length || frame.sapho) return []; + const index = frame.queue.order.indexOf(holder); + return index === 0 ? ['last'] : index === 1 ? ['first'] : []; +} + +/** Bind a single meaningful reorder and invalidate all old station actions. */ +export function reorderDiscoveryStorm( + g: DiscoveryStormContext, + frame: DiscoveryStorm, + input: {holder: string; event: unknown; mode: 'first' | 'last'; nextEvent: string}, +): DiscoveryStorm { + const modes = discoveryStormSaphoModes(g, frame, input.holder); + need(input.event === frame.event && modes.includes(input.mode) && + typeof input.nextEvent === 'string' && input.nextEvent.length > 0 && input.nextEvent !== frame.event, + 'Choose an available Sapho ordering for the current station event.'); + const originalOrder = [...frame.queue!.order]; + const order = [...originalOrder].reverse(); + const next: DiscoveryStorm = { + ...frame, event: input.nextEvent, owner: order[0], + queue: {order, choices: []}, + sapho: {event: frame.event, nextEvent: input.nextEvent, originalOrder, holder: input.holder, mode: input.mode}, + signature: '', + }; + next.signature = discoveryStormSignature(next); + validateDiscoveryStorm(g, next); + return next; +} export function chooseDiscoveryStorm( g: DiscoveryStormContext, frame: DiscoveryStorm, diff --git a/game/engine.ts b/game/engine.ts index 7e149df0..8b35ec27 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -144,7 +144,7 @@ import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup'; import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, usesNativeBattleSpecial } from './battle-card-slots'; import { HARASS_WITHDRAW_CARD, HarassWithdrawError, isHarassWithdraw, quoteHarassWithdraw, defaultHarassWithdrawAllocation, harassWithdrawNeedsAllocation, type HarassWithdrawSelection, type HarassWithdrawContext, type HarassWithdrawPreview } from './harass-withdraw'; import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions'; -import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, discoveryStormDistance, type StormMovementSource, type DiscoveryStorm } from './discovery-storm'; +import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, discoveryStormDistance, discoveryStormSaphoModes, reorderDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm'; import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight'; import { advanceDiscoveryEntry, beginDiscoveryEntryArrival, createDiscoveryEntryRound, finishDiscoveryEntryArrival, quoteDiscoveryEntry, validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound, type DiscoveryEntryArrivalChild, type DiscoveryEntryRound, type DiscoveryEntrySelection } from './discovery-entry'; import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, isDiscoverySpiceCardId, isDiscoveryTokenId, DISCOVERY_SPICE_CARDS, DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, JACURUTU_SIETCH, type DiscoveryState } from './discoveries'; @@ -29071,7 +29071,7 @@ function offerMoritaniDuke(g: Game) { return true; } type SaphoOption = { - scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion' | 'revival' | 'charity'; + scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion' | 'revival' | 'charity' | 'testingStation'; event: string; mode: 'first' | 'last'; /** Display only; action submission keeps scope/event/mode. */ @@ -29158,6 +29158,13 @@ function saphoExtortionDecision(g: Game) { decision?.kind === 'moritaniExtortion' && decision.player === state.queue[state.cursor] && decision.event === extortionEvent(state); } +function saphoTestingStationDecision(g: Game) { + const station = g.ecologicalStorm, decision = g.decision; + return g.phase === 0 && !g.pendingTreacheryDiscard && !g.semutaPreview && + !g.semutaAdvancedOrdinaryPreview && !g.semutaRecruitsPreview && + station?.stage === 'choose' && decision?.kind === 'ecologicalStorm' && + decision.player === station.owner && decision.event === station.event; +} function saphoBattleCardReserved(g: Game, player: string, card: string): boolean { const battle = g.battle, plan = battle?.plans[player]; return !!battle && (!!plan && [plan.weapon, plan.defense, plan.leader].includes(card) || @@ -29312,7 +29319,7 @@ function saphoCleanWindow(g: Game) { !g.truthtrance && !g.phaseOpening && !g.response && - (!g.decision || saphoExtortionDecision(g)) && + (!g.decision || saphoExtortionDecision(g) || saphoTestingStationDecision(g)) && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingExchange && @@ -29408,6 +29415,12 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] { ) return []; const options: SaphoOption[] = []; + if (saphoTestingStationDecision(g)) { + const frame = g.ecologicalStorm!; + for (const mode of nexusRule(() => discoveryStormSaphoModes(g, frame, p.id))) + options.push({scope: 'testingStation', event: frame.event, mode}); + return options; + } if (g.phase===2 && g.advanced && g.charityOrder) { const context=charityOrderContext(g); const event=charityOrderRule(()=>saphoCharityOrderEvent(context,g.charityOrder!)); @@ -29514,7 +29527,18 @@ function playSapho(g: Game, p: Player, action: Action) { 'This Juice of Sapho opportunity is unavailable, already completed or expired.', ); recordNativePrintedCardPlay(g, p.id, 'juiceOfSapho'); - if (option.scope === 'battleAggressor') { + if (option.scope === 'testingStation') { + requireRule(saphoTestingStationDecision(g) && g.ecologicalStorm, + 'The original Testing Station choice is no longer open.'); + const frame = nexusRule(() => reorderDiscoveryStorm(g, g.ecologicalStorm!, { + holder: p.id, event: option.event, mode: option.mode, nextEvent: crypto.randomUUID(), + })); + discard(g, p, 'richese-juice-of-sapho'); + g.ecologicalStorm = frame; + g.decision = {kind:'ecologicalStorm', player:frame.owner, event:frame.event}; + log(g, `${p.name} discarded Juice of Sapho to take the Testing Station choice ${option.mode}. Both occupants retain one adjustment and the original storm movement remains.`, + {faction:p.faction,name:'Juice of Sapho'}); + } else if (option.scope === 'battleAggressor') { const b = g.battle!; const next = saphoAggressorRule(() => changeSaphoAggressor(b.saphoAggressor, b.saphoAggressorEvents, b, g.turn, p.id, option.event)); diff --git a/game/reference.ts b/game/reference.ts index 845dbfd9..7db5538b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -425,7 +425,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Development tables support the cache auction first or last, with Once Around or Silent bidding. Advanced Black Market offers a hand card using normal, Once Around or Silent bidding; other players may buy it, and an unbid or canceled offer returns to the seller.', 'The controls show the card only to entitled viewers, accept a public Black Market claim, and let bidders choose their own and pledged ally spice. Silent bids remain private until all eligible players submit. A donor cannot reduce pledged funding during unfinished Silent bidding, and a Black Market card reserved for sale cannot be played elsewhere. Atreides inspection, applicable Harkonnen bonus draws and Ixian allied replacement follow the sale’s origin.', 'Other buyers pay Richese for a cache or Black Market sale. Richese buying its own cache card pays the Emperor or Bank. Under a visibly provisional paid-retention policy, a positive Black Market seller win likewise pays Emperor or Bank, retains the same held card without another hand slot, and counts as one sale. No self-refund or duplicate acquisition occurs. Only winning committed funding is collected; losing Silent bids spend nothing. Zero/unbid retention remains free and unsold. An unbid cache card may be kept for free only if the hand has room, or removed from the game.', - 'The opt-in Richese Nexus Betrayal auction preview adds a neutral before-payment acknowledgement. A legal rival holder may veto a public self-cache purchase or send another buyer’s sale payment to the bank. See its separate guide and bounded verification evidence; normal hidden self-purchases and special-Karama acquisition are not connected triggers.', + 'The opt-in Richese Nexus Betrayal preview adds a neutral acknowledgement before supported purchases and payments. A legal rival holder may veto a Richese self-purchase or send another buyer’s sale payment to the bank. Normal hidden lots and ordinary Karama acquisitions use a provisional blind attempt: the Nexus is spent even if the card is not Richese-family. A private special-Karama purchase also has a pre-cost response. See its separate guide for the supported boundaries.', 'A bidder whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot, unless it is the Black Market seller retaining its already-held card. The same current hand rule governs eligibility, commitments and settlement. A full-hand owner’s unbid cache card is still removed automatically. If every ordinary hand is full, normal-pool preparation does not draw and lose unseen cards.', 'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.', 'Karama may prevent the declared cache auction before the ordinary pool is drawn. Cancellation leaves the cache intact and restores its deducted ordinary lot; first/last position does not create another cache offer that round. The independent reduction for a completed Black Market sale remains, including provisional paid retention. An exhausted cache causes no cache deduction. The supplied Advanced source excludes Richese lots from Ixian Technology.', @@ -511,6 +511,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.', 'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.', 'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.', + 'Before either of two Ecological Testing Station occupants chooses, use Sapho to take your adjustment first or last if that changes the order. Each occupant still makes one choice against the cumulative storm distance. Physical storm order and its original source stay unchanged. The current occupant can act before another player intervenes, including an automated seat. The initial prototype excludes Semuta reaction profiles and replay of a recovered Sapho; this online priority is provisional.', 'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. At a clean boundary, the holder may pause its own claimed turn and resume last without repeating the claim or payment. Completed turns stay closed and physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.', 'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.', 'When two or more players declare simultaneous Truthtrances in a quiet unrevealed battle, their public question-order window can use Sapho to move the holder’s group first or last. The current battle and committed plans remain in place. In the supported Basic Semuta preview, the discard and any exchange finish before the reordered questions resume.', @@ -6303,7 +6304,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Forces, elite identity, destination restrictions, native Homeworld sources and movement remain unchanged. Fremen retain free reinforcement. Native Guild rate cancellation changes the one-counter sand bill from1 to2 without canceling the Nexus source; an already canceled rate is reflected in the quote. Unfunded declared Nexus stays spent without refund/reusable coupon while ordinary shipment remains available under the selected affordability policy. An independently purchased ordinary price remains separate. This is no concealed marker or extra shipment.', 'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.', 'An independently purchased Karama rate also applies to native unallied Richese Cunning: bill one pair unit plus any High paid companions, halve and round up once, and pay the Spice Bank. The original grant stays bound through responses. No-Field prevention leaves it available for a legal ordinary replacement while Nexus stays spent. Advanced Guild stopping uses the shipment and clears the grant, returning only its original unclaimed activation card still in discard under the existing provisional refund policy. A previously claimed cost cannot refund after rediscard.', - 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.', + 'Richese Betrayal has a separate opt-in preview. An unallied rival may spend its physical Richese Nexus card to veto a supported Richese self-purchase, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Publicly possible holders acknowledge the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and ordinary Karama acquisitions use a provisional blind attempt: the Nexus is spent even if the card is not Richese-family. A private special-Karama purchase has its own pre-cost response. See the dedicated guide for supported profiles and remaining release gates; that evidence does not verify Nexus Kull.', 'Guild Cunning is declared when finishing the ordinary Shipment and Movement turn. It spends the Nexus and opens its native Karama response. Allowance grants one second native shipment at normal Guild prices, including supported cross, return and Homeworld routes; cancellation preserves the first shipment and movement. Every later movement must spend an earned unused Hajr grant; there is no new ordinary move. A genuinely recovered and legally replayed Hajr adds another grant, with fresh post-movement timing in Advanced. Classic core retains original funding through Guild fee acknowledgement; other envelopes keep their source limits. The physical Ornithopter may provisionally replace one unused Hajr grant. Both original card groups remain legal until completed or abandoned, with extra credit only after both actually move. Earlier completed flight credits remain counted; declaring Cunning during an unfinished flight abandons its remaining group before the original Nexus response resumes. This adds no ordinary move.', 'Cunning native cross/return permission and rate use separate unpaid controls while original shipped history stays unchanged. A denial within its second opportunity allows a legal reserve replacement; denial of an earlier ordinary opportunity does not remove that new signed grant. Typed sources, original prices and approval stay bound; neither first payment nor ordinary movement reopens. Independent purchased price, immune Junction and other card sources remain distinct.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to the holder’s own reserves, including the native-Homeworld return when Homeworlds are enabled, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Arbitrary foreign-world transport and concealed No-Fields are not granted.', diff --git a/tests/discovery-storm-sapho-quote.test.ts b/tests/discovery-storm-sapho-quote.test.ts new file mode 100644 index 00000000..e9e7ce02 --- /dev/null +++ b/tests/discovery-storm-sapho-quote.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + chooseDiscoveryStorm, + discoveryStormSaphoModes, + discoveryStormSignature, + finishDiscoveryStorm, + reorderDiscoveryStorm, + validateDiscoveryStorm, +} from '../game/discovery-storm'; +import {createTestingStationContestFixture} from './fixture-testing-station-contest'; + +// Real revealed station, original storm source and both occupants. The helper +// tests ordering only; physical Sapho ownership and disposal belong to engine. +const source = () => createTestingStationContestFixture().game; + +void test('legacy station signature stays unchanged and offers only meaningful two-seat moves', () => { + const game = source(), frame = game.ecologicalStorm!; + const [first, second] = frame.queue!.order; + assert.equal(frame.signature, JSON.stringify([frame.event, frame.turn, frame.owner, + frame.token, frame.from, frame.base, frame.delta, frame.stage, + frame.queue!.order, frame.queue!.choices])); + assert.deepEqual(discoveryStormSaphoModes(game, frame, first), ['last']); + assert.deepEqual(discoveryStormSaphoModes(game, frame, second), ['first']); + assert.deepEqual(discoveryStormSaphoModes(game, frame, 'nonoccupant'), []); +}); + +for (const mode of ['first', 'last'] as const) void test( + `Sapho ${mode} binds a fresh station event and preserves both cumulative choices`, () => { + const game = source(), original = structuredClone(game.ecologicalStorm!); + const holder = original.queue!.order[mode === 'first' ? 1 : 0]; + const frame = reorderDiscoveryStorm(game, original, + {holder, event: original.event, mode, nextEvent: 'sapho-fresh-event'}); + assert.deepEqual(original, game.ecologicalStorm); + assert.deepEqual(frame.queue!.order, [...original.queue!.order].reverse()); + assert.equal(frame.owner, frame.queue!.order[0]); + assert.deepEqual(frame.sapho, {event: original.event, nextEvent: 'sapho-fresh-event', + originalOrder: original.queue!.order, holder, mode}); + assert.deepEqual(discoveryStormSaphoModes(game, frame, holder), []); + assert.throws(() => chooseDiscoveryStorm(game, frame, frame.owner, original.event, 0)); + let next = chooseDiscoveryStorm(game, frame, frame.owner, frame.event, 0); + assert.equal(next.event, 'sapho-fresh-event:1'); + validateDiscoveryStorm(game, next); + next = chooseDiscoveryStorm(game, next, next.owner, next.event, 0); + assert.equal(next.stage, 'traversal'); + validateDiscoveryStorm(game, next); + next = finishDiscoveryStorm(next); + validateDiscoveryStorm(game, next); + assert.deepEqual(next.sapho, frame.sapho); + assert.deepEqual(next.queue!.choices.map(choice => choice.owner), frame.queue!.order); + }, +); + +void test('stale, ineffective, replayed and tampered station reorder requests fail closed', () => { + const game = source(), frame = game.ecologicalStorm!; + const [first, second] = frame.queue!.order, before = structuredClone(frame); + for (const input of [ + {holder: first, event: 'stale', mode: 'last' as const, nextEvent: 'fresh'}, + {holder: first, event: frame.event, mode: 'first' as const, nextEvent: 'fresh'}, + {holder: second, event: frame.event, mode: 'first' as const, nextEvent: frame.event}, + {holder: 'other', event: frame.event, mode: 'last' as const, nextEvent: 'fresh'}, + ]) assert.throws(() => reorderDiscoveryStorm(game, frame, input)); + assert.deepEqual(frame, before); + const reordered = reorderDiscoveryStorm(game, frame, + {holder: first, event: frame.event, mode: 'last', nextEvent: 'fresh'}); + assert.throws(() => reorderDiscoveryStorm(game, reordered, + {holder: second, event: 'fresh', mode: 'last', nextEvent: 'again'})); + const changed = structuredClone(reordered); + changed.sapho!.originalOrder.reverse(); + changed.signature = discoveryStormSignature(changed); + assert.throws(() => validateDiscoveryStorm(game, changed)); + const afterFirst = chooseDiscoveryStorm(game, frame, first, frame.event, 0); + assert.deepEqual(discoveryStormSaphoModes(game, afterFirst, second), []); +}); diff --git a/tests/fixture-testing-station-contest.ts b/tests/fixture-testing-station-contest.ts index 402dd9b5..d98966c4 100644 --- a/tests/fixture-testing-station-contest.ts +++ b/tests/fixture-testing-station-contest.ts @@ -105,6 +105,14 @@ export function nextTestingStationContestStep( game.decision?.kind === 'ecazAmbassador' ) return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + // Fresh Advanced Charity and Revival are ordered. Finish only the actual + // current opportunity; the reused Banker stepper's generic Charity fallback + // otherwise chooses lobby order, which need not match the current queue. + if (game.status === 'playing' && clean(game)) { + const actor = game.phase === 2 ? game.charityOrder?.remaining[0] + : game.phase === 4 ? game.revivalOrder?.remaining[0] : undefined; + if (actor) return {actor, action: {type:'ready'}}; + } return nextSpiceBankerIncomeNativeStep(game); } diff --git a/tests/sapho-testing-station-public-start.test.ts b/tests/sapho-testing-station-public-start.test.ts new file mode 100644 index 00000000..06839f16 --- /dev/null +++ b/tests/sapho-testing-station-public-start.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; +import { createDiscoveryStorm, createStormSource, discoveryStormDistance } from '../game/discovery-storm'; +import { discoveryStormActions } from '../game/discovery-storm-options'; +import { advanceTestingStationContest } from './fixture-testing-station-contest'; + +const CARD = 'richese-juice-of-sapho'; +const STATION = 'ecological-testing-station'; +function seat(game: Game, id: string) { + const player = game.players.find(candidate => candidate.id === id); + assert.ok(player); return player; +} +function physicalCards(game: Game) { + return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)]; +} + +/** A real lobby selects CHOAM expansion and Discovery through public actions, + * starts and completes the original faction setup. Only after setup, the + * revealed physical station, two ordinary reserve forces, Storm source and + * its unchosen queue are staged to reach the rare contested boundary; one + * existing physical Sapho moves from Richese's cache into its intended owner. + * This is not a natural full game, purchase, reveal, or collection proof. */ +function fixture(advanced: boolean) { + let game = createGame('SAPHOSTATIONPUBLIC', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); + for (const faction of ['atreides', 'emperor'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + game = applyAction(game, game.host, {type:'moduleOption', module:'discoveries', enabled:true}); + for (const player of game.players) game = applyAction(game, player.id, {type:'ready'}); + game = applyAction(game, game.host, {type:'start', ...(advanced ? {advancedPreview:true} : {})}); + assert.equal(game.status, 'setup'); + assert.equal(game.discoveryEnabled, true); + assert.deepEqual(game.expansions, ['choam']); + assert.ok(game.discoveries && game.richeseCache); + const startInventory = physicalCards(game).map(card => card.id).sort(); + assert.equal(startInventory.filter(id => id === CARD).length, 1); + game = advanceTestingStationContest(game, state => state.status === 'playing'); + assert.equal(game.phase, 0); + + const token = game.discoveries!.tokens.find(candidate => candidate.face === STATION); + assert.ok(token && token.status === 'supply'); + const position = Object.values(DISCOVERY_CARD_PLACEMENTS).find(candidate => candidate.type === token.type); + assert.ok(position); + Object.assign(token, {status:'placed', territory:position.territory, sector:position.sector, revealedTurn:game.turn}); + validateDiscoveryState(game.discoveries!); + for (const id of ['atreides', 'emperor']) { + const player = seat(game, id); assert.ok(player.reserves > 0); + player.reserves--; + player.forces[`${STATION}:0`] = (player.forces[`${STATION}:0`] ?? 0) + 1; + } + const occupants = game.order.filter(id => id === 'atreides' || id === 'emperor'); + assert.equal(occupants.length, 2); + const holder = occupants[1]; + const index = game.richeseCache!.findIndex(card => card.id === CARD); assert.ok(index >= 0); + const [sapho] = game.richeseCache!.splice(index, 1); + seat(game, holder).hand.push(sapho); + assert.deepEqual(physicalCards(game).map(card => card.id).sort(), startInventory); + game.stormPending = 3; + game.stormMovementSource = createStormSource(game.turn, 'dials', 3); + game.ready = game.players.map(player => player.id); + game.phaseOpening = null; + game.stormResolution = null; + game.ecologicalStorm = createDiscoveryStorm(game, 'public-station-setup'); + game.decision = {kind:'ecologicalStorm', player:game.ecologicalStorm.owner, event:game.ecologicalStorm.event}; + return {game, holder, occupants, startInventory}; +} + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} public Richese/Discovery start reaches staged conserved Sapho station use`, () => { + const f = fixture(advanced), prior = structuredClone(f.game); + const option = viewGame(prior, f.holder).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first'); + assert.ok(option, 'The existing physical Sapho can take the pending station choice first'); + let game = applyAction(prior, f.holder, {type:'card', card:CARD, scope:option.scope, event:option.event, mode:option.mode}); + assert.deepEqual(game.order, prior.order); + assert.deepEqual(game.ecologicalStorm?.queue?.order, [...f.occupants].reverse()); + assert.equal(game.decision?.player, f.holder); + assert.equal(game.discard.filter(card => card.id === CARD).length, 1); + for (const delta of [1, -1]) { + const actor = game.ecologicalStorm!.owner; + const action = discoveryStormActions(viewGame(game, actor)).find(row => row.delta === delta); + assert.ok(action); + game = applyAction(game, actor, action); + } + assert.equal(game.ecologicalStorm?.stage, 'complete'); + assert.equal(discoveryStormDistance(game.ecologicalStorm!), 3); + assert.deepEqual(physicalCards(game).map(card => card.id).sort(), f.startInventory); + }, +); diff --git a/tests/sapho-testing-station-runtime.test.ts b/tests/sapho-testing-station-runtime.test.ts new file mode 100644 index 00000000..c42536dc --- /dev/null +++ b/tests/sapho-testing-station-runtime.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { richeseCards } from '../game/richese-cards'; +import { discoveryStormActions } from '../game/discovery-storm-options'; +import { discoveryStormDistance } from '../game/discovery-storm'; +import { stormSectorAfter } from '../game/disaster-rules'; +import { createTestingStationContestFixture } from './fixture-testing-station-contest'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { JuiceOfSapho, SaphoOrderButton } = await import('../components/juice-of-sapho'); +aliases.deregister(); + +const card = 'richese-juice-of-sapho'; +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +const originals = new Map>(); +function fixture(advanced: boolean, place: 'first' | 'second') { + let original = originals.get(advanced); + if (!original) { + original = createTestingStationContestFixture({advanced}); + originals.set(advanced, original); + } + const game = structuredClone(original.game); + const holder = original.order[place === 'first' ? 0 : 1]; + const target = game.players.find(player => player.id === holder)!; + // The original fixture has only Ecaz components. Supply its one real printed + // Sapho face from the Richese cache, retaining a full hand by transferring an + // existing held card to the deck. This explicitly staged acquisition is not + // evidence of mixed-expansion roster admission or a natural purchase. + const sapho = richeseCards().find(candidate => candidate.id === card); + assert.ok(sapho); + assert.ok(![...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].some(candidate => candidate.id === card)); + if (target.hand.length >= 4) game.deck.push(target.hand.pop()!); + target.hand.push(sapho); + return {game, holder, original}; +} +function saphoOption(game: Game, holder: string, mode: 'first' | 'last') { + const option = viewGame(game, holder).saphoOptions.find(choice => String(choice.scope) === 'testingStation' && choice.mode === mode); + assert.ok(option, `${holder} has a real ${mode} station opportunity`); + return option; +} +function saphoAction(game: Game, holder: string, mode: 'first' | 'last'): Action { + const option = saphoOption(game, holder, mode); + return {type:'card', card, scope:option.scope, event:option.event, mode:option.mode}; +} +function stationAction(game: Game, actor: string, delta: number) { + const action = discoveryStormActions(viewGame(game, actor)).find(choice => choice.delta === delta); + assert.ok(action, `${actor} can make the ${delta} station adjustment`); + return action; +} +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test( + `${advanced ? 'Advanced' : 'Basic'} Sapho changes the contested station order ${mode} exactly once`, () => { + const f = fixture(advanced, mode === 'first' ? 'second' : 'first'); + const initial = f.game, action = saphoAction(initial, f.holder, mode); + const physicalOrder = [...initial.order], beforeOrder = [...initial.ecologicalStorm!.queue!.order]; + const beforeEvent = initial.ecologicalStorm!.event, sourceDistance = initial.stormPending!; + const other = beforeOrder.find(id => id !== f.holder)!; + reject(initial, other, action); + reject(initial, f.holder, {...action, event:'stale-station-event'}); + let game = applyAction(reload(initial), f.holder, action); + assert.deepEqual(game.order, physicalOrder); + assert.deepEqual(game.ecologicalStorm?.queue?.order, [...beforeOrder].reverse()); + assert.notEqual(game.ecologicalStorm?.event, beforeEvent); + assert.equal(game.ecologicalStorm?.owner, game.ecologicalStorm?.queue?.order[0]); + assert.equal(game.decision?.player, game.ecologicalStorm?.owner); + assert.equal(game.discard.filter(candidate => candidate.id === card).length, 1); + assert.ok(!game.players.find(player => player.id === f.holder)!.hand.some(candidate => candidate.id === card)); + reject(game, f.holder, action); + const first = game.ecologicalStorm!.owner; + const firstDelta = sourceDistance < 40 ? 1 : -1; + game = applyAction(reload(game), first, stationAction(game, first, firstDelta)); + assert.equal(discoveryStormDistance(game.ecologicalStorm!), sourceDistance + firstDelta); + assert.equal(game.stormPending, sourceDistance + firstDelta); + const second = game.ecologicalStorm!.owner; + assert.notEqual(first, second); + game = applyAction(reload(game), second, stationAction(game, second, -firstDelta)); + assert.equal(game.ecologicalStorm?.stage, 'complete'); + assert.equal(discoveryStormDistance(game.ecologicalStorm!), sourceDistance); + assert.equal(game.storm, stormSectorAfter(initial.storm, sourceDistance)); + assert.equal(game.phase, 1); + assert.equal(game.discard.filter(candidate => candidate.id === card).length, 1); + }, +); + +void test('station Sapho controls render and submit the exact supported action, with busy protection', () => { + for (const mode of ['first', 'last'] as const) { + const f = fixture(false, mode === 'first' ? 'second' : 'first'); + const view = viewGame(f.game, f.holder), option = saphoOption(f.game, f.holder, mode); + const html = renderToStaticMarkup(createElement(JuiceOfSapho, {game:view, busy:false, act() {}})); + assert.match(html, /Testing Station/i); + const sent: Action[] = []; + SaphoOrderButton({card, option, busy:false, act(action) {sent.push(action);}}).props.onClick(); + assert.deepEqual(sent, [saphoAction(f.game, f.holder, mode)]); + SaphoOrderButton({card, option, busy:true, act(action) {sent.push(action);}}).props.onClick(); + assert.equal(sent.length, 1); + } +}); + +void test('all minimal bot profiles have a legal current station choice after Sapho', () => { + const f = fixture(false, 'second'); + const game = applyAction(f.game, f.holder, saphoAction(f.game, f.holder, 'first')); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, f.holder); + view.players.find(player => player.id === f.holder)!.bot = difficulty; + const choice = botActions(view).find(action => action.type === 'decision'); + assert.ok(choice, `${difficulty} can choose the original station adjustment`); + const next = applyAction(game, f.holder, choice); + assert.equal(next.ecologicalStorm?.queue?.choices.length, 1); + } +});