diff --git a/README.md b/README.md index 1dd12a08..34e26496 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ or use the same selector as host in an existing base-game lobby. Everyone must ready again after changing rules, then the host chooses **Begin Advanced preview**. This mode is unfinished; see [preview scope and limits](docs/ADVANCED_PREVIEW.md). +Advanced uses the user-authorized revised rules: every newly opened Storm +after the first draws the Storm Deck, even without Fremen. Finishing your +Shipment and Movement sends your own shared allied fighters to Tanks; +advisors, Polar Sink and native Ecaz coexistence retain their exceptions. +Basic and an already recorded current Storm keep their existing protocol. +See [Advanced source boundaries](docs/BASE_ADVANCED_READINESS_20260907.md#authorized-advanced-source-follow-up--1-october-2026) +and [allied separation](docs/ALLIED_TRANSIT_RULES.md). + Before starting, open **Configure** on an AI seat to change its difficulty, faction or player circle without replacing it. Changes save immediately and clear human readiness. See [lobby AI configuration](docs/LOBBY_AI_CONFIGURATION.md). diff --git a/components/advanced-ally-separation.tsx b/components/advanced-ally-separation.tsx new file mode 100644 index 00000000..316074a7 --- /dev/null +++ b/components/advanced-ally-separation.tsx @@ -0,0 +1,12 @@ +import type { GameView } from '@/game/engine'; + +/** The engine quote carries public locations only, never concealed force values. */ +export function AdvancedAllySeparation({ quote }: { + quote: GameView['advancedAllySeparation']; +}) { + if (!quote?.territories.length) return null; + return + Finishing now sends your forces in{' '} + {quote.territories.join(', ')} to Tanks under the Advanced alliance rule. + ; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index 214a74ad..28ecdb3c 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2,6 +2,7 @@ import { integerInputValue } from '../lib/integer-input'; import { EcazSetup } from './ecaz-setup'; import { StormCardInspector, StormCardLogInspector } from './storm-cards'; +import { stormCardDistance } from '@/game/storm-cards'; import { KwisatzInspector } from './kwisatz-inspector'; import { ForceInspector } from './force-inspector'; import { FactionInspector } from './faction-inspector'; @@ -220,6 +221,7 @@ import { TERROR_DEFINITIONS } from '@/game/moritani-terror'; import { MoritaniRetention } from './moritani-retention'; import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assassinate'; import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion'; +import { AdvancedAllySeparation } from './advanced-ally-separation'; export function GameTable({ game: g, send, @@ -3596,6 +3598,11 @@ export function GameTable({ {Object.values(g.stormRevealed).join(' + ')}

)} + {g.stormDialers.length === 0 && !g.stormRevealed && ( + stormCardDistance(entry.component) !== null, + )?.component} /> + )} {actionButton( g.ready.includes(me.id) ? 'Waiting for the table' @@ -3605,7 +3612,7 @@ export function GameTable({ )} )} - {g.phase === 0 && g.stormPending === null && ( + {g.phase === 0 && g.stormPending === null && g.stormDialers.length > 0 && ( <>

The two storm dialers choose secretly. Their sum moves the @@ -4551,6 +4558,7 @@ export function GameTable({ )} )} + {g.nexusGuildCunning?.offer && (

Guild Cunning ends your ordinary shipment and movement, then offers one second shipment at normal Guild prices. Only an unused Hajr move can follow it. The physical Ornithopter card combination remains unavailable.

@@ -4568,7 +4576,8 @@ export function GameTable({ nexusGuildSkipShipmentAction(g, guildCunning.event) ?? {type: 'endMovement'}, !nexusGuildSkipShipmentAction(g, guildCunning.event))} {actionButton(guildCunning?.stage === 'secondShipment' ? 'Decline second shipment and finish' : - guildCunning ? 'Finish Guild turn' : 'Finish shipment & movement', {type: 'endMovement'})} + guildCunning ? 'Finish Guild turn' : 'Finish shipment & movement', {type: 'endMovement'}, false, + g.advancedAllySeparation?.territories.length ? 'advanced-ally-separation' : undefined)} ) : (

diff --git a/components/storm-cards.tsx b/components/storm-cards.tsx index c6dd6c38..45777a66 100644 --- a/components/storm-cards.tsx +++ b/components/storm-cards.tsx @@ -53,9 +53,9 @@ export function StormCardInspector({ distance, context = 'reference' }: { Storm Card — {sectors(distance)} {guide.description} -

In the Advanced game with Fremen, the first storm uses the Battle Wheels. - For later turns, the selected Storm Card is revealed and supplies the storm’s counterclockwise distance. - It returns to the deck before the next forecast is drawn.

+

The first Advanced storm uses the Battle Wheels. + Every later new Advanced Storm opening reveals a Storm Card for its counterclockwise distance, even without Fremen. + The card returns to the deck before the next draw. Only native Fremen has the private forecast advantage.

This face shows the card’s value. Weather Control and other effects can change the actual movement; the table’s storm result records that movement.

diff --git a/docs/ALLIED_TRANSIT_RULES.md b/docs/ALLIED_TRANSIT_RULES.md index 49f586c3..2f1c863e 100644 --- a/docs/ALLIED_TRANSIT_RULES.md +++ b/docs/ALLIED_TRANSIT_RULES.md @@ -2,6 +2,63 @@ Audit: 7 September 2026. Read-only source and code audit, retained by the coordinating agent. Scope is classic GF9 base rules and the applicable November 2020 FAQ, not tournament or movie-game rules. +## Authorized Advanced source cutover — 1 October 2026 + +The user-supplied root `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` +is now authorized for Advanced, including its marked unofficial departures. +It has 44 physical PDF pages and SHA-256 +`7077aa87a973221d86d4bd2da945b40c4dec3d371caa6a68cb209758ddf1e516`; +metadata says v.2.3, but the visible editorial heading on physical page 3 +says Version 2.2. The PDF remains user-owned and is not republished. + +**Alliance Constraint, physical PDF page 15:** at the end of either +player's Shipment and Movement, its own forces in a territory shared with +its ally go to Tanks, except Polar Sink. Advisors do not trigger the +constraint. The footnote identifies both per-player timing and advisor +coexistence as intentional departures from the original rules and November +2020 FAQ. **Questions and Answers, physical page 40** explains the aim: +advisor occupation should not lock allies out, and the first acting player +should bear its own departure obligation. + +Accordingly, normal Advanced ends no longer wait for the ally's later turn +or exempt an alliance formed this turn. Advisor exemption works in both +directions: sharing with advisors kills neither the advisors nor allied +fighters. BG fighters are not exempt. Native Ecaz peaceful coexistence and +its existing Karama conditions remain unchanged. Homeworlds are not Dune +territories and keep their native allied-entry prohibitions. Basic retains +the existing later-player and formation-turn conditions, including its +non-exempt advisor treatment. + +This resolves the **Advanced** timing/advisor and end-turn consequence +questions below. It does not remove the current allied-entry guard, grant +new shipments or extra moves, alter alliance formation/breaking windows, +or settle every optional arrival composition. A forced end consequence +does not require a new loss confirmation; the existing `endMovement` +action remains legal even when no escape is available. The native force, +elite and No-Field custody paths remove the ending player's group once, +not its ally's group. + +The own clean-turn warning uses the canonical engine quote via +`GameView.advancedAllySeparation: { territories: string[] } | null`. +It contains public territory names only, not force counts, concealed +values or raw source receipts. Pending response/decision controls retain +priority. Already completed turns are not reevaluated. + +**Verification: Partial development verified.** The selected source-wave +checks pass 339/339 across seventeen files; full types/lint and 6,169 +offline cases pass. Authenticated SQLite covers restart/CAS/privacy and +unchanged Basic behavior. Genuine unallied Basin groups separated by +Storm subsequently ally at a natural Nexus; native ending sends only the +actor's two physical forces, including one elite, to Tanks. Hajr and +advisor/Ecaz/Polar cases retain their own continuation. Actual 390-pixel +first/later human ending in backed-up `CA6FVKVD` and `U5335T2L` changes +Emperor Tanks from zero to two, leaves Atreides at zero and survives refresh. +Saved physical inventory and all original authenticated seats remain intact. +No allied-entry guard was removed to create the fixture. Basic and wider +public/faction/module gates remain unchanged. The September publisher audit +below is historical comparison, not the current Advanced authority. +The supplied revision is unofficial, not a later publisher FAQ. + ## Publisher sources and established facts **Base rulebook**, printed pp9–10,12,18,23: https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf . Read local publisher text `/tmp/dune-rules/base.txt`; official indexed p12 independently corroborates it. @@ -20,7 +77,7 @@ Audit: 7 September 2026. Read-only source and code audit, retained by the coordi Exact retrieval queries included the official PDF path plus `"ship into a territory" "ally"` and `"What is the difference between an advisor"`. Searches also checked allied territory, next-turn, Tanks, and advisor/alliance terms. The relevant later clarification is November, not the older April FAQ. Search results for the 2021 movie edition and non-rule forum content were excluded. No later relevant official clarification was located in these bounded searches; that is not proof none exists. -## Current implementation comparison +## Historical implementation comparison — 7 September 2026 Line numbers refer to the saved engine read during this audit. @@ -30,7 +87,7 @@ Line numbers refer to the saved engine read during this audit. - `game/advisors.ts` distinguishes total presence from fighter count. Using total presence for allied separation matches the advisor FAQ. Using fighter count for stronghold capacity matches the base advisor exception. These are intentionally different questions. - A blanket removal of the entry guard would be incomplete: the current formation-turn exemption is player-wide and the end-turn rule allows the first mover to leave shared forces for the second ally. Neither records whether a specific group entered voluntarily this turn or was present when the alliance formed. -## Implementable boundaries and material unanswered cases +## Historical implementable boundaries and unanswered cases The clearest supported new behavior is **ship into allied territory, then move the complete visiting group to a legal non-allied destination during the shipper's turn**. A movement path passing through an ally and ending elsewhere is also clear. Permit neither a third fighting faction in a stronghold nor storm traversal under this exception. Preserve Polar Sink's exception. Separate shared territories by their history rather than treating every alliance formed this turn as blanket permission to create new co-occupation. @@ -41,4 +98,12 @@ The following are actual source limits, not established alternative rules: 3. **New voluntary entry during alliance formation turn.** Distinguish the FAQ departure requirement from the p12 delayed deadline for already shared forces. The precise treatment of mixed old/new cohorts in the same territory is not supplied as a token-level rule. Do not let a blanket `allySinceTurn` check silently waive the new entry's obligation. 4. **Other arrival mechanisms.** Advisor accompaniment has its explicit separation instruction; its timing can differ from BG's own turn. Worm rides, No-Field materialization, Enemy of My Enemy and competing expansion arrivals need their own composition. Homeworlds are outside this base transit permission. +Under the authorized Advanced cutover above, the ending player's shared +fighters are lost even if departure was prevented or the alliance formed +this turn; no old/new cohort exemption is inferred. Thus items 1 and 3 no +longer leave the ordinary **Advanced end-turn consequence** unresolved. +Their Basic-policy and prospective entry-permission questions remain. +Item 4's advisor separation premise is superseded in Advanced, while +independent arrival timing and unsupported combinations stay bounded. + Recommended bounded checks before changing the entry guard: ordinary path transit with a clean endpoint; paid shipment followed by complete legal exit; no legal exit available; temporary city access and remaining movement range; ally-plus-enemy stronghold capacity; Polar co-occupation; advisors versus fighters; old Nexus co-occupation in formation turn and following turn, both action orders; multiple shared territories; serialized continuation and duplicate requests. Bind the no-exit outcome only after its policy is source-supported or explicitly adjudicated. No runtime or tests were changed by this audit. diff --git a/docs/BASE_ADVANCED_READINESS_20260907.md b/docs/BASE_ADVANCED_READINESS_20260907.md index 843c2f3f..edb7c15c 100644 --- a/docs/BASE_ADVANCED_READINESS_20260907.md +++ b/docs/BASE_ADVANCED_READINESS_20260907.md @@ -16,6 +16,63 @@ The coordinator subsequently implemented the two bounded findings concerning Fre The later implementation now provides the shared, strict offline initializer described in [ADVANCED_SETUP_TEST_SEAM.md](ADVANCED_SETUP_TEST_SEAM.md). New Basic and offline Advanced tables enforce prediction before private traitors, all traitor selections before force placement, Fremen before the Advanced advisor, and Treachery after every placement. Legacy saved setup continues without redealing. All 456 setup combinations pass. The old `tests/bots.test.ts` Basic-then-flip helper discussed below has been replaced: four profiles now complete registered games from genuine Advanced creation through the shared initializer. The older descriptions below remain the original audit snapshot. The larger [456-game Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md) now also completes with 116,270 accepted actions and zero rejections or checked failures across all base rosters and player counts. Its selected invariants and 5,943 actual JSON continuations broaden the evidence; they do not resolve the named rules, strength-calibration or browser acceptance gaps. +## Authorized Advanced source follow-up — 1 October 2026 + +The user authorized the root `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, +including its marked unofficial rulings. Its SHA-256 is +`7077aa87a973221d86d4bd2da945b40c4dec3d371caa6a68cb209758ddf1e516`. +It has 44 physical PDF pages; metadata says v.2.3 while the visible editorial +heading on physical page 3 says Version 2.2. This is a supplied unofficial +revision, not publisher provenance or a newly published application rulebook. +See the [source amendment and cutover](RULE_DECISIONS.md#advanced-core-cutover--1-october-2026). + +Physical page 6 makes every newly opened Advanced Storm after the first +use a random Storm Deck card, even without Fremen. The six canonical faces +are 1–6; the previous revealed card is eligible again at the next determination. +First-turn nearby-player 0–20 dials remain unchanged. Native Fremen still +privately learns its next card through its existing cancellable foresight; +absence of Fremen grants no player that knowledge. Already recorded current +Storm dials or cards finish their saved protocol without a reset or reroll. +The next new Advanced turn uses the Deck. Basic's dial rules are unchanged. + +Physical page 15's Alliance Constraint and explicit departure footnote, +with the rationale on physical page 40, require the ending player's own +shared allied force groups to go to Tanks at **each** Shipment and Movement +end. Neither the ally's later turn nor formation this turn postpones that +Advanced consequence. Advisor coexistence is exempt in both directions: +neither the advisors nor the allied fighters are lost for sharing with advisors. +BG fighters remain subject to ordinary separation. Polar Sink and native +Ecaz peaceful coexistence, including its existing Karama conditions, remain; +Homeworlds are outside this Dune-territory rule and keep their native alliance +prohibitions. Basic retains its existing later-player/formation-turn policy. +These clauses settle the Advanced timing and advisor questions in +[the earlier allied-transit audit](ALLIED_TRANSIT_RULES.md), not every entry +permission, optional arrival composition or Basic no-exit remedy. + +The engine's canonical allied-separation quote supplies the existing finish +consequence and the own clean-turn `GameView.advancedAllySeparation` warning, +whose only payload is affected public territory names. The ordinary +`endMovement` action is unchanged. Pending responses retain priority; forced +loss adds no confirmation. Ending remains legal when no escape is available. +Actual loss custody continues through the native force/elite/No-Field handling, +without exposing concealed values in that warning or reevaluating completed +turns. These are core Advanced defaults, not a secret opt-in source profile. + +**Verification: Partial development verified.** Selected native source, +authenticated SQLite, controls and four-profile legal-bot checks pass +339/339 across seventeen files. Full types/lint, 6,169 offline cases and +build pass. Standalone native continuation proves exact owned normal/elite +loss and advisor/Ecaz/Polar exceptions, plus a genuine prior-revision +Advanced dial save followed by a new no-Fremen card opening. +Actual 390-pixel human Storm inspection/confirmation, first/later ending +and expanded guide in three privately backed-up new QA rooms survive +refresh. Saved native inventory, eight prior rooms and all 33 original +seats in eleven QA rooms remain preserved after isolated build reloads. +Local HTTP is 51/55, four POST 503s, not green or deployed acceptance. +Earlier calibration and subsystem evidence below remain historical. +Public/faction/optional-module gates and full Advanced certification remain +unchanged. Duke revival/ghola, Kull and broader secrecy work remain separate. + ## Concrete remaining behavior | Priority | Finding and evidence | Next edit or audit target | diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index dffce29f..47410601 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -9,13 +9,39 @@ an [authorized Advanced source of truth](RULE_DECISIONS.md#authorized-source-ame including its identified unofficial rulings. The 44-page file has 2.3 metadata but a visibly retained 2.2 editorial heading; the index records its SHA-256 and physical-page citations. Current Guild payment/source passages -were compared with the wave. Wider Advanced reconciliation is not yet -implemented, and adoption does not reset games, convert Basic, republish -the local PDF or open mode gates. +were compared with the wave. Wider Advanced reconciliation remains open. +Adoption does not reset games, convert Basic, republish the local PDF or +open mode gates. ## Current checkpoint and work +Five implementation lanes reconciled two source-clear Advanced defaults +from the authorized revised rulebook: all new post-first Storm openings +draw the Storm Deck, even without Fremen; each player's own combined-turn +end applies allied separation without waiting for the ally. Advisor +coexistence in either direction, native Ecaz and Polar Sink remain exempt. +Basic is unchanged; a genuine prior-revision recorded Advanced dial save +finishes its original sum, then uses the Deck on its next new turn. +Canonical engine quotes supply own public loss-territory names only, +with unchanged native ending, force/elite/No-Field custody and pending locks. +Four existing minimal bot policies use native availability, not a second +separation calculator. Independent rules and storage reviews have no finding. +Consumer fixture repairs retain original Storm bindings and native progress; +they do not weaken source validation or remove the allied-entry guard. +Types, lint, **6,169 offline tests**, **339 selected cases** and build pass. +Standalone native continuation covers exact owned normal/elite losses, +advisor/Ecaz/Polar exceptions, unchanged Basic and captured old Storm. +Three backed-up fresh authenticated Advanced preview rooms exercise actual +390-pixel public Storm inspection/confirmation, first/later human ending +and refresh. Read-only proof conserves physical forces/elites/Treachery, +retains the eight prior Guild rooms, and all **33 seats in 11 QA rooms** +reauthenticate after necessary isolated-worker build reloads. +The actual expanded source guide retains Partial gates and exercised evidence. +Local built-worker HTTP is **51/55**, four POST 503s, not green. +Full Advanced, wider source differences, strategic AI and live acceptance +remain open; container publication is not deployment. + Five implementation lanes connected [Guild Nexus Betrayal](NEXUS_GUILD_RULES.md#nexus-guild-betrayal): full funded shipment-payment replacement, including own upfront payment and the explicit occupied-Junction override. Fresh `guild-betrayal` admits diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d87672be..7d86093a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,62 @@ # Dune implementation status +## 1 October 2026 — authorized Advanced Storm and allied separation defaults + +Five implementation lanes connected the user-supplied revised Advanced +source's physical pages 6, 15 and 40 without a source-fork profile or reset. +Every newly opened post-first Advanced Storm draws one of the six canonical +faces, even without Fremen. First-turn wheels, Basic's protocol and an +already recorded current opening remain unchanged; native Fremen alone +keeps cancellable private foresight. Public inspection retains the original +card face when Weather Control changes actual movement. + +A focused canonical policy now quotes the ending player's own shared +territories in Advanced without the later-ally/formation-turn grace period. +BG advisors are exempt in both directions; native Ecaz and Polar Sink +coexistence remain. Basic retains its earlier deadline and formation policy. +Native ordinary/deferred endings keep force, elite and concealed No-Field +custody, with preflight before parking a retiring Ornithopter. +The own clean-turn warning exposes public names only, not counts or source +receipts, and adds no forced-loss confirmation. It is a block-layout semantic +output; all existing pending controls retain priority. + +Independent frozen rules/storage reviews have no finding. Full verification +initially found 40 consumers assuming missing original Storm frames or +superseded Advanced timing/dial protocols. Four repair lanes retained +counter/SQL/privacy/custody coverage, migrated native phase progression +and removed an incidental HTML-role pin. Genuine ordinary overlaps use +paid Emperor/Atreides Basin groups in opposite sectors separated by first +Storm 10, then a natural Nexus alliance; no allied-entry guard was weakened. +A permanent captured real `8a562991` Advanced dial save is genuinely +Advanced, not a Basic-flag flip, and preserves its original continuation. + +`npm run check` passes types, lint and **6,169 offline cases**; the selected +17-file union passes **339/339**, and production build passes. +Standalone runtime covers seven Advanced loss/exception scenarios, Basic +new-alliance preservation and prior recorded dials followed by a new +no-Fremen Deck opening. Authenticated SQLite covers first/next Storm, +private foresight, restore, concurrency, owned losses, Hajr, exceptions, +corrupt sources and durable rejected-action immutability. + +Actual admitted previews `CA6FVKVD`, `PTSMVMDY` and `U5335T2L` were +privately backed up before exact-version native preparation, retaining +the same room, rules, circles, IDs and credentials. Phone-sized human +first/later ending sends only Emperor's two physical forces, including one +elite, to Tanks, leaves its ally intact and survives refresh. No-Fremen +turn-two inspection shows the drawn five-sector face; human confirmation +moves Storm from one to six and refresh retains Spice Blow. The final +warning is a 354-pixel block within a 390-pixel viewport. Actual expanded +guidance exposes the contract and Partial verification boundary. +Read-only saved proof conserves original native setup inventory and cards; +eight prior Guild rooms are unchanged. All 33 original authenticated seats +in those eleven QA rooms restore after required isolated build reloads. + +Local HTTP is **51/55**, four known Wrangler-proxy POST 503s, not green. +No POST retry/auth weakening or database reset was added. The user-owned +44-page PDF remains local/unpublished, including its retained 2.2 editorial +heading versus 2.3 filename/metadata. Broader source differences, full modes, +strategic AI, public gates and final-revision live acceptance remain open. + ## 1 October 2026 — full funded Guild Nexus Betrayal payment Five implementation lanes integrated engine, pure funding/custody/history, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index f012f9c9..7b7b0ada 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -29,14 +29,48 @@ Guild Nexus full-payment/own-payment/Junction override or supply a new response order; its bounded prepayment contract remains source-consistent. This is a source comparison, not runtime verification. -Future Advanced reconciliation must distinguish the revision's Storm Deck -simplification even without native Fremen (6), its explicitly revised -alliance-constraint timing and advisor coexistence (15, 40), secrecy of -spice/discards and hand counts outside Bidding (16), and Duke Vidal's -Tleilaxu-ghola exception (30). These are source-led reconciliation targets, -not claims that existing behavior has already changed. Optional variants -remain optional, Basic is not silently converted to Advanced, and no saved -game is reset or redealt by adopting a source. +## Advanced core cutover — 1 October 2026 + +The supplied revision now resolves two normal Advanced defaults, not a new +opt-in profile or a publisher ruling: + +- **Storm Movement, physical PDF page 6:** after the first turn, draw a + random Storm Deck card and return the last revealed card before the next + determination, expressly even without native Fremen. The first Storm + still uses the two nearby players' secret 0–20 dials. The implementation + reuses the six canonical faces 1–6 and its structured Storm source; native + Fremen keeps its private next-card forecast and separate peek cancellation. + Without Fremen, nobody gains a forecast or a borrowed Fremen advantage. +- **Alliance Constraint, physical PDF page 15 and rationale on 40:** at + each player's Shipment and Movement end, that ending player's own forces + in territories shared with its ally go to Tanks. Advanced does not wait + for the ally's later turn or exempt an alliance formed this turn. Bene + Gesserit advisors do not trigger this constraint in either direction; + fighters remain ordinary forces. Polar Sink and native Ecaz peaceful + coexistence remain exceptions. Homeworlds are not Dune territories, and + their native allied-entry prohibitions are unchanged. + +The page 15 footnote explicitly identifies the changed timing and advisor +coexistence as departures from the original rules and November 2020 FAQ. +Page 40 explains that choice; it is the supplied revision's unofficial +authority, not a later GF9 clarification. See the +[allied separation source comparison](ALLIED_TRANSIT_RULES.md#authorized-advanced-source-cutover--1-october-2026) +and [readiness follow-up](BASE_ADVANCED_READINESS_20260907.md#authorized-advanced-source-follow-up--1-october-2026). + +Basic retains its existing dials and later-player/formation-turn separation +policy. A saved current Storm completes its already recorded dial/card +protocol without resetting, rerolling or redealing; the next new Advanced +Storm opening uses the Deck. Completed movement consequences are not +reevaluated. The own-turn warning exposes only affected public territory +names through `GameView.advancedAllySeparation`; the ordinary `endMovement` +action stays unchanged, with no confirmation for mandatory losses. + +Verification of this cutover is **Planned** pending the coordinator's +frozen native, authenticated SQLite and phone/browser evidence. Existing +focused tests and old calibration counts do not verify these new defaults. +Public, faction, optional-module and full-mode readiness gates are unchanged. +Secrecy (physical page 16), Duke Vidal's ghola exception (30), Kull and other +independent decisions are not reconciled by this cutover. ## Recorded contracts and implementation boundaries diff --git a/docs/STORM_CARD_INSPECTION.md b/docs/STORM_CARD_INSPECTION.md index 72fbda7a..bdf9033c 100644 --- a/docs/STORM_CARD_INSPECTION.md +++ b/docs/STORM_CARD_INSPECTION.md @@ -5,6 +5,46 @@ remain unchanged. Six numeric Storm Card faces can be enlarged from the internal reference. An entitled Fremen player can inspect the existing private forecast, and every viewer can inspect a card after its public reveal in the chronicle. +## Authorized Advanced Storm default — 1 October 2026 + +The user authorized `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf`, +including marked unofficial rulings. **Storm Movement, physical PDF page 6** +uses a random Storm Deck card after the first turn, expressly even without +native Fremen; the last revealed card returns before the next determination. +The source has 44 physical pages and SHA-256 +`7077aa87a973221d86d4bd2da945b40c4dec3d371caa6a68cb209758ddf1e516`. +Its metadata says v.2.3, while the visible editorial heading on physical +page 3 says Version 2.2. This is authorized unofficial provenance, not a +publisher simplification. The local PDF is not republished or linked in-app. + +Every new Advanced opening after the first uses the existing six faces 1–6 +and structured source, rather than a new saved deck array. First Storm +nearby-player 0–20 dials and Basic dials remain unchanged. A saved current +dial/card opening finishes as recorded without a reroll; the next new +Advanced turn uses the Deck. Native Fremen alone retains its actual private +next-card forecast and separate peek cancellation. Without Fremen there is +no forecast grant, canceled foresight power or extra private confirmation. +Public reveals use the existing inspector in either roster; recorded card +faces stay distinct from Weather Control or Testing Station movement. + +**Verification of the new default: Partial development verified.** The +selected source-wave native/SQLite/control/bot checks pass 339/339 across +seventeen files; full types/lint, 6,169 offline cases and build pass. +A captured real prior-revision Advanced dial opening finishes +its original sum, then the next native no-Fremen opening draws a canonical +card. Actual new authenticated preview `PTSMVMDY`, privately backed up +before native preparation, reveals five sectors at turn two. Its +390-pixel inspector shows that public face, explains the roster-independent +Deck and native-only forecast, then human confirmation advances Storm +from one to six; refresh continues Spice Blow without a reroll. +Saved native inventory and original seats remain intact after isolated +build reloads. Local built-worker HTTP is 51/55, four POST 503s, not green +or deployed acceptance. Older inspection evidence below covers its stated +boundary. Public/faction/module gates and full-mode certification remain +unchanged; inspection alone did not change rules, this cutover does. + +## Existing inspection contract + `game/storm-cards.ts` is a face catalog, not a new deck or source of a live draw. The inspector accepts one already-authorized value and no game, deck or seat. The rules gallery shows all possibilities without selecting a live card. diff --git a/game/allied-separation.ts b/game/allied-separation.ts new file mode 100644 index 00000000..2dff5d39 --- /dev/null +++ b/game/allied-separation.ts @@ -0,0 +1,50 @@ +export type AlliedSeparationQuoteContext = Readonly<{ + advanced: boolean; + turn: number; + player: string; + ally: string | null; + playerAllySinceTurn?: number; + allySinceTurn?: number; + remaining: readonly string[]; + territories: readonly Readonly<{ + territory: string; + polar: boolean; + ownPresent: boolean; + allyPresent: boolean; + ownAdvisors: boolean; + allyAdvisors: boolean; + ecazCoexist: boolean; + }>[]; +}>; + +/** + * Quote the ending player's loss territories, not casualties or force custody. + * The engine supplies canonical presence, advisor stance and Ecaz coexistence. + * Advanced separates at every player's end; Basic retains its legacy timing. + */ +export function quoteAlliedSeparation( + context: AlliedSeparationQuoteContext, +): string[] { + if (!context.ally) return []; + if ( + !context.advanced && + (context.remaining.includes(context.ally) || + (context.playerAllySinceTurn === context.turn && + context.allySinceTurn === context.turn)) + ) + return []; + + const territories: string[] = []; + for (const group of context.territories) { + if ( + group.polar || + !group.ownPresent || + !group.allyPresent || + group.ecazCoexist || + (context.advanced && (group.ownAdvisors || group.allyAdvisors)) + ) + continue; + territories.push(group.territory); + } + return territories; +} diff --git a/game/engine.ts b/game/engine.ts index 18efc350..236f7060 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -541,6 +541,7 @@ import { settleAdvisors, arrivalAsAdvisor, } from './advisors'; +import { quoteAlliedSeparation } from './allied-separation'; import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat'; import { FACTIONS, faction, type FactionId } from './catalog'; import { quoteLobbyBotConfiguration } from './lobby-bot-configuration'; @@ -9114,9 +9115,14 @@ function discoveryIntegrity(g: Game) { } function ecologicalStormIntegrity(g: Game) { const frame = g.ecologicalStorm, continuation = g.pendingTreacheryDiscard?.continuation; + requireRule(!(g.status === 'playing' && g.phase === 0 && g.advanced && g.turn > 1 && + g.stormDialers.length === 0 && g.stormPending !== null) || !!g.stormMovementSource, + 'The revealed Advanced storm has lost its original movement source.'); if (g.stormMovementSource) { nexusRule(() => validateStormSource(g.stormMovementSource!)); const source = g.stormMovementSource; + 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), 'The storm must retain its current turn and original movement source.'); @@ -13286,9 +13292,53 @@ function finishMovedGroup(g: Game, move: CompletedMovement) { intrusion(g, p, to); if (movementOrigins(move).some(source => source !== to)) openTerritoryEntry(g, p, to, s, n, elite, 'movement'); } +/** Native board classification is shared by the consequence and its own-seat preview. */ +function alliedSeparationTerritories(g: Game, p: Player) { + const ally = p.ally ? getPlayer(g, p.ally) : null; + return quoteAlliedSeparation({ + advanced: g.advanced, + turn: g.turn, + player: p.id, + ally: ally?.id ?? null, + playerAllySinceTurn: p.allySinceTurn, + allySinceTurn: ally?.allySinceTurn, + remaining: g.movementRemaining ?? g.order.slice(g.order.indexOf(p.id)), + territories: gameTerritories(g).map(terr => ({ + territory: terr.id, + polar: terr.type === 'polar', + ownPresent: at(p, terr.id) > 0, + allyPresent: !!ally && at(ally, terr.id) > 0, + ownAdvisors: isAdvisor(p, terr.id), + allyAdvisors: !!ally && isAdvisor(ally, terr.id), + ecazCoexist: !!ally && sharesEcazOccupation(g, p, terr.id), + })), + }); +} +function requireAlliedSeparationCustody(g: Game, p: Player) { + const deployed = p.noField?.deployed; + requireRule(!deployed || !alliedSeparationTerritories(g, p).includes(deployed.location.territory), + 'This removal of a concealed No-Field awaits its specific reveal and casualty rules.'); +} +function advancedAllySeparationView(g: Game, p: Player): {territories: string[]} | null { + if (!g.advanced || g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || + g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || + g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || + g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingShipment || + g.pendingHomeworldShipment || g.pendingGuildTransport || g.pendingGuildBetrayal || + g.pendingRicheseBetrayal || g.pendingKull || g.pendingAmbassador || g.pendingTerrorEntry || + g.pendingArrivalOverlap || g.pendingCapture || g.pendingIxAlly || g.pendingIxMove || + g.pendingFremenMove || g.pendingChoamMove || g.bureaucratPayments?.pending || + pendingNexusTraitors(g) || g.nexusCards?.phase?.stage === 'drawing' || + currentGuildCunning(g, p.id)?.stage === 'pending' || + currentGuildCunning(g, p.id)?.stage === 'secondShipment') return null; + const territories = alliedSeparationTerritories(g, p).map(id => territory(id).name); + return territories.length ? {territories} : null; +} + /** A played movement card is already retired before queue advancement. */ function finishMovementTurn(g: Game, id: string) { const p = getPlayer(g, id); + requireAlliedSeparationCustody(g, p); const cunning = currentGuildCunning(g,id); if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); } g.karamaShipping = null; @@ -13308,20 +13358,8 @@ function finishMovementTurn(g: Game, id: string) { `${p.name} and ${getPlayer(g, p.ally!).name} remain together in ${shared.map((t) => t.name).join(', ')} under Ecaz Occupy. No allied separation losses apply.`, { faction: 'ecaz', name: 'Occupy' }, ); - if (p.ally) - for (const terr of gameTerritories(g)) - if ( - terr.type !== 'polar' && - at(p, terr.id) && - at(getPlayer(g, p.ally), terr.id) && - !sharesEcazOccupation(g, p, terr.id) && - !g.movementRemaining.includes(p.ally) && - !( - p.allySinceTurn === g.turn && - getPlayer(g, p.ally).allySinceTurn === g.turn - ) - ) - killTerritory(g, p, terr.id); + for (const terr of alliedSeparationTerritories(g, p)) + killTerritory(g, p, terr); g.movementRemaining = g.movementRemaining.filter((other) => other !== id); if (g.saphoMovementLast?.player === id) g.saphoMovementLast = null; movementTurn(g); @@ -16666,9 +16704,9 @@ function beginStormTurn(g: Game) { if (g.dukeVidal) delete g.dukeVidal.leader.usedAt; for (const p of g.players) if (p.elites) p.elites.revived = 0; g.stormDialers = g.lastBattle; - if (g.advanced && byFaction(g, 'fremen')) { + if (g.advanced) { g.stormDialers = []; - g.stormPending = g.stormCard ?? shuffle([1, 2, 3, 4, 5, 6])[0]; + g.stormPending = (byFaction(g, 'fremen') ? g.stormCard : null) ?? shuffle([1, 2, 3, 4, 5, 6])[0]; g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'card',g.stormPending!)); g.stormCard = null; g.stormCardKnown = false; @@ -28956,6 +28994,9 @@ function applyActionInner( 'Wait for your movement turn.', ); if (action.nexus !== undefined) { declareGuildCunning(g,p,action.nexus); return g; } + // Reject unsupported concealed custody before retiring a played movement + // card and parking its native ending continuation. + requireAlliedSeparationCustody(g, p); if (g.ornithopter?.player === id) finishOrnithopter(g, 'end'); else finishMovementTurn(g, id); return g; @@ -29889,6 +29930,7 @@ export function viewGame(state: Game, id: string) { guildRateCanceled: g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === id, stormPending: g.stormPending ?? null, + advancedAllySeparation: advancedAllySeparationView(g, me), stormForecast: me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null, stormRevealed: diff --git a/game/reference.ts b/game/reference.ts index b39cc085..73bdcd26 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -42,7 +42,7 @@ export type RuleTopic = { }; export const PHASE_HELP = [ - 'Seal the storm dials. After the distance is revealed, play any storm cards and confirm movement.', + 'Complete the table’s storm dials or card reveal. After the distance is revealed, play any storm cards and confirm movement.', 'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.', 'Claim charity once if below two spice. Advanced Bene Gesserit receives two spice regardless of wealth. Then mark yourself ready.', 'Bid or pass in turn. A winning bidder chooses how to pay before income and bonus-card responses resolve.', @@ -111,7 +111,7 @@ const phaseDetails = [ 'On the first turn, the players nearest the start on its two sides each secretly dial zero through twenty. Add both dials to move the storm. Later basic turns use the two players who last used the battle wheels, with dials one through three. If no battle intervened, the previous storm dialers continue. Both dials stay hidden until both are submitted.', 'After revelation, everyone has a chance to play Weather Control or Family Atomics. A card change clears previous confirmations.', 'Confirm movement to let the storm advance once everyone is ready.', - 'In the advanced game with Fremen, later turns reveal the forecast storm card instead of collecting new dials. Storm casualties and Fremen foresight each resolve before the spice draw.', + 'Every new Advanced turn after the first reveals a random Storm Deck card, one through six, even without Fremen. First-turn dials are unchanged. Native Fremen keeps its private forecast; without Fremen nobody receives one. An already recorded saved Storm finishes its existing dial or card protocol without a reroll.', ], [ 'Everyone confirms the draw. A new blow remains open for Harvester before the table accepts it.', @@ -148,6 +148,8 @@ const phaseDetails = [ 'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory and shared desert allocation are supported. Allied Ecaz and Fremen together in Sietch Tabr do not block the Fremen final-turn victory; its other conditions still apply. Combined combat and the Habbanya extension remain unfinished.', 'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.', 'Finish shipment and movement explicitly. Any Bene Gesserit free-shipment decision and its response must finish first.', + 'In Advanced, finishing your turn sends your own fighters in territories shared with your ally to Tanks, even if your ally acts later or the alliance formed this turn. Polar Sink, Bene Gesserit advisor coexistence in either direction and native Ecaz peaceful coexistence are exempt. The finish warning names affected territories without revealing concealed forces. Loss is mandatory, not a separate confirmation; finishing remains legal if you cannot escape.', + 'Basic keeps its existing later-player separation deadline and same-turn alliance exemption; advisors have no new Basic exemption. Homeworlds keep their separate allied-entry restrictions.', 'Advanced Guild may choose when to take its complete shipment-and-movement turn. The table offers a choice before another player starts. Guild cannot interrupt a turn already underway.', ], [ @@ -2133,6 +2135,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Inspect the six possible faces, your permitted private forecast, and a card already revealed to the table.', steps: [ 'The gallery shows the six possible values, one through six. It does not identify a live draw.', + 'Every new Advanced Storm after the first uses these cards, even without Fremen. That does not give another faction Fremen’s private forecast. First-turn dials, Basic and an already recorded saved opening keep their own protocol.', 'When the table grants Fremen its private forecast, Inspect Storm Card opens that exact face. Other players do not receive the private forecast.', 'When the card is publicly revealed, its chronicle entry gains an inspector. The recorded face remains available while that entry remains in the chronicle, including after refresh.', 'The card value and actual movement can differ. Weather Control and Ecological Testing Station do not rewrite the recorded card face. Read the current storm result for the distance actually moved.', @@ -2140,7 +2143,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['storm', 'advanced-storm-spice', 'faction-fremen', 'privacy', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Six numeric faces and a public reveal record connect existing Storm Card state to inspection. Movement, draws and mode gates are unchanged; full component artwork and printed-face verification remain open.', evidence: ['game/storm-cards.ts', 'game/engine.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Six numeric faces and a public reveal record connect existing Storm Card state to inspection. Inspection itself changes no draw or movement; the later Advanced default is tracked separately. Full component artwork and printed-face verification remain open.', evidence: ['game/storm-cards.ts', 'game/engine.ts'] }, { area: 'Player controls', status: 'Implemented', detail: 'Private forecasts, public revealed-card chronicle entries and the six-face reference gallery share a keyboard-accessible enlarged inspector.', evidence: ['components/storm-cards.tsx', 'components/game-table.tsx', 'components/rules-reference.tsx'] }, { area: 'AI', status: 'Implemented', detail: 'Inspection adds no game action or AI decision. Existing forecast authorization, legal choices and profiles remain unchanged.' }, { area: 'Documentation', status: 'Partial', detail: 'Guidance distinguishes private forecasts, public history, reference examples and modified storm distance. This is not complete physical-component certification.', evidence: ['docs/STORM_CARD_INSPECTION.md'] }, @@ -2907,7 +2910,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Supported entry opportunities offer private Moritani controls followed by a public Karama response and, if allowed, an accept/refuse decision for the entrant. The entrant sees the alliance terms, not the hidden token’s name, face or Moritani’s private eligibility details. Complete Moritani games remain unavailable.', 'When an eligible faction would trigger Terror, Moritani may offer that entrant an alliance before revealing the token. This offer cannot be made to Ecaz.', 'If accepted, Moritani and the entrant become allies immediately, ending any existing alliance either had. Return the unrevealed Terror token to Moritani’s supply.', - 'If newly allied forces already share a territory, neither is immediately removed. If they still share it at the beginning of the next turn, one faction must leave during that turn’s Shipment and Movement; if the first does not leave, the second must leave or lose its forces there to the Tanks.', + 'Accepting the alliance does not immediately remove shared forces. In Advanced, the ending player’s own shared fighters go to Tanks at each Shipment and Movement end, including this formation turn, unless a normal exception applies. Basic retains the later-player deadline on the following turn. This does not change the offer’s formation timing or open unsupported entry combinations.', 'If the entrant refuses the offer, Moritani must reveal the token. Making an offer and receiving a refusal does not retain the ordinary option to decline the trigger.', 'This alliance opportunity comes from the faction ability; it does not wait for a Nexus. Karama can prevent the alliance forming, while Moritani may still reveal the token.', 'In the current controls, Karama is checked before the entrant replies. If canceled, the decision returns to Moritani: reveal the token or leave it hidden. The canceled offer cannot be offered again during this entry opportunity.', @@ -3023,15 +3026,23 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', summary: - 'Two spice piles, Fremen forecasts, half storm losses and additional worms.', + 'Post-first-turn Storm Deck in every Advanced roster, native Fremen forecasts, two spice piles and worm responses.', steps: [ - 'These phase rules have engine support and focused tests. The six classic factions can start the unfinished Advanced preview while remaining powers are completed.', + 'Advanced normally uses these phase rules, not an extra source profile. The adopted unofficial revision changes later Storm determination; it is not a publisher clarification or complete-mode certification. The six classic factions can start the unfinished Advanced preview while remaining powers are completed.', 'Resolve blow A, its worms, Harvester window, Nexus and rides, then do the same for blow B. Each pile uses its own previous discard. First-turn worms are set aside and shuffled back only after both blows finish.', - 'After the first storm, Fremen privately learns a random storm card for the next turn. A foresight cancellation hides that card from Fremen without changing its eventual distance. Weather Control may replace a revealed card distance before movement.', + 'Keep the first storm’s two secret nearby-player dials, each zero through twenty. Every later newly opened Advanced Storm draws a random card from one through six, including without Fremen. The last revealed face is eligible again next time. A saved current dial or card opening finishes as recorded; returning to the table never rerolls it.', + 'After the first storm, native Fremen privately learns the actual card for the next turn. Canceling foresight hides that card from Fremen without changing its eventual distance. Without Fremen, nobody gets a forecast or a foresight response. Weather Control may replace a revealed distance before movement without changing the recorded card face.', 'Fremen storm losses are half the exposed group, rounded up. Choose ordinary and Fedaykin casualties when more than one combination is possible. A legal reserve shipment into storm applies this loss only to the arriving group.', 'Fremen may place additional worms from a spice blow in a sand territory. Canceling this placement prevents further additional-worm placements for the rest of the turn. Ordinary worm appearances and their Nexus still resolve.', 'Fremen worm survival and optional allied protection have separate responses before casualties. Canceling Fremen survival destroys the Fremen forces at that worm’s location and removes that ride; it does not cancel an allowed protection of allied forces.', ], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'New post-first Advanced Storm openings use the canonical six-card source in every roster; first dials, native foresight and recorded current openings retain their own protocol. Wider Advanced interactions remain incomplete.', evidence: ['game/engine.ts', 'game/storm-cards.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Controls follow the current recorded dial or card opening. Public card inspection does not grant a private forecast; pending responses keep priority.', evidence: ['components/game-table.tsx', 'components/storm-cards.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Legal Storm participation follows the projected opening, including recorded dials and no-Fremen cards. Strategy calibration and full-mode acceptance remain separate.', evidence: ['game/bots.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Internal guidance distinguishes the authorized unofficial Advanced default, unchanged Basic rules, first dials, native forecasts and saved continuation.', evidence: ['docs/RULE_DECISIONS.md', 'docs/BASE_ADVANCED_READINESS_20260907.md', 'docs/STORM_CARD_INSPECTION.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused native and authenticated SQLite checks cover fresh no-Fremen cards, first dials, a genuinely captured prior Advanced dial opening, forecast privacy and unchanged Basic continuation. Actual phone inspection and confirmation continued a saved no-Fremen card opening; broader Advanced and deployment acceptance remain open.', evidence: ['tests/advanced-source-engine.test.ts', 'tests/advanced-source-recovery.test.ts', 'tests/advanced-source-controls.test.tsx', 'tests/bot-advanced-source.test.ts', 'docs/STORM_CARD_INSPECTION.md'] }, + ], example: 'Five Fremen forces caught in storm lose three tokens. If the group includes two Fedaykin, Fremen can keep both by losing three ordinary tokens.', related: [ @@ -3043,6 +3054,28 @@ export const RULE_TOPICS: RuleTopic[] = [ 'card-harvester', ], }, + { + id: 'advanced-allied-separation', + title: 'Advanced alliances: ending shared occupation', + category: 'Advanced & expansions', + coverage: 'Partial', + summary: 'Each ending player loses its own shared fighters; advisor coexistence, Polar Sink and native Ecaz coexistence are exempt.', + steps: [ + 'Under the adopted unofficial Advanced revision, finish each player’s Shipment and Movement separately. Your own fighters still sharing a territory with your ally go to Tanks when your turn ends. Your ally’s fighters stay; its later turn does not postpone your loss. An alliance formed this turn has no Advanced grace period.', + 'Polar Sink is exempt. If either allied group is Bene Gesserit advisors, coexistence sends neither group to Tanks. Bene Gesserit fighters have no advisor exemption. Native Ecaz peaceful coexistence keeps its own conditions, including applicable Karama effects.', + 'Before an ordinary finish, the table warns which public territories would lose your forces. It does not reveal force amounts or concealed No-Field values. Finish movement uses the existing action, without another loss confirmation. If no legal escape is available, you may still finish and accept the mandatory consequence.', + 'Complete any pending shipment, movement, arrival or response before ordinary finish controls become available. Extra-move and deferred-turn endings use the same separation rule; they do not replay the arrival or charge shipment again.', + 'Basic still uses its existing later-player deadline and formation-turn exemption, without this advisor exception. Homeworlds are outside this Dune-territory rule and keep their native allied-entry restrictions. The rule grants no new allied-entry permission or alliance formation window, and completed turns are not reevaluated.', + ], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'One canonical separation quote distinguishes Basic from Advanced timing and exceptions; native ending-group custody remains responsible for physical losses.', evidence: ['game/allied-separation.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'The own clean-turn warning contains public loss territories only; forced loss adds no action or confirmation and shared pending-control locks retain priority.', evidence: ['components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four legal policies use native projected movement/ending availability and can finish with mandatory loss; no duplicate separation calculator or strategic alliance calibration was added.', evidence: ['game/bots.ts', 'tests/bot-advanced-source.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'The unofficial per-player/advisor departures are distinguished from the earlier publisher/FAQ and unchanged Basic policy.', evidence: ['docs/ALLIED_TRANSIT_RULES.md', 'docs/RULE_DECISIONS.md', 'docs/BASE_ADVANCED_READINESS_20260907.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Genuine native overlaps cover first/later newly allied ends, exact normal/elite custody, Hajr, advisor/Ecaz/Polar exceptions and unchanged Basic policy. Authenticated SQLite covers restart, races, privacy and immutable rejects. Actual phone ending moved only the ending group to Tanks and refresh retained it; wider combinations, full modes and deployment remain open.', evidence: ['tests/allied-separation.test.ts', 'tests/advanced-source-engine.test.ts', 'tests/advanced-source-recovery.test.ts', 'tests/bot-advanced-source.test.ts', 'docs/ALLIED_TRANSIT_RULES.md'] }, + ], + related: ['movement', 'spice-blow', 'advanced-advisors', 'ecaz-ambassadors', 'homeworlds'], + }, { id: 'victory', title: 'Winning and the final turn', @@ -3687,6 +3720,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'Bene Gesserit forces share one token type throughout a territory. The map marks advisors with a dashed outer ring and labels their stance in the territory details.', 'Advisors do not fight, contest stronghold victory, block another faction’s entry, collect spice, earn stronghold income, grant ornithopters or enable Family Atomics. Storms, worms, explosions and Atomics still destroy them.', + 'In Advanced, advisors may coexist with their ally without triggering the end-turn alliance constraint in either direction. Neither the advisors nor the allied fighters are lost for that coexistence. Bene Gesserit fighters remain subject to ordinary separation; Basic receives no new advisor exception.', 'After Fremen setup, choose the starting advisor’s territory and sector. Advisors become fighters automatically whenever no other faction remains in their territory.', 'A normal shipment enters as fighters unless joining existing advisors. Moving or shipping into your existing group must match its type. Advisors moved into an occupied territory without another Bene Gesserit group may remain peaceful or request a flip to fighters.', 'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.', diff --git a/tests/advanced-guild.test.ts b/tests/advanced-guild.test.ts index fd7298dd..8469f4a2 100644 --- a/tests/advanced-guild.test.ts +++ b/tests/advanced-guild.test.ts @@ -157,18 +157,51 @@ void test('Karama can stop Guild delaying its normal turn, but taking that norma assert.deepEqual(g.movementRemaining, ['g', 'h', 'f']); assert.equal(g.guildTimingLocked, true); }); -void test('allied co-occupation after Nexus uses actual completed movement turns when Guild changes order', () => { - let g = fixture(); - g.players[0].ally = 'g'; - g.players[1].ally = 'e'; - g.players[0].forces = { 'arrakeen:10': 1 }; - g.players[0].reserves = 19; - g.players[1].forces = { 'arrakeen:10': 5 }; - g = allow(choose(g, true)); - g = end(g); - assert.equal(g.players[1].forces['arrakeen:10'], 5); - assert.equal(g.active, 'e'); - g = end(g); - assert.equal(g.players[0].forces['arrakeen:10'], undefined); - assert.equal(g.players[0].tanks, 1); +void test('allied co-occupation loses only the ending faction’s fighters when Guild changes order', () => { + for (const guildFirst of [true, false]) { + let g = fixture(); + g.players[0].ally = 'g'; + g.players[1].ally = 'e'; + g.players[0].forces = { 'arrakeen:10': 1 }; + g.players[0].reserves = 19; + g.players[1].forces = { 'arrakeen:10': 5 }; + g.players[1].reserves = 15; + g = allow(choose(g, guildFirst)); + const first = guildFirst ? 'g' : 'e'; + const survivor = guildFirst ? 'e' : 'g'; + const lost = guildFirst ? 5 : 1; + const retained = guildFirst ? 1 : 5; + assert.equal(g.active, first); + g = end(g); + assert.deepEqual(g.players.find((p) => p.id === first)!.forces, {}); + assert.equal(g.players.find((p) => p.id === first)!.tanks, lost); + assert.deepEqual(g.players.find((p) => p.id === survivor)!.forces, { + 'arrakeen:10': retained, + }); + assert.equal(g.players.find((p) => p.id === survivor)!.tanks, 0); + const acted = [first]; + while (g.phase === 5) { + if (g.decision?.kind === 'guildTiming') g = allow(choose(g, false)); + acted.push(g.active!); + g = end(g); + } + assert.deepEqual( + acted, + guildFirst ? ['g', 'e', 'h', 'f'] : ['e', 'h', 'f', 'g'], + ); + assert.deepEqual(g.players.find((p) => p.id === survivor)!.forces, { + 'arrakeen:10': retained, + }); + assert.equal(g.players.find((p) => p.id === survivor)!.tanks, 0); + for (const id of ['e', 'g']) { + const p = g.players.find((p) => p.id === id)!; + assert.equal(p.reserves, id === 'e' ? 19 : 15); + assert.equal( + p.reserves + + p.tanks + + Object.values(p.forces).reduce((sum, n) => sum + n, 0), + 20, + ); + } + } }); diff --git a/tests/advanced-phases.test.ts b/tests/advanced-phases.test.ts index 9a8bc08b..4bb1ae80 100644 --- a/tests/advanced-phases.test.ts +++ b/tests/advanced-phases.test.ts @@ -11,6 +11,8 @@ import { } from '../game/engine'; import { spiceDeck, baseDeck } from '../game/cards'; import { TERRITORIES } from '../game/board'; +import { createStormSource } from '../game/discovery-storm'; +import { isStormCardDistance, stormCardDistance } from '../game/storm-cards'; function fixture() { let g = createGame('ADVTEST2', newPlayer('f', 'Fremen', 'fremen')); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); @@ -346,23 +348,57 @@ void test('canceling storm shipment protection destroys only the arriving group void test('Weather Control overrides an advanced storm card after revelation and a new private forecast follows', () => { let g = fixture(); - g.turn = 2; - g.phase = 0; - g.stormDialers = []; - g.stormPending = 3; + g = applyAction(g, 'f', { type: 'stormDial', amount: 0 }); + g = applyAction(g, 'a', { type: 'stormDial', amount: 0 }); + g = allow(ready(g)); + const original = g.stormCard!; + assert.ok(isStormCardDistance(original)); + g.phase = 8; + g.ready = []; + g = ready(g); + assert.equal(g.turn, 2); + assert.equal(g.phase, 0); + assert.equal(g.stormPending, original); + assert.deepEqual( + g.stormMovementSource, + createStormSource(g.turn, 'card', original), + ); + const inspection = g.log.find( + (entry) => stormCardDistance(entry.component) === original, + )!; + assert.equal(stormCardDistance(inspection.component), original); const previous = g.storm; const weather = baseDeck().find((c) => c.effect === 'weather')!; - placeFixtureHand(g, 1, [weather]); + const karama = baseDeck().find((c) => c.effect === 'karama')!; + placeFixtureHand(g, 1, [weather, karama]); g = applyAction(g, 'f', { type: 'ready' }); g = applyAction(g, 'a', { type: 'card', card: weather.id, amount: 9 }); assert.equal(g.stormPending, 9); + assert.deepEqual( + g.stormMovementSource, + createStormSource(g.turn, 'weather', 9), + ); + for (const player of g.players) + assert.equal( + stormCardDistance( + viewGame(g, player.id).log.find((entry) => entry.seq === inspection.seq) + ?.component, + ), + original, + ); assert.deepEqual(g.ready, []); g = ready(g); assert.equal(g.storm, ((previous - 1 + 9) % 18) + 1); - assert.equal(g.response, null); - assert.equal(viewGame(g, 'f').stormForecast, g.stormCard); + assert.equal(g.stormMovementSource, undefined); + assert.equal(g.stormPending, null); + assert.equal(g.discard.filter((card) => card.id === weather.id).length, 1); + assert.equal(g.response?.kind, 'stormPeek'); + const forecast = g.stormCard!; + assert.ok(isStormCardDistance(forecast)); + assert.equal(viewGame(g, 'f').stormForecast, null); + assert.equal(viewGame(g, 'a').stormForecast, null); g = allow(g); - assert.ok(viewGame(g, 'f').stormForecast); + assert.equal(viewGame(g, 'f').stormForecast, forecast); assert.equal(viewGame(g, 'a').stormForecast, null); }); diff --git a/tests/advanced-source-controls.test.tsx b/tests/advanced-source-controls.test.tsx new file mode 100644 index 00000000..65107816 --- /dev/null +++ b/tests/advanced-source-controls.test.tsx @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import type { ComponentProps } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame } from '../game/engine'; +import type { GameView } from '../game/engine'; +import { advanceToForecast, createAdvancedSourceFixture, settleAdvancedSourceResponses } from './fixture-advanced-source'; +import { DEFAULT_ROOM_CONTROL } from '../lib/room-control'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +// Next/CSS test loading hooks must be registered before this UI dependency is loaded. +const { GameTable } = await import('../components/game-table'); + +function table(game: GameView, busy = false): string { + return renderToStaticMarkup(createElement(GameTable, { game, send: async () => {}, busy, onExit() {} })); +} + +void test('actual first Storm gives the recorded dialers battle wheels and no public card or private forecast', () => { + const fixture = createAdvancedSourceFixture({ storm: 'first' }); + for (const player of fixture.game.players) { + const own = viewGame(fixture.game, player.id); + const html = table(own); + if (own.stormDialers.includes(player.id)) { + assert.match(html, /id="storm-dial"[^>]*min="0"[^>]*max="20"/); + assert.match(html, />Lock storm dial/); + } else { + assert.doesNotMatch(html, /id="storm-dial"/); + } + assert.doesNotMatch(html, /aria-label="Inspect Storm Card:/); + assert.equal(own.stormForecast, null); + } +}); + +void test('subsequent Advanced Storm without Fremen offers the public card and existing confirmation, not dials or forecast', () => { + const fixture = createAdvancedSourceFixture({ storm: 'next', fremen: false }); + for (const player of fixture.game.players) { + const own = viewGame(fixture.game, player.id); + const html = table(own); + assert.match(html, />Confirm storm movement/); + assert.match(html, /aria-label="Inspect Storm Card: [1-6] sectors? · Public reveal"/); + assert.doesNotMatch(html, /id="storm-dial"|>Lock storm dial|Waiting for secret storm dials|Your storm forecast:/); + assert.equal(own.stormForecast, null); + } +}); + +void test('later Basic Storm keeps native recorded dial controls and confirms their revealed sum rather than a card', () => { + const fixture = createAdvancedSourceFixture({ advanced: false, storm: 'next' }); + let game = fixture.game; + assert.equal(game.stormPending, null); + assert.equal(game.stormDialers.length, 2); + for (const dialer of fixture.game.stormDialers) { + const own = viewGame(game, dialer); + assert.match(table(own), /id="storm-dial"[^>]*min="1"[^>]*max="3"/); + game = applyAction(game, dialer, { type: 'stormDial', amount: 2 }); + } + for (const player of game.players) { + const html = table(viewGame(game, player.id)); + assert.match(html, />Confirm storm movement/); + assert.doesNotMatch(html, /id="storm-dial"|aria-label="Inspect Storm Card:/); + } +}); + +void test('each ending seat sees only its own public mandatory loss warning; normal end remains available without a second prompt', () => { + const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' }); + const own = viewGame(fixture.game, fixture.actor); + assert.ok(own.advancedAllySeparation?.territories.length); + const html = table(own); + for (const name of own.advancedAllySeparation.territories) assert.ok(html.includes(name)); + assert.match(html, /aria-describedby="advanced-ally-separation"[^>]*>Finish shipment & movement/); + assert.doesNotMatch(html, />Confirm (?:alliance|force loss)/); + for (const player of fixture.game.players.filter(player => player.id !== fixture.actor)) { + const other = viewGame(fixture.game, player.id); + assert.equal(other.advancedAllySeparation, null); + assert.doesNotMatch(table(other), /id="advanced-ally-separation"/); + } + const finished = applyAction(fixture.game, fixture.actor, { type: 'endMovement' }); + assert.doesNotMatch(table(viewGame(finished, fixture.actor)), /id="advanced-ally-separation"/); +}); + +void test('room and transport locks keep the existing end button disabled despite mandatory loss being legal', () => { + const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' }); + const own = viewGame(fixture.game, fixture.actor); + assert.match(table(own, true), /disabled=""[^>]*>Finish shipment & movement/); + for (const restriction of ['paused', 'closed', 'autopilot'] as const) { + const restricted: ComponentProps['game'] = structuredClone(own); + if (restriction === 'autopilot') restricted.players.find(player => player.id === own.me)!.autopilot = 'Easy'; + else restricted.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true }; + assert.match(table(restricted), /disabled=""[^>]*>Finish shipment & movement/); + } +}); + +void test('native forecast response owns the surface and reveals a private face only to Fremen after it settles', () => { + const fixture = createAdvancedSourceFixture({ storm: 'first', fremen: true, karama: true }); + const pending = advanceToForecast(fixture.game); + assert.equal(pending.response?.kind, 'stormPeek'); + const counter = pending.players.find(player => player.faction !== 'fremen' && player.hand.some(card => card.effect === 'karama')); + assert.ok(counter, 'the native deal supplies a genuine counter to pass'); + const card = counter.hand.find(card => card.effect === 'karama')!; + assert.ok(viewGame(pending, counter.id).responseControls?.cancelCards.includes(card.id)); + for (const player of pending.players) { + const own = viewGame(pending, player.id); + const html = table(own); + assert.equal(own.stormForecast, null); + assert.doesNotMatch(html, />Confirm storm movement|>Finish shipment & movement|id="advanced-ally-separation"|Your storm forecast:/); + } + const settled = settleAdvancedSourceResponses(pending); + assert.ok(settled.players.find(player => player.id === counter.id)!.hand.some(held => held.id === card.id)); + assert.equal(settled.discard.some(discarded => discarded.id === card.id), false); + for (const player of settled.players) { + const own = viewGame(settled, player.id); + const html = table(own); + if (player.faction === 'fremen') { + assert.equal(own.stormForecast, pending.stormCard); + assert.match(html, /aria-label="Inspect Storm Card: [1-6] sectors? · Private forecast"/); + } else { + assert.equal(own.stormForecast, null); + assert.doesNotMatch(html, /aria-label="Inspect Storm Card: [1-6] sectors? · Private forecast"/); + } + } +}); + +aliases.deregister(); diff --git a/tests/advanced-source-engine.test.ts b/tests/advanced-source-engine.test.ts new file mode 100644 index 00000000..479a811e --- /dev/null +++ b/tests/advanced-source-engine.test.ts @@ -0,0 +1,251 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {applyAction, viewGame, type Game} from '../game/engine'; +import {splitLocation, territory} from '../game/board'; +import {stormCardDistance} from '../game/storm-cards'; +import {createAdvancedSourceFixture, advanceToForecast, advanceToNextStorm, settleAdvancedSourceResponses} from './fixture-advanced-source'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; +function confirmStorm(state: Game): Game { + let game = state; + for (const player of state.players) game = applyAction(game, player.id, {type: 'ready'}); + return game; +} +function receiptFaces(game: Game): number[] { + return game.log.flatMap(entry => { + const face = stormCardDistance(entry.component); + return face === null ? [] : [face]; + }); +} +function group(game: Game, id: string, where: string[]) { + const player = game.players.find(p => p.id === id)!; + const amount = Object.entries(player.forces).filter(([key]) => where.includes(splitLocation(key).territory)) + .reduce((sum, [, n]) => sum + n, 0); + const elite = Object.entries(player.elites?.forces ?? {}).filter(([key]) => where.includes(splitLocation(key).territory)) + .reduce((sum, [, n]) => sum + n, 0); + return {amount, elite, tanks: player.tanks, eliteTanks: player.elites?.tanks ?? 0}; +} + +function custody(game: Game, id: string) { + const player = game.players.find(p => p.id === id)!; + return {forces: player.forces, reserves: player.reserves, tanks: player.tanks, + elites: player.elites ?? null, advisors: player.advisors ?? null, + noField: player.noField ?? null, hand: player.hand}; +} + +void test('actual first Advanced storm keeps two native 0..20 wheels and commits once', () => { + let {game} = createAdvancedSourceFixture(); + assert.equal(game.turn, 1); + assert.equal(game.stormDialers.length, 2); + assert.equal(game.stormPending, null); + const [first, second] = game.stormDialers; + game = applyAction(game, first, {type: 'stormDial', amount: 20}); + const checkpoint = JSON.stringify(game); + assert.throws(() => applyAction(game, first, {type: 'stormDial', amount: 0}), /locked/); + assert.equal(JSON.stringify(game), checkpoint); + game = applyAction(reload(game), second, {type: 'stormDial', amount: 20}); + assert.equal(game.stormPending, 40); + assert.equal(game.stormMovementSource?.kind, 'dials'); + game = confirmStorm(game); + assert.equal(game.phase, 1); + assert.equal(game.storm, 5); + assert.equal(game.stormMovementSource, undefined); + assert.deepEqual(receiptFaces(game), []); +}); + +void test('every subsequent Advanced opening uses one canonical public card without granting a Fremen forecast', () => { + let {game} = createAdvancedSourceFixture({storm: 'next'}); + assert.equal(game.turn, 2); + assert.deepEqual(game.stormDialers, []); + const face = game.stormPending!; + assert.ok(face >= 1 && face <= 6); + assert.equal(game.stormMovementSource?.kind, 'card'); + assert.deepEqual(receiptFaces(game), [face]); + const before = JSON.stringify(game); + for (const p of game.players) { + const view = viewGame(game, p.id); + assert.equal(view.stormForecast, null); + assert.equal(view.stormPending, face); + assert.equal(view.stormRevealed, null); + } + assert.equal(JSON.stringify(game), before); + const first = game.players[0].id; + assert.throws(() => applyAction(game, first, {type: 'stormDial', amount: 1}), /not dialing/); + game = applyAction(reload(game), first, {type: 'ready'}); + const savedSource = game.stormMovementSource; + assert.deepEqual(receiptFaces(game), [face]); + for (const p of game.players.slice(1)) game = applyAction(game, p.id, {type: 'ready'}); + assert.equal(game.phase, 1); + assert.equal(game.storm, 1 + face); + assert.deepEqual(receiptFaces(game), [face]); + assert.equal(savedSource?.distance, face); + game = advanceToNextStorm(game); + assert.equal(game.turn, 3); + assert.deepEqual(game.stormDialers, []); + assert.equal(receiptFaces(game).length, 2); +}); + +void test('native forecast remains private and the held physical face is the next actual reveal, including cancellation', () => { + for (const cancel of [false, true]) { + const fixture = createAdvancedSourceFixture({fremen: true, karama: true}); + let game = advanceToForecast(fixture.game); + const fremen = game.players.find(p => p.faction === 'fremen')!; + const face = game.stormCard!; + const counter = game.players.find(p => p.faction !== 'fremen' && p.hand.some(c => c.effect === 'karama'))!; + assert.ok(counter, 'the native deal supplies a genuine counter even when it will pass'); + const card = counter.hand.find(c => c.effect === 'karama')!; + assert.equal(game.response?.kind, 'stormPeek'); + assert.ok(viewGame(game, counter.id).responseControls?.cancelCards.includes(card.id)); + const forces = structuredClone(game.players.map(p => ({id: p.id, custody: custody(game, p.id)}))); + assert.equal(viewGame(game, fremen.id).stormForecast, null); + if (cancel) { + game = applyAction(game, counter.id, {type: 'card', card: card.id, mode: 'cancel'}); + } else game = settleAdvancedSourceResponses(game); + for (const before of forces) { + const after = custody(game, before.id); + assert.deepEqual({...after, hand: undefined}, {...before.custody, hand: undefined}); + } + assert.equal(game.players.find(p => p.id === counter.id)!.hand.some(c => c.id === card.id), !cancel); + assert.equal(game.discard.filter(c => c.id === card.id).length, cancel ? 1 : 0); + for (const p of game.players) + assert.equal(viewGame(game, p.id).stormForecast, !cancel && p.id === fremen.id ? face : null); + assert.equal(game.stormCard, face); + assert.deepEqual(receiptFaces(game), []); + game = advanceToNextStorm(reload(game)); + assert.equal(game.stormPending, face); + assert.deepEqual(receiptFaces(game), [face]); + assert.equal(game.stormCard, null); + } + const automatic = createAdvancedSourceFixture({fremen: true, karama: false}); + let game = advanceToForecast(automatic.game); + assert.equal(game.response, null, 'a native forecast without an eligible counter resolves automatically'); + assert.ok(game.stormCardKnown); + const face = game.stormCard!; + assert.ok(face >= 1 && face <= 6); + for (const player of game.players) { + assert.equal(player.hand.some(card => card.effect === 'karama'), false); + assert.equal(viewGame(game, player.id).stormForecast, player.faction === 'fremen' ? face : null); + } + assert.deepEqual(receiptFaces(game), []); + game = advanceToNextStorm(reload(game)); + assert.equal(game.stormPending, face); + assert.deepEqual(receiptFaces(game), [face]); + assert.equal(game.stormCard, null); +}); + +void test('Basic next-turn wheels retain their native 1..3 bounds and never become the Advanced Deck', () => { + let {game} = createAdvancedSourceFixture({advanced: false, storm: 'next'}); + assert.equal(game.stormDialers.length, 2); + assert.equal(game.stormPending, null); + assert.equal(game.stormCard, null); + const before = JSON.stringify(game); + assert.throws(() => applyAction(game, game.stormDialers[0], {type: 'stormDial', amount: 0}), /Storm dial/); + assert.equal(JSON.stringify(game), before); + for (const id of game.stormDialers) game = applyAction(game, id, {type: 'stormDial', amount: 3}); + assert.equal(game.stormPending, 6); + assert.equal(game.stormMovementSource?.kind, 'dials'); + assert.deepEqual(receiptFaces(game), []); +}); + +void test('corrupt or orphaned card source cannot mutate a saved native pending Storm', () => { + const original = createAdvancedSourceFixture({storm: 'next'}).game; + for (const corrupt of [ + (game: Game) => {game.stormMovementSource!.signature = 'foreign';}, + (game: Game) => {delete game.stormMovementSource;}, + (game: Game) => {game.stormPending = game.stormPending === 6 ? 5 : 6;}, + (game: Game) => {game.stormMovementSource!.turn++;}, + ]) { + const game = reload(original); + corrupt(game); + const before = JSON.stringify(game); + assert.throws(() => viewGame(game, game.players[0].id), /source|storm/i); + assert.throws(() => applyAction(game, game.players[0].id, {type: 'ready'}), /source|storm/i); + assert.equal(JSON.stringify(game), before); + } +}); + +void test('Advanced ends own exact normal and elite custody now, even with the new ally still to act', () => { + for (const alliance of ['firstEnding', 'laterEnding'] as const) { + const fixture = createAdvancedSourceFixture({alliance}); + const {actor, ally, lossTerritories} = fixture; + let game = fixture.game; + assert.ok(game.movementRemaining!.includes(ally!)); + assert.equal(game.players.find(p => p.id === actor)!.allySinceTurn, game.turn); + assert.equal(game.players.find(p => p.id === ally)!.allySinceTurn, game.turn); + if (alliance === 'laterEnding') assert.ok(game.order.indexOf(actor) > 0); + const own = group(game, actor, lossTerritories); + assert.equal(own.amount, 2); + assert.equal(own.elite, 1); + const ownCustody = structuredClone(custody(game, actor)); + const other = structuredClone(custody(game, ally!)); + const immutable = JSON.stringify(game); + assert.deepEqual(viewGame(game, actor).advancedAllySeparation, + {territories: lossTerritories.map(id => territory(id).name)}); + assert.equal(viewGame(game, ally!).advancedAllySeparation, null); + assert.equal(JSON.stringify(game), immutable); + game = applyAction(reload(game), actor, {type: 'endMovement'}); + const after = group(game, actor, lossTerritories); + assert.deepEqual(after, {amount: 0, elite: 0, tanks: own.tanks + own.amount, eliteTanks: own.eliteTanks + own.elite}); + const endedCustody = custody(game, actor); + assert.equal(endedCustody.reserves, ownCustody.reserves); + assert.equal(endedCustody.elites?.reserves, ownCustody.elites?.reserves); + assert.deepEqual(endedCustody.advisors, ownCustody.advisors); + assert.deepEqual(endedCustody.noField, ownCustody.noField); + assert.deepEqual(endedCustody.hand, ownCustody.hand); + assert.deepEqual(custody(game, ally!), other); + assert.ok(!game.movementRemaining?.includes(actor)); + const committed = JSON.stringify(game); + assert.throws(() => applyAction(game, actor, {type: 'endMovement'}), /movement turn/); + assert.equal(JSON.stringify(game), committed); + } +}); + +void test('Basic preserves newly allied overlap and only the later old ally loses its own group', () => { + const fresh = createAdvancedSourceFixture({advanced: false, alliance: 'newThisTurn'}); + const before = structuredClone(custody(fresh.game, fresh.actor)); + const after = applyAction(fresh.game, fresh.actor, {type: 'endMovement'}); + assert.deepEqual(custody(after, fresh.actor), before); + assert.equal(viewGame(fresh.game, fresh.actor).advancedAllySeparation, null); + const old = createAdvancedSourceFixture({advanced: false, alliance: 'laterEnding'}); + assert.equal(old.turn, 3); + assert.ok(!old.game.movementRemaining?.includes(old.ally!)); + const own = group(old.game, old.actor, old.lossTerritories); + const ally = structuredClone(custody(old.game, old.ally!)); + const ended = applyAction(old.game, old.actor, {type: 'endMovement'}); + assert.equal(group(ended, old.actor, old.lossTerritories).tanks, own.tanks + own.amount); + assert.equal(group(ended, old.actor, old.lossTerritories).amount, 0); + assert.deepEqual(custody(ended, old.ally!), ally); +}); + +void test('native advisor stance exempts both directions; Ecaz and Polar custody also survives end', () => { + for (const alliance of ['ownAdvisors', 'allyAdvisors', 'ecaz', 'polar'] as const) { + const fixture = createAdvancedSourceFixture({alliance}); + const {game, actor, ally} = fixture; + const where = alliance === 'ecaz' ? ['imperial_basin'] : alliance === 'polar' ? ['polar_sink'] : ['arrakeen']; + const own = group(game, actor, where); + assert.ok(own.amount > 0); + assert.ok(group(game, ally!, where).amount > 0); + assert.equal(viewGame(game, actor).advancedAllySeparation, null); + const ownCustody = structuredClone(custody(game, actor)); + const other = structuredClone(custody(game, ally!)); + const ended = applyAction(game, actor, {type: 'endMovement'}); + assert.deepEqual(group(ended, actor, where), own); + assert.deepEqual(custody(ended, actor), ownCustody); + assert.deepEqual(custody(ended, ally!), other); + } +}); + +void test('actual dealt Hajr retires once and the final native end still applies exactly one owned loss', () => { + const fixture = createAdvancedSourceFixture({alliance: 'firstEnding', hajr: true}); + let game = fixture.game; + const actor = fixture.actor; + const card = game.players.find(p => p.id === actor)!.hand.find(c => c.effect === 'hajr')!; + const own = group(game, actor, fixture.lossTerritories); + game = applyAction(game, actor, {type: 'card', card: card.id}); + assert.deepEqual(group(game, actor, fixture.lossTerritories), own); + assert.equal(game.discard.filter(c => c.id === card.id).length, 1); + assert.ok(game.hajr.includes(actor)); + game = applyAction(reload(game), actor, {type: 'endMovement'}); + assert.equal(group(game, actor, fixture.lossTerritories).tanks, own.tanks + own.amount); + assert.equal(game.discard.filter(c => c.id === card.id).length, 1); +}); diff --git a/tests/advanced-source-recovery.test.ts b/tests/advanced-source-recovery.test.ts new file mode 100644 index 00000000..eb1cfe05 --- /dev/null +++ b/tests/advanced-source-recovery.test.ts @@ -0,0 +1,600 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import type { DatabaseSync, SQLOutputValue } from 'node:sqlite'; +import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, type GameView } from '../game/engine'; +import { stormCardDistance, isStormCardDistance } from '../game/storm-cards'; +import { createStormSource } from '../game/discovery-storm'; +import type { FactionId } from '../game/catalog'; +import type { RoomsClock, SeatAuth } from '../db/rooms'; +import type * as Rooms from '../db/rooms'; +import { splitLocation, territory } from '../game/board'; +import { unitStore } from './fixture-nexus-room-store'; +import { createAdvancedSourceFixture, createRecordedAdvancedSourceFixture, advanceToForecast, advanceToNextStorm, + type AdvancedSourceFixtureOptions as Options, type AdvancedSourceFixture } from './fixture-advanced-source'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; +const plain = (value: T): T => JSON.parse(JSON.stringify(value)); +type SqlRow = Record; +interface Fixture extends AdvancedSourceFixture { + sqlite: DatabaseSync; + restart(): typeof Rooms; + hooks: { beforeWrite?: () => Promise }; + initial: Game; + code: string; + tokens: string[]; + auths: SeatAuth[]; + save(g: Game): void; + credentialRows: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; recoveries: SqlRow[] }; + unrelatedCode: string; + unrelatedToken: string; + unrelatedRow: SqlRow | undefined; + stock: string[]; + forces: { id: string; total: number; elite: number }[]; +} +function player(g: Game, id: string) { + const p = g.players.find(seat => seat.id === id); + assert.ok(p, `missing native seat ${id}`); + return p; +} +function credentials(sqlite: DatabaseSync) { + return { + seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(), + entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(), + keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(), + recoveries: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(), + }; +} +function forceStock(g: Game) { + return g.players.map(p => ({ id: p.id, + total: p.reserves + p.tanks + Object.values(p.forces).reduce((n, amount) => n + amount, 0), + elite: p.elites ? p.elites.reserves + p.elites.tanks + + Object.values(p.elites.forces).reduce((n, amount) => n + amount, 0) : 0 })); +} +function cardStock(g: Game) { + const ids = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), + ...(g.auction?.cards.slice(g.auction.index) ?? [])].map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'each physical Treachery card has one custodian'); + return ids; +} +function receiptFaces(g: { log: readonly { component?: unknown }[] }) { + return g.log.flatMap(entry => { + const distance = stormCardDistance(entry.component); + return distance === null ? [] : [distance]; + }); +} +async function fixture(t: test.TestContext, options: Options = {}, recorded?: Game): Promise { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const factions: FactionId[] = recorded ? recorded.players.map(p => p.faction) : + options.alliance === 'ecaz' ? ['ecaz', 'fremen', 'atreides'] : + options.alliance === 'ownAdvisors' || options.alliance === 'allyAdvisors' ? ['atreides', 'beneGesserit', 'fremen'] : + options.alliance === 'firstEnding' || options.alliance === 'laterEnding' || options.alliance === 'newThisTurn' ? + ['atreides', 'emperor', 'guild'] : options.alliance ? ['atreides', 'fremen', 'guild'] : ['atreides', 'emperor']; + if (!options.alliance && options.fremen) factions.push('fremen'); + const made = await store.rooms.createRoom('Advanced source recovery', factions[0], options.advanced ?? true, + options.alliance === 'ecaz' ? ['ecaz'] : []); + const code = made.view.code, tokens = [made.token]; + for (const faction of factions.slice(1)) tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); + let auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + if (recorded) { + // Bind isolated credentials to the already native saved IDs, never rewrite + // the captured Game's identity or its historical source. + for (const [index, auth] of auths.entries()) + assert.equal(store.sqlite.prepare('UPDATE seats SET player_id = ? WHERE room_code = ? AND token_hash = ?') + .run(recorded.players[index].id, code, auth.tokenHash).changes, 1); + auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + } + const native = recorded ? createRecordedAdvancedSourceFixture(recorded) : + createAdvancedSourceFixture({ ...options, seatIds: auths.map(auth => auth.playerId) }); + const initial = native.game; + assert.deepEqual(initial.players.map(p => p.faction), factions, 'authenticated seats match the native constructor roster'); + initial.code = code; + initial.host = auths[0].playerId; + initial.version = (await store.rooms.readRoom(code)).version; + const save = (g: Game) => { + assert.equal(store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code).changes, 1); + }; + save(initial); + const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'harkonnen', false, []); + return { ...store, ...native, initial, code, tokens, auths, save, + credentialRows: credentials(store.sqlite), unrelatedCode: unrelated.view.code, + unrelatedToken: unrelated.token, + unrelatedRow: store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code), + stock: cardStock(initial), forces: forceStock(initial) }; +} +const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code); +function totalChanges(f: Fixture) { + const n = f.sqlite.prepare('SELECT total_changes() AS changes').get()?.changes; + assert.equal(typeof n, 'number'); + return n as number; +} +function protectedRows(f: Fixture) { + assert.deepEqual(credentials(f.sqlite), f.credentialRows); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), f.unrelatedRow); +} +async function authenticate(f: Fixture, id: string) { + const index = f.auths.findIndex(auth => auth.playerId === id); + assert.ok(index >= 0); + const auth = await f.restart().authenticate(f.code, f.tokens[index]); + assert.equal(auth.playerId, id); + assert.equal(auth.tokenHash, f.auths[index].tokenHash); + return auth; +} +async function act(f: Fixture, g: Game, id: string, action: Action) { + const changes = totalChanges(f); + await f.restart().act(f.code, await authenticate(f, id), g.version, action, clock); + assert.equal(totalChanges(f), changes + 1, 'one authenticated native action commits one room row'); + protectedRows(f); + return f.restart().readRoom(f.code); +} +async function rejected(f: Fixture, g: Game, auth: SeatAuth, action: Action, version = g.version) { + const before = row(f), changes = totalChanges(f); + await assert.rejects(f.restart().act(f.code, auth, version, action, clock)); + assert.deepEqual(row(f), before); + assert.equal(totalChanges(f), changes, 'rejected action writes no durable table'); + protectedRows(f); +} +async function restored(f: Fixture, g: Game) { + const before = row(f), changes = totalChanges(f), original = plain(g); + assert.deepEqual(plain(await f.restart().readRoom(f.code)), original); + assert.deepEqual(plain(normalizeAutomaticGame(g)), original); + const views: GameView[] = []; + for (const auth of f.auths) { + const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)); + assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId))); + for (const key of ['stormMovementSource', 'stormResolution', 'stormCard', 'stormCardKnown', 'stormDials']) + assert.equal(Object.hasOwn(view, key), false, `${key} stays outside the public projection`); + for (const rival of view.players.filter(p => p.id !== auth.playerId)) + for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false); + views.push(view); + } + await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]); + assert.deepEqual(plain(g), original, 'read and normalize leave their caller-owned input immutable'); + assert.deepEqual(row(f), before); + assert.equal(totalChanges(f), changes, 'restart, reads and idle recovery perform no durable writes'); + assert.deepEqual(cardStock(g), f.stock); + protectedRows(f); + return views; +} +async function readyStorm(f: Fixture, state: Game) { + let g = state; + for (const id of state.players.map(p => p.id)) g = await act(f, g, id, { type: 'ready' }); + return g; +} + +for (const advanced of [false, true]) { + void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} first Storm preserves actual private dials across authenticated restart`, async t => { + const f = await fixture(t, { advanced, storm: 'first' }); + let g = f.initial; + assert.equal(g.turn, 1); + assert.equal(g.stormDialers.length, 2); + assert.equal(g.stormPending, null); + await restored(f, g); + const [first, second] = g.stormDialers; + g = await act(f, g, first, { type: 'stormDial', amount: 2 }); + for (const view of await restored(f, g)) { + assert.equal(view.stormRevealed, null); + assert.deepEqual(view.stormSubmitted, [first]); + assert.equal(view.stormForecast, null); + } + await rejected(f, g, await authenticate(f, first), { type: 'stormDial', amount: 3 }); + await rejected(f, g, await authenticate(f, second), { type: 'stormDial', amount: 0 }, f.initial.version); + g = await act(f, g, second, { type: 'stormDial', amount: 0 }); + assert.equal(g.stormPending, 2); + assert.equal(g.stormMovementSource?.kind, 'dials'); + assert.equal(g.stormMovementSource?.distance, 2); + for (const view of await restored(f, g)) assert.deepEqual(view.stormRevealed, { [first]: 2, [second]: 0 }); + const origin = g.storm; + g = await readyStorm(f, g); + assert.equal(g.storm, ((origin + 1) % 18) + 1); + assert.equal(g.phase, 1); + assert.equal(g.stormPending, null); + assert.deepEqual(receiptFaces(g), []); + await restored(f, g); + }); +} + +void test('SQLite subsequent Advanced Storm without Fremen reveals one canonical public card and traverses its saved distance once', async t => { + const f = await fixture(t, { storm: 'next' }); + let g = f.initial; + assert.equal(g.turn, 2); + assert.equal(g.players.some(p => p.faction === 'fremen'), false); + assert.deepEqual(g.stormDialers, []); + assert.equal(g.stormCard, null, 'no absent faction receives a held private forecast'); + assert.equal(g.stormCardKnown, false); + const face = g.stormPending; + assert.ok(isStormCardDistance(face)); + assert.equal(g.stormMovementSource?.kind, 'card'); + assert.equal(g.stormMovementSource?.distance, face); + assert.deepEqual(receiptFaces(g), [face]); + for (const view of await restored(f, g)) { + assert.deepEqual(view.stormDialers, []); + assert.equal(view.stormRevealed, null); + assert.equal(view.stormForecast, null); + assert.equal(view.stormPending, face); + assert.deepEqual(receiptFaces(view), [face]); + } + for (const auth of f.auths) await rejected(f, g, auth, { type: 'stormDial', amount: 0 }); + const origin = g.storm; + g = await act(f, g, g.players[0].id, { type: 'ready' }); + await restored(f, g); + assert.equal(g.stormPending, face); + assert.deepEqual(receiptFaces(g), [face]); + await rejected(f, g, f.auths[0], { type: 'ready' }); + for (const p of g.players.slice(1)) g = await act(f, g, p.id, { type: 'ready' }); + assert.equal(g.storm, ((origin + face - 1) % 18) + 1); + assert.equal(g.stormPending, null); + assert.equal(g.phase, 1); + assert.notEqual(g.response?.kind, 'stormPeek', 'there is no absent-Fremen forecast response or extra confirmation'); + assert.equal(g.stormCard, null); + assert.equal(g.stormCardKnown, false); + assert.deepEqual(receiptFaces(g), [face]); + await restored(f, g); + await rejected(f, g, f.auths[0], { type: 'ready' }, f.initial.version); +}); + +void test('SQLite subsequent Basic Storm without Fremen retains its native wheel protocol', async t => { + const f = await fixture(t, { advanced: false, storm: 'next' }); + let g = f.initial; + assert.equal(g.turn, 2); + assert.equal(g.stormPending, null); + assert.equal(g.stormDialers.length, 2); + assert.deepEqual(receiptFaces(g), []); + await restored(f, g); + for (const [index, id] of g.stormDialers.entries()) g = await act(f, g, id, { type: 'stormDial', amount: index + 1 }); + assert.equal(g.stormPending, 3); + assert.equal(g.stormMovementSource?.kind, 'dials'); + await restored(f, g); + const origin = g.storm; + g = await readyStorm(f, g); + assert.equal(g.storm, ((origin + 2) % 18) + 1); + assert.deepEqual(receiptFaces(g), []); + await restored(f, g); +}); + +void test('SQLite genuinely recorded Advanced wheel opening survives current restart, seals its original dials once and cuts over only on the next turn', async t => { + // Synthetic native capture generated from exact 8a56299155d0f12c8c4678bfbe5913be71fea5bb: + // createGame Advanced Atreides/Emperor, actual readiness/start and setup, + // then legal first-turn phase actions through the original next-Storm opener. + const captured = JSON.parse(readFileSync(fileURLToPath(new URL('./fixtures/advanced-recorded-storm-8a.json', + import.meta.url)), 'utf8')) as Game; + const original = plain(captured); + const f = await fixture(t, {}, captured); + assert.deepEqual(captured, original, 'the stored native capture remains caller-owned and immutable'); + let g = f.initial; + assert.equal(g.advanced, true); + assert.equal(g.turn, 2); + assert.equal(g.phase, 0); + assert.equal(g.players.some(p => p.faction === 'fremen'), false); + assert.equal(g.players.find(p => p.faction === 'emperor')?.elites?.reserves, 5, + 'the historical setup is genuinely Advanced, not a changed Basic flag'); + assert.deepEqual(g.players, captured.players); + assert.deepEqual(g.stormDialers, captured.stormDialers); + assert.equal(g.stormPending, null); + assert.equal(g.stormMovementSource, undefined); + await restored(f, g); + const [first, second] = g.stormDialers; + g = await act(f, g, first, { type: 'stormDial', amount: 3 }); + await restored(f, g); + assert.deepEqual(g.stormDialers, captured.stormDialers); + assert.deepEqual(g.stormDials, { [first]: 3 }); + assert.equal(g.stormPending, null); + await rejected(f, g, await authenticate(f, first), { type: 'stormDial', amount: 1 }); + await rejected(f, g, await authenticate(f, second), { type: 'stormDial', amount: 1 }, f.initial.version); + g = await act(f, g, second, { type: 'stormDial', amount: 1 }); + assert.equal(g.stormPending, 4); + assert.equal(g.stormMovementSource?.kind, 'dials'); + assert.equal(g.stormMovementSource?.distance, 4); + for (const view of await restored(f, g)) { + assert.deepEqual(view.stormRevealed, { [first]: 3, [second]: 1 }); + assert.equal(view.stormForecast, null); + assert.deepEqual(receiptFaces(view), []); + } + const origin = g.storm; + g = await act(f, g, first, { type: 'ready' }); + await restored(f, g); + g = await race(f, g, second, { type: 'ready' }); + assert.equal(g.storm, ((origin + 3) % 18) + 1); + assert.equal(g.phase, 1); + assert.deepEqual(receiptFaces(g), []); + await restored(f, g); + await rejected(f, g, await authenticate(f, second), { type: 'ready' }, g.version - 1); + g = advanceToNextStorm(g); + f.save(g); + assert.equal(g.turn, 3); + assert.deepEqual(g.stormDialers, []); + assert.equal(g.stormMovementSource?.kind, 'card'); + assert.ok(isStormCardDistance(g.stormPending)); + assert.deepEqual(receiptFaces(g), [g.stormPending]); + for (const view of await restored(f, g)) { + assert.equal(view.stormRevealed, null); + assert.equal(view.stormForecast, null); + } +}); + +for (const cancel of [false, true]) { + void test(`SQLite native Fremen forecast remains private after ${cancel ? 'a genuine Karama counter' : 'all responses pass'} and supplies the next public card`, async t => { + const f = await fixture(t, { fremen: true, storm: 'first', karama: true }); + let g = advanceToForecast(f.initial); + f.save(g); + assert.equal(g.response?.kind, 'stormPeek'); + const face = g.stormCard; + assert.ok(isStormCardDistance(face)); + const fremen = g.players.find(p => p.faction === 'fremen')!; + const counter = g.players.find(p => p.faction !== 'fremen' && p.hand.some(c => c.effect === 'karama')); + assert.ok(counter, 'the native constructor genuinely dealt a counter even when it will pass'); + const card = counter.hand.find(c => c.effect === 'karama')!; + assert.ok(viewGame(g, counter.id).responseControls?.cancelCards.includes(card.id)); + assert.deepEqual(forceStock(g), f.forces); + for (const view of await restored(f, g)) assert.equal(view.stormForecast, null); + if (cancel) { + g = await act(f, g, counter.id, { type: 'card', card: card.id, mode: 'cancel' }); + } else { + while (g.response) { + const responder = g.players.find(p => !g.response!.passed.includes(p.id)); + assert.ok(responder); + g = await act(f, g, responder.id, { type: 'passResponse' }); + } + } + assert.equal(g.stormCard, face, 'a counter hides knowledge without replacing the physical upcoming card'); + assert.equal(player(g, counter.id).hand.some(c => c.id === card.id), !cancel); + assert.equal(g.discard.filter(c => c.id === card.id).length, cancel ? 1 : 0); + assert.deepEqual(forceStock(g), f.forces); + for (const view of await restored(f, g)) { + assert.equal(view.stormForecast, !cancel && view.me === fremen.id ? face : null); + assert.deepEqual(receiptFaces(view), []); + } + g = advanceToNextStorm(g); + f.save(g); + assert.equal(g.stormPending, face); + assert.deepEqual(g.stormDialers, []); + assert.equal(g.stormCard, null); + assert.deepEqual(forceStock(g), f.forces); + for (const view of await restored(f, g)) { + assert.equal(view.stormForecast, null); + assert.deepEqual(receiptFaces(view), [face]); + } + }); +} + +async function race(f: Fixture, g: Game, id: string, action: Action) { + const auth = await authenticate(f, id), changes = totalChanges(f); + let arrivals = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await gate; + }; + try { + const results = await Promise.allSettled([0, 1].map(() => + f.restart().act(f.code, auth, g.version, action, clock).finally(release))); + assert.equal(arrivals, 2, 'both native submissions reach the SQLite version fence'); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + assert.equal(results.filter(result => result.status === 'rejected').length, 1); + } finally { delete f.hooks.beforeWrite; } + assert.equal(totalChanges(f), changes + 1, 'only the CAS winner changes any durable row'); + const done = await f.restart().readRoom(f.code); + assert.equal(done.version, g.version + 1); + protectedRows(f); + return done; +} + +void test('SQLite competing final Advanced card confirmations commit the saved distance once', async t => { + const f = await fixture(t, { storm: 'next' }); + let g = f.initial; + const origin = g.storm, face = g.stormPending; + assert.ok(isStormCardDistance(face)); + for (const p of g.players.slice(0, -1)) g = await act(f, g, p.id, { type: 'ready' }); + await restored(f, g); + const actor = g.players[g.players.length - 1].id; + g = await race(f, g, actor, { type: 'ready' }); + assert.equal(g.storm, ((origin + face - 1) % 18) + 1); + assert.equal(g.phase, 1); + assert.deepEqual(receiptFaces(g), [face]); + await restored(f, g); + await rejected(f, g, await authenticate(f, actor), { type: 'ready' }, g.version - 1); +}); + +function custody(g: Game, id: string) { + const p = player(g, id); + return { forces: p.forces, elites: p.elites ?? null, advisors: p.advisors ?? null, + reserves: p.reserves, tanks: p.tanks, noField: p.noField ?? null, hand: p.hand }; +} +function lossCounts(g: Game, id: string, territories: readonly string[]) { + const p = player(g, id); + return { + total: Object.entries(p.forces).reduce((n, [key, amount]) => + n + (territories.includes(splitLocation(key).territory) ? amount : 0), 0), + elite: Object.entries(p.elites?.forces ?? {}).reduce((n, [key, amount]) => + n + (territories.includes(splitLocation(key).territory) ? amount : 0), 0), + }; +} +for (const alliance of ['firstEnding', 'laterEnding'] as const) { + void test(`SQLite Advanced ${alliance} newly allied ending sends only that player's shared group to Tanks once through CAS`, async t => { + const f = await fixture(t, { alliance }); + const before = f.initial, actor = player(before, f.actor); + assert.equal(before.phase, 5); + assert.equal(before.active, actor.id); + assert.ok(f.ally); + assert.equal(actor.ally, f.ally); + assert.equal(actor.allySinceTurn, before.turn, 'newly formed alliances receive the same mandatory ending consequence'); + assert.ok(before.movementRemaining?.includes(f.ally), 'the ally has not reached its own ending yet'); + assert.equal(before.order.indexOf(actor.id), alliance === 'firstEnding' ? 0 : 1, + 'the later scenario genuinely follows a completed nonallied seat'); + if (alliance === 'laterEnding') { + assert.ok(f.other); + assert.equal(before.movementRemaining?.includes(f.other), false); + } + const losses = lossCounts(before, actor.id, f.lossTerritories); + assert.ok(losses.total > 0, 'genuine native actions placed a shared fighter group'); + assert.equal(actor.faction, 'emperor'); + assert.equal(losses.elite, 1, 'the genuine paid shipment exercises elite classification within the physical loss'); + const otherCustody = before.players.filter(p => p.id !== actor.id).map(p => [p.id, plain(custody(before, p.id))]); + const beforeTanks = actor.tanks, beforeEliteTanks = actor.elites?.tanks ?? 0; + const outside = Object.fromEntries(Object.entries(actor.forces).filter(([key]) => + !f.lossTerritories.includes(splitLocation(key).territory))); + const views = await restored(f, before); + for (const view of views) assert.deepEqual(view.advancedAllySeparation, + view.me === actor.id ? { territories: f.lossTerritories.map(id => territory(id).name) } : null); + if (alliance === 'firstEnding') { + const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken); + await rejected(f, before, { playerId: actor.id, tokenHash: foreign.tokenHash }, { type: 'endMovement' }); + await rejected(f, before, await authenticate(f, f.ally), { type: 'endMovement' }); + } + const done = await race(f, before, actor.id, { type: 'endMovement' }); + const ended = player(done, actor.id); + assert.equal(ended.tanks, beforeTanks + losses.total); + assert.equal(ended.elites?.tanks ?? 0, beforeEliteTanks + losses.elite); + assert.deepEqual(lossCounts(done, actor.id, f.lossTerritories), { total: 0, elite: 0 }); + assert.deepEqual(Object.fromEntries(Object.entries(ended.forces).filter(([key]) => + !f.lossTerritories.includes(splitLocation(key).territory))), outside); + for (const [id, expected] of otherCustody) assert.deepEqual(custody(done, id as string), expected); + assert.deepEqual(forceStock(done), f.forces, 'casualties conserve physical and elite inventory'); + assert.deepEqual(cardStock(done), f.stock); + await restored(f, done); + await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' }); + await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' }, before.version); + assert.equal(player(await f.restart().readRoom(f.code), actor.id).tanks, beforeTanks + losses.total); + }); +} + +void test('SQLite native dealt Hajr retires without an early alliance casualty and the subsequent real ending loses the group once', async t => { + const f = await fixture(t, { alliance: 'firstEnding', hajr: true }); + let g = f.initial; + const actor = player(g, f.actor), card = actor.hand.find(c => c.effect === 'hajr'); + assert.ok(card, 'the actual native starting deal supplies Hajr to the ending actor'); + assert.ok(f.ally); + const losses = lossCounts(g, actor.id, f.lossTerritories); + assert.ok(losses.total > 0); + const beforeForces = plain(actor.forces), beforeElite = plain(actor.elites), allyCustody = plain(custody(g, f.ally)); + g = await act(f, g, actor.id, { type: 'card', card: card.id }); + assert.equal(player(g, actor.id).tanks, actor.tanks); + assert.deepEqual(player(g, actor.id).forces, beforeForces); + assert.deepEqual(player(g, actor.id).elites, beforeElite); + assert.equal(player(g, actor.id).hand.some(c => c.id === card.id), false); + assert.equal(g.discard.filter(c => c.id === card.id).length, 1); + assert.equal(g.hajr.includes(actor.id), true); + await restored(f, g); + g = await race(f, g, actor.id, { type: 'endMovement' }); + assert.equal(player(g, actor.id).tanks, actor.tanks + losses.total); + assert.deepEqual(lossCounts(g, actor.id, f.lossTerritories), { total: 0, elite: 0 }); + assert.deepEqual(custody(g, f.ally), allyCustody); + assert.equal(g.discard.filter(c => c.id === card.id).length, 1); + assert.deepEqual(forceStock(g), f.forces); + await restored(f, g); + await rejected(f, g, await authenticate(f, actor.id), { type: 'card', card: card.id }); + await rejected(f, g, await authenticate(f, actor.id), { type: 'endMovement' }); +}); + +for (const advanced of [false, true]) { + for (const alliance of ['ownAdvisors', 'allyAdvisors', 'ecaz', 'polar'] as const) { + if (!advanced && (alliance === 'ownAdvisors' || alliance === 'allyAdvisors')) continue; + void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${alliance} retains both allied groups on genuine native ending`, async t => { + const f = await fixture(t, { advanced, alliance }); + const before = f.initial; + assert.equal(before.active, f.actor); + assert.ok(f.ally); + const shared = alliance === 'polar' ? 'polar_sink' : alliance === 'ecaz' ? 'imperial_basin' : 'arrakeen'; + assert.ok(lossCounts(before, f.actor, [shared]).total > 0); + assert.ok(lossCounts(before, f.ally, [shared]).total > 0, 'the exception is exercised with actual co-present groups'); + if (alliance === 'ownAdvisors' || alliance === 'allyAdvisors') { + const advisor = player(before, alliance === 'ownAdvisors' ? f.actor : f.ally); + assert.equal(advisor.faction, 'beneGesserit'); + assert.ok(advisor.advisors?.[shared], 'actual BG advisor classification, not merely a BG faction'); + } + const occupants = before.players.map(p => [p.id, plain(custody(before, p.id))]); + for (const view of await restored(f, before)) assert.equal(view.advancedAllySeparation, null); + const done = await act(f, before, f.actor, { type: 'endMovement' }); + for (const [id, expected] of occupants) assert.deepEqual(custody(done, id as string), expected); + assert.deepEqual(forceStock(done), f.forces); + await restored(f, done); + await rejected(f, done, await authenticate(f, f.actor), { type: 'endMovement' }, before.version); + }); + } +} + +void test('SQLite Basic newly allied native movement retains the same-turn fighter group without an Advanced warning', async t => { + const f = await fixture(t, { advanced: false, alliance: 'newThisTurn' }); + const actor = player(f.initial, f.actor); + assert.equal(actor.allySinceTurn, f.initial.turn); + assert.ok(lossCounts(f.initial, f.actor, f.lossTerritories).total > 0); + assert.ok(f.ally && lossCounts(f.initial, f.ally, f.lossTerritories).total > 0); + const before = plain(custody(f.initial, f.actor)); + for (const view of await restored(f, f.initial)) assert.equal(view.advancedAllySeparation, null); + const done = await act(f, f.initial, f.actor, { type: 'endMovement' }); + assert.deepEqual(custody(done, f.actor), before); + await restored(f, done); +}); + +void test('SQLite Basic old alliance loses only the later ending player after the native earlier ally has retained its group', async t => { + const f = await fixture(t, { advanced: false, alliance: 'laterEnding' }); + const before = f.initial, actor = player(before, f.actor); + assert.ok(f.ally); + assert.ok((actor.allySinceTurn ?? before.turn) < before.turn); + assert.equal(before.movementRemaining?.includes(f.ally), false); + const losses = lossCounts(before, actor.id, f.lossTerritories); + assert.ok(losses.total > 0); + assert.ok(lossCounts(before, f.ally, f.lossTerritories).total > 0, 'the earlier ending ally genuinely retained its shared group'); + const earlierCustody = plain(custody(before, f.ally)); + await restored(f, before); + const done = await race(f, before, actor.id, { type: 'endMovement' }); + assert.equal(player(done, actor.id).tanks, actor.tanks + losses.total); + assert.deepEqual(lossCounts(done, actor.id, f.lossTerritories), { total: 0, elite: 0 }); + assert.deepEqual(custody(done, f.ally), earlierCustody); + assert.deepEqual(forceStock(done), f.forces); + await restored(f, done); + await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' }); +}); + +void test('SQLite saved Advanced source actions reject foreign, forged-seat and stale credentials without changing unrelated rows', async t => { + const f = await fixture(t, { storm: 'next' }); + const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken); + const before = row(f), changes = totalChanges(f); + await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken)); + await assert.rejects(f.restart().readSeatView(f.code, foreign)); + const forged = { playerId: f.auths[0].playerId, tokenHash: foreign.tokenHash }; + await assert.rejects(f.restart().readSeatView(f.code, forged)); + assert.equal(totalChanges(f), changes); + assert.deepEqual(row(f), before); + await rejected(f, f.initial, foreign, { type: 'ready' }); + await rejected(f, f.initial, forged, { type: 'ready' }); + await rejected(f, f.initial, f.auths[0], { type: 'ready' }, f.initial.version - 1); + await restored(f, f.initial); +}); + +for (const corrupt of [ + { name: 'altered source distance', change: (g: Game) => { g.stormMovementSource!.distance++; } }, + { name: 're-signed source from another turn', change: (g: Game) => { + g.stormMovementSource = createStormSource(g.turn - 1, 'card', g.stormPending!); + } }, + { name: 'orphaned pending distance', change: (g: Game) => { g.stormPending = g.stormPending === 6 ? 5 : 6; } }, + { name: 'lost original source', change: (g: Game) => { delete g.stormMovementSource; } }, + { name: 're-signed noncanonical card face', change: (g: Game) => { + g.stormPending = 7; + g.stormMovementSource = createStormSource(g.turn, 'card', 7); + } }, +] as const) { + void test(`SQLite Advanced ${corrupt.name} rejects projection, normalization and action immutably with no durable writes`, async t => { + const f = await fixture(t, { storm: 'next' }); + const broken = plain(f.initial); + corrupt.change(broken); + f.save(broken); + const original = plain(broken), before = row(f), changes = totalChanges(f); + assert.deepEqual(await f.restart().readRoom(f.code), original, 'raw restart preserves the corrupted record for rejection, not repair'); + for (const auth of f.auths) { + assert.throws(() => viewGame(broken, auth.playerId)); + await assert.rejects(f.restart().readSeatView(f.code, auth)); + } + assert.throws(() => normalizeAutomaticGame(broken)); + assert.throws(() => applyAction(broken, f.actor, { type: 'ready' })); + await rejected(f, broken, await authenticate(f, f.actor), { type: 'ready' }); + assert.deepEqual(plain(broken), original); + assert.deepEqual(row(f), before); + assert.equal(totalChanges(f), changes); + protectedRows(f); + }); +} diff --git a/tests/allied-separation.test.ts b/tests/allied-separation.test.ts new file mode 100644 index 00000000..a4afa2d7 --- /dev/null +++ b/tests/allied-separation.test.ts @@ -0,0 +1,198 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + quoteAlliedSeparation, + type AlliedSeparationQuoteContext, +} from '../game/allied-separation'; + +type TerritoryFacts = AlliedSeparationQuoteContext['territories'][number]; + +function group( + territory: string, + facts: Partial> = {}, +): TerritoryFacts { + return { + territory, + polar: false, + ownPresent: true, + allyPresent: true, + ownAdvisors: false, + allyAdvisors: false, + ecazCoexist: false, + ...facts, + }; +} + +function context( + overrides: Partial = {}, +): AlliedSeparationQuoteContext { + return { + advanced: true, + turn: 4, + player: 'ending', + ally: 'ally', + playerAllySinceTurn: 2, + allySinceTurn: 2, + remaining: ['ending', 'ally', 'other'], + territories: [group('arrakeen')], + ...overrides, + }; +} + +void test('Advanced separates on the first, intermediate and last allied ending regardless of alliance age', () => { + for (const remaining of [ + ['ending', 'ally', 'other'], + ['ending', 'other'], + ['ending'], + ]) { + for (const timestamps of [ + {}, + { playerAllySinceTurn: 2, allySinceTurn: 2 }, + { playerAllySinceTurn: 4, allySinceTurn: 4 }, + { playerAllySinceTurn: 4, allySinceTurn: 2 }, + ]) { + assert.deepEqual( + quoteAlliedSeparation(context({ + playerAllySinceTurn: undefined, + allySinceTurn: undefined, + remaining, + ...timestamps, + })), + ['arrakeen'], + ); + } + } +}); + +void test('Basic waits for the ally to finish, then exempts only an alliance recorded new for both players', () => { + const basic = context({ advanced: false }); + assert.deepEqual(quoteAlliedSeparation(basic), []); + + const afterAlly = { ...basic, remaining: ['ending', 'other'] }; + assert.deepEqual(quoteAlliedSeparation(afterAlly), ['arrakeen']); + assert.deepEqual(quoteAlliedSeparation({ + ...afterAlly, + playerAllySinceTurn: 4, + allySinceTurn: 4, + }), []); + + for (const timestamps of [ + { playerAllySinceTurn: 4, allySinceTurn: 2 }, + { playerAllySinceTurn: 2, allySinceTurn: 4 }, + { playerAllySinceTurn: undefined, allySinceTurn: undefined }, + { playerAllySinceTurn: 4, allySinceTurn: undefined }, + { playerAllySinceTurn: undefined, allySinceTurn: 4 }, + ]) { + assert.deepEqual( + quoteAlliedSeparation({ ...afterAlly, ...timestamps }), + ['arrakeen'], + ); + } +}); + +void test('a Basic same-turn alliance loses its exemption next turn while Advanced separates on both turns', () => { + const newAlliance = context({ + advanced: false, + playerAllySinceTurn: 4, + allySinceTurn: 4, + remaining: ['ending'], + }); + assert.deepEqual(quoteAlliedSeparation(newAlliance), []); + assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, turn: 5 }), ['arrakeen']); + assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, advanced: true }), ['arrakeen']); + assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, advanced: true, turn: 5 }), ['arrakeen']); +}); + +void test('only shared non-exempt territories are quoted, preserving each territory independently', () => { + const territories = [ + group('arrakeen'), + group('carthag', { ownPresent: false }), + group('broken_land', { allyPresent: false }), + group('polar_sink', { polar: true }), + group('sietch_tabr', { ecazCoexist: true }), + group('hagga_basin'), + ]; + for (const advanced of [false, true]) { + assert.deepEqual(quoteAlliedSeparation(context({ + advanced, + remaining: ['ending'], + territories, + })), ['arrakeen', 'hagga_basin']); + } +}); + +void test('ending without an ally never loses forces even when territory facts show shared presence', () => { + for (const advanced of [false, true]) { + assert.deepEqual(quoteAlliedSeparation(context({ + advanced, + ally: null, + remaining: ['ending'], + territories: [group('arrakeen'), group('hagga_basin')], + })), []); + } +}); + +void test('Advanced advisors on either side coexist, but conversion to fighters restores separation', () => { + for (const stance of ['ownAdvisors', 'allyAdvisors'] as const) { + const advisors = context({ + territories: [group('arrakeen', { [stance]: true }), group('hagga_basin')], + }); + assert.deepEqual(quoteAlliedSeparation(advisors), ['hagga_basin']); + assert.deepEqual(quoteAlliedSeparation({ + ...advisors, + territories: [group('arrakeen'), group('hagga_basin')], + }), ['arrakeen', 'hagga_basin']); + } + assert.deepEqual(quoteAlliedSeparation(context({ + territories: [group('arrakeen', { ownAdvisors: true, allyAdvisors: true })], + })), []); +}); + +void test('advisor facts do not introduce a new exemption into Basic legacy separation', () => { + for (const stances of [ + { ownAdvisors: true }, + { allyAdvisors: true }, + { ownAdvisors: true, allyAdvisors: true }, + ]) { + assert.deepEqual(quoteAlliedSeparation(context({ + advanced: false, + remaining: ['ending'], + territories: [group('arrakeen', stances)], + })), ['arrakeen']); + } +}); + +void test('loss follows current Ecaz coexistence and presence rather than a previous exemption or quote', () => { + for (const advanced of [false, true]) { + const occupation = context({ + advanced, + remaining: ['ending'], + territories: [group('arrakeen', { ecazCoexist: true })], + }); + assert.deepEqual(quoteAlliedSeparation(occupation), []); + const suppressed = { ...occupation, territories: [group('arrakeen')] }; + assert.deepEqual(quoteAlliedSeparation(suppressed), ['arrakeen']); + assert.deepEqual(quoteAlliedSeparation({ + ...suppressed, + territories: [group('arrakeen', { allyPresent: false })], + }), []); + assert.deepEqual(quoteAlliedSeparation(occupation), []); + } +}); + +void test('quoting frozen canonical facts does not change them or retain a mutable result', () => { + const input = Object.freeze(context({ + remaining: Object.freeze(['ending', 'ally']), + territories: Object.freeze([ + Object.freeze(group('arrakeen')), + Object.freeze(group('carthag', { ownAdvisors: true })), + Object.freeze(group('polar_sink', { polar: true })), + ]), + })); + const before = structuredClone(input); + const result = quoteAlliedSeparation(input); + assert.deepEqual(result, ['arrakeen']); + result.splice(0, 1, 'carthag'); + assert.deepEqual(quoteAlliedSeparation(input), ['arrakeen']); + assert.deepEqual(input, before); +}); diff --git a/tests/bot-advanced-source.test.ts b/tests/bot-advanced-source.test.ts new file mode 100644 index 00000000..4528a7c8 --- /dev/null +++ b/tests/bot-advanced-source.test.ts @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { splitLocation } from '../game/board'; +import { advanceToForecast, createAdvancedSourceFixture, settleAdvancedSourceResponses } from './fixture-advanced-source'; + +void test('all four policies dial the actual first Storm then confirm the recorded result exactly once', () => { + for (const profile of DIFFICULTIES) { + const fixture = createAdvancedSourceFixture({ storm: 'first', fremen: false }); + let game = fixture.game; + for (const dialer of fixture.game.stormDialers) { + const own = viewGame(game, dialer); + own.players.find(player => player.id === dialer)!.bot = profile; + const actions = botActions(own); + assert.equal(actions[0]?.type, 'stormDial'); + assert.ok(Number.isInteger(actions[0].amount)); + assert.ok(Number(actions[0].amount) >= 0 && Number(actions[0].amount) <= 20); + game = applyAction(game, dialer, actions[0]); + const submitted = viewGame(game, dialer); + submitted.players.find(player => player.id === dialer)!.bot = profile; + if (game.stormPending === null) assert.deepEqual(botActions(submitted), []); + } + assert.notEqual(game.stormPending, null); + const actor = game.players[0].id; + const own = viewGame(game, actor); + own.players.find(player => player.id === actor)!.bot = profile; + assert.deepEqual(botActions(own), [{ type: 'ready' }]); + game = applyAction(game, actor, botActions(own)[0]); + const confirmed = viewGame(game, actor); + confirmed.players.find(player => player.id === actor)!.bot = profile; + assert.deepEqual(botActions(confirmed), []); + } +}); + +void test('all four policies confirm subsequent Advanced public cards without a Fremen seat or phantom forecast', () => { + for (const profile of DIFFICULTIES) { + const fixture = createAdvancedSourceFixture({ storm: 'next', fremen: false }); + let game = fixture.game; + assert.deepEqual(game.stormDialers, []); + assert.ok(Number(game.stormPending) >= 1 && Number(game.stormPending) <= 6); + const distance = game.stormPending; + for (const player of fixture.game.players) { + const own = viewGame(game, player.id); + own.players.find(seat => seat.id === player.id)!.bot = profile; + assert.equal(own.stormForecast, null); + assert.deepEqual(botActions(own), [{ type: 'ready' }]); + game = applyAction(game, player.id, botActions(own)[0]); + } + game = settleAdvancedSourceResponses(game); + assert.notEqual(game.phase, 0); + assert.equal(game.storm, ((fixture.game.storm - 1 + Number(distance)) % 18) + 1); + assert.equal(game.stormCard, null); + } +}); + +void test('all four policies finish actual later Basic dial protocols without drawing a card', () => { + for (const profile of DIFFICULTIES) { + const fixture = createAdvancedSourceFixture({ advanced: false, storm: 'next', fremen: false }); + let game = fixture.game; + const amounts: number[] = []; + for (const dialer of fixture.game.stormDialers) { + const own = viewGame(game, dialer); + own.players.find(player => player.id === dialer)!.bot = profile; + const actions = botActions(own); + assert.equal(actions[0]?.type, 'stormDial'); + const amount = Number(actions[0].amount); + assert.ok(Number.isInteger(amount) && amount >= 1 && amount <= 3); + amounts.push(amount); + game = applyAction(game, dialer, actions[0]); + } + assert.equal(game.stormPending, amounts.reduce((sum, amount) => sum + amount, 0)); + assert.equal(game.stormMovementSource?.kind, 'dials'); + assert.equal(game.stormCard, null); + const own = viewGame(game, game.players[0].id); + own.players[0].bot = profile; + assert.deepEqual(botActions(own), [{ type: 'ready' }]); + game = applyAction(game, own.me, botActions(own)[0]); + assert.ok(game.ready.includes(own.me)); + } +}); + +void test('every legal policy retains a real end action when finishing destroys its own allied group', () => { + for (const profile of DIFFICULTIES) { + const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' }); + const own = viewGame(fixture.game, fixture.actor); + own.players.find(player => player.id === fixture.actor)!.bot = profile; + assert.ok(own.advancedAllySeparation?.territories.length); + const end = botActions(own).find(action => action.type === 'endMovement'); + assert.ok(end, `${profile} must not deadlock on a mandatory consequence`); + const before = fixture.game.players.find(player => player.id === fixture.actor)!; + const allyBefore = fixture.game.players.find(player => player.id === fixture.ally)!; + const losses = Object.entries(before.forces).filter(([key]) => fixture.lossTerritories.includes(splitLocation(key).territory)) + .reduce((sum, [, amount]) => sum + amount, 0); + const eliteLosses = Object.entries(before.elites?.forces ?? {}).filter(([key]) => fixture.lossTerritories.includes(splitLocation(key).territory)) + .reduce((sum, [, amount]) => sum + amount, 0); + const result = applyAction(fixture.game, fixture.actor, end); + const after = result.players.find(player => player.id === fixture.actor)!; + assert.equal(after.tanks, before.tanks + losses); + assert.equal(after.elites?.tanks ?? 0, (before.elites?.tanks ?? 0) + eliteLosses); + assert.equal(after.reserves, before.reserves); + assert.equal(after.elites?.reserves, before.elites?.reserves); + assert.deepEqual(after.hand, before.hand); + for (const id of fixture.lossTerritories) { + assert.equal(Object.entries(after.forces).filter(([key]) => key.startsWith(`${id}:`)).reduce((sum, [, amount]) => sum + amount, 0), 0); + } + assert.deepEqual(result.players.find(player => player.id === fixture.ally)!.forces, allyBefore.forces); + const allyAfter = result.players.find(player => player.id === fixture.ally)!; + assert.equal(allyAfter.reserves, allyBefore.reserves); + assert.equal(allyAfter.tanks, allyBefore.tanks); + assert.deepEqual(allyAfter.elites, allyBefore.elites); + assert.deepEqual(allyAfter.advisors, allyBefore.advisors); + assert.deepEqual(allyAfter.hand, allyBefore.hand); + assert.notEqual(result.active, fixture.actor); + assert.equal(viewGame(result, fixture.actor).advancedAllySeparation, null); + } +}); + +void test('all four policies can finish with mandatory losses after their actual ordinary movement is exhausted', () => { + for (const profile of DIFFICULTIES) { + const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' }); + const own = viewGame(fixture.game, fixture.actor); + const actor = own.players.find(player => player.id === fixture.actor)!; + actor.bot = profile; + const move = botActions(own).find(action => action.type === 'move' && + typeof action.from === 'string' && fixture.lossTerritories.includes(splitLocation(action.from).territory) && + (actor.forces[String(action.from)] ?? 0) > 1); + assert.ok(move, `${profile} has an actual legal movement from its shared group`); + const game = settleAdvancedSourceResponses(applyAction(fixture.game, fixture.actor, + { ...move, amount: 1, elite: 0 })); + const exhausted = viewGame(game, fixture.actor); + const afterMove = exhausted.players.find(player => player.id === fixture.actor)!; + afterMove.bot = profile; + assert.equal(afterMove.moved, afterMove.movesAllowed); + assert.ok(exhausted.advancedAllySeparation?.territories.length); + const end = botActions(exhausted).find(action => action.type === 'endMovement'); + assert.ok(end, `${profile} retains forced legal end instead of an invalid-movement loop`); + const finished = applyAction(game, fixture.actor, end); + assert.ok(finished.players.find(player => player.id === fixture.actor)!.tanks > afterMove.tanks); + assert.notEqual(finished.active, fixture.actor); + } +}); + +void test('native higher-priority forecast response cannot fall through to Storm confirmation or movement for any profile', () => { + for (const profile of DIFFICULTIES) { + const fixture = createAdvancedSourceFixture({ storm: 'first', fremen: true, karama: true }); + const pending = advanceToForecast(fixture.game); + assert.equal(pending.response?.kind, 'stormPeek'); + const counter = pending.players.find(player => player.faction !== 'fremen' && player.hand.some(card => card.effect === 'karama')); + assert.ok(counter, 'the native deal supplies a genuinely eligible forecast counter'); + const card = counter.hand.find(card => card.effect === 'karama')!; + assert.ok(viewGame(pending, counter.id).responseControls?.cancelCards.includes(card.id)); + const forces = pending.players.map(player => ({id: player.id, forces: player.forces, + reserves: player.reserves, tanks: player.tanks, elites: player.elites ?? null, advisors: player.advisors ?? null})); + for (const player of pending.players) { + const own = viewGame(pending, player.id); + own.players.find(seat => seat.id === player.id)!.bot = profile; + assert.equal(own.stormForecast, null); + for (const action of botActions(own)) { + assert.ok(action.type === 'passResponse' || (action.type === 'card' && action.mode === 'cancel')); + const result = applyAction(pending, player.id, action); + assert.equal(result.phase, pending.phase); + assert.equal(result.storm, pending.storm); + assert.deepEqual(result.players.map(player => ({id: player.id, forces: player.forces, + reserves: player.reserves, tanks: player.tanks, elites: player.elites ?? null, advisors: player.advisors ?? null})), forces); + for (const seat of result.players) { + const before = pending.players.find(before => before.id === seat.id)!; + assert.deepEqual(seat.hand, before.hand.filter(held => + !(action.type === 'card' && seat.id === player.id && held.id === action.card))); + } + if (action.type === 'card') assert.equal(result.discard.filter(discarded => discarded.id === action.card).length, 1); + else assert.deepEqual(result.discard, pending.discard); + } + } + } +}); diff --git a/tests/choam-jubba.test.ts b/tests/choam-jubba.test.ts index 39bb3932..ff95803c 100644 --- a/tests/choam-jubba.test.ts +++ b/tests/choam-jubba.test.ts @@ -15,6 +15,7 @@ import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { richeseCards } from '../game/richese-cards'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { createStormSource } from '../game/discovery-storm'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const send = (g: Game, id: string, action: Action) => @@ -44,6 +45,8 @@ function fixture(advanced = false) { g.turn = 2; g.storm = 5; g.stormPending = 3; + if (advanced) + g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending); g.order = ['c', 'e', 'b']; for (const p of g.players) { p.hand = []; diff --git a/tests/choam-storm-quote.test.ts b/tests/choam-storm-quote.test.ts index cd60eaf1..366bc4bd 100644 --- a/tests/choam-storm-quote.test.ts +++ b/tests/choam-storm-quote.test.ts @@ -2,11 +2,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createGame, newPlayer, applyAction, type Game } from '../game/engine'; import { baseDeck } from '../game/cards'; +import { createStormSource } from '../game/discovery-storm'; import { quoteChoamStormOffer, ChoamStormQuoteError, } from '../game/choam-storm-quote'; -function actual() { +function actual(firstStorm = false) { let g = createGame('STORMQUOTE', newPlayer('c', 'CHOAM', 'choam'), true, [ 'choam', ]); @@ -17,15 +18,19 @@ function actual() { Object.assign(g, { status: 'playing', phase: 0, - turn: 2, - storm: 5, - stormPending: 3, + turn: firstStorm ? 1 : 2, + storm: firstStorm ? 0 : 5, + stormPending: firstStorm ? null : 3, order: ['c', 'e', 'b'], deck: baseDeck(), }); + if (firstStorm) g.stormDialers = ['c', 'e']; + else g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending!); for (const p of g.players) Object.assign(p, { forces: {}, reserves: 20, hand: [], spice: 10 }); - g.players[0].forces = { 'red_chasm:7': 4 }; + g.players[0].forces = firstStorm + ? { 'meridian:1': 2, 'cielago_north:3': 2 } + : { 'red_chasm:7': 4 }; g.players[0].reserves = 16; g.players[1].forces = { 'red_chasm:7': 3 }; g.players[1].reserves = 17; @@ -39,13 +44,17 @@ function actual() { const jubba = hold(0, 'Jubba Cloak'), printed = hold(1, 'Karama'), bg = hold(2, 'Baliset'); + if (firstStorm) { + g = applyAction(g, 'c', { type: 'stormDial', amount: 20 }); + g = applyAction(g, 'e', { type: 'stormDial', amount: 20 }); + } for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); assert.equal(g.decision?.kind, 'choamStorm'); g = applyAction(g, 'c', { type: 'card', mode: 'choam', card: jubba.id, - territory: 'red_chasm', + territory: firstStorm ? 'meridian' : 'red_chasm', }); assert.equal(g.response?.kind, 'choamWorthless'); return { g, jubba, printed, bg }; @@ -93,10 +102,7 @@ void test('lost current threat and prior protection both stop at storm protectio assert.equal(g.players[1].tanks, 0); }); void test('initial storm origin zero and full forty-sector traversal remain finite and preserve sector identities', () => { - const { g } = actual(); - g.storm = 0; - Object.assign(g.stormResolution!, { from: 0, distance: 40 }); - g.players[0].forces = { 'meridian:1': 2, 'cielago_north:3': 2 }; + const { g } = actual(true); const q = quoteChoamStormOffer(g); assert.equal(q.kind, 'decision'); if (q.kind !== 'decision') throw Error('No decision'); diff --git a/tests/choam-worthless-cancellation-recovery.test.ts b/tests/choam-worthless-cancellation-recovery.test.ts index 484a0f67..9b902c05 100644 --- a/tests/choam-worthless-cancellation-recovery.test.ts +++ b/tests/choam-worthless-cancellation-recovery.test.ts @@ -10,6 +10,7 @@ import * as bots from '../game/bots'; import * as seatAiDelegation from '../lib/seat-ai-delegation'; import { baseDeck } from '../game/cards'; import { newRevivalRules } from '../game/revival'; +import { createStormSource } from '../game/discovery-storm'; import type * as Rooms from '../db/rooms'; /** Execute the production room module and SQL, with a hook immediately before its CAS. */ @@ -153,6 +154,7 @@ async function fixture(source: Source, converted = true) { Object.assign(g.players[2], { reserves: 14, tanks: 6 }); else { g.stormPending = 3; + g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending); Object.assign(g.players[1], { forces: { 'red_chasm:7': 4 }, reserves: 16 }); Object.assign(g.players[2], { forces: { 'red_chasm:7': 3 }, reserves: 17 }); g.spice = { 'red_chasm:7': 8, 'basin:9': 4 }; diff --git a/tests/choam-worthless-cancellation.test.ts b/tests/choam-worthless-cancellation.test.ts index 2ea04207..80a0f1fc 100644 --- a/tests/choam-worthless-cancellation.test.ts +++ b/tests/choam-worthless-cancellation.test.ts @@ -10,6 +10,7 @@ import { } from '../game/engine'; import { baseDeck } from '../game/cards'; import { newRevivalRules } from '../game/revival'; +import { createStormSource } from '../game/discovery-storm'; import { quoteChoamWorthlessCancellation, ChoamWorthlessCancellationError, @@ -136,6 +137,7 @@ function source(kind: Case, advanced = true) { g.phase = 0; g.storm = 5; g.stormPending = 3; + g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending); player(g, 'c').forces = { 'red_chasm:7': 4 }; player(g, 'c').reserves = 16; g = ready(g); diff --git a/tests/fixture-advanced-source.ts b/tests/fixture-advanced-source.ts new file mode 100644 index 00000000..f7898123 --- /dev/null +++ b/tests/fixture-advanced-source.ts @@ -0,0 +1,370 @@ +import assert from 'node:assert/strict'; +import {applyAction, createGame, initializeBaseGameForAudit, initializeFactionExpansionsGameForAudit, + initializeHomeworldGameForAudit, joinGame, newPlayer, viewGame, type Game, type Action} from '../game/engine'; +import {botActions} from '../game/bots'; +import {TERRITORIES, splitLocation} from '../game/board'; +import {fremenReserveEntry} from '../game/bot-mobility'; +import type {FactionId} from '../game/catalog'; + +export type AdvancedSourceFixtureOptions = { + initial?: Game; + advanced?: boolean; + fremen?: boolean; + seatIds?: string[]; + alliance?: 'firstEnding'|'laterEnding'|'newThisTurn'|'ownAdvisors'|'allyAdvisors'|'ecaz'|'polar'; + storm?: 'first'|'next'; + modules?: boolean; + /** Reorder original dealt counters; false guarantees a counter-free native starting deal. */ + karama?: boolean; + hajr?: boolean; +}; +export type AdvancedSourceFixture = { + game: Game; + actor: string; + ally: string | null; + other: string | null; + turn: number; + lossTerritories: string[]; +}; + +function ordinaryAlliance(mode: AdvancedSourceFixtureOptions['alliance']): mode is 'firstEnding'|'laterEnding'|'newThisTurn' { + return mode === 'firstEnding' || mode === 'laterEnding' || mode === 'newThisTurn'; +} + +/** Accept a captured native historical Advanced opening without changing its + * rules, source, dialers, setup inventories, saved identity or continuation. */ +export function createRecordedAdvancedSourceFixture(state: Game): AdvancedSourceFixture { + const game = structuredClone(state); + assert.equal(game.status, 'playing'); + assert.equal(game.advanced, true); + assert.equal(game.phase, 0); + assert.ok(game.turn > 1 && game.stormDialers.length === 2); + assert.ok(game.stormPending === null || game.stormMovementSource?.kind === 'dials'); + for (const p of game.players) viewGame(game, p.id); + return {game, actor: game.stormDialers[0], ally: null, other: game.stormDialers[1], + turn: game.turn, lossTerritories: []}; +} + +/** Only consumes a native pending window; never redeclares its parent action. */ +export function settleAdvancedSourceResponses(state: Game): Game { + let game = state; + for (let step = 0; step < 100; step++) { + if (game.pendingTreacheryDiscard) game = applyAction(game, game.players[0].id, {type: 'advanceBots'}); + else if (game.response) game = applyAction(game, + game.players.find(p => !game.response!.passed.includes(p.id))!.id, {type: 'passResponse'}); + else if (game.decision?.kind === 'guildShipment') + game = applyAction(game, game.decision.player, {type: 'decision', allow: true}); + else return game; + } + throw new Error('Native Advanced source response did not settle.'); +} + +function nativeStep(game: Game): Game { + if (game.pendingTreacheryDiscard) return applyAction(game, game.players[0].id, {type: 'advanceBots'}); + if (game.response) return applyAction(game, + game.players.find(p => !game.response!.passed.includes(p.id))!.id, {type: 'passResponse'}); + if (game.phaseOpening) return applyAction(game, + game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, {type: 'ready'}); + if (game.status === 'playing' && !game.decision) { + if (game.phase === 0 && game.stormPending === null) { + const dialer = game.stormDialers.find(id => game.stormDials[id] === undefined)!; + return applyAction(game, dialer, {type: 'stormDial', amount: game.turn === 1 ? 0 : 1}); + } + if (game.phase === 3 && game.auction) return applyAction(game, game.auction.active, {type: 'passBid'}); + if (game.phase === 5) return applyAction(game, game.active!, {type: 'endMovement'}); + if (game.phase !== 6 || !game.active) { + const seat = game.players.find(p => !game.ready.includes(p.id)); + if (seat) return applyAction(game, seat.id, {type: 'ready'}); + } + } + for (const p of game.players) { + const view = viewGame(game, p.id); + view.players.find(seat => seat.id === p.id)!.bot = 'Easy'; + const actions = botActions(view); + const action: Action | undefined = actions.find(a => a.type === 'ready') ?? actions[0]; + if (action) return applyAction(game, p.id, action); + } + throw new Error(`No native source action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`); +} + +/** Stop at the genuine held counter window, or its automatic known-card + * endpoint. Callers requiring pass/cancel must prepare karama: true. */ +export function advanceToForecast(state: Game): Game { + let game = state; + for (let step = 0; step < 800; step++) { + assert.ok(game.players.some(p => p.faction === 'fremen') && game.advanced, + 'Only actual native Advanced Fremen receive a forecast.'); + if (game.response?.kind === 'stormPeek' || + (game.phase === 1 && game.stormCard !== null && game.stormCardKnown && !game.response)) return game; + assert.equal(game.status, 'playing', 'The native forecast must open before the game ends.'); + game = nativeStep(game); + } + throw new Error('The native forecast did not open.'); +} +export function advanceToNextStorm(state: Game): Game { + let game = state; + const turn = state.turn; + for (let step = 0; step < 1200; step++) { + if (game.status === 'playing' && game.turn > turn && game.phase === 0 && + !game.phaseOpening && !game.response && !game.decision) return game; + game = nativeStep(game); + } + throw new Error('The next native Storm turn did not open.'); +} +export function finishToMovement(state: Game): Game { + let game = state; + for (let step = 0; step < 800; step++) { + if (game.status === 'playing' && game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) return game; + game = nativeStep(game); + } + throw new Error('Native Shipment and Movement did not open.'); +} + +/** Reorders conserved, unplayed physical Spice Cards; does not manufacture a + * worm, territory, phase, force, source frame or private receipt. */ +function orderSpice(game: Game, territory: string, worm = false, position = 0): void { + const index = game.spiceDeck.findIndex((card, i) => i >= position && (worm ? + 'worm' in card && !card.greatMaker && !card.suppressed : + 'territory' in card && (!territory || card.territory === territory))); + assert.ok(index >= position, 'The required actual Spice Card remains in the deck.'); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); +} +function completeSetup(state: Game, mode: AdvancedSourceFixtureOptions['alliance']): Game { + let game = state; + const advisors = mode === 'ownAdvisors' || mode === 'allyAdvisors'; + for (let step = 0; step < 100 && game.status === 'setup'; step++) { + if (advisors && game.setupStage === 'forces') { + const bg = game.players.find(p => p.faction === 'beneGesserit'); + if (bg && viewGame(game, bg.id).setupPending.includes(bg.id) && game.players.every(p => p.faction !== 'fremen' || p.reserves === 10)) { + game = applyAction(game, bg.id, {type: 'advisorSetup', territory: 'arrakeen', sector: 10}); + continue; + } + } + game = nativeStep(game); + } + assert.equal(game.status, 'playing'); + return game; +} + +function arrangeStartingCards(game: Game, options: AdvancedSourceFixtureOptions): void { + if (options.karama !== undefined || options.hajr) + assert.ok(game.players.every(p => p.hand.length === 0), + 'Arrange only unplayed faces before the native starting deal.'); + if (options.karama === false) { + let position = 0; + for (const p of game.players) { + for (let count = 0; count < (p.faction === 'harkonnen' ? 2 : 1); count++) { + const index = game.deck.findIndex((c, i) => i >= position && c.effect !== 'karama' && + (p.faction !== 'beneGesserit' || c.kind !== 'worthless')); + assert.ok(index >= position, 'A conserved non-counter starting face must remain.'); + game.deck.splice(position++, 0, game.deck.splice(index, 1)[0]); + } + } + } + for (const [effect, owner] of [ + ['karama', options.karama ? game.players.find(p => p.faction !== 'fremen') : undefined], + ['hajr', options.hajr ? options.alliance ? + game.players.find(p => p.faction === (ordinaryAlliance(options.alliance) ? 'emperor' : 'fremen')) : + game.players[0] : undefined], + ] as const) { + if (!owner) continue; + const index = game.deck.findIndex(c => c.effect === effect); + assert.ok(index >= 0, 'The required original unplayed starting face must remain.'); + const drawPosition = game.players.slice(0, game.players.indexOf(owner)) + .reduce((sum, p) => sum + (p.faction === 'harkonnen' ? 2 : 1), 0); + game.deck.splice(drawPosition, 0, game.deck.splice(index, 1)[0]); + } +} + +function freshSetup(options: AdvancedSourceFixtureOptions): Game { + if (options.initial) { + const game = structuredClone(options.initial); + assert.equal(game.status, 'setup', 'Only a fresh admitted setup may be prepared.'); + assert.equal(game.turn, 1); + if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); + if (options.seatIds) assert.deepEqual(game.players.map(p => p.id), options.seatIds); + arrangeStartingCards(game, options); + return completeSetup(game, options.alliance); + } + const mode = options.alliance; + const ids = options.seatIds ?? ['source-a', 'source-b', 'source-c']; + const roster: FactionId[] = mode === 'ecaz' ? ['ecaz', 'fremen', 'atreides'] : + mode === 'ownAdvisors' || mode === 'allyAdvisors' ? ['atreides', 'beneGesserit', 'fremen'] : + ordinaryAlliance(mode) ? ['atreides', 'emperor', 'guild'] : + mode ? ['atreides', 'fremen', 'guild'] : options.fremen ? ['atreides', 'emperor', 'fremen'] : ['atreides', 'emperor']; + assert.ok(ids.length >= roster.length, 'Supply final seat IDs before setup.'); + let game = createGame('ADVANCEDSOURCE', newPlayer(ids[0], roster[0], roster[0]), options.advanced ?? true, + mode === 'ecaz' ? ['ecaz'] : []); + for (let i = 1; i < roster.length; i++) joinGame(game, newPlayer(ids[i], roster[i], roster[i])); + if (mode) { + // Actual printed circles select a native first allied ending. The later + // case has a nonallied seat ahead of it; no movement order is forged. + const fremen = game.players.find(p => p.faction === (ordinaryAlliance(mode) ? 'emperor' : 'fremen'))!; + const native = game.players.find(p => p.faction === (mode === 'ecaz' ? 'ecaz' : 'atreides'))!; + const remaining = game.players.find(p => p.id !== fremen.id && p.id !== native.id)!; + if (ordinaryAlliance(mode)) + for (const [index, player] of game.players.entries()) + game = applyAction(game, player.id, {type: 'seatPosition', position: index + 4}); + const positions = ordinaryAlliance(mode) ? mode === 'laterEnding' + ? [[remaining.id, 1], [fremen.id, 2], [native.id, 3]] as const + : [[fremen.id, 1], [native.id, 2], [remaining.id, 3]] as const : + [[remaining.id, 6], [fremen.id, 3], [native.id, 4]] as const; + for (const [id, position] of positions) + if (game.playerPositions?.[id] !== position) + game = applyAction(game, id, {type: 'seatPosition', position}); + } + if (options.modules) game = applyAction(game, ids[0], {type: 'homeworlds', enabled: true}); + for (const p of game.players) game = applyAction(game, p.id, {type: 'ready'}); + game = mode === 'ecaz' ? initializeFactionExpansionsGameForAudit(game) : options.modules ? + initializeHomeworldGameForAudit(game) : initializeBaseGameForAudit(game); + arrangeStartingCards(game, options); + return completeSetup(game, mode); +} + +/** Protected Basin groups can legally share a territory before allying: the + * first Storm separates their sectors, so no battle joins them. The next + * natural Nexus allies them without violating allied entry restrictions. */ +function createOrdinaryAlliance(state: Game, mode: NonNullable): AdvancedSourceFixture { + let game = state; + const emperor = game.players.find(p => p.faction === 'emperor')!; + const atreides = game.players.find(p => p.faction === 'atreides')!; + assert.ok(emperor && atreides); + const seed = TERRITORIES.find(t => game.spiceDeck.some(c => 'territory' in c && c.territory === t.id) && + game.players.every(p => Object.keys(p.forces).every(key => splitLocation(key).territory !== t.id)))!; + orderSpice(game, seed.id); + if (game.advanced) orderSpice(game, '', false, 1); + while (game.phaseOpening || game.response || game.decision) game = nativeStep(game); + assert.equal(game.phase, 0); + for (const [index, id] of game.stormDialers.entries()) + game = applyAction(game, id, {type: 'stormDial', amount: index === 0 ? 4 : 5}); + game = finishToMovement(game); + assert.equal(game.storm, 10); + while (game.phase === 5) { + const actor = game.active!; + if (actor === emperor.id || actor === atreides.id) + game = settleAdvancedSourceResponses(applyAction(game, actor, {type: 'ship', + territory: 'imperial_basin', sector: actor === emperor.id ? 9 : 11, amount: 2, + ...(game.advanced && actor === emperor.id ? {elite: 1} : {})})); + game = applyAction(game, actor, {type: 'endMovement'}); + } + game = advanceToNextStorm(game); + orderSpice(game, '', true); + orderSpice(game, '', false, 1); + if (game.advanced) orderSpice(game, '', false, 2); + let allied = false; + for (let step = 0; step < 500; step++) { + if (!allied && game.phase === 1 && game.nexus && !game.spiceWindow && + !game.spiceResolution && !game.response && !game.decision && !game.phaseOpening) { + game = applyAction(game, emperor.id, {type: 'alliance', target: atreides.id}); + game = applyAction(game, atreides.id, {type: 'alliance', target: emperor.id}); + allied = true; + continue; + } + if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break; + game = nativeStep(game); + } + assert.ok(allied && game.phase === 5); + let actor = emperor.id; + if (mode === 'laterEnding' && !game.advanced) { + game = advanceToNextStorm(game); + orderSpice(game, ''); + game = finishToMovement(game); + actor = atreides.id; + } + while (game.active !== actor) game = applyAction(game, game.active!, {type: 'endMovement'}); + const ally = game.players.find(p => p.id === actor)!.ally!; + assert.ok(ally && game.players.find(p => p.id === ally)!.ally === actor); + for (const id of [actor, ally]) + assert.equal(Object.entries(game.players.find(p => p.id === id)!.forces) + .filter(([key]) => splitLocation(key).territory === 'imperial_basin') + .reduce((total, [, count]) => total + count, 0), 2); + return {game, actor, ally, other: game.players.find(p => p.id !== actor && p.id !== ally)!.id, + turn: game.turn, lossTerritories: ['imperial_basin']}; +} + +export function createAdvancedSourceFixture(options: AdvancedSourceFixtureOptions = {}): AdvancedSourceFixture { + let game = freshSetup(options); + const mode = options.alliance; + const advisors = mode === 'ownAdvisors' || mode === 'allyAdvisors'; + const host = game.players[0].id; + if (!mode) { + if (options.storm === 'next') game = advanceToNextStorm(game); + return {game, actor: host, ally: null, other: game.players[1]?.id ?? null, turn: game.turn, lossTerritories: []}; + } + if (ordinaryAlliance(mode)) return createOrdinaryAlliance(game, mode); + const fremen = game.players.find(p => p.faction === 'fremen'); + const native = game.players.find(p => p.faction === (mode === 'ecaz' ? 'ecaz' : 'atreides')); + assert.ok(fremen && native, 'An actual native Fremen and destination owner must be admitted.'); + const bg = game.players.find(p => p.faction === 'beneGesserit'); + if (advisors) assert.ok(bg && game.advanced, 'Advisor cases require genuine Advanced BG setup.'); + const sand = TERRITORIES.find(t => fremenReserveEntry(t.id) && t.sectors.some(s => s > 7) && + game.players.every(p => Object.keys(p.forces).every(key => splitLocation(key).territory !== t.id)) && + game.spiceDeck.some(c => 'territory' in c && c.territory === t.id)); + assert.ok(sand); + const sandSector = sand.sectors.find(s => s > 7)!; + // The actual first blow seeds the native worm occurrence on the next turn. + orderSpice(game, sand.id); + if (game.advanced) orderSpice(game, '', false, 1); + game = finishToMovement(game); + assert.equal(game.spiceDiscard[0].findLast(c => 'territory' in c)?.territory, sand.id, + 'The first physical pile must seed the later native worm source.'); + while (game.phase === 5) { + const actor = game.active!; + if (!advisors && actor === fremen.id) game = settleAdvancedSourceResponses(applyAction(game, actor, + {type: 'ship', territory: sand.id, sector: sandSector, amount: 2, ...(game.advanced ? {elite: 1} : {})})); + if (mode === 'polar' && actor === native.id) game = settleAdvancedSourceResponses(applyAction(game, actor, + {type: 'ship', territory: 'polar_sink', sector: 0, amount: 2})); + game = applyAction(game, actor, {type: 'endMovement'}); + } + game = advanceToNextStorm(game); + orderSpice(game, '', true); + // Finish the worm's replacement and second physical pile with conserved + // territory faces, not an incidental extra worm or Sandtrout. + orderSpice(game, '', false, 1); + if (game.advanced) orderSpice(game, '', false, 2); + let rode = advisors; + const destination = mode === 'polar' ? 'polar_sink' : mode === 'ecaz' ? 'imperial_basin' : 'arrakeen'; + const destinationSector = mode === 'polar' ? 0 : mode === 'ecaz' ? + splitLocation(Object.keys(game.players.find(p => p.id === native.id)!.forces)[0]).sector : 10; + const left = advisors ? native.id : fremen.id; + const right = advisors ? bg!.id : native.id; + let allied = false; + for (let step = 0; step < 500; step++) { + // Natural Nexus precedes its ride. Completing that ride clears nexus, + // so reciprocal offers must occur at this actual legal boundary. + if (!allied && game.phase === 1 && game.nexus && !game.spiceWindow && + !game.spiceResolution && !game.response && !game.decision && !game.phaseOpening) { + game = applyAction(game, left, {type: 'alliance', target: right}); + game = applyAction(game, right, {type: 'alliance', target: left}); + allied = true; + continue; + } + if (!rode && game.decision?.kind === 'wormRide' && game.decision.player === fremen.id) { + assert.equal(game.decision.territory, sand.id); + const forces = Object.fromEntries(Object.entries(game.players.find(p => p.id === fremen.id)!.forces) + .filter(([key]) => splitLocation(key).territory === sand.id)); + game = applyAction(game, fremen.id, {type: 'decision', accept: true, territory: destination, + sector: destinationSector, forces}); + rode = true; + continue; + } + if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break; + game = nativeStep(game); + } + assert.ok(allied && rode && game.phase === 5 && !game.decision && !game.response, + 'The genuine reciprocal Nexus alliance and requested worm ride must reach native movement.'); + const pair = [left, right].sort((a, b) => game.movementRemaining!.indexOf(a) - game.movementRemaining!.indexOf(b)); + const actor = mode === 'ownAdvisors' ? bg!.id : mode === 'allyAdvisors' ? native.id : pair[0]; + while (game.active !== actor) game = applyAction(game, game.active!, {type: 'endMovement'}); + const ally = game.players.find(p => p.id === actor)!.ally; + assert.ok(ally); + assert.equal(game.players.find(p => p.id === ally)!.ally, actor); + for (const id of [actor, ally]) + assert.ok(Object.entries(game.players.find(p => p.id === id)!.forces) + .some(([key, amount]) => amount > 0 && splitLocation(key).territory === destination), + 'Both genuine allied force groups must occupy the requested edge territory.'); + // The exception scenarios have overlap but correctly no consequence quote. + const lossTerritories = advisors || mode === 'ecaz' || mode === 'polar' ? [] : [destination]; + return {game, actor, ally, other: game.players.find(p => p.id !== actor && p.id !== ally)?.id ?? null, + turn: game.turn, lossTerritories}; +} diff --git a/tests/fixtures/advanced-recorded-storm-8a.json b/tests/fixtures/advanced-recorded-storm-8a.json new file mode 100644 index 00000000..37d52829 --- /dev/null +++ b/tests/fixtures/advanced-recorded-storm-8a.json @@ -0,0 +1,602 @@ +{ + "schema": 1, + "code": "OLDSTORM", + "version": 0, + "host": "00000000-0000-4000-8000-0000000000a1", + "status": "playing", + "advanced": true, + "expansions": [], + "players": [ + { + "id": "00000000-0000-4000-8000-0000000000a1", + "name": "Recorded QA Atreides", + "faction": "atreides", + "ready": true, + "spice": 11, + "reserves": 10, + "tanks": 0, + "forces": { + "arrakeen:10": 10 + }, + "hand": [ + { + "name": "Shield", + "kind": "shield", + "id": "treachery-9" + }, + { + "name": "Maula Pistol", + "kind": "projectile", + "id": "treachery-1" + } + ], + "leaders": [ + { + "id": "atreides-0", + "name": "Thufir Hawat", + "strength": 5, + "faction": "atreides", + "dead": false, + "deaths": 0 + }, + { + "id": "atreides-1", + "name": "Lady Jessica", + "strength": 5, + "faction": "atreides", + "dead": false, + "deaths": 0 + }, + { + "id": "atreides-2", + "name": "Gurney Halleck", + "strength": 4, + "faction": "atreides", + "dead": false, + "deaths": 0 + }, + { + "id": "atreides-3", + "name": "Duncan Idaho", + "strength": 2, + "faction": "atreides", + "dead": false, + "deaths": 0 + }, + { + "id": "atreides-4", + "name": "Dr. Yueh", + "strength": 1, + "faction": "atreides", + "dead": false, + "deaths": 0 + } + ], + "traitors": [ + "emperor-3" + ], + "traitorChoices": [], + "ally": null, + "bribes": 0, + "revived": 0, + "freeForcesRevived": 0, + "leaderRevived": false, + "revivalCycle": 0, + "shipped": false, + "moved": 0, + "battleLosses": 0 + }, + { + "id": "00000000-0000-4000-8000-0000000000e1", + "name": "Recorded QA Emperor", + "faction": "emperor", + "ready": true, + "spice": 10, + "reserves": 20, + "tanks": 0, + "forces": {}, + "hand": [ + { + "name": "Lasgun", + "kind": "lasgun", + "id": "treachery-8" + }, + { + "name": "Shield", + "kind": "shield", + "id": "treachery-12" + } + ], + "leaders": [ + { + "id": "emperor-0", + "name": "Hasimir Fenring", + "strength": 6, + "faction": "emperor", + "dead": false, + "deaths": 0 + }, + { + "id": "emperor-1", + "name": "Captain Aramsham", + "strength": 5, + "faction": "emperor", + "dead": false, + "deaths": 0 + }, + { + "id": "emperor-2", + "name": "Caid", + "strength": 3, + "faction": "emperor", + "dead": false, + "deaths": 0 + }, + { + "id": "emperor-3", + "name": "Burseg", + "strength": 3, + "faction": "emperor", + "dead": false, + "deaths": 0 + }, + { + "id": "emperor-4", + "name": "Bashar", + "strength": 2, + "faction": "emperor", + "dead": false, + "deaths": 0 + } + ], + "traitors": [ + "emperor-0" + ], + "traitorChoices": [], + "ally": null, + "bribes": 0, + "revived": 0, + "freeForcesRevived": 0, + "leaderRevived": false, + "revivalCycle": 0, + "shipped": false, + "moved": 0, + "battleLosses": 0, + "elites": { + "reserves": 5, + "tanks": 0, + "forces": {}, + "revived": 0 + } + } + ], + "playerPositions": { + "00000000-0000-4000-8000-0000000000a1": 1, + "00000000-0000-4000-8000-0000000000e1": 2 + }, + "turn": 2, + "phase": 0, + "storm": 16, + "order": [ + "00000000-0000-4000-8000-0000000000a1", + "00000000-0000-4000-8000-0000000000e1" + ], + "active": null, + "movementRemaining": [], + "guildTimingGranted": false, + "guildTimingLocked": false, + "ready": [], + "deck": [ + { + "name": "Tleilaxu Ghola", + "kind": "special", + "effect": "ghola", + "id": "treachery-27" + }, + { + "name": "Crysknife", + "kind": "projectile", + "id": "treachery-0" + }, + { + "name": "Cheap Heroine", + "kind": "hero", + "id": "treachery-23" + }, + { + "name": "Shield", + "kind": "shield", + "id": "treachery-11" + }, + { + "name": "Harvester", + "kind": "special", + "effect": "harvester", + "id": "treachery-28" + }, + { + "name": "Snooper", + "kind": "snooper", + "id": "treachery-14" + }, + { + "name": "Jubba Cloak", + "kind": "worthless", + "id": "treachery-18" + }, + { + "name": "Truthtrance", + "kind": "special", + "effect": "truthtrance", + "id": "treachery-32" + }, + { + "name": "Weather Control", + "kind": "special", + "effect": "weather", + "id": "treachery-25" + }, + { + "name": "Gom Jabbar", + "kind": "poison", + "id": "treachery-6" + }, + { + "name": "Ellaca Drug", + "kind": "poison", + "id": "treachery-7" + }, + { + "name": "Stunner", + "kind": "projectile", + "id": "treachery-3" + }, + { + "name": "Chaumas", + "kind": "poison", + "id": "treachery-4" + }, + { + "name": "Chaumurky", + "kind": "poison", + "id": "treachery-5" + }, + { + "name": "Family Atomics", + "kind": "special", + "effect": "atomics", + "id": "treachery-24" + }, + { + "name": "Trip to Gamont", + "kind": "worthless", + "id": "treachery-21" + }, + { + "name": "Kulon", + "kind": "worthless", + "id": "treachery-19" + }, + { + "name": "Baliset", + "kind": "worthless", + "id": "treachery-17" + }, + { + "name": "Shield", + "kind": "shield", + "id": "treachery-10" + }, + { + "name": "Truthtrance", + "kind": "special", + "effect": "truthtrance", + "id": "treachery-31" + }, + { + "name": "La La La", + "kind": "worthless", + "id": "treachery-20" + }, + { + "name": "Snooper", + "kind": "snooper", + "id": "treachery-15" + }, + { + "name": "Snooper", + "kind": "snooper", + "id": "treachery-16" + }, + { + "name": "Karama", + "kind": "special", + "effect": "karama", + "id": "treachery-29" + }, + { + "name": "Slip Tip", + "kind": "projectile", + "id": "treachery-2" + }, + { + "name": "Snooper", + "kind": "snooper", + "id": "treachery-13" + }, + { + "name": "Karama", + "kind": "special", + "effect": "karama", + "id": "treachery-30" + }, + { + "name": "Cheap Hero", + "kind": "hero", + "id": "treachery-22" + }, + { + "name": "Hajr", + "kind": "special", + "effect": "hajr", + "id": "treachery-26" + } + ], + "discard": [], + "spiceDeck": [ + { + "territory": "rock_outcroppings", + "amount": 6, + "sector": 13 + }, + { + "territory": "habbanya_erg", + "amount": 8, + "sector": 16 + }, + { + "territory": "south_mesa", + "amount": 10, + "sector": 5 + }, + { + "worm": true + }, + { + "worm": true + }, + { + "territory": "habbanya_ridge_flat", + "amount": 10, + "sector": 18 + }, + { + "territory": "sihaya_ridge", + "amount": 6, + "sector": 9 + }, + { + "territory": "the_minor_erg", + "amount": 8, + "sector": 8 + }, + { + "territory": "cielago_north", + "amount": 8, + "sector": 3 + }, + { + "territory": "the_great_flat", + "amount": 10, + "sector": 15 + }, + { + "worm": true + }, + { + "worm": true + }, + { + "worm": true + }, + { + "territory": "broken_land", + "amount": 8, + "sector": 12 + }, + { + "territory": "red_chasm", + "amount": 8, + "sector": 7 + }, + { + "territory": "funeral_plain", + "amount": 6, + "sector": 15 + }, + { + "worm": true + }, + { + "territory": "cielago_south", + "amount": 12, + "sector": 2 + }, + { + "territory": "wind_pass_north", + "amount": 6, + "sector": 17 + } + ], + "spicePeekKnown": true, + "spiceDiscard": [ + [ + { + "territory": "oh_gap", + "amount": 6, + "sector": 10 + } + ], + [ + { + "territory": "hagga_basin", + "amount": 6, + "sector": 12 + } + ] + ], + "spice": { + "oh_gap:10": 6, + "hagga_basin:12": 6, + "arrakeen:10": 0 + }, + "log": [ + { + "seq": 1, + "text": "Recorded QA Atreides opened the table." + }, + { + "seq": 2, + "text": "Recorded QA Emperor joined as Emperor." + }, + { + "seq": 3, + "text": "Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards." + }, + { + "seq": 4, + "text": "Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt." + }, + { + "seq": 5, + "text": "Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards." + }, + { + "seq": 6, + "text": "Recorded QA Atreides received 1 starting Treachery Card after force placement.", + "automatic": { + "faction": "atreides", + "name": "Starting cards" + } + }, + { + "seq": 7, + "text": "Recorded QA Emperor received 1 starting Treachery Card after force placement.", + "automatic": { + "faction": "emperor", + "name": "Starting cards" + } + }, + { + "seq": 8, + "text": "Setup complete. The first storm awaits two secret dials." + }, + { + "seq": 9, + "text": "Storm distance revealed: 15 sectors. Resolve storm cards before moving." + }, + { + "seq": 10, + "text": "Storm moved 15 sectors to sector 16." + }, + { + "seq": 11, + "text": "6 spice appeared in Oh Gap." + }, + { + "seq": 12, + "text": "6 spice appeared in Hagga Basin." + }, + { + "seq": 13, + "text": "Atreides won a treachery card for 1 spice. The declared spice payment was the only available payment method.", + "automatic": { + "faction": "atreides", + "name": "Auction payment" + } + }, + { + "seq": 14, + "text": "Recorded QA Emperor received 1 spice from another faction’s 1-spice paid auction bid.", + "automatic": { + "faction": "emperor", + "name": "Auction income" + } + }, + { + "seq": 15, + "text": "Emperor won a treachery card for 1 spice. The declared spice payment was the only available payment method.", + "automatic": { + "faction": "emperor", + "name": "Auction payment" + } + }, + { + "seq": 16, + "text": "Atreides received 2 spice from strongholds." + }, + { + "seq": 17, + "text": "Turn 2 begins." + } + ], + "stormDials": {}, + "stormPending": null, + "stormCard": null, + "stormCardKnown": false, + "stormResolution": null, + "karamaShipping": null, + "stormDialers": [ + "00000000-0000-4000-8000-0000000000a1", + "00000000-0000-4000-8000-0000000000e1" + ], + "lastBattle": [ + "00000000-0000-4000-8000-0000000000a1", + "00000000-0000-4000-8000-0000000000e1" + ], + "auction": null, + "battle": null, + "nexus": false, + "allianceOffers": {}, + "winner": [], + "shieldWallDestroyed": false, + "spiceWindow": null, + "hajr": [], + "decision": null, + "pendingCapture": null, + "response": null, + "wormRides": [], + "wormPlacementCanceledTurn": 0, + "spiceResolution": null, + "spiceSequence": null, + "freeRevival": [], + "emperorExtra": {}, + "aid": {}, + "sandtrout": false, + "advancedPreview": true, + "traitorReserve": [ + "emperor-2", + "atreides-1", + "emperor-1", + "atreides-0", + "atreides-4", + "atreides-2", + "emperor-4", + "atreides-3" + ], + "summonedBeforeBlow": false, + "currentAuctionSale": null, + "revivalRules": { + "expanded": [], + "limitBlocked": false, + "fullPrice": false, + "allyDiscount": null, + "discountBlocked": false, + "earlyBlocked": [] + }, + "revivalRequests": {}, + "saphoMovementLast": null, + "recruits": null, + "auditorInsight": null, + "nexusChoamInsight": null +} diff --git a/tests/karama-movement-cancellation.test.ts b/tests/karama-movement-cancellation.test.ts index 73dfb2a4..44b972cb 100644 --- a/tests/karama-movement-cancellation.test.ts +++ b/tests/karama-movement-cancellation.test.ts @@ -293,7 +293,21 @@ for (const bg of [false, true]) } else { assert.equal(done.pendingMobileMove, null); assert.deepEqual(done.mobileStronghold, g.mobileStronghold); - assert.deepEqual(done.stormDialers, g.lastBattle); + assert.equal(done.turn, g.turn); + assert.equal(done.phase, 0); + assert.ok( + Number.isInteger(done.stormPending) && + done.stormPending! >= 1 && + done.stormPending! <= 6, + ); + assert.equal(done.stormMovementSource?.kind, 'card'); + assert.equal(done.stormMovementSource?.turn, done.turn); + assert.equal(done.stormMovementSource?.distance, done.stormPending); + for (const dialer of g.lastBattle) + assert.throws( + () => applyAction(done, dialer, { type: 'stormDial', amount: 1 }), + /not dialing/, + ); assert.equal(player(done, 'p').elites!.revived, 0); assert.equal( done.log.filter((l) => l.text === `Turn ${g.turn} begins.`).length, diff --git a/tests/ordinary-card-discard-continuations.test.ts b/tests/ordinary-card-discard-continuations.test.ts index 7ddac9fd..47db48c3 100644 --- a/tests/ordinary-card-discard-continuations.test.ts +++ b/tests/ordinary-card-discard-continuations.test.ts @@ -18,6 +18,7 @@ import { botActions, runBots } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { baseDeck, treacheryDeck } from '../game/cards'; import { createTechTokens } from '../game/tech-tokens'; +import { createStormSource } from '../game/discovery-storm'; import { createDukeVidal, acquireDuke, @@ -300,6 +301,7 @@ void test('Family Atomics can recover after killing its own qualifying Wall figh revived: 0, }; g.stormPending = 1; + g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending); g.ready = ['t']; const { pending, done } = play(g, 'p', { type: 'card', card }); assert.equal(pending.shieldWallDestroyed, true); diff --git a/tests/stronghold-engine.test.ts b/tests/stronghold-engine.test.ts index 1486c365..b4afb5fa 100644 --- a/tests/stronghold-engine.test.ts +++ b/tests/stronghold-engine.test.ts @@ -16,6 +16,7 @@ import { type StrongholdId, } from '../game/stronghold-cards'; import { MOBILE_STRONGHOLD, MOBILE_LOCATION, territory } from '../game/board'; +import { finishToMovement } from './fixture-advanced-source'; const key = (t: string) => `${t}:${territory(t).sectors[0]}`; const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!; function fixture(t: StrongholdId = 'arrakeen', owner = 'p', choam = false) { @@ -199,11 +200,14 @@ void test('Stronghold Cards are unowned at the start and transfer only at the fi assert.equal(g.strongholdCards!.owners.arrakeen, 'p'); const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(g))); assert.deepEqual(restored.strongholdCards, g.strongholdCards); - g.phase = 8; - g.active = null; - g.ready = []; - g.decision = null; - g.response = null; + g = finishToMovement(g); + while (g.phase === 5) + g = applyAction(g, g.active!, { type: 'endMovement' }); + assert.equal(g.phase, 7); + assert.equal(g.strongholdCards!.owners.arrakeen, 'p'); + g = readyAll(g); + assert.equal(g.phase, 8); + assert.equal(g.strongholdCards!.claimedTurn, 1); seat(g, 'p').forces = {}; seat(g, 'p').reserves = 20; seat(g, 'q').forces = { 'arrakeen:10': 1 }; diff --git a/tests/terminal-cancellation.test.ts b/tests/terminal-cancellation.test.ts index faf358d8..ae28dc95 100644 --- a/tests/terminal-cancellation.test.ts +++ b/tests/terminal-cancellation.test.ts @@ -139,9 +139,13 @@ function opportunity(kind: Case, advanced = true) { return applyAction(g, 'p', { type: 'choamInflation', side: 'double' }); } if (kind === 'stormPeek') { - g.phase = 0; - g.stormPending = 1; - g.stormDialers = []; + g.phase = 8; + g.turn = 1; + g.active = null; + g = openPhase(g); + assert.equal(g.turn, 2); + assert.equal(g.phase, 0); + assert.equal(g.stormMovementSource?.kind, 'card'); return openPhase(g); } if (kind === 'atreidesSpice') {