From 554f2bf9e9047458fb35ed308b7851c82d184ac8 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:49:07 +1100 Subject: [PATCH] Connect physical Jacurutu allocation and cumulative station choices --- components/discoveries.tsx | 2 +- components/discovery-storm.tsx | 8 +- components/game-table.tsx | 6 + components/jacurutu-dial.tsx | 66 +++ docs/CURRENT_STATUS.md | 18 + docs/DISCOVERY_PROTOTYPE.md | 119 +++-- docs/IMPLEMENTATION_STATUS.md | 47 ++ docs/RULE_DECISIONS.md | 4 +- game/admin-seat-ai-shape.ts | 1 + game/bots.ts | 2 + game/discovery-battle.ts | 78 +++- game/discovery-storm-options.ts | 2 +- game/discovery-storm.ts | 111 +++-- game/engine.ts | 129 +++-- game/reference.ts | 17 +- game/richese-cancellation.ts | 1 + tests/discovery-battle.test.ts | 125 ++++- tests/discovery-storm.test.ts | 193 +++++++- tests/fixture-jacurutu-mixed-dial.ts | 281 +++++++++++ tests/fixture-testing-station-contest.ts | 442 ++++++++++++++++++ tests/jacurutu-mixed-dial-runtime.test.ts | 182 ++++++++ tests/testing-station-contest-runtime.test.ts | 238 ++++++++++ 22 files changed, 1943 insertions(+), 129 deletions(-) create mode 100644 components/jacurutu-dial.tsx create mode 100644 tests/fixture-jacurutu-mixed-dial.ts create mode 100644 tests/fixture-testing-station-contest.ts create mode 100644 tests/jacurutu-mixed-dial-runtime.test.ts create mode 100644 tests/testing-station-contest-runtime.test.ts diff --git a/components/discoveries.tsx b/components/discoveries.tsx index 0b933ca0..e965c389 100644 --- a/components/discoveries.tsx +++ b/components/discoveries.tsx @@ -96,7 +96,7 @@ export function DiscoveryPanel({ game, act, busy }: Props) {

Inspection and reveal are optional. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu income, Testing Station storm choices and Shrine card conversion are prototyped. {game.advanced ? 'A sole Orgiz occupant takes one spice for each territory where one rival collected board spice.' - : 'Basic keeps the provisional one-spice transfer per positive board deposit collected by one rival.'} Contested Orgiz, different or shared collectors, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending; uncertain transfers do not block ordinary Collection.

+ : 'Basic keeps the provisional one-spice transfer per positive board deposit collected by one rival.'} Mixed physical Jacurutu dials now use a defeated-player choice before settlement. Testing Station occupants each choose once in original storm order, cumulatively. These ordering/allocation interpretations are provisional. Contested Cistern/Orgiz and different or shared collectors remain pending; uncertain transfers do not block ordinary Collection.

); } diff --git a/components/discovery-storm.tsx b/components/discovery-storm.tsx index 34e5d07b..9ad9dd6d 100644 --- a/components/discovery-storm.tsx +++ b/components/discovery-storm.tsx @@ -20,11 +20,11 @@ export function DiscoveryStormDecision({ return (

- Your forces occupy Ecological Testing Station. The storm will move{' '} - {offer.base} sectors. You may decrease or increase this by one, or keep - the ordinary movement. + Your forces occupy Ecological Testing Station. The storm currently + moves {offer.base} sectors. You may decrease or increase this by one, or + keep the current movement.

-

This choice cannot change Weather Control.

+

Prototype ordering: each occupying faction chooses once in storm order; adjustments are cumulative. This choice cannot change Weather Control.

{offer.options.map((option) => { const action = actions.find( diff --git a/components/game-table.tsx b/components/game-table.tsx index af606c98..487b1e15 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -34,6 +34,7 @@ import { GreatMakerDecision } from './great-maker'; import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries'; import { DiscoveryEntryDecision } from './discovery-entry'; import { DiscoveryStormDecision } from './discovery-storm'; +import { JacurutuDial } from './jacurutu-dial'; import { NexusChoamTrade, NexusChoamInspectionDecision, NexusChoamInspectionInsight } from './nexus-choam-trade'; import { NexusChoamBetrayal } from './nexus-choam-betrayal'; import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal'; @@ -2616,6 +2617,8 @@ export function GameTable({ ? 'Choose Smuggler collection sectors' : g.decision.kind === 'sandmasterVictorySpice' ? 'Choose Sandmaster victory spice sector' + : g.decision.kind === 'jacurutuDial' + ? 'Identify your Jacurutu physical dial' : g.decision.kind === 'harassWithdraw' ? 'Choose undialed forces to return' : g.decision.kind === 'diplomatRetreat' @@ -3072,6 +3075,9 @@ export function GameTable({ ) : g.decision.kind === 'sandmasterVictorySpice' ? ( + ) : g.decision.kind === 'jacurutuDial' && g.jacurutuDial ? ( + act({type:'decision',event:g.jacurutuDial!.event,allocation})} busy={busy} /> ) : g.decision.kind === 'harassWithdraw' ? ( <> diff --git a/components/jacurutu-dial.tsx b/components/jacurutu-dial.tsx new file mode 100644 index 00000000..d4d53de8 --- /dev/null +++ b/components/jacurutu-dial.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { + JACURUTU_BATTLE_INCOME_RULE, + type JacurutuDialChoice, + type JacurutuDialOffer, +} from '@/game/discovery-battle'; +import { Button } from './ui/button'; + +/** Concrete physical reading of one legal allocation of the revealed dial. */ +export function jacurutuDialChoiceLabel(choice: JacurutuDialChoice): string { + const { normal, elite, paidNormal, paidElite } = choice.dialed; + const counters = [ + `${normal} ordinary counter${normal === 1 ? '' : 's'} (${paidNormal} paid)`, + `${elite} elite counter${elite === 1 ? '' : 's'} (${paidElite} paid)`, + ].join(' · '); + return `${counters} — ${ + choice.amount === 0 + ? 'no spice from the bank' + : `${choice.amount} spice from the bank` + }`; +} + +/** + * The defeated player's own allocation of their revealed dial. Only the legal + * physical readings reported for this battle are offered; the numeric wheel + * value alone never decides the counters. + */ +export function JacurutuDial({ + offer, + onChoose, + busy, +}: { + offer: JacurutuDialOffer; + onChoose: (allocation: number) => void; + busy: boolean; +}) { + if (!offer.choices.length) return null; + return ( +
+

+ You lost this battle in Jacurutu Sietch. Choose which counters your + revealed dial actually committed; the winner collects one bank spice + for each opposing undialed force sent to the Tanks. +

+

{JACURUTU_BATTLE_INCOME_RULE}

+

Prototype interpretation: the defeated player identifies the physical dial allocation before battle settlement.

+
+ {offer.choices.map((choice, allocation) => ( + + ))} +
+
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 11e3f6fc..2299f2df 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,24 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Recommended Jacurutu and Testing Station choices — 14 October.** +Following the choose-recommended-option priority, mixed physical Jacurutu dials +now use a defeated-player exact ordinary/elite/paid selection before battle +mutates. Original withdrawal/retreat counters do not earn Tanks income; +unique readings stay automatic, and original payment/casualty/Collection resumes +once. Testing Station gives each non-advisor occupant one cumulative adjustment +in original storm order, then traverses once; natural source/base/from/token, +Weather exclusion and existing sole-frame signatures remain. +Both are visible provisional interpretations, not new publisher or separately +answered numerical user rulings. Actual native original setup, token lottery, +ship/reveal/entry, declared battle or worm alliance retain printed stock and +starting armies. Actual390px dial choice earns1 with two losing Tanks/one elite; +native city income remains separate. Guild+1 changes pending2→3 without moving +storm3; Ecaz−1 then moves it once to5 and opens Spice Blow, with both garrisons +intact. Browser errors empty and no root overflow. Related12-file union104/104, +types/lint and app build pass; [precise source and proof](DISCOVERY_PROTOTYPE.md#recommended-physical-dial-and-cumulative-storm-choices--14-october-2026). +Cistern/Orgiz/shared-collector effects and full acceptance remain open. + **Classic Homeworld normal Banker and whole-invoice accounting — 14 October.** Fresh `banker-income --homeworlds` reuses original classic Basic/Advanced occupation/all14 setup and income-v1, without Nexus/Discovery/Tech/Strongholds diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index 37299a89..24ee6830 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -5,6 +5,65 @@ partial rules coverage. It connects physical components, real actions, private controls, AI choices and saved continuation. Normal public Discovery and Advanced start gates, and the publication gate, remain closed. +## Recommended physical dial and cumulative storm choices — 14 October 2026 + +Under the user's choose-recommended-option priority, the prototype now selects +two explicit implementation interpretations without another question: +the defeated Jacurutu player identifies the physical reading of their revealed +dial, and each Testing Station non-advisor occupant chooses once in original +storm order, cumulatively. These are not separately answered numerical user +rulings or newly located publisher clarifications. Controls label them provisional. + +Jacurutu's owned event and canonical ordinary/elite/paid allocation choices +pause before support, casualties, card cleanup or rewards mutate. A chosen +reading must match all four original commitment fields. Only opposing undialed +counters actually sent to the Tanks earn spice. Original Harass returns bind +their already selected physical dial; actual withdrawal/retreat counters are +excluded. Unique readings stay automatic. Native resolution resumes once; +its subsequent ordinary city Collection remains separate. + +Genuine Advanced Emperor/Atreides/Guild setup draws the original Hagga Basin +Discovery, places the actual Jacurutu supply token at printed Gara Kulon8, +ships one ordinary/one Sardaukar, inspects/reveals it and free-enters next turn. +An actual opposing shipment and declared battle produce dial2/support1: +Sardaukar-only earns1; ordinary-plus-Sardaukar earns0. Both destroy the same two +losing counters, spend original support once and resume native Collection. +The same original dial1/support0 position earns1 automatically. All four +properly configured legal bot policies choose an offered physical reading. + +Testing Station preserves the original natural source/base/from/token and +existing sole-frame signatures. A two-owner signed queue stores original storm +order and one delta per owner. First choice leaves storm/protection untouched; +second starts one final traversal. Choices use current cumulative distance, +bounded0..40. Wrong owner/event/index, replay and Weather Control fail closed. +No new route, station token, starting army or source is fabricated. + +Genuine Ecaz/Guild/Fremen/Emperor setup retains every original starting army: +real paid shipment, Collection reveal, next-turn entry, worm Nexus, reciprocal +alliance and allied shipment yield two station occupants. Earlier natural +sole-occupant opportunities use the real Keep control. The Advanced path also +finishes its original Guild interception before checking the arrival. Original +unplayed Spice ordering and actual supply lottery are explicitly staged. + +Actual390px Jacurutu selection settles Emperor6→5, opposing Atreides9→10 +(support−2, reward1, original city2) and the same two Tanks/one elite; observer +Guild13→14 retains its original Collection. Actual station Guild+1 changes +pending2→3 without moving storm3; Ecaz−1 then moves it once to5 and opens +Spice Blow, preserving both original garrisons. Browser errors empty; no root +overflow. `/tmp/dune-discovery-choice-ui-proof.json` records private local proof. +No complete-module, broad-assurance or deployed acceptance is claimed. + +Evidence: `tests/fixture-jacurutu-mixed-dial.ts`, +`tests/jacurutu-mixed-dial-runtime.test.ts`, +`tests/fixture-testing-station-contest.ts`, +`tests/testing-station-contest-runtime.test.ts`, and the original +`tests/discovery-battle.test.ts` / `tests/discovery-storm.test.ts`. + +Frozen related12-file union104/104, types/lint and app build pass. The build +retains existing module-registration, chunk-size and route-classification +warnings. Throwaway native probes are removed; permanent tests exercise +source/actor/transition/payment boundaries, not wording or fixture-copy echoes. + ## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 Original native Nx/Discovery now admits both selected Ix+CHOAM families @@ -583,9 +642,10 @@ Focused native cases are in `tests/discovery-module-profile.test.ts`, `tests/discovery-native-marker-runtime.test.ts` and `tests/discovery-native-e3-runtime.test.ts`. Fixtures explicitly label controlled unplayed Spice order, placement lottery and conserved counter/card/identity -positions; clocks, choices and outcomes use original producers. This does not -resolve contested Cistern/Testing Station/Orgiz, shared-payer theft, mixed-dial -Jacurutu, complete module strategy or public-start/deployed acceptance. +positions; clocks, choices and outcomes use original producers. At that earlier +checkpoint, contested benefits and mixed-dial Jacurutu remained unresolved. +The 14 October recommended dial and station interpretations supersede those two +guards; Cistern/Orgiz, shared-payer theft, strategy and full acceptance remain open. ### Bounded runtime evidence @@ -780,16 +840,16 @@ conversion was fabricated. another payment. Unresolved Ecaz shared lots and contested Orgiz occupation withhold their uncertain transfers, not the ordinary Collection phase. -- **Jacurutu:** winning a battle here automatically grants one bank spice per - opposing undialed physical force sent to the Tanks. Winner casualties and - pre-existing Tanks do not count. Ordinary and unambiguous Advanced plans are - playable. Mixed normal/elite plans with different possible physical counts - leave an explicit unpaid gap pending the allocation ruling below. -- **Ecological Testing Station:** a sole non-advisor occupant receives an owned - decrease/keep/increase choice after the ordinary storm-card window closes, - before traversal and CHOAM/Fremen protection. The original natural dial/card - source, turn, physical station and chosen distance remain bound through saved - protection and typed loss continuations. Weather Control bypasses the station. +- **Jacurutu:** one bank spice per opposing undialed physical counter actually + sent to the Tanks. Unique readings remain automatic. For different possible + counts, the defeated owner selects an exact legal ordinary/elite/paid reading + before settlement under the provisional 14 October interpretation. Original + withdrawal/retreat counters never count; winner losses and old Tanks do not. +- **Ecological Testing Station:** each non-advisor occupant chooses once in + original storm order after the ordinary storm-card window and before traversal. + Adjustments accumulate on current movement; bounds0..40 and original source + remain authoritative. Weather Control bypasses the station. Sequential + cumulative ordering is a visibly provisional implementation interpretation. - **Shrine:** current non-advisor occupation of the publicly revealed location authorizes Truthtrance as Karama or Karama as Truthtrance in the existing effect paths. Cards retain their physical identity and custody. A committed converted @@ -839,24 +899,23 @@ trigger described above. Two fighters occupying Orgiz still withhold theft. An Ecaz shared lot or inconsistent collected payers withholds that territory's transfer; independent territories and ordinary Collection proceed. -The Testing Station's two-occupant case leaves ordering and cumulative adjustment -unresolved. The prototype opens only the sole-occupant path and continues an -ordinary storm without that contested benefit. Choosing after the ordinary -storm-card window is an implementation timing interpretation: the printed -source specifies the Storm phase and excludes Weather Control, but does not -supply a separate station/card priority order. A legacy pending storm without -new source metadata keeps its existing movement; a subsequent naturally -produced storm can use the station. No source is inferred from display logs. +The 14 October recommended prototype selects one cumulative adjustment per +Testing Station occupant in original storm order. The printed source specifies +the Storm phase and excludes Weather Control but does not give a separate +station/card priority or contested-order FAQ. The selected ordering remains an +implementation interpretation, not publisher authority. Existing sole-occupant +signed frames remain valid; no original movement is invented for a legacy +pending storm without source metadata. Jacurutu's text says opposing **undialed forces**, not a numeric wheel difference. Advanced combat permits different physical allocations: an Emperor with one ordinary force and one Sardaukar, dial two and one paid support, can dial the supported Sardaukar alone or the supported ordinary plus unsupported Sardaukar. -Those choices leave one or zero undialed counters. The existing loser resolution -has no physical allocation choice. The user question asks whether the defeated -player should select those counters; it remains pending. The prototype records -the unpaid gap instead of silently choosing a reward. Source: publisher-authored -Ecaz & Moritani rulebook p13, with no clarification in its bundled Q&A. +Those choices leave one or zero undialed counters. The 14 October prototype +selects the recommended defeated-player physical allocation before any battle +mutation. The former unpaid-gap guard is removed; numeric wheel subtraction +alone remains invalid. Publisher-authored Ecaz & Moritani p13 supplies the +undialed-counter trigger, not this choice timing. No new clarification is claimed. ## Evidence and next dependencies @@ -925,10 +984,10 @@ to Spice Blow. Refresh restored the same station forces and private Snooper. This is targeted control/recovery evidence, not an unmodified complete game. The 187 opening saved rooms remain preserved; no server restart was needed. -1. Resolve contested Cistern/Orgiz/Testing Station benefits, Ecaz shared-lot - Orgiz allocation and Jacurutu's mixed physical dial allocation; complete - remaining controls and effects when settled. Basic's per-collected-deposit - Orgiz interpretation remains provisional; the Advanced count is source-cut over. +1. Resolve contested Cistern/Orgiz benefits and Ecaz shared-lot Orgiz allocation. + Jacurutu mixed dial and Testing Station order now use the visible recommended + interpretations above. Basic Orgiz remains provisional per collected deposit; + the Advanced territory count is source-cut over. 2. Integrate combined modules and complete games, then refine interaction, strategy and presentation coverage before opening normal start gates. 3. Use the existing prototype starter and saved-room verification for further diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 9c500750..4e1ed909 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,52 @@ # Dune implementation status +## 14 October 2026 — recommended physical Jacurutu dial and cumulative station queue + +The choose-recommended-option priority removes another clarification stop. +The prototype selects defeated-player physical dial allocation at Jacurutu and +one cumulative Testing Station adjustment per non-advisor occupant in original +storm order. Both controls label these provisional implementation interpretations; +the printed source does not supply their choice timing/contested-order FAQ. +No separate numerical user answer or new publisher ruling is claimed. + +Jacurutu's owned event preserves exact canonical ordinary/elite/paid readings +before battle mutation. Selection must match all four fields; actual Harass +returns bind their already selected commitment and retreat/withdrawal counters +never enter Tanks income. Unique readings stay automatic. Both1/0 readings +spend the same support and produce the same original two losing Tanks/one elite, +winner losses and original subsequent city Collection; all four configured +minimal policies produce a legal selection. + +Testing Station adds a signed two-owner queue while preserving original sole +frame signatures. Source/base/from/token and one ordered delta per owner stay +bound. The first action updates current pending movement without traversal; +the final action traverses once. Cumulative0..40 bounds, actor/event/replay +rejection and printed Weather exclusion are exercised. + +Native fixtures use real original setup, printed blow/supply lottery, paid +shipment, Collection inspect/reveal, next-turn entry and actual battle or worm +alliance. Owner removed a child’s arbitrary conserved starting-force relocation; +every original starting army remains intact. The station fixture also had to +choose Keep on its earlier real sole opportunity and finish Advanced Guild +interception before checking the allied arrival. Initial wallet expectations +omitted ordinary city Collection; the repaired tests preserve that original +quote separately rather than changing the engine’s payout. A Weather fixture +assumed seat order, while actual storm order made its second occupant Ecaz2; +the meaningful assertion now preserves its actual original garrison. Wording, +markup, log-growth and staging-copy assertions were deleted, not re-pinned. + +Actual390px Jacurutu earns1: Emperor6→5, Atreides9→10 (−2 support,+1 reward, ++2 original city), Guild13→14 from original Collection. Actual station Guild+1 +changes pending2→3 without moving storm3; Ecaz−1 traverses once to5, enters +Spice Blow and preserves original2/1 garrisons. Browser errors empty; no root +overflow. Private proof `/tmp/dune-discovery-choice-ui-proof.json`. +Native throwaways and isolated host/tab/scaffold are removed. + +Final related12-file union104/104, types/lint and app build pass with existing +module-registration, chunk-size and route-classification warnings. The exhaustive +decision registries are migrated. Comprehensive assurance, other pending +Discovery effects, complete modes and deployed acceptance remain open. + ## 14 October 2026 — classic Homeworld final Bank income and ruled accounting The user selected whole-invoice low Guild/Emperor rounding, first eligible diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 513727e6..fdbfa8e2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -550,8 +550,8 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Moritani Atomics after alliance change | The publisher's persistent −1 hand-limit text names Moritani and “your ally,” but not whether the penalty follows a later ally, remains on the original ally or affects both. Requested online source searches found no clarification; the alliance-change case remains gated. | [Terror source boundary](MORITANI_TERROR_RULES.md#material-gaps-and-ordinary-compositions) | | Moritani Sneak Attack entry classification | The printed verb is “Send”; no retrieved ruling classifies the off-planet reserve transfer as a shipment for other arrival powers. The engine now **explicitly adopts** the supported shipment composition: a positive Sneak Attack in Shipment and Movement accrues the Heighliners token once for its owner, then offers the Bene Gesserit Intrusion (Advanced, with fighters present) and the free spiritual-advisor shipment, with the committed Terror entry resolving first; a zero-force resolution accrues nothing and opens no reaction. This order is an implementation interpretation, not a publisher ruling. | [Source boundary and adopted classification](MORITANI_ASSASSINATION_SNEAK.md#adopted-shipment-classification-7-october-2026) | | Orgiz collection | Basic retains the publisher-p13 per-blow ambiguity and provisional one-transfer-per-positive-deposit mapping. Authorized supplied Advanced rulebook physical24 instead says each time Collection occurs in a territory containing spice: Advanced applies one transfer per actual positive territory Collection, only to a unique rival payer. Multiple source keys do not add transfers. Contested ownership, inconsistent/different payers and Ecaz shared lots still withhold only uncertain transfers; ordinary Collection proceeds. No new publisher ruling on those pending cases is claimed. | [Advanced source cutover and Basic boundary](DISCOVERY_PROTOTYPE.md#authorized-advanced-collection-wording) | -| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | -| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | +| Jacurutu physical dial | Under the 14 October choose-recommendation priority, the prototype selects the defeated-player exact ordinary/elite/paid reading before settlement. Unique readings remain automatic; actual returned counters never earn income. This replaces the earlier unpaid gap without claiming a separate numerical user answer or publisher ruling. | [Recommended runtime and source](DISCOVERY_PROTOTYPE.md#recommended-physical-dial-and-cumulative-storm-choices--14-october-2026) | +| Testing Station contest/timing | The 14 October recommended prototype gives each non-advisor occupant one cumulative adjustment in original storm order, after the ordinary card window and before a single traversal. Original source/base and Weather immunity stay unchanged. Ordering/stacking is explicit provisional interpretation, not a newly found publisher answer. | [Recommended runtime and source](DISCOVERY_PROTOTYPE.md#recommended-physical-dial-and-cumulative-storm-choices--14-october-2026) | | CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. The connected prototype chooses a uniform use-or-continue window for every winner when Nexus is enabled and CHOAM absent; nonholders pass, while a holder may decide after the result. This privacy-preserving prompt is a product inference, not publisher text. A holder-only pause would reveal custody and a precommitment would remove the printed choice. Ordinary Karama cancellation remains unestablished by retrieved sources; no response is invented. | [After-victory inspection boundary](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) | | Moritani Secret Ally battle timing | The printed after-loss retention is optional and the Nexus card remains secret. The connected prototype offers the same choice to every loser when Moritani is absent and at least one publicly revealed played card is retainable; a loser with no eligible public card has no useful choice, independent of hidden custody. Only an unallied holder may spend the physical card. This prompt is a privacy-preserving product inference, not publisher timing text. Native Moritani alliance retention and its Karama response remain separate; no generic Secret Ally counter is invented. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) | | Ecaz Secret Ally traitor inquiry | The printed panel asks one selected player whether they hold any of the holder's leaders as Traitors, without identifying a leader; it specifies neither native-versus-controlled scope nor answer audience. The connected prototype chooses native printed faction leaders (including dead/captured), ordinary Traitor Cards rather than Face Dancers, and a holder-only historical yes/no. This is a privacy-preserving product interpretation, **not** a publisher ruling. Foreign Ghola/captive scope and table-wide disclosure remain release boundaries. | [Ecaz inquiry source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) | diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts index 63f82bde..33b18187 100644 --- a/game/admin-seat-ai-shape.ts +++ b/game/admin-seat-ai-shape.ts @@ -24,6 +24,7 @@ const nullableCount = (value: unknown) => value === null || count(value); const decisions = { ecazBattleLead: true, + jacurutuDial: true, harassWithdraw: true, smugglerCollection: true, sandmasterVictorySpice: true, diff --git a/game/bots.ts b/game/bots.ts index bc5fcbb6..71589954 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -2682,6 +2682,8 @@ function policyActions(g: GameView): Action[] { if (d.kind === 'discoveryDiscard') return discoveryBotActions(g); if (d.kind === 'discoveryEntry') return discoveryEntryBotActions(g); if (d.kind === 'ecologicalStorm') return discoveryStormActions(g); + if (d.kind === 'jacurutuDial') + return g.jacurutuDial?.choices.map((_,allocation) => ({type:'decision',event:d.event,allocation})) ?? []; if (d.kind === 'greatMakerVote' || d.kind === 'greatMakerRide') return greatMakerBotActions(g); if (d.kind === 'nexusFremenCunningOffer') return [{ type: 'decision', event: d.event, diff --git a/game/discovery-battle.ts b/game/discovery-battle.ts index 3dfb7b0c..1856b839 100644 --- a/game/discovery-battle.ts +++ b/game/discovery-battle.ts @@ -15,6 +15,25 @@ export type JacurutuTankLosses = Readonly<{ elite: number; }>; +/** One legal physical reading of the revealed opposing dial. */ +export type JacurutuDialChoice = Readonly<{ + /** The physical ordinary/elite counters the defeated player committed to the + * revealed dial, with the plan's paid support split. */ + dialed: Casualties; + /** Bank spice the winner collects for the counters left undialed. */ + amount: number; +}>; + +/** The defeated player's pre-settlement choice between legal physical readings + * of a single revealed dial. */ +export type JacurutuDialOffer = Readonly<{ + event: string; + /** The defeated player, who owns the physical allocation of their own dial. */ + owner: string; + winner: string; + choices: readonly JacurutuDialChoice[]; +}>; + export type JacurutuBattleIncomeQuote = | Readonly<{ kind: 'income'; @@ -29,7 +48,7 @@ export type JacurutuBattleIncomeQuote = winner: string; opponent: string; sentToTanks: JacurutuTankLosses; - choices: readonly Readonly<{ dialed: Casualties; amount: number }>[]; + choices: readonly JacurutuDialChoice[]; }>; export type JacurutuBattleIncomeInput = Readonly<{ @@ -43,6 +62,10 @@ export type JacurutuBattleIncomeInput = Readonly<{ /** Actual opposing counters destroyed by this battle, after any future * withdrawal or other non-Tanks movement has been removed from the pool. */ sentToTanks: JacurutuTankLosses | null; + /** The defeated player's own physical allocation for the revealed dial, once + * they have selected one. Absent while the automatic single reading, or a + * saved choice, remains pending. */ + selectedDial?: Casualties | null; }>; export class JacurutuBattleIncomeError extends Error { @@ -61,7 +84,10 @@ function whole(value: number): boolean { * * A numeric Battle Wheel dial is not a physical counter count in Advanced * combat. When different legal ordinary/elite dial allocations would change - * the income, the quote preserves those choices instead of inventing one. + * the income, the quote preserves those choices instead of inventing one. A + * supplied `selectedDial` must equal one offered reading exactly, including its + * paid support split: the same counters with a different paid distribution + * describe a different committed strength. */ export function quoteJacurutuBattleIncome( input: JacurutuBattleIncomeInput, @@ -90,11 +116,45 @@ export function quoteJacurutuBattleIncome( throw new JacurutuBattleIncomeError( 'Jacurutu income needs the opposing plan and exact forces sent to the Tanks.', ); - const dialed = casualtyOptions( + const eligible = casualtyOptions( forces, input.opponentDial, input.opponentSupport, - ).filter( + ); + if (!eligible.length) + throw new JacurutuBattleIncomeError( + 'Jacurutu income has no physical dial allocation matching the opposing plan.', + ); + const selected = input.selectedDial; + if (selected != null) { + // All four canonical fields must match one offered reading: the same + // ordinary/elite counters with the same paid support describe a different + // committed strength than a different paid distribution of those counters. + const match = eligible.find( + (choice) => + choice.normal === selected.normal && + choice.elite === selected.elite && + choice.paidNormal === selected.paidNormal && + choice.paidElite === selected.paidElite, + ); + if (!match) + throw new JacurutuBattleIncomeError( + 'The chosen Jacurutu dial allocation is not a legal physical allocation of the opposing revealed forces, dial and support.', + ); + if (match.normal > sent.normal || match.elite > sent.elite) + throw new JacurutuBattleIncomeError( + 'The chosen Jacurutu dial allocation exceeds the opposing counters actually sent to the Tanks.', + ); + return { + kind: 'income', + winner: input.winner, + opponent: input.opponent, + amount: sent.normal + sent.elite - match.normal - match.elite, + sentToTanks: { ...sent }, + dialed: [{ ...match }], + }; + } + const dialed = eligible.filter( (choice) => choice.normal <= sent.normal && choice.elite <= sent.elite, ); if (!dialed.length) @@ -123,3 +183,13 @@ export function quoteJacurutuBattleIncome( dialed: choices.map((choice) => choice.dialed), }; } + +/** Resolves the canonical saved choice index to its exact physical allocation. */ +export function jacurutuDialSelection( + offer: JacurutuDialOffer, + allocation: number, +): Casualties | null { + if (!Number.isSafeInteger(allocation) || allocation < 0) return null; + const choice = offer.choices[allocation]; + return choice ? { ...choice.dialed } : null; +} diff --git a/game/discovery-storm-options.ts b/game/discovery-storm-options.ts index 465f2c87..5f50fd3c 100644 --- a/game/discovery-storm-options.ts +++ b/game/discovery-storm-options.ts @@ -14,7 +14,7 @@ export function discoveryStormActions(game: GameView): Action[] { offer.blocked ) return []; - // Keeping the ordinary movement is the conservative first prototype policy. + // Keeping the current cumulative distance is the conservative policy. return [...offer.options] .sort((a, b) => Math.abs(a.delta) - Math.abs(b.delta)) .map((option) => ({ diff --git a/game/discovery-storm.ts b/game/discovery-storm.ts index 83d21958..390fef04 100644 --- a/game/discovery-storm.ts +++ b/game/discovery-storm.ts @@ -26,6 +26,10 @@ export type DiscoveryStorm = { delta: -1 | 0 | 1 | null; stage: 'choose' | 'traversal' | 'complete'; signature: string; + queue?: { + order: string[]; + choices: { owner: string; delta: -1 | 0 | 1 }[]; + }; }; export type DiscoveryStormContext = Pick< Game, @@ -37,6 +41,7 @@ export type DiscoveryStormContext = Pick< | 'discoveryEnabled' | 'discoveries' | 'players' + | 'order' > & { stormMovementSource?: StormMovementSource }; export function stormSourceSignature( source: Omit, @@ -69,7 +74,7 @@ export function validateStormSource(source: StormMovementSource) { export function discoveryStormSignature( frame: Omit, ) { - return JSON.stringify([ + const values: unknown[] = [ frame.event, frame.turn, frame.owner, @@ -78,9 +83,22 @@ export function discoveryStormSignature( frame.base, frame.delta, frame.stage, - ]); + ]; + if (frame.queue) values.push(frame.queue.order, frame.queue.choices); + return JSON.stringify(values); } -export function discoveryStormOffer(g: DiscoveryStormContext) { +export function discoveryStormDistance(frame: DiscoveryStorm): number { + return frame.base + (frame.queue + ? frame.queue.choices.reduce((sum, choice) => sum + choice.delta, 0) + : frame.delta ?? 0); +} +function stationOccupants(g: DiscoveryStormContext): string[] { + return g.order.filter((id) => { + const player = g.players.find((p) => p.id === id); + return player && fighterCount(player, 'ecological-testing-station') > 0; + }); +} +export function discoveryStormOffer(g: DiscoveryStormContext, frame?: DiscoveryStorm) { const token = g.discoveryEnabled && g.discoveries?.tokens.find( @@ -90,11 +108,10 @@ export function discoveryStormOffer(g: DiscoveryStormContext) { t.revealedTurn !== null, ); if (!token) return null; - const occupants = g.players.filter( - (p) => fighterCount(p, 'ecological-testing-station') > 0, - ); - if (occupants.length !== 1) return null; - const owner = occupants[0].id, + const occupants = stationOccupants(g); + if (!occupants.length || occupants.length > 2) return null; + if (frame) validateDiscoveryStorm(g, frame); + const owner = frame?.owner ?? occupants[0], source = g.stormMovementSource; if (source) validateStormSource(source); let blocked: string | null = null; @@ -105,10 +122,10 @@ export function discoveryStormOffer(g: DiscoveryStormContext) { 'This storm was already pending before the station became available. Its original movement is preserved.'; else if (source.kind === 'weather') blocked = 'Ecological Testing Station cannot change Weather Control.'; - else if (source.distance !== g.stormPending) + else if (source.distance !== (frame?.base ?? g.stormPending)) blocked = 'The current storm movement no longer matches this station opportunity.'; - const base = g.stormPending ?? 0; + const base = frame ? discoveryStormDistance(frame) : g.stormPending ?? 0; const options = ([-1, 0, 1] as const) .filter((delta) => base + delta >= 0 && base + delta <= 40) .map((delta) => ({ delta, distance: base + delta })); @@ -121,7 +138,7 @@ export function createDiscoveryStorm( const offer = discoveryStormOffer(g); need( offer && !offer.blocked && typeof event === 'string' && event.length > 0, - offer?.blocked ?? 'No sole occupant may adjust this storm.', + offer?.blocked ?? 'No station occupant may adjust this storm.', ); const frame: DiscoveryStorm = { event, @@ -134,6 +151,8 @@ export function createDiscoveryStorm( stage: 'choose', signature: '', }; + const occupants = stationOccupants(g); + if (occupants.length === 2) frame.queue = { order: occupants, choices: [] }; frame.signature = discoveryStormSignature(frame); validateDiscoveryStorm(g, frame); return frame; @@ -144,8 +163,9 @@ export function validateDiscoveryStorm( ) { need( record(frame) && - Object.keys(frame).sort().join(',') === - 'base,delta,event,from,owner,signature,stage,token,turn' && + Object.keys(frame).sort().join(',') === (frame.queue + ? '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 && typeof frame.owner === 'string' && @@ -161,9 +181,40 @@ export function validateDiscoveryStorm( ['choose', 'traversal', 'complete'].includes(frame.stage) && (frame.stage === 'choose' ? frame.delta === null - : [-1, 0, 1].includes(frame.delta as number) && - frame.base + frame.delta! >= 0 && - frame.base + frame.delta! <= 40) && + : [-1, 0, 1].includes(frame.delta as number)), + 'The station has lost its signed storm choice.', + ); + if (frame.queue) { + const queue = frame.queue; + need( + record(queue) && + Object.keys(queue).sort().join(',') === 'choices,order' && + Array.isArray(queue.order) && + queue.order.length === 2 && + new Set(queue.order).size === 2 && + queue.order.every((id) => typeof id === 'string' && g.players.some((p) => p.id === id)) && + Array.isArray(queue.choices) && + queue.choices.length <= 2 && + queue.choices.every((choice, index) => + record(choice) && + Object.keys(choice).sort().join(',') === 'delta,owner' && + choice.owner === queue.order[index] && + [-1, 0, 1].includes(choice.delta)) && + frame.owner === queue.order[Math.min(queue.choices.length, 1)] && + (frame.stage === 'choose' + ? queue.choices.length < 2 + : queue.choices.length === 2 && frame.delta === queue.choices[1].delta), + 'The station must retain exactly one ordered choice per original occupant.', + ); + let distance = frame.base; + for (const choice of queue.choices) { + distance += choice.delta; + need(distance >= 0 && distance <= 40, 'Every station choice must retain a legal current distance.'); + } + } + need( + discoveryStormDistance(frame) >= 0 && + discoveryStormDistance(frame) <= 40 && frame.signature === discoveryStormSignature(frame), 'The station has lost its signed storm choice.', ); @@ -197,18 +248,15 @@ export function validateDiscoveryStorm( 'The station cannot replace its original storm source or affect Weather Control.', ); if (frame.stage === 'choose') { - const offer = discoveryStormOffer(g); + const occupants = stationOccupants(g); need( - offer && - !offer.blocked && - offer.owner === frame.owner && - offer.token === frame.token && - offer.base === frame.base, + JSON.stringify(occupants) === JSON.stringify(frame.queue?.order ?? [frame.owner]) && + g.stormPending === discoveryStormDistance(frame), 'The station choice no longer belongs to its original occupant and storm.', ); } else need( - g.stormPending === frame.base + frame.delta!, + g.stormPending === discoveryStormDistance(frame), 'The adjusted storm must retain the chosen distance.', ); } @@ -226,14 +274,21 @@ export function chooseDiscoveryStorm( frame.event === event && typeof delta === 'number' && [-1, 0, 1].includes(delta) && - frame.base + delta >= 0 && - frame.base + delta <= 40, + discoveryStormDistance(frame) + delta >= 0 && + discoveryStormDistance(frame) + delta <= 40, 'Choose the current station opportunity: decrease one, keep the distance, or increase one.', ); - const next = { + const choices = frame.queue + ? [...frame.queue.choices, { owner, delta: delta as -1 | 0 | 1 }] + : null; + const more = choices !== null && choices.length < frame.queue!.order.length; + const next: DiscoveryStorm = { ...frame, - delta: delta as -1 | 0 | 1, - stage: 'traversal' as const, + owner: more ? frame.queue!.order[choices!.length] : frame.owner, + event: more ? `${frame.event}:1` : frame.event, + delta: more ? null : delta as -1 | 0 | 1, + stage: more ? 'choose' : 'traversal', + ...(choices ? { queue: { order: [...frame.queue!.order], choices } } : {}), signature: '', }; next.signature = discoveryStormSignature(next); diff --git a/game/engine.ts b/game/engine.ts index 084add9e..52672a57 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -32,11 +32,11 @@ import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup'; import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair } 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, type StormMovementSource, type DiscoveryStorm } from './discovery-storm'; +import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, discoveryStormDistance, 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, type DiscoveryState } from './discoveries'; -import { quoteJacurutuBattleIncome } from './discovery-battle'; +import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken, isDiscoverySpiceCardId, isDiscoveryTokenId, DISCOVERY_SPICE_CARDS, DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, JACURUTU_SIETCH, type DiscoveryState } from './discoveries'; +import { quoteJacurutuBattleIncome, jacurutuDialSelection, type JacurutuDialOffer } from './discovery-battle'; import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker'; import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade'; import { quoteNexusChoamInspection, sampleNexusChoamInspection, nexusChoamInspectionSignature, type NexusChoamInspectionReceipt } from './nexus-choam-inspection'; @@ -695,6 +695,8 @@ export type Battle = { smugglerCollectionVersion?: 1; smugglerCollection?: SmugglerBattleReceipt | null; sandmasterPile?: string; + /** The defeated player identifies its revealed physical dial before settlement. */ + jacurutuDial?: JacurutuDialOffer & { selectedDial?: Casualties; retreat?: DiplomatRetreatSelection }; /** New battles allow physical withdrawal choice after reveal, never in the sealed plan. */ harassAllocationVersion?: 1; harassAllocationEvent?: string; @@ -955,6 +957,7 @@ export type Decision = | { kind: 'discoveryDiscard'; player: string; event: string } | { kind: 'discoveryEntry'; player: string; event: string } | { kind: 'ecologicalStorm'; player: string; event: string } + | { kind: 'jacurutuDial'; player: string; event: string } | { kind: 'greatMakerVote'; player: string; event: string } | { kind: 'greatMakerRide'; player: string; event: string } | { kind: 'wormRide'; player: string; territory: string } @@ -10342,7 +10345,7 @@ function startStormMovement(g: Game, n: number) { const offer = nexusRule(() => discoveryStormOffer(g)); if (offer && !offer.blocked) { g.ecologicalStorm = nexusRule(() => createDiscoveryStorm(g,crypto.randomUUID())); - g.decision = {kind:'ecologicalStorm',player:offer.owner,event:g.ecologicalStorm.event}; + g.decision = {kind:'ecologicalStorm',player:g.ecologicalStorm.owner,event:g.ecologicalStorm.event}; } else moveStorm(g,n); } function choamStormOptions(g: Game) { @@ -10678,6 +10681,7 @@ function afterWorm(g: Game) { } function discoveryIntegrity(g: Game) { ecologicalStormIntegrity(g); + jacurutuDialIntegrity(g); discoveryEntryIntegrity(g); if (!g.discoveryEnabled) { requireRule(!g.discoveries && !g.discoveryStash, 'Discovery custody requires its enabled module.'); @@ -10742,7 +10746,7 @@ function ecologicalStormIntegrity(g: Game) { requireRule(source.kind !== 'card' || isStormCardDistance(source.distance), 'The revealed Storm Card must retain a canonical distance.'); requireRule(g.status === 'playing' && g.phase === 0 && source.turn === g.turn && - g.stormPending === (frame?.stage === 'traversal' ? source.distance+frame.delta! : source.distance), + g.stormPending === (frame && frame.stage !== 'complete' ? discoveryStormDistance(frame) : source.distance), 'The storm must retain its current turn and original movement source.'); } const controls = [g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume, @@ -10758,7 +10762,7 @@ function ecologicalStormIntegrity(g: Game) { else { requireRule(!choices.length,'A completed station choice cannot remain open.'); if (frame.stage === 'traversal') requireRule(g.stormResolution && g.stormResolution.discoveryStorm === frame.signature && - g.stormResolution.from === frame.from && g.stormResolution.distance === frame.base+frame.delta!, + g.stormResolution.from === frame.from && g.stormResolution.distance === discoveryStormDistance(frame), 'The storm traversal has lost its exact station adjustment.'); else requireRule(!g.stormResolution?.discoveryStorm,'The completed station choice cannot retain a traversal.'); } @@ -22055,6 +22059,51 @@ function advanceBattleOutcome(g: Game) { log(g, `${getPlayer(g, offer.player).name} may retreat surviving undialed forces with Diplomat before battle losses.`, { faction: getPlayer(g, offer.player).faction, name: 'Diplomat retreat' }); } +function currentJacurutuIncome(g: Game, quote: ReturnType, retreat?: DiplomatRetreatSelection) { + const b = g.battle!; + if (b.territory !== JACURUTU_SIETCH || !g.discoveryEnabled || !quote.winner || + !g.discoveries?.tokens.some(token => token.face === 'jacurutu-sietch' && + token.status === 'placed' && token.revealedTurn !== null)) return null; + const opponent = quote.winner + ? getPlayer(g, quote.winner === b.attacker ? b.defender : b.attacker) : null; + const forces = opponent + ? combatForces(g, opponent, b.territory, getPlayer(g, opponent.id === b.attacker ? b.defender : b.attacker)) : null; + const plan = opponent ? b.plans[opponent.id] : null; + const withdrawal = quote.harassWithdraw?.find(entry => entry.player === opponent?.id); + const withdrawalDial = withdrawal && forces && plan + ? casualtyOptions(forces,plan.dial,plan.support).find(choice => + choice.normal === withdrawal.remaining.normal && choice.elite === withdrawal.remaining.elite) + : undefined; + requireRule(!withdrawal || withdrawalDial, 'Harass & Withdraw must retain its original physical dial.'); + return nexusRule(() => quoteJacurutuBattleIncome({ + territory: b.territory, + jacurutuRevealed: true, + winner: quote.winner, + opponent: opponent?.id ?? null, + opponentForces: forces, + opponentDial: plan?.dial ?? null, + opponentSupport: plan?.support ?? null, + sentToTanks: forces ? { + normal: forces.normal - (withdrawal?.returned.normal ?? 0) - (retreat?.normal ?? 0), + elite: forces.elite - (withdrawal?.returned.elite ?? 0) - (retreat?.elite ?? 0), + } : null, + selectedDial: b.jacurutuDial?.selectedDial ?? withdrawalDial, + })); +} +function jacurutuDialIntegrity(g: Game) { + const frame = g.battle?.jacurutuDial; + const decisions = homeworldSavedDecisions(g).filter(decision => decision.kind === 'jacurutuDial'); + if (!frame && !decisions.length) return; + requireRule(frame && g.battle?.revealed && g.phase === 6 && frame.event.length > 0 && + !frame.selectedDial && decisions.length === 1 && + decisions[0].player === frame.owner && decisions[0].event === frame.event && + !!g.battle.diplomatRetreat === !!frame.retreat, + 'The Jacurutu dial choice must retain its original revealed battle and defeated owner.'); + const income = currentJacurutuIncome(g, currentBattleResolutionQuote(g), frame.retreat); + requireRule(income?.kind === 'allocationRequired' && income.opponent === frame.owner && + income.winner === frame.winner && JSON.stringify(income.choices) === JSON.stringify(frame.choices), + 'The Jacurutu dial choice changed its original counters, support or Tanks losses.'); +} function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { const b = g.battle!; requireRule(!!b.diplomatRetreat === !!retreat, 'Resolve the pending Diplomat retreat before battle losses.'); @@ -22078,29 +22127,18 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { ? d : a : null; - const losingForces = losingPlayer - ? combatForces(g, losingPlayer, b.territory, losingPlayer === a ? d : a) - : null; - const losingPlan = losingPlayer ? b.plans[losingPlayer.id] : null; - const jacurutuIncome = quoteJacurutuBattleIncome({ - territory: b.territory, - jacurutuRevealed: - g.discoveryEnabled === true && - !!g.discoveries?.tokens.some( - (token) => - token.face === 'jacurutu-sietch' && - token.status === 'placed' && - token.revealedTurn !== null, - ), - winner: quote.winner, - opponent: losingPlayer?.id ?? null, - opponentForces: losingForces, - opponentDial: losingPlan?.dial ?? null, - opponentSupport: losingPlan?.support ?? null, - sentToTanks: losingForces - ? { normal: losingForces.normal, elite: losingForces.elite } - : null, - }); + const jacurutuIncome = currentJacurutuIncome(g, quote, retreat); + if (jacurutuIncome?.kind === 'allocationRequired') { + b.jacurutuDial ??= { event: crypto.randomUUID(), owner: jacurutuIncome.opponent, + winner: jacurutuIncome.winner, choices: jacurutuIncome.choices, ...(retreat ? {retreat} : {}) }; + g.decision = {kind:'jacurutuDial',player:b.jacurutuDial.owner,event:b.jacurutuDial.event}; + return; + } + if (b.jacurutuDial) { + requireRule(b.jacurutuDial.selectedDial && jacurutuIncome?.kind === 'income', + 'Commit the original physical Jacurutu dial before settling its battle.'); + delete b.jacurutuDial; + } for (const revelation of quote.revelations) { const holder = getPlayer(g, revelation.player), identity = revelation.identity; @@ -22308,14 +22346,6 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { `${winner!.name} gained ${jacurutuIncome.amount} spice from the bank at Jacurutu Sietch for ${jacurutuIncome.amount} opposing undialed force${jacurutuIncome.amount === 1 ? '' : 's'} sent to the Tanks.`, { faction: winner!.faction, name: 'Jacurutu Sietch income' }, ); - } else if (jacurutuIncome?.kind === 'allocationRequired') { - const amounts = [ - ...new Set(jacurutuIncome.choices.map((choice) => choice.amount)), - ].sort((left, right) => left - right); - log( - g, - `Jacurutu Sietch income remains unpaid: ${losingPlayer!.name}'s revealed dial and support permit ${amounts.join(' or ')} undialed physical forces among the counters sent to the Tanks. The physical dial allocation needs a player choice.`, - ); } for (const income of quote.strongholdIncome) { const player = getPlayer(g, income.player); @@ -29911,6 +29941,18 @@ function applyActionInner( if (destination === null) log(g, `${p.name} declined the Diplomat retreat.`, { faction: p.faction, name: 'Diplomat retreat' }); resolveBattle(g, { destination: destination as string | null, normal: normal as number, elite: elite as number }); + } else if (decision.kind === 'jacurutuDial') { + const frame = g.battle?.jacurutuDial; + requireRule(frame && frame.owner === id && frame.event === decision.event && action.event === decision.event && + Object.keys(action).every(key => ['type','event','allocation'].includes(key)) && + typeof action.allocation === 'number' && Number.isSafeInteger(action.allocation) && + action.allocation >= 0 && action.allocation < frame.choices.length && !frame.selectedDial, + 'Identify the physical counters represented by your current revealed Jacurutu dial.'); + const chosen = frame.choices[action.allocation]; + frame.selectedDial = jacurutuDialSelection(frame,action.allocation)!; + log(g, `${p.name} identified ${chosen.dialed.normal} normal and ${chosen.dialed.elite} elite physical counters represented by their Jacurutu dial; the battle winner receives ${chosen.amount} spice for opposing undialed counters actually sent to the Tanks.`, + {faction:p.faction,name:'Jacurutu physical dial'}); + resolveBattle(g, frame.retreat); } else if (decision.kind === 'smugglerCollection') { const receipt = g.battle?.smugglerCollection; requireRule(receipt?.event === decision.event && receipt.player === id && @@ -30347,9 +30389,11 @@ function applyActionInner( requireRule(g.ecologicalStorm && Object.keys(action).every(key => ['type','event','delta'].includes(key)), 'Choose only the current station storm adjustment.'); g.ecologicalStorm = nexusRule(() => chooseDiscoveryStorm(g,g.ecologicalStorm!,p.id,action.event,action.delta)); - g.stormPending = g.ecologicalStorm.base+g.ecologicalStorm.delta!; - log(g, `${p.name} ${g.ecologicalStorm.delta === 0 ? 'kept' : g.ecologicalStorm.delta === 1 ? 'increased' : 'decreased'} storm movement ${g.ecologicalStorm.delta === 0 ? 'at' : 'by one, to'} ${g.stormPending} sectors using Ecological Testing Station.`, {faction:p.faction,name:'Ecological Testing Station'}); - moveStorm(g,g.stormPending); + g.stormPending = nexusRule(() => discoveryStormDistance(g.ecologicalStorm!)); + log(g, `${p.name} ${action.delta === 0 ? 'kept' : action.delta === 1 ? 'increased' : 'decreased'} storm movement ${action.delta === 0 ? 'at' : 'by one, to'} ${g.stormPending} sectors using Ecological Testing Station.`, {faction:p.faction,name:'Ecological Testing Station'}); + if (g.ecologicalStorm.stage === 'choose') + g.decision = {kind:'ecologicalStorm',player:g.ecologicalStorm.owner,event:g.ecologicalStorm.event}; + else moveStorm(g,g.stormPending); } else if (decision.kind === 'greatMakerVote') { decideGreatMakerVote(g,p,action); } else if (decision.kind === 'greatMakerRide') { @@ -33000,8 +33044,11 @@ export function viewGame(state: Game, id: string) { ecazOccupyPreview: !!g.ecazOccupyPreview, strongholdCards: g.strongholdCards ?? null, discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)), + jacurutuDial: g.decision?.kind === 'jacurutuDial' && g.decision.player === id && g.battle?.jacurutuDial + ? {event:g.battle.jacurutuDial.event,owner:g.battle.jacurutuDial.owner,winner:g.battle.jacurutuDial.winner, + choices:structuredClone(g.battle.jacurutuDial.choices)} : null, ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose' - ? {...nexusRule(() => discoveryStormOffer(g))!,event:g.ecologicalStorm.event} : null, + ? {...nexusRule(() => discoveryStormOffer(g,g.ecologicalStorm))!,event:g.ecologicalStorm.event} : null, discoveryEntry: g.discoveryEntry?.stage === 'choose' && g.decision?.kind === 'discoveryEntry' && g.decision.player === id ? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null, greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null, diff --git a/game/reference.ts b/game/reference.ts index de1bc2ce..3c828ebb 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4227,8 +4227,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Native typed forces enter as their actual ordinary and elite groups, never as passengers from the mobile stronghold or the concealed value of a No-Field. Richese may ship or move a concealed marker into an actually revealed nested location at sector zero; it remains one effective presence until its original reveal materializes available reserves. CHOAM Card Stash uses its five-card limit, drawing first and choosing any held discard afterward.', 'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.', 'A sole Cistern occupant receives two bank spice during Collection. In Advanced, a sole Orgiz occupant takes one collected spice per territory where an unambiguous rival collected board spice. Multiple collected deposits in that territory do not add transfers. Basic retains its provisional one-per-positive-deposit interpretation. Contested ownership, different or shared payers leave only that uncertain transfer unpaid; ordinary Collection continues.', - 'Jacurutu automatically pays one bank spice for each opposing undialed physical force sent to the Tanks. Where Advanced normal/elite allocations would produce different rewards, this prototype records the unpaid gap pending a ruling; it never guesses from the numeric wheel alone.', - 'A sole non-advisor occupant of Ecological Testing Station may decrease ordinary storm movement by one, keep it, or increase it by one. Choose after the ordinary storm-card window, before movement and storm protection. Weather Control cannot be changed. Two-occupant adjustment order remains unresolved. An already saved pending storm without its original movement source keeps its distance until a new storm is produced.', + 'Jacurutu pays one bank spice for each opposing undialed physical force actually sent to the Tanks. Where an Advanced dial admits different physical counts, the defeated player chooses an exact ordinary/elite/paid reading before support, casualties or rewards settle; unique readings remain automatic. This choice timing is a visibly provisional implementation interpretation. Original Harass returns bind their physical dial and actual withdrawal/retreat counters do not earn spice.', + 'Each non-advisor Testing Station occupant may decrease ordinary storm movement by one, keep it or increase it by one. The provisional order is the original storm order, once per owner, with cumulative adjustments on current distance. Choose after the ordinary card window; traversal/protection starts only after all choices. Weather Control is unaffected. Original natural source/base and existing sole-frame history remain authoritative; legacy movement without a source is not invented.', 'A current non-advisor Shrine occupant may play a physical Truthtrance card as Karama, or a physical Karama as Truthtrance, within the implemented timing and effect paths. The printed identity and actual card custody remain unchanged; a committed Truthtrance conversion survives departure. This does not create missing Karama powers or settle their pending rules.', 'All four AI profiles use the same offered inspection, reveal, stash-discard, Great Maker, free-entry, Ornithopter and Testing Station choices as the player controls. Private knowledge and unfinished decisions survive saved JSON continuations. These focused paths do not establish complete variant compliance or combined-module acceptance.', ], @@ -4236,28 +4236,29 @@ export const RULE_TOPICS: RuleTopic[] = [ checklist: [ { area: 'Implementation', status: 'Partial', - detail: 'Original classic and supported paired E1/E2 or standalone E3 Skills/Nexus/Discovery, and separate paired E1/E2 no-Skills Nexus/Discovery, retain selected Tech/Advanced Strongholds alongside existing original classic/native Discovery entries. Actual Maker votes/typed rides and settled-alliance closing deals, one-invoice signed markers, training, rescue, Cyborg substitution and cleanup/Tech/Face Dance use original consumers. Held benefits stay source-local; mandatory Ecaz coalition keeps owner-labelled armies. Smuggler policies use projected authoritative support. Advanced Orgiz aggregates actual territory Collection; Basic stays provisional per deposit. Pending sources and public starts remain guarded.', + detail: 'Original classic and supported native Skills/Nexus/Discovery entries retain their original selected modules, Maker votes/typed rides, source-local held benefits, training and owner-labelled coalition aftermath. Mixed Jacurutu now pauses for an exact defeated-player physical reading before settlement, excluding actual returns from Tanks income. Testing Station uses a signed cumulative storm-order owner queue before one traversal; original source and sole-frame history remain. Advanced Orgiz aggregates territory Collection; Basic stays provisional. Other pending sources and public starts remain guarded.', evidence: ['game/discovery-module-profile.ts', 'game/richese-no-field.ts', 'game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'], }, { area: 'Player controls', status: 'Partial', - detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard with card inspection, ordered Great Maker controls, multi-sector free entry and carried-Ornithopter movement use shared table controls. Testing Station offers three legal distances and Shrine uses existing card controls. Unambiguous Jacurutu and bounded Orgiz income are automatic and logged; unresolved rewards have no invented choice.', - evidence: ['components/discoveries.tsx', 'components/discovery-entry.tsx', 'components/discovery-flight-movement.tsx', 'components/discovery-storm.tsx', 'components/truthtrance.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx', 'components/game-table.tsx'], + detail: 'Private backs and readable authorized faces, optional inspection/reveal, owned stash discard, ordered Great Maker, free entry and carried movement use existing controls. Jacurutu offers exact physical readings only to the defeated owner before settlement. Testing Station offers cumulative legal distances to each current owner in original storm order. Unique rewards remain automatic; Shrine uses original card controls.', + evidence: ['components/discoveries.tsx', 'components/jacurutu-dial.tsx', 'components/discovery-entry.tsx', 'components/discovery-flight-movement.tsx', 'components/discovery-storm.tsx', 'components/truthtrance.tsx', 'components/great-maker.tsx', 'components/spice-card-inspector.tsx', 'components/game-table.tsx'], }, { area: 'AI', status: 'Partial', - detail: 'Four profiles share the private quoted token, discard, vote, reserve-ride, free-entry, carried-Ornithopter and conservative Testing Station actions. Shrine uses the existing legal Karama and Truthtrance policies. Complete Discovery strategy, combined games and strength calibration remain unfinished.', + detail: 'Four profiles share private quoted inspection, discard, vote, ride, entry and carried-flight actions. Properly configured policies select a legal Jacurutu physical reading and each current Testing Station owner uses the same cumulative controls. Shrine uses original Karama/Truthtrance policies. Strategy, combined games and calibration remain unfinished.', evidence: ['game/discovery-options.ts', 'game/discovery-entry-options.ts', 'game/discovery-flight-options.ts', 'game/discovery-storm-options.ts', 'game/shrine.ts', 'game/great-maker-options.ts', 'game/bots.ts'], }, { area: 'Documentation', status: 'Partial', - detail: 'Sourced identities and native/classic-skill boundaries are recorded. Advanced Orgiz follows the authorized territory-Collection trigger; Basic keeps its provisional deposit mapping. Great Maker first-turn/Sandtrout, exhausted supply, entry order and source-storm handling remain explicit inferences. Contested Cistern/Testing Station/Orgiz, mixed-force Jacurutu and shared-lot theft are unresolved.', + detail: 'Sourced identities and native/classic-skill boundaries are recorded. Advanced Orgiz follows territory Collection; Basic remains provisional per deposit. Recommended defeated-player physical Jacurutu allocation and cumulative storm-order Testing Station choices are explicit, visible prototype interpretations, not new publisher rulings. Great Maker, entry and source-storm inferences remain; contested Cistern/Orgiz and shared-lot theft stay open.', evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md'], }, { area: 'Verification', status: 'Partial', - detail: 'Mixed E1/E2 and paired E3 preserves Discovery7/8/all12 Nexus, optional all14/HW/Tech/Advanced Strongholds. New31 physical programmes plus2 stock regressions; final489/489 affected checks across42 files pass with types/lint. Across64 original families,258/288 games finish338931/339426 actions and9013 JSON across three frozen trees and preserved continuations. Native Ecaz assassination, actual separate Loyalty stock and existing selected overlap order are repaired without new printed rulings. Actual original Maker votes/both piles and native card draws retain physical sources; no human nested entry or winning Suk is invented. Source guards and full acceptance remain open.', + detail: 'Current recommended Jacurutu/Testing Station batch passes104/104 in12 related files, types/lint and build. Genuine Advanced dial2/support1 produces1-or0 reward with the same original losses, support and later Collection; unique readings stay automatic. Original Basic/Advanced station sources, real alliance/garrisons and390px first/second cumulative choices preserve one final traversal, source and Weather exclusion. All four legal policies are exercised. Earlier native-family evidence remains source-bound; broader combinations, assurance, strategy and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', + 'tests/fixture-jacurutu-mixed-dial.ts', 'tests/jacurutu-mixed-dial-runtime.test.ts', 'tests/fixture-testing-station-contest.ts', 'tests/testing-station-contest-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'], }, ], diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index b9ec2a3b..10439c22 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -5,6 +5,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards'; // are envelope checks, not a duplicate implementation of each future choice. const DECISIONS = { ecazBattleLead: true, + jacurutuDial: true, homeworldOccupiedIncome: true, homeworldOccupiedPercentage: true, homeworldOccupiedBonus: true, diff --git a/tests/discovery-battle.test.ts b/tests/discovery-battle.test.ts index 9f4c928e..de54e9ff 100644 --- a/tests/discovery-battle.test.ts +++ b/tests/discovery-battle.test.ts @@ -1,8 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + jacurutuDialSelection, quoteJacurutuBattleIncome, - JACURUTU_BATTLE_INCOME_RULE, + type JacurutuDialOffer, } from '../game/discovery-battle'; import { applyAction, normalizeAutomaticGame, type Game } from '../game/engine'; import { JACURUTU_SIETCH } from '../game/discoveries'; @@ -11,7 +12,6 @@ import { discoveryBattleBeforeFinalVote } from './fixture-discovery-battle'; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); void test('the source-bound contract quotes only opposing undialed counters actually sent to the Tanks', () => { - assert.match(JACURUTU_BATTLE_INCOME_RULE, /opposing undialed force/); const ordinary = { normal: 5, elite: 0, @@ -148,7 +148,6 @@ void test('real Basic and Advanced battles pay once from the losing plan and add ).length, 1, ); - assert.match(done.log.at(-1)!.text, /Jacurutu|battle|Ready|won/i); const stable = reload(done); assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable); } @@ -198,3 +197,123 @@ void test('a real traitor winner still receives only the opposing undialed Tanks 1, ); }); + +const mixedDialInput = { + territory: JACURUTU_SIETCH, + jacurutuRevealed: true, + winner: 'winner', + opponent: 'emperor', + opponentForces: { + normal: 1, + elite: 1, + eliteStrength: 2 as const, + freeSupport: false, + }, + opponentDial: 2, + opponentSupport: 1, + sentToTanks: { normal: 1, elite: 1 }, +}; + +void test('the defeated mixed dial offers both physical readings and the index resolver returns them', () => { + const pending = quoteJacurutuBattleIncome(mixedDialInput); + assert.equal(pending?.kind, 'allocationRequired'); + if (pending?.kind !== 'allocationRequired') return; + assert.deepEqual( + pending.choices.map((choice) => choice.amount), + [1, 0], + ); + assert.deepEqual( + pending.choices.map((choice) => choice.dialed), + [ + { normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }, + { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 }, + ], + ); + assert.deepEqual(pending.sentToTanks, { normal: 1, elite: 1 }); + const offer: JacurutuDialOffer = { + event: 'event', + owner: 'emperor', + winner: 'winner', + choices: pending.choices, + }; + assert.deepEqual(jacurutuDialSelection(offer, 0), { + normal: 0, + elite: 1, + paidNormal: 0, + paidElite: 1, + }); + assert.deepEqual(jacurutuDialSelection(offer, 1), { + normal: 1, + elite: 1, + paidNormal: 1, + paidElite: 0, + }); + for (const allocation of [2, -1, 0.5, Number.NaN]) + assert.equal(jacurutuDialSelection(offer, allocation), null); + for (const [allocation, amount] of [ + [0, 1], + [1, 0], + ] as const) + assert.deepEqual( + quoteJacurutuBattleIncome({ + ...mixedDialInput, + selectedDial: pending.choices[allocation].dialed, + }), + { + kind: 'income', + winner: 'winner', + opponent: 'emperor', + amount, + sentToTanks: { normal: 1, elite: 1 }, + dialed: [pending.choices[allocation].dialed], + }, + ); +}); + +void test('a selected reading must be a legal allocation of the revealed dial and never exceed the Tanks', () => { + // The same counters with the other paid distribution describe a different + // committed strength and are not a reading of this dial and support. + assert.throws( + () => + quoteJacurutuBattleIncome({ + ...mixedDialInput, + selectedDial: { normal: 1, elite: 1, paidNormal: 0, paidElite: 1 }, + }), + /legal physical allocation/, + ); + // The revealed plan and support cannot have dialed only the ordinary counter. + assert.throws( + () => + quoteJacurutuBattleIncome({ + ...mixedDialInput, + selectedDial: { normal: 1, elite: 0, paidNormal: 1, paidElite: 0 }, + }), + /legal physical allocation/, + ); + // A real reading of the plan that the settled Tanks losses cannot contain. + assert.throws( + () => + quoteJacurutuBattleIncome({ + ...mixedDialInput, + sentToTanks: { normal: 0, elite: 1 }, + selectedDial: { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 }, + }), + /exceeds/, + ); + // Withdrawal leaves the committed Sardaukar reading and a zero reward. + assert.deepEqual( + quoteJacurutuBattleIncome({ + ...mixedDialInput, + sentToTanks: { normal: 0, elite: 1 }, + selectedDial: { normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }, + }), + { + kind: 'income', + winner: 'winner', + opponent: 'emperor', + amount: 0, + sentToTanks: { normal: 0, elite: 1 }, + dialed: [{ normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }], + }, + ); +}); diff --git a/tests/discovery-storm.test.ts b/tests/discovery-storm.test.ts index 9b828400..96359c38 100644 --- a/tests/discovery-storm.test.ts +++ b/tests/discovery-storm.test.ts @@ -11,10 +11,13 @@ import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { discoveryStormActions } from '../game/discovery-storm-options'; import { + createDiscoveryStorm, createStormSource, + discoveryStormDistance, discoveryStormOffer, discoveryStormSignature, } from '../game/discovery-storm'; +import { fighterCount } from '../game/advisors'; import { discoveryStormFixture, confirmDiscoveryStorm, @@ -90,25 +93,20 @@ void test('Weather Control replaces natural provenance and bypasses the station, g = applyAction(g, 'a', { type: 'card', card: card.id, amount }); assert.equal(g.stormMovementSource?.kind, 'weather'); assert.deepEqual(g.ready, []); - assert.match(discoveryStormOffer(g)!.blocked!, /Weather Control/); g = confirmDiscoveryStorm(reload(g)); assert.equal(g.ecologicalStorm, undefined); assert.equal(g.storm, 6 + amount); assert.equal(g.discard.filter((c) => c.id === card.id).length, 1); } }); -void test('unoccupied, contested and legacy unknown-source storms continue without inventing entitlement or changing distance', () => { - for (const mode of ['empty', 'contested', 'legacy']) { +void test('unoccupied and legacy unknown-source storms continue without inventing entitlement or changing distance', () => { + for (const mode of ['empty', 'legacy']) { let g = discoveryStormFixture(); if (mode === 'empty') { g.players[0].reserves += g.players[0].forces['ecological-testing-station:0']; delete g.players[0].forces['ecological-testing-station:0']; } - if (mode === 'contested') { - g.players[1].reserves--; - g.players[1].forces['ecological-testing-station:0'] = 1; - } if (mode === 'legacy') delete g.stormMovementSource; g = confirmDiscoveryStorm(g); assert.equal(g.ecologicalStorm, undefined); @@ -198,3 +196,184 @@ void test('adjusted Advanced storm retains its receipt through Fremen protection assert.equal(g.ecologicalStorm?.stage, 'complete'); assert.equal(g.storm, 9); }); + +const stationKey = 'ecological-testing-station:0'; +/** A second distinct non-advisor occupant beside the actual entrant. */ +function contest(g: Game, second = 'g'): Game { + const player = g.players.find((candidate) => candidate.id === second)!; + player.reserves -= 2; + player.forces[stationKey] = (player.forces[stationKey] ?? 0) + 2; + return g; +} +const occupants = (g: Game) => + g.order.filter((id) => { + const player = g.players.find((candidate) => candidate.id === id)!; + return fighterCount(player, 'ecological-testing-station') > 0; + }); +const control = (g: Game, id: string, delta: number) => { + const action = discoveryStormActions(viewGame(g, id)).find( + (candidate) => candidate.delta === delta, + ); + assert.ok(action, `${id} must receive a legal ${delta} station control.`); + return action; +}; + +void test('two original non-advisor occupants each choose once in original storm order with one cumulative traversal', () => { + let g = confirmDiscoveryStorm(contest(discoveryStormFixture())); + const before = reload(g); + const order = occupants(g); + assert.equal(order.length, 2); + assert.notEqual(order[0], order[1]); + assert.equal(g.decision?.kind, 'ecologicalStorm'); + assert.equal(g.decision?.player, order[0]); + assert.equal(g.ecologicalStorm?.owner, order[0]); + assert.deepEqual(g.ecologicalStorm?.queue, { order, choices: [] }); + assert.equal(g.storm, 6); + const event = g.decision!.event; + assert.equal(viewGame(g, order[0]).ecologicalStorm?.base, 2); + assert.equal(viewGame(g, order[1]).ecologicalStorm, null); + assert.deepEqual(discoveryStormActions(viewGame(g, order[1])), []); + reject(g, order[1], control(g, order[0], 1)); + reject(g, order[0], { ...control(g, order[0], 1), event: `${event}:1` }); + reject(g, order[0], { ...control(g, order[0], 1), distance: 1 }); + for (const delta of [-2, 2, 0.5, NaN]) + reject(g, order[0], { ...control(g, order[0], 0), delta }); + assert.deepEqual(g, before); + g = applyAction(reload(g), order[0], control(g, order[0], 1)); + assert.equal(g.ecologicalStorm?.stage, 'choose'); + assert.equal(g.ecologicalStorm?.owner, order[1]); + assert.equal(g.decision?.player, order[1]); + assert.ok(g.decision?.kind === 'ecologicalStorm'); + assert.equal(g.decision?.event, `${event}:1`); + assert.equal(discoveryStormDistance(g.ecologicalStorm!), 3); + assert.equal(g.stormPending, 3); + assert.equal(g.storm, 6); + assert.equal(g.phase, 0); + assert.deepEqual(normalizeAutomaticGame(reload(g)), g); + reject(g, order[0], control(before, order[0], 1)); + reject(g, order[1], { ...control(g, order[1], 0), event }); + const second = viewGame(g, order[1]).ecologicalStorm!; + assert.deepEqual(second.options.map((option) => option.distance), [2, 3, 4]); + assert.equal(viewGame(g, order[0]).ecologicalStorm, null); + g = applyAction(reload(g), order[1], control(g, order[1], -1)); + assert.equal(g.ecologicalStorm?.stage, 'complete'); + assert.deepEqual(g.ecologicalStorm?.queue?.choices, [ + { owner: order[0], delta: 1 }, + { owner: order[1], delta: -1 }, + ]); + assert.equal(g.storm, 8); + assert.equal(g.phase, 1); + assert.equal(g.stormMovementSource, undefined); + assert.equal(g.stormResolution, null); + assert.deepEqual(normalizeAutomaticGame(reload(g)), g); + reject(g, order[1], { type: 'decision', event: `${event}:1`, delta: 0 }); + reject(g, order[0], { type: 'decision', event, delta: 1 }); +}); + +void test('a contested station shares one cumulative bound: two decreases reach zero and opposite choices cancel', () => { + let g = contest(discoveryStormFixture()); + g.stormPending = 3; + g.stormMovementSource = createStormSource(g.turn, 'dials', 3); + g = confirmDiscoveryStorm(g); + const order = occupants(g); + let floored = applyAction(reload(g), order[0], control(g, order[0], -1)); + assert.equal(discoveryStormDistance(floored.ecologicalStorm!), 2); + assert.deepEqual( + discoveryStormOffer(floored, floored.ecologicalStorm!)!.options.map( + (option) => option.distance, + ), + [1, 2, 3], + ); + floored = applyAction(reload(floored), order[1], control(floored, order[1], -1)); + assert.equal(discoveryStormDistance(floored.ecologicalStorm!), 1); + const single = contest(discoveryStormFixture()); + single.stormPending = 1; + single.stormMovementSource = createStormSource(single.turn, 'dials', 1); + let one = confirmDiscoveryStorm(single); + const oneOrder = occupants(one); + one = applyAction(reload(one), oneOrder[0], control(one, oneOrder[0], -1)); + assert.deepEqual( + discoveryStormOffer(one, one.ecologicalStorm!)!.options.map( + (option) => option.delta, + ), + [0, 1], + ); + reject(one, oneOrder[1], { + type: 'decision', + event: one.ecologicalStorm!.event, + delta: -1, + }); + one = applyAction(reload(one), oneOrder[1], control(one, oneOrder[1], 0)); + assert.equal(discoveryStormDistance(one.ecologicalStorm!), 0); + assert.equal(one.storm, 6); + assert.equal(one.phase, 1); + const ceiling = contest(discoveryStormFixture()); + ceiling.storm = 6; + ceiling.stormPending = 40; + ceiling.stormMovementSource = createStormSource(ceiling.turn, 'dials', 40); + let high = confirmDiscoveryStorm(ceiling); + const highOrder = occupants(high); + assert.deepEqual( + discoveryStormOffer(high, high.ecologicalStorm!)!.options.map( + (option) => option.delta, + ), + [-1, 0], + ); + reject(high, highOrder[0], { + type: 'decision', + event: high.ecologicalStorm!.event, + delta: 1, + }); + high = applyAction(reload(high), highOrder[0], control(high, highOrder[0], -1)); + assert.equal(discoveryStormDistance(high.ecologicalStorm!), 39); + high = applyAction(reload(high), highOrder[1], control(high, highOrder[1], 1)); + assert.equal(discoveryStormDistance(high.ecologicalStorm!), 40); + assert.equal(high.storm, ((6 - 1 + 40) % 18) + 1); +}); + +void test('a lone Bene Gesserit advisor group is not a station occupant and never receives a contested choice', () => { + const g = contest(discoveryStormFixture()); + const advisor = g.players[1]; + advisor.faction = 'beneGesserit'; + advisor.advisors = { 'ecological-testing-station': {} }; + assert.equal(occupants(g).includes(advisor.id), false); + assert.deepEqual(occupants(g), [g.players[0].id]); + const frame = createDiscoveryStorm(g, 'advisor-exclusion'); + assert.equal(frame.queue, undefined); + assert.equal(frame.owner, g.players[0].id); + assert.equal(discoveryStormOffer(g, frame)!.owner, g.players[0].id); +}); + +void test('Weather Control denies every occupant of a contested station', () => { + for (const advanced of [false, true]) { + let g = contest(discoveryStormFixture(advanced)); + const card = holdStormCard(g, 'a', 'weather'); + g = applyAction(g, 'g', { type: 'ready' }); + g = applyAction(g, 'a', { type: 'card', card: card.id, amount: 2 }); + assert.equal(g.stormMovementSource?.kind, 'weather'); + assert.equal(discoveryStormActions(viewGame(g, occupants(g)[0])).length, 0); + g = confirmDiscoveryStorm(reload(g)); + assert.equal(g.ecologicalStorm, undefined); + assert.equal(g.storm, 8); + } +}); + +void test('every AI level submits the second occupant’s remaining cumulative choice', () => { + const g = confirmDiscoveryStorm(contest(discoveryStormFixture(true))); + const order = occupants(g); + const committed = applyAction( + reload(g), + order[0], + control(g, order[0], 0), + ); + for (const level of DIFFICULTIES) { + const view = viewGame(committed, order[1]); + view.players.find((player) => player.id === order[1])!.bot = level; + const actions = botActions(view); + assert.ok(actions.length); + for (const action of actions) + assert.doesNotThrow(() => applyAction(reload(committed), order[1], action)); + } + assert.equal(committed.decision?.player, order[1]); + assert.equal(discoveryStormDistance(committed.ecologicalStorm!), 2); +}); diff --git a/tests/fixture-jacurutu-mixed-dial.ts b/tests/fixture-jacurutu-mixed-dial.ts new file mode 100644 index 00000000..5a7dfd60 --- /dev/null +++ b/tests/fixture-jacurutu-mixed-dial.ts @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict'; +import { + applyAction, + battles, + createGame, + initializeDiscoveryGameForAudit, + joinGame, + newPlayer, + viewGame, + type Action, + type Game, + type Player, +} from '../game/engine'; +import { + DISCOVERY_CARD_PLACEMENTS, + JACURUTU_SIETCH, + type DiscoveryOpaqueTokenId, +} from '../game/discoveries'; +import type { JacurutuDialOffer } from '../game/discovery-battle'; +import type { Casualties } from '../game/combat'; +import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; + +export type JacurutuMixedDialStep = { actor: string; action: Action }; + +export type JacurutuMixedDialOptions = { + /** The defeated Emperor's actual revealed plan. Dial 2 with support 1 leaves + * two legal physical readings of the same wheel; dial 1 with support 0 + * leaves exactly one. */ + dial?: number; + support?: number; +}; + +export type JacurutuMixedDialFixture = { + /** Authenticated fresh lobby before any deal. */ + initial: Game; + /** The original physical Discovery setup. */ + setup: Game; + /** The genuine battle in Jacurutu Sietch before its final traitor vote. */ + game: Game; + actions: JacurutuMixedDialStep[]; + staging: string[]; + /** Actual battle winner (Atreides). */ + attacker: string; + /** Actual battle loser (Emperor) holding one ordinary and one Sardaukar. */ + defender: string; + observer: string; + territory: string; + /** The surrounding territory the defeated forces entered from. */ + parent: string; + token: string; + /** The last original traitor vote that completes the reveal. */ + finalVote: JacurutuMixedDialStep; + defenderPlan: Action; + attackerPlan: Action; + defenderLeader: string; + attackerLeader: string; + /** Physical counters the defeated army actually sent to the Tanks. */ + defenderTanks: { normal: number; elite: number }; + dial: number; + support: number; +}; + +/** Engine surface owned by the parent integration; asserted here as a contract. */ +export type JacurutuBattleReceipt = JacurutuDialOffer & { + selectedDial?: Casualties | null; +}; +export type JacurutuDialDecision = { kind: 'jacurutuDial'; player: string; event: string }; + +export function jacurutuBattleReceipt(game: Game): JacurutuBattleReceipt | null { + const battle = game.battle as (Game['battle'] & { jacurutuDial?: JacurutuBattleReceipt }) | null; + return battle?.jacurutuDial ? structuredClone(battle.jacurutuDial) : null; +} + +export function jacurutuCurrentDecision(game: Game): JacurutuDialDecision | null { + const decision = game.decision as JacurutuDialDecision | null; + return decision?.kind === 'jacurutuDial' ? decision : null; +} + +const EMPEROR = 'emperor'; +const ATREIDES = 'atreides'; + +type JacurutuProgress = { token: DiscoveryOpaqueTokenId | null; emperorShipped: boolean; atreidesShipped: boolean }; + +function clean(game: Game) { + return !game.phaseOpening && !game.response && !game.decision; +} + +/** Genuine native policy plus the four real actions this scenario needs. */ +function nextJacurutuMixedDialStep(game: Game, progress: JacurutuProgress): JacurutuMixedDialStep { + if (game.decision?.kind === 'discoveryEntry') { + const owner = game.decision.player; + const offer = viewGame(game, owner).discoveryEntry!; + if (owner === EMPEROR && !offer.blocked) + return { actor: owner, action: { type: 'decision', event: game.decision.event, accept: true, + groups: offer.sources.map(({ source, normal, elite }) => ({ source, normal, elite })) } }; + return { actor: owner, action: { type: 'decision', event: game.decision.event, accept: false } }; + } + // Collection: the sole surrounding occupant inspects then reveals the token. + if (game.phase === 7 && clean(game) && progress.token) { + const discoveries = viewGame(game, EMPEROR).discoveries!; + if (discoveries.canInspect.includes(progress.token)) + return { actor: EMPEROR, action: { type: 'discovery', token: progress.token, reveal: false } }; + if (discoveries.canReveal.includes(progress.token)) + return { actor: EMPEROR, action: { type: 'discovery', token: progress.token, reveal: true } }; + } + if (game.phase === 5 && clean(game)) { + if (game.turn === 1 && game.active === EMPEROR && !progress.emperorShipped) { + progress.emperorShipped = true; + return { actor: EMPEROR, action: { type: 'ship', territory: DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'].territory, + sector: DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'].sector, amount: 2, elite: 1, allyPayment: 0 } }; + } + if (game.turn === 2 && game.active === ATREIDES && !progress.atreidesShipped) { + progress.atreidesShipped = true; + return { actor: ATREIDES, action: { type: 'ship', territory: JACURUTU_SIETCH, sector: 0, + amount: 3, elite: 0, allyPayment: 0 } }; + } + return { actor: game.active!, action: { type: 'endMovement' } }; + } + if (game.phase === 6 && !game.battle && clean(game)) { + const pending = battles(game); + if (pending.length) { + const choice = pending[0]; + assert.equal(choice.territory, JACURUTU_SIETCH, 'Only the revealed nested location may hold this battle.'); + const chooser = choice.chooser ?? choice.attacker; + return { actor: chooser, action: { type: 'chooseBattle', territory: choice.territory, + target: choice.attacker === chooser ? choice.defender : choice.attacker } }; + } + } + const next = nextSpiceBankerIncomeNativeStep(game); + assert.ok(next, `No genuine Jacurutu dial step at ${game.status}/${game.turn}/${game.phase}.`); + return next; +} + +function advance( + state: Game, + until: (game: Game) => boolean, + progress: JacurutuProgress, + actions: JacurutuMixedDialStep[], +): Game { + let game = state; + for (let step = 0; step < 1500 && !until(game); step++) { + assert.notEqual(game.status, 'finished'); + const next = nextJacurutuMixedDialStep(game, progress); + actions.push(next); + game = applyAction(game, next.actor, next.action); + } + assert.ok(until(game), 'The genuine Jacurutu dial policy did not reach its requested window.'); + return game; +} + +/** Control only the actual placement lottery of the real Discovery blow. */ +function withPlacement(game: Game, operation: () => T): T { + const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === 'hiereg'); + const selected = eligible.findIndex(token => token.face === JACURUTU_SIETCH); + assert.ok(selected >= 0, 'The original Hiereg supply must still hold Jacurutu Sietch.'); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) + array[0] = Math.floor((selected + 0.5) / eligible.length * 0x100000000); + else Reflect.apply(original, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +function leaderOf(game: Game, id: string, strongest: boolean): Player['leaders'][number] { + const player = game.players.find(seat => seat.id === id)!; + const leader = player.leaders.filter(candidate => !candidate.dead && candidate.usedAt === undefined) + .sort((left, right) => strongest ? right.strength - left.strength : left.strength - right.strength)[0]; + assert.ok(leader, `Faction ${player.faction} must still hold an unused leader.`); + return leader; +} + +function applyStep(game: Game, step: JacurutuMixedDialStep, actions: JacurutuMixedDialStep[]): Game { + actions.push(step); + return applyAction(game, step.actor, step.action); +} + +/** + * Genuine Advanced Discovery runtime: original setup, first real Storm, the + * actual Hiereg Discovery blow placing Jacurutu Sietch at its printed + * destination, a real Emperor shipment beside it, the real private inspect and + * reveal, the real next-turn free entry, a real Atreides shipment into the + * revealed location and the real declared battle. The only staging is the + * order of four original unplayed Spice Cards and the placement lottery; no + * card, wallet, force, phase, hand or receipt is written directly. + */ +export function createJacurutuMixedDialFixture(options: JacurutuMixedDialOptions = {}): JacurutuMixedDialFixture { + const dial = options.dial ?? 2; + const support = options.support ?? 1; + const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + assert.equal(placement.type, 'hiereg'); + let game = createGame('JACURUTUDIAL', newPlayer(EMPEROR, 'Emperor', 'emperor'), true, []); + joinGame(game, newPlayer(ATREIDES, 'Atreides', 'atreides')); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + assert.deepEqual(game.expansions, []); + for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + game.discoveryEnabled = true; + const initial = structuredClone(game); + game = initializeDiscoveryGameForAudit(game); + const setup = structuredClone(game); + const actions: JacurutuMixedDialStep[] = []; + const staging: string[] = []; + // 1. The original setup and the first real Storm. + game = advance(game, state => state.status === 'playing', { token: null, emperorShipped: false, atreidesShipped: false }, actions); + const discovery = game.spiceDeck.findIndex(card => 'discovery' in card && card.discovery === 'discovery-hagga-basin'); + assert.ok(discovery >= 0); + game.spiceDeck.unshift(game.spiceDeck.splice(discovery, 1)[0]); + for (let position = 1; position < 4; position++) { + const ordinary = game.spiceDeck.findIndex((card, index) => index >= position && card !== undefined && + 'territory' in card && !card.discovery && card.territory !== placement.territory); + assert.ok(ordinary >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]); + } + staging.push('Reordered four original unplayed Spice Cards so the real first blow is the Hiereg Discovery card; selected only the actual placement lottery.'); + const progress: JacurutuProgress = { token: null, emperorShipped: false, atreidesShipped: false }; + game = advance(game, state => state.phase === 1 && clean(state), progress, actions); + game = withPlacement(game, () => advance(game, + state => state.discoveries!.tokens.some(token => token.face === JACURUTU_SIETCH && token.status === 'placed'), + progress, actions)); + const token = game.discoveries!.tokens.find(candidate => candidate.face === JACURUTU_SIETCH)!; + assert.equal(token.status, 'placed'); + assert.equal(token.territory, placement.territory); + assert.equal(token.sector, placement.sector); + assert.notEqual(token.sector, game.storm); + progress.token = token.id; + // 2. The real Emperor shipment in his own first Movement turn beside the + // face-down token, then the real private inspect and reveal in that turn's + // Collection. The storm order never decides this sequence. + game = advance(game, state => state.phase === 5 && clean(state) && state.active === EMPEROR, progress, actions); + game = advance(game, state => state.discoveries!.tokens.find(candidate => candidate.id === token.id)!.revealedTurn === 1, progress, actions); + // 3. The real next-turn free entry into the revealed location. + game = advance(game, state => state.decision?.kind === 'discoveryEntry' && state.decision.player === EMPEROR, progress, actions); + game = applyStep(game, nextJacurutuMixedDialStep(game, progress), actions); + const entered = game.players.find(player => player.id === EMPEROR)!; + const enteredKey = `${JACURUTU_SIETCH}:0`; + assert.equal(entered.forces[enteredKey], 2, 'The real free entry must move both undialed counters.'); + assert.equal(entered.elites!.forces[enteredKey], 1); + // 4. The real Atreides shipment into the revealed location and the battle. + game = advance(game, state => state.phase === 6 && !!state.battle && clean(state) && !state.battle.preparation, progress, actions); + const defenderLeader = leaderOf(game, EMPEROR, false); + const attackerLeader = leaderOf(game, ATREIDES, true); + assert.ok(attackerLeader.strength >= defenderLeader.strength + 3, 'The real strength comparison must leave no tie.'); + const defenderPlan: Action = { type: 'battlePlan', leader: defenderLeader.id, dial, support }; + const attackerPlan: Action = { type: 'battlePlan', leader: attackerLeader.id, dial: 2, support: 2 }; + for (const [actor, plan] of [[EMPEROR, defenderPlan], [ATREIDES, attackerPlan]] as const) { + for (let step = 0; step < 20 && (!clean(game) || game.battle?.preparation); step++) + game = applyStep(game, nextJacurutuMixedDialStep(game, progress), actions); + game = applyStep(game, { actor, action: plan }, actions); + } + const defender = game.players.find(player => player.id === EMPEROR)!; + assert.equal(game.battle!.plans[EMPEROR]!.dial, dial); + assert.equal(game.battle!.plans[EMPEROR]!.support, support); + assert.equal(game.battle!.plans[ATREIDES]!.dial, 2); + assert.equal(defender.forces[enteredKey], 2); + assert.equal(defender.elites!.forces[enteredKey], 1); + // Hold back the final original traitor vote so the caller completes the reveal. + for (let step = 0; step < 20; step++) { + const voters = viewGame(game, game.host).battle!.traitorVoters + .filter(id => game.battle!.traitorCalls[id] === undefined); + if (voters.length <= 1) break; + game = applyStep(game, { actor: voters[0], action: { type: 'traitorCall', call: false } }, actions); + } + const voters = viewGame(game, game.host).battle!.traitorVoters + .filter(id => game.battle!.traitorCalls[id] === undefined); + assert.equal(voters.length, 1, 'One original traitor vote must still be pending.'); + const finalVote: JacurutuMixedDialStep = { actor: voters[0], action: { type: 'traitorCall', call: false } }; + return { + initial, setup, game, actions, staging, + attacker: ATREIDES, defender: EMPEROR, observer: 'guild', + territory: JACURUTU_SIETCH, parent: `${placement.territory}:${placement.sector}`, token: token.id, + finalVote, defenderPlan, attackerPlan, + defenderLeader: defenderLeader.id, attackerLeader: attackerLeader.id, + defenderTanks: { normal: 1, elite: 1 }, dial, support, + }; +} diff --git a/tests/fixture-testing-station-contest.ts b/tests/fixture-testing-station-contest.ts new file mode 100644 index 00000000..3dafa5a8 --- /dev/null +++ b/tests/fixture-testing-station-contest.ts @@ -0,0 +1,442 @@ +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeDiscoveryGameForAudit, + joinGame, + newPlayer, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { fighterCount } from '../game/advisors'; +import { DISCOVERY_CARD_PLACEMENTS, type DiscoveryTokenFace } from '../game/discoveries'; +import { discoveryStormActions } from '../game/discovery-storm-options'; +import { PLAYER_CIRCLE_SECTORS } from '../game/player-positions'; +import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; + +export type TestingStationContestStep = { actor: string; action: Action }; +export type TestingStationContestFixture = { + initial: Game; + setup: Game; + afterSetup: Game; + /** Natural turn-3 Storm phase with two actual occupants and the ordinary source. */ + game: Game; + /** The same turn-3 Storm phase before any dial, card or ready: the ordinary window. */ + beforeStorm: Game; + actions: TestingStationContestStep[]; + staging: string[]; + owner: string; + second: string; + token: string; + parent: string; + parentSector: number; + source: { turn: number; kind: string; distance: number }; + /** The two occupants in original storm order. */ + order: [string, string]; + event: string; +}; + +const STATION = 'ecological-testing-station'; + +/** Printed player-circle storm order for the board's current storm position. */ +export function testingStationStormOrder( + game: Game, + ids: readonly string[], +): string[] { + const distance = (id: string) => + (PLAYER_CIRCLE_SECTORS[game.playerPositions![id] - 1] - game.storm + 18) % + 18 || 18; + return [...ids].sort((a, b) => distance(a) - distance(b)); +} + +export function testingStationOccupants(game: Game): string[] { + return game.order.filter((id) => { + const player = game.players.find((candidate) => candidate.id === id); + return !!player && fighterCount(player, STATION) > 0; + }); +} + +const clean = (game: Game) => + !game.response && !game.phaseOpening && !game.decision; + +/** Real native continuations; only the target turn-three station window stays open. */ +export function nextTestingStationContestStep( + game: Game, +): TestingStationContestStep | null { + if (game.status === 'finished') return null; + if (game.decision?.kind === 'ecologicalStorm') { + if (game.turn >= 3) return null; + const actor = game.decision.player; + const action = discoveryStormActions(viewGame(game,actor)).find(choice => choice.delta === 0); + assert.ok(action, 'Earlier natural station opportunities must preserve the ordinary distance.'); + return {actor,action}; + } + if (game.decision?.kind === 'discoveryEntry') { + const decision = game.decision; + const offer = viewGame(game, decision.player).discoveryEntry; + return { + actor: decision.player, + action: + offer && !offer.blocked && offer.sources.length + ? { + type: 'decision', + event: decision.event, + accept: true, + groups: offer.sources, + } + : { type: 'decision', event: decision.event, accept: false }, + }; + } + // Optional native offers stay declined so no unrelated ride, Ambassador or + // Battle can change the two-occupant board under audit. + if (game.decision?.kind === 'wormRide') + return { actor: game.decision.player, action: { type: 'decision', accept: false } }; + if ( + game.decision?.kind === 'ecazPlacement' || + game.decision?.kind === 'ecazAmbassador' + ) + return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + return nextSpiceBankerIncomeNativeStep(game); +} + +function step( + game: Game, + actions?: TestingStationContestStep[], +): Game { + const next = nextTestingStationContestStep(game); + assert.ok( + next, + `Original Discovery/Ecaz continuation stopped at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`, + ); + actions?.push(structuredClone(next)); + return applyAction(game, next.actor, next.action); +} + +export function advanceTestingStationContest( + state: Game, + until: (game: Game) => boolean, + actions?: TestingStationContestStep[], +): Game { + let game = state; + for (let n = 0; n < 2600; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + game = step(game, actions); + } + throw Error('Original Discovery/Ecaz chronology did not reach its boundary.'); +} + +/** Conserved staging only: original unplayed Spice Cards move to the front. */ +function frontCard( + game: Game, + position: number, + matches: (card: Game['spiceDeck'][number]) => boolean, +): void { + const index = game.spiceDeck.findIndex( + (card, i) => i >= position && matches(card), + ); + assert.ok( + index >= position, + 'The original unplayed Spice Card must remain available.', + ); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); +} + + +/** Only the actual Hiereg supply lottery is selected; token identity stays real. */ +function selectedTokenLottery( + game: Game, + face: DiscoveryTokenFace, + operation: () => T, +): T { + const pool = game + .discoveries!.tokens.filter( + (token) => token.status === 'supply' && token.type === 'hiereg', + ) + .map((token) => token.face); + const index = pool.indexOf(face); + assert.ok(index >= 0, 'The station Hiereg token must remain in supply.'); + const scalar = Math.floor(((index + 0.5) / pool.length) * 0x100000000); + const descriptor = Object.getOwnPropertyDescriptor( + crypto, + 'getRandomValues', + ); + const native = crypto.getRandomValues.bind(crypto); + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) { + array[0] = scalar; + return array; + } + return Reflect.apply(native, crypto, [array]) as V; + }; + try { + return operation(); + } finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Held-card staging only: a real unplayed Weather Control card moves to a hand. */ +export function holdTestingStationStormCard( + game: Game, + owner: string, + effect: string, +) { + const card = [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand)] + .find((candidate) => candidate.effect === effect); + assert.ok(card, 'The original Treachery deck must still hold that card.'); + game.deck = game.deck.filter((candidate) => candidate.id !== card.id); + game.discard = game.discard.filter((candidate) => candidate.id !== card.id); + for (const player of game.players) + player.hand = player.hand.filter((candidate) => candidate.id !== card.id); + game.players.find((player) => player.id === owner)!.hand.push(card); + return card; +} + +/** Original fresh Ecaz/Guild/Fremen/Emperor Discovery setup. Two distinct + * non-advisor occupants reach the revealed station through real controls: the + * first free-enters from the printed Hagga Basin destination, a real worm Nexus + * forms the reciprocal alliance, and the ally then pays the ordinary stronghold + * rate to ship in. No force, phase, source, token or frame is fabricated. */ +export function createTestingStationContestFixture( + options: { advanced?: boolean } = {}, +): TestingStationContestFixture { + const advanced = options.advanced ?? false; + let game = createGame( + 'TESTINGSTATIONCONTEST', + newPlayer('ecaz', 'Ecaz', 'ecaz'), + advanced, + ['ecaz'], + ); + for (const faction of ['guild', 'fremen', 'emperor'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + game.discoveryEnabled = true; + for (const player of game.players) + if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + const ecaz = game.players.find((player) => player.faction === 'ecaz')!.id; + const guild = game.players.find((player) => player.faction === 'guild')!.id; + assert.notEqual(ecaz, guild); + const initial = structuredClone(game); + const actions: TestingStationContestStep[] = []; + const staging: string[] = []; + + game = initializeDiscoveryGameForAudit(game); + const setup = structuredClone(game); + game = advanceTestingStationContest(game, (state) => state.status === 'playing', actions); + + // Printed Hagga Basin Discovery blow on turn one; a real ordinary worm on turn + // two; everything else stays an original unplayed card of the same deck. + const wormPosition = advanced ? 2 : 1; + frontCard(game, 0, (card) => 'territory' in card && card.discovery === 'discovery-hagga-basin'); + if (advanced) + frontCard(game, 1, (card) => 'territory' in card && !card.discovery); + frontCard(game, wormPosition, (card) => 'worm' in card && !card.greatMaker); + for (let position = wormPosition + 1; position < wormPosition + 3; position++) + frontCard(game, position, (card) => 'territory' in card && !card.discovery); + staging.push( + 'Reordered original unplayed Spice Cards only: the printed Hagga Basin Discovery first, then an ordinary Shai-Hulud for turn two.', + ); + const afterSetup = structuredClone(game); + + game = selectedTokenLottery(game, 'ecological-testing-station', () => + advanceTestingStationContest( + game, + (state) => + state.discoveries!.tokens.some( + (token) => token.face === 'ecological-testing-station' && token.status === 'placed', + ), + actions, + ), + ); + const token = game.discoveries!.tokens.find( + (candidate) => candidate.face === 'ecological-testing-station', + )!; + const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + assert.equal(token.status, 'placed'); + assert.equal(token.territory, placement.territory); + assert.equal(token.sector, placement.sector); + staging.push( + 'Selected the actual Hiereg supply lottery; the station token itself was placed by the printed card.', + ); + + // Turn one: the collector ships to the token's printed destination, then + // inspects and reveals the token during the ordinary Collection phase. + game = advanceTestingStationContest( + game, + (state) => state.phase === 5 && state.active === ecaz && clean(state), + actions, + ); + const shipment: TestingStationContestStep = { + actor: ecaz, + action: { + type: 'ship', + territory: placement.territory, + sector: placement.sector, + amount: 2, + elite: 0, + allyPayment: 0, + }, + }; + actions.push(structuredClone(shipment)); + game = applyAction(game, shipment.actor, shipment.action); + game = advanceTestingStationContest( + game, + (state) => state.phase === 7 && clean(state) && !state.pendingShipment, + actions, + ); + assert.equal(game.turn, 1); + assert.ok(viewGame(game, ecaz).discoveries!.canInspect.includes(token.id)); + const inspect: TestingStationContestStep = { + actor: ecaz, + action: { type: 'discovery', token: token.id, reveal: false }, + }; + const reveal: TestingStationContestStep = { + actor: ecaz, + action: { type: 'discovery', token: token.id, reveal: true }, + }; + for (const next of [inspect, reveal]) { + actions.push(structuredClone(next)); + game = applyAction(game, next.actor, next.action); + } + assert.equal( + game.discoveries!.tokens.find((candidate) => candidate.id === token.id)!.revealedTurn, + 1, + ); + staging.push( + 'Ordinary paid shipment to the printed destination followed by that seat’s real Collection inspection and public reveal.', + ); + + // Turn two: the location's own next-turn free entry, then a real worm Nexus + // and reciprocal alliance, then the ally's ordinary paid shipment. + game = advanceTestingStationContest( + game, + (state) => + state.turn === 2 && + state.decision?.kind === 'discoveryEntry' && + state.decision.player === ecaz, + actions, + ); + const entry = nextTestingStationContestStep(game); + assert.ok(entry, 'The station occupant must receive its real free-entry choice.'); + assert.equal(entry.action.type, 'decision'); + assert.equal(entry.action.accept, true); + actions.push(structuredClone(entry)); + game = applyAction(game, entry.actor, entry.action); + assert.equal(fighterCount(game.players.find((p) => p.id === ecaz)!, STATION), 2); + staging.push( + 'The station occupant entered through the printed next-turn free entry; no force was placed there directly.', + ); + + game = advanceTestingStationContest( + game, + (state) => + state.turn === 2 && + state.phase === 1 && + !!state.nexus && + !state.spiceWindow && + !state.spiceResolution && + clean(state), + actions, + ); + const offer: TestingStationContestStep = { actor: ecaz, action: { type: 'alliance', target: guild } }; + const accept: TestingStationContestStep = { actor: guild, action: { type: 'alliance', target: ecaz } }; + for (const next of [offer, accept]) { + actions.push(structuredClone(next)); + game = applyAction(game, next.actor, next.action); + } + assert.equal(game.players.find((player) => player.id === ecaz)!.ally, guild); + assert.equal(game.players.find((player) => player.id === guild)!.ally, ecaz); + game = advanceTestingStationContest( + game, + (state) => state.phase === 5 && state.active === guild && clean(state), + actions, + ); + const secondShipment: TestingStationContestStep = { + actor: guild, + action: { + type: 'ship', + territory: STATION, + sector: 0, + amount: 1, + elite: 0, + allyPayment: 0, + }, + }; + actions.push(structuredClone(secondShipment)); + game = applyAction(game, secondShipment.actor, secondShipment.action); + game = advanceTestingStationContest(game,clean,actions); + assert.equal(fighterCount(game.players.find((p) => p.id === guild)!, STATION), 1); + staging.push( + 'A real worm Nexus produced the reciprocal Ecaz/Guild alliance; the ally then used its own ordinary paid shipment into the occupied revealed location.', + ); + + game = advanceTestingStationContest( + game, + (state) => state.turn === 3 && state.phase === 0 && clean(state), + actions, + ); + const beforeStorm = structuredClone(game); + game = advanceTestingStationContest( + game, + (state) => state.turn === 3 && state.phase === 0 && state.decision?.kind === 'ecologicalStorm', + actions, + ); + const frame = game.ecologicalStorm!; + assert.ok( + actions.some((next) => next.action.type === 'ship') && + actions.some((next) => next.action.type === 'alliance') && + actions.some((next) => next.action.type === 'discovery') && + actions.some( + (next) => next.action.type === 'decision' && next.action.accept === true, + ), + 'The contested station must be reached through real controls only.', + ); + const occupants = testingStationOccupants(game); + assert.equal(occupants.length, 2); + assert.deepEqual(frame.queue?.order, occupants); + assert.deepEqual( + occupants, + testingStationStormOrder(game, occupants), + 'Both occupants must queue in original storm order.', + ); + const seats = game.players + .map((player) => player.id) + .filter((id) => occupants.includes(id)); + if (JSON.stringify(seats) !== JSON.stringify(testingStationStormOrder(game, seats))) + assert.notDeepEqual( + occupants, + seats, + 'The contested queue must use the printed storm order, never seat order.', + ); + assert.equal(frame.owner, occupants[0]); + assert.equal(game.decision!.player, frame.owner); + assert.ok(game.decision?.kind === 'ecologicalStorm'); + assert.equal(frame.event, game.decision!.event); + assert.notEqual(game.stormMovementSource?.kind, 'weather'); + assert.equal(game.stormMovementSource?.turn, 3); + assert.equal(game.stormPending, game.stormMovementSource!.distance); + assert.equal(game.ecologicalStorm!.base, game.stormPending); + return { + initial, + setup, + afterSetup, + game, + beforeStorm, + actions, + staging, + owner: occupants[0], + second: occupants[1], + token: token.id, + parent: placement.territory, + parentSector: placement.sector, + source: { + turn: game.stormMovementSource!.turn, + kind: game.stormMovementSource!.kind, + distance: game.stormMovementSource!.distance, + }, + order: [occupants[0], occupants[1]], + event: frame.event, + }; +} diff --git a/tests/jacurutu-mixed-dial-runtime.test.ts b/tests/jacurutu-mixed-dial-runtime.test.ts new file mode 100644 index 00000000..dcb0c71b --- /dev/null +++ b/tests/jacurutu-mixed-dial-runtime.test.ts @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { + createJacurutuMixedDialFixture, + jacurutuBattleReceipt, + jacurutuCurrentDecision, +} from './fixture-jacurutu-mixed-dial'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); + +const boardCount = (game: Game, id: string) => + Object.values(game.players.find(player => player.id === id)!.forces) + .reduce((sum, count) => sum + count, 0); + +const settlementLogs = (game: Game) => + game.log.filter(entry => entry.automatic?.name === 'Jacurutu Sietch income'); + +/** The genuine fixture advanced to its single pending physical dial choice. */ +function pausedDialChoice(options?: { dial?: number; support?: number }) { + const fixture = createJacurutuMixedDialFixture(options); + const game = applyAction(reload(fixture.game), fixture.finalVote.actor, fixture.finalVote.action); + return { fixture, game }; +} + +void test('the genuine Advanced battle is reached through real blows, shipments and free entry', () => { + const fixture = createJacurutuMixedDialFixture(); + const token = fixture.game.discoveries!.tokens.find(candidate => candidate.id === fixture.token)!; + assert.equal(token.face, 'jacurutu-sietch'); + assert.equal(token.status, 'placed'); + assert.equal(token.revealedTurn, 1); + assert.equal(`${token.territory}:${token.sector}`, fixture.parent); + assert.equal(fixture.parent, 'gara_kulon:8'); + const emperor = fixture.game.players.find(player => player.id === fixture.defender)!; + assert.equal(emperor.faction, 'emperor'); + assert.equal(fixture.game.battle!.territory, fixture.territory); + assert.equal(emperor.forces['jacurutu-sietch:0'], 2); + assert.equal(emperor.elites!.forces['jacurutu-sietch:0'], 1); + for (const player of fixture.game.players) { + assert.equal(player.reserves + player.tanks + boardCount(fixture.game, player.id), 20); + assert.ok(player.reserves >= (player.elites?.reserves ?? 0)); + assert.ok(player.tanks >= (player.elites?.tanks ?? 0)); + } + assert.equal(jacurutuBattleReceipt(fixture.game), null); + assert.equal(jacurutuCurrentDecision(fixture.game), null); +}); + +void test('the defeated owner alone sees both real physical readings of the mixed dial', () => { + const { fixture, game } = pausedDialChoice(); + const decision = jacurutuCurrentDecision(game); + assert.ok(decision); + assert.equal(decision.player, fixture.defender); + const receipt = jacurutuBattleReceipt(game)!; + assert.equal(receipt.event, decision.event); + assert.equal(receipt.owner, fixture.defender); + assert.equal(receipt.winner, fixture.attacker); + assert.deepEqual(receipt.choices.map(choice => choice.amount), [1, 0]); + assert.deepEqual(receipt.choices.map(choice => choice.dialed), [ + { normal: 0, elite: 1, paidNormal: 0, paidElite: 1 }, + { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 }, + ]); + assert.deepEqual(receipt.selectedDial ?? null, null); + assert.equal(viewGame(game, fixture.attacker).jacurutuDial, null); + const ownerView = viewGame(game, fixture.defender).jacurutuDial!; + assert.deepEqual(ownerView.choices.map(choice => choice.amount), [1, 0]); + // The gate pauses before any settlement: no payment, casualty or reward yet. + for (const player of game.players) { + assert.equal(player.spice, fixture.game.players.find(candidate => candidate.id === player.id)!.spice); + assert.equal(player.tanks, fixture.game.players.find(candidate => candidate.id === player.id)!.tanks); + } + assert.equal(game.players.find(player => player.id === fixture.defender)!.forces['jacurutu-sietch:0'], 2); + assert.equal(settlementLogs(game).length, 0); +}); + +void test('either real reading settles the same original battle once and differs only by the Bank reward', () => { + const { fixture, game } = pausedDialChoice(); + const before = reload(game); + const decision = jacurutuCurrentDecision(game)!; + const spiceBefore = Object.fromEntries(game.players.map(player => [player.id, player.spice])); + const first = applyAction(reload(game), fixture.defender, { + type: 'decision', event: decision.event, allocation: 0, + }); + assert.deepEqual(game, before, 'A valid choice must not mutate the caller’s paused battle.'); + const second = applyAction(reload(game), fixture.defender, { + type: 'decision', event: decision.event, allocation: 1, + }); + const spiceOf = (settled: Game, id: string) => settled.players.find(player => player.id === id)!.spice; + // The original battle completion also opens ordinary Collection; its city + // income remains independent of Jacurutu's reward and both support payments. + const collection = Object.fromEntries(quoteSpiceCollection(game).receipts.map(receipt => + [receipt.player,receipt.strongholds+receipt.collected])); + assert.equal(first.phase,7); + assert.equal(spiceOf(first, fixture.defender), spiceBefore[fixture.defender] - 1 + collection[fixture.defender]); + assert.equal(spiceOf(second, fixture.defender), spiceBefore[fixture.defender] - 1 + collection[fixture.defender]); + assert.equal(spiceOf(first, fixture.attacker), spiceBefore[fixture.attacker] - 2 + 1 + collection[fixture.attacker]); + assert.equal(spiceOf(second, fixture.attacker), spiceBefore[fixture.attacker] - 2 + collection[fixture.attacker]); + assert.equal(spiceOf(first, fixture.observer), spiceBefore[fixture.observer] + collection[fixture.observer]); + // Everything except the Bank reward is the identical single continuation. + for (const [index, player] of first.players.entries()) + assert.deepEqual({ ...player, spice: 0 }, { ...second.players[index], spice: 0 }); + assert.equal(settlementLogs(first).length, 1); + assert.equal(settlementLogs(second).length, 0); + assert.equal(first.log.filter(entry => entry.automatic?.name === 'Battle').length, 1); + assert.equal(first.log.filter(entry => entry.automatic?.name === 'Jacurutu physical dial').length, 1); + assert.equal(second.log.filter(entry => entry.automatic?.name === 'Jacurutu physical dial').length, 1); + const emperor = first.players.find(player => player.id === fixture.defender)!; + assert.equal(emperor.tanks, fixture.defenderTanks.normal + fixture.defenderTanks.elite); + assert.equal(emperor.elites!.tanks, fixture.defenderTanks.elite); + assert.equal(emperor.forces['jacurutu-sietch:0'], undefined); + const atreides = first.players.find(player => player.id === fixture.attacker)!; + assert.equal(atreides.tanks, 2, 'The winning Advanced commitment settles its own losses once.'); + assert.equal(atreides.forces['jacurutu-sietch:0'], 1); + for (const player of first.players) + assert.equal(player.reserves + player.tanks + boardCount(first, player.id), 20); + assert.equal(first.battle, null); + assert.equal(jacurutuCurrentDecision(first), null); + assert.equal(jacurutuBattleReceipt(first), null); + assert.equal(first.lastBattleContext?.winner, fixture.attacker); + const stable = reload(first); + assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable); + assert.throws(() => applyAction(reload(first), fixture.defender, { + type: 'decision', event: decision.event, allocation: 0, + }), /not available|pending decision/); +}); + +void test('a unique reading of the same real battle pays automatically without a choice', () => { + const fixture = createJacurutuMixedDialFixture({ dial: 1, support: 0 }); + assert.equal(fixture.game.battle!.plans[fixture.defender]!.dial, fixture.dial); + assert.equal(fixture.game.battle!.plans[fixture.defender]!.support, fixture.support); + const spiceBefore = Object.fromEntries(fixture.game.players.map(player => [player.id, player.spice])); + const done = applyAction(reload(fixture.game), fixture.finalVote.actor, fixture.finalVote.action); + assert.equal(jacurutuCurrentDecision(done), null); + assert.equal(jacurutuBattleReceipt(done), null); + assert.equal(done.battle, null); + const collection = Object.fromEntries(quoteSpiceCollection(fixture.game).receipts.map(receipt => + [receipt.player,receipt.strongholds+receipt.collected])); + assert.equal(done.phase,7); + assert.equal(done.players.find(player => player.id === fixture.attacker)!.spice, spiceBefore[fixture.attacker] - 2 + 1 + collection[fixture.attacker]); + assert.equal(done.players.find(player => player.id === fixture.defender)!.spice, spiceBefore[fixture.defender] + collection[fixture.defender]); + assert.equal(settlementLogs(done).length, 1); + const emperor = done.players.find(player => player.id === fixture.defender)!; + assert.equal(emperor.tanks, 2); + assert.equal(emperor.elites!.tanks, 1); + assert.equal(emperor.forces['jacurutu-sietch:0'], undefined); + for (const player of done.players) + assert.equal(player.reserves + player.tanks + boardCount(done, player.id), 20); + const stable = reload(done); + assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable); +}); + +void test('a wrong actor, stale event or stale index never changes the paused battle', () => { + const { fixture, game } = pausedDialChoice(); + const before = reload(game); + const decision = jacurutuCurrentDecision(game)!; + assert.throws(() => applyAction(reload(game), fixture.attacker, { + type: 'decision', event: decision.event, allocation: 0, + }), /pending decision/); + assert.throws(() => applyAction(reload(game), fixture.observer, { + type: 'decision', event: decision.event, allocation: 0, + }), /pending decision/); + for (const action of [ + { type: 'decision', event: 'stale-event', allocation: 0 }, + { type: 'decision', event: decision.event, allocation: 2 }, + { type: 'decision', event: decision.event, allocation: -1 }, + { type: 'decision', event: decision.event, allocation: 0.5 }, + { type: 'decision', event: decision.event }, + { type: 'decision', event: decision.event, allocation: 0, dial: 2 }, + ] as Action[]) + assert.throws(() => applyAction(reload(game), fixture.defender, action), + /Jacurutu dial/); + assert.deepEqual(game, before); + const receipt = jacurutuBattleReceipt(game)!; + assert.deepEqual(receipt.selectedDial ?? null, null); + assert.deepEqual(viewGame(game, fixture.defender).jacurutuDial!.choices.map(choice => choice.amount), [1, 0]); +}); diff --git a/tests/testing-station-contest-runtime.test.ts b/tests/testing-station-contest-runtime.test.ts new file mode 100644 index 00000000..0d59e534 --- /dev/null +++ b/tests/testing-station-contest-runtime.test.ts @@ -0,0 +1,238 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { discoveryStormActions } from '../game/discovery-storm-options'; +import { discoveryStormDistance, discoveryStormOffer } from '../game/discovery-storm'; +import { fighterCount } from '../game/advisors'; +import { stormSectorAfter } from '../game/disaster-rules'; +import { + advanceTestingStationContest, + createTestingStationContestFixture, + holdTestingStationStormCard, + testingStationOccupants, + testingStationStormOrder, +} from './fixture-testing-station-contest'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} +/** Normalization must accept the signed queue and stay stable afterwards. */ +function normalized(game: Game): Game { + const once = normalizeAutomaticGame(reload(game)); + assert.deepEqual(normalizeAutomaticGame(reload(once)), once); + return once; +} +const controls = (game: Game, actor: string) => + discoveryStormActions(viewGame(game, actor)); +const chosen = (game: Game, actor: string, delta: number) => { + const action = controls(game, actor).find((candidate) => candidate.delta === delta); + assert.ok(action, `${actor} must receive the real ${delta} station control.`); + return action; +}; +const at = (game: Game, actor: string) => + game.players.find((player) => player.id === actor)!; + +void test('two real occupants of the revealed station each choose once in original storm order through the ordinary controls', () => { + const fixture = createTestingStationContestFixture(); + const g = fixture.game, + source = g.stormMovementSource!; + assert.equal(g.status, 'playing'); + assert.equal(g.turn, 3); + assert.equal(g.phase, 0); + assert.notEqual(source.kind, 'weather'); + assert.equal(source.turn, 3); + assert.equal(source.distance, g.stormPending); + assert.deepEqual(fixture.source, { + turn: source.turn, + kind: source.kind, + distance: source.distance, + }); + const token = g.discoveries!.tokens.find( + (candidate) => candidate.id === fixture.token, + )!; + assert.equal(token.face, 'ecological-testing-station'); + assert.equal(token.status, 'placed'); + assert.equal(token.revealedTurn, 1); + assert.deepEqual([token.territory, token.sector], [fixture.parent, fixture.parentSector]); + assert.deepEqual(testingStationOccupants(g), fixture.order); + assert.deepEqual(fixture.order, testingStationStormOrder(g, fixture.order)); + assert.equal(fixture.order.length, 2); + for (const id of fixture.order) + assert.ok(fighterCount(at(g, id), 'ecological-testing-station') > 0); + assert.equal(g.decision!.player, fixture.owner); + assert.equal(g.ecologicalStorm!.queue!.order.join(','), fixture.order.join(',')); + assert.deepEqual(g.ecologicalStorm!.queue!.choices, []); + // Privacy: only the current occupant receives the projected offer. + assert.equal(viewGame(g, fixture.owner).ecologicalStorm!.owner, fixture.owner); + assert.equal(viewGame(g, fixture.second).ecologicalStorm, null); + assert.deepEqual(controls(g, fixture.second), []); + for (const player of g.players) + if (!fixture.order.includes(player.id)) + assert.equal(viewGame(g, player.id).ecologicalStorm, null); + const pending = normalized(g); + assert.deepEqual(pending.ecologicalStorm?.queue?.order, fixture.order); + assert.equal(pending.decision?.player, fixture.owner); + + const afterFirst = applyAction(reload(g), fixture.owner, chosen(g, fixture.owner, 1)); + assert.equal(afterFirst.ecologicalStorm!.stage, 'choose'); + assert.equal(afterFirst.decision!.player, fixture.second); + assert.ok(afterFirst.decision?.kind === 'ecologicalStorm'); + assert.equal(afterFirst.decision!.event, `${fixture.event}:1`); + assert.equal(afterFirst.ecologicalStorm!.owner, fixture.second); + assert.deepEqual(afterFirst.ecologicalStorm!.queue!.choices, [ + { owner: fixture.owner, delta: 1 }, + ]); + assert.equal(discoveryStormDistance(afterFirst.ecologicalStorm!), source.distance + 1); + assert.equal(afterFirst.stormPending, source.distance + 1); + assert.equal(afterFirst.storm, g.storm); + assert.equal(afterFirst.phase, 0); + assert.equal(afterFirst.stormResolution, null); + const queued = normalized(afterFirst); + assert.equal(queued.ecologicalStorm?.owner, fixture.second); + assert.equal(queued.stormPending, source.distance + 1); + + const done = applyAction(reload(afterFirst), fixture.second, chosen(afterFirst, fixture.second, -1)); + assert.equal(done.ecologicalStorm!.stage, 'complete'); + assert.deepEqual(done.ecologicalStorm!.queue!.choices, [ + { owner: fixture.owner, delta: 1 }, + { owner: fixture.second, delta: -1 }, + ]); + assert.equal(discoveryStormDistance(done.ecologicalStorm!), source.distance); + assert.equal(done.storm, stormSectorAfter(g.storm, source.distance)); + assert.equal(done.phase, 1); + assert.equal(done.stormMovementSource, undefined); + assert.equal(done.stormResolution, null); + const settled = normalized(done); + assert.equal(settled.ecologicalStorm?.stage, 'complete'); + assert.equal(settled.storm, stormSectorAfter(g.storm, source.distance)); + // The committed queue cannot be replayed by either original occupant. + reject(done, fixture.owner, chosen(g, fixture.owner, 1)); + reject(done, fixture.second, chosen(afterFirst, fixture.second, -1)); +}); + +void test('the same native two-occupant proof holds for the Advanced revealed Storm Card source', () => { + const fixture = createTestingStationContestFixture({ advanced: true }); + const g = fixture.game; + assert.equal(g.stormMovementSource?.kind, 'card'); + assert.equal(fixture.source.kind, 'card'); + assert.equal(fixture.source.turn, 3); + assert.equal(g.stormPending, fixture.source.distance); + assert.deepEqual(testingStationOccupants(g), fixture.order); + assert.deepEqual(fixture.order, testingStationStormOrder(g, fixture.order)); + assert.equal(g.ecologicalStorm!.queue!.order.join(','), fixture.order.join(',')); + assert.equal(g.ecologicalStorm!.base, fixture.source.distance); + assert.equal(viewGame(g, fixture.second).ecologicalStorm, null); + const first = applyAction(reload(g), fixture.owner, chosen(g, fixture.owner, 1)); + assert.equal(first.storm, g.storm); + assert.equal(first.phase, 0); + assert.equal(first.decision!.player, fixture.second); + assert.equal(first.stormPending, fixture.source.distance + 1); + const done = applyAction(reload(first), fixture.second, chosen(first, fixture.second, -1)); + assert.equal(done.ecologicalStorm!.stage, 'complete'); + assert.equal(done.storm, stormSectorAfter(g.storm, fixture.source.distance)); + assert.equal(done.phase, 1); +}); + + +void test('one shared cumulative bound: two real decreases reach zero while opposite choices cancel to the ordinary distance', () => { + const fixture = createTestingStationContestFixture(); + const base = fixture.game, + [first, second] = fixture.order, + pending = base.stormPending!; + const decrease = applyAction(reload(base), first, chosen(base, first, -1)); + assert.equal(discoveryStormDistance(decrease.ecologicalStorm!), pending - 1); + const remaining = controls(decrease, second) + .map((action) => action.delta as number) + .sort((a, b) => a - b); + assert.deepEqual(remaining, pending - 1 > 0 ? [-1, 0, 1] : [0, 1]); + const floor = applyAction( + reload(decrease), + second, + chosen(decrease, second, pending - 1 > 0 ? -1 : 0), + ); + assert.equal( + discoveryStormDistance(floor.ecologicalStorm!), + Math.max(0, pending - 2), + ); + assert.equal(floor.storm, stormSectorAfter(base.storm, Math.max(0, pending - 2))); + assert.equal(floor.phase, 1); + if (pending - 1 === 0) { + reject(decrease, second, { type: 'decision', event: decrease.ecologicalStorm!.event, delta: -1 }); + assert.deepEqual( + discoveryStormOffer(decrease, decrease.ecologicalStorm!)!.options.map( + (option) => option.distance, + ), + [0, 1], + ); + } + const up = applyAction(reload(base), first, chosen(base, first, 1)); + const cancel = applyAction( + reload(up), + second, + chosen(up, second, -1), + ); + assert.equal(discoveryStormDistance(cancel.ecologicalStorm!), pending); + assert.equal(cancel.storm, stormSectorAfter(base.storm, pending)); +}); + +void test('wrong owner, stale event, injected fields and replayed choices fail closed on the signed queue', () => { + const fixture = createTestingStationContestFixture(); + const base = fixture.game, + [first, second] = fixture.order; + const firstAction = chosen(base, first, 1); + reject(base, second, firstAction); + reject(base, first, { ...firstAction, event: `${fixture.event}:1` }); + reject(base, first, { ...firstAction, distance: 1 }); + for (const delta of [-2, 2, 0.5, NaN]) reject(base, first, { ...firstAction, delta }); + const committed = applyAction(reload(base), first, firstAction); + reject(committed, first, firstAction); + reject(committed, second, firstAction); + const secondAction = chosen(committed, second, 0); + reject(committed, first, secondAction); + const done = applyAction(reload(committed), second, secondAction); + assert.equal(done.ecologicalStorm!.stage, 'complete'); + reject(done, second, secondAction); +}); + +void test('a real Weather Control play replaces the ordinary source and denies both occupants any station change', () => { + const fixture = createTestingStationContestFixture(); + const [first] = fixture.order, + before = fixture.beforeStorm; + assert.equal(before.stormPending, null); + let g = structuredClone(before); + const holder = g.players.find((player) => player.id !== first)!.id; + const card = holdTestingStationStormCard(g, first, 'weather'); + g = advanceTestingStationContest( + g, + (state) => + state.phase === 0 && + state.stormPending !== null && + state.stormMovementSource?.kind === 'dials', + ); + // One ordinary ready opens the storm-card window; only the real card play follows. + g = applyAction(g, holder, { type: 'ready' }); + g = applyAction(g, first, { type: 'card', card: card.id, amount: 2 }); + assert.equal(g.stormMovementSource?.kind, 'weather'); + assert.equal(g.stormPending, 2); + assert.equal( + viewGame(g, first).ecologicalStorm, + null, + 'Weather Control must deny the current occupant its station choice.', + ); + assert.equal(viewGame(g,fixture.second).ecologicalStorm,null); + g = advanceTestingStationContest(g, (state) => state.phase === 1); + assert.equal(g.storm, stormSectorAfter(before.storm, 2)); + assert.equal(g.decision, null); + assert.equal(fighterCount(at(g, fixture.second), 'ecological-testing-station'), + fighterCount(at(before, fixture.second), 'ecological-testing-station')); + assert.equal(g.discard.filter((candidate) => candidate.id === card.id).length, 1); +});