diff --git a/components/ecaz-setup.tsx b/components/ecaz-setup.tsx new file mode 100644 index 00000000..1529e8dc --- /dev/null +++ b/components/ecaz-setup.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { + ECAZ_START_FORCES, + ECAZ_START_LOCATIONS, + quoteEcazStartingForces, +} from '@/game/ecaz-setup'; +import { splitLocation } from '@/game/board'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +export function EcazSetup({ + game, + act, + busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const [placements, setPlacements] = useState>({ + [ECAZ_START_LOCATIONS[0]]: ECAZ_START_FORCES, + }); + const me = game.players.find((player) => player.id === game.me); + if ( + game.status !== 'setup' || + game.setupStage !== 'forces' || + me?.faction !== 'ecaz' || + me.reserves !== 20 || + !game.setupPending.includes(game.me) + ) + return null; + let quoted: Record | null = null; + try { + quoted = quoteEcazStartingForces(placements); + } catch { + /* Show the allocation until its total is legal. */ + } + const total = Object.values(placements).reduce( + (sum, count) => sum + count, + 0, + ); + return ( +
+

+ Place six forces in Imperial Basin. Choose how many go in each sector; + fourteen forces remain in reserves. +

+ {ECAZ_START_LOCATIONS.map((location) => ( + + ))} +

+ {total} / {ECAZ_START_FORCES} forces assigned +

+ +
+ ); +} diff --git a/components/game-table.tsx b/components/game-table.tsx index f683eb53..4e418204 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,4 +1,6 @@ 'use client'; +import { EcazSetup } from './ecaz-setup'; +import { IxRicheseTechnology } from './ix-richese-technology'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options'; import { matchesShipment, @@ -206,6 +208,7 @@ export function GameTable({ const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name; const setupStage = g.setupStage; const setupFremen = g.players.find((p) => p.faction === 'fremen'); + const setupEcaz = g.players.find((p) => p.faction === 'ecaz'); const setupSisterhood = g.players.find((p) => p.faction === 'beneGesserit'); const setupPendingNames = (g.setupPending ?? []) .filter((id) => id !== me.id) @@ -213,6 +216,7 @@ export function GameTable({ .filter(Boolean) .join(', '); const waitingForFremen = !!setupFremen && setupFremen.reserves !== 10; + const waitingForEcaz = setupStage === 'forces' && !!setupEcaz && setupEcaz.reserves === 20; const setupStatus = setupStage === 'leaderSkills' ? g.leaderSkills?.offer @@ -235,6 +239,10 @@ export function GameTable({ ? 'Place your ten starting forces. Bene Gesserit places its advisor afterward.' : g.leaderSkills ? 'Place your ten starting forces. Starting cards were already dealt before skill assignment.' : 'Place your ten starting forces. Starting cards are dealt automatically after placement.' : `Waiting for ${setupFremen.name} to place the Fremen starting forces.` + : waitingForEcaz + ? setupEcaz.id === me.id + ? 'Place your six starting forces among the sectors of Imperial Basin. Fourteen remain in reserves.' + : `Waiting for ${setupEcaz.name} to place the Ecaz starting forces.` : g.advanced && setupSisterhood && !setupSisterhood.advisorSetup ? setupSisterhood.id === me.id ? 'Choose a printed board territory and sector for your starting advisor.' @@ -1643,20 +1651,21 @@ export function GameTable({ : 'Setup order: prediction, traitors, starting forces, then the automatic treachery-card deal and Storm phase.'}

)} + {(!setupStage || setupStage === 'forces') && g.advanced && me.faction === 'beneGesserit' && !me.advisorSetup && ( <>

- After Fremen placement, choose a territory and sector on + After the other starting force choices, choose a territory and sector on the map for your starting advisor. It becomes a fighter if alone.

{destination} - {waitingForFremen && ( + {(waitingForFremen || waitingForEcaz) && (

- {setupFremen.name} must finish placing the Fremen forces + {waitingForFremen ? setupFremen!.name : setupEcaz!.name} must finish placing starting forces before you place an advisor.

)} @@ -1670,9 +1679,10 @@ export function GameTable({ 'Place starting advisor', { type: 'advisorSetup', territory: selected, sector }, waitingForFremen || + waitingForEcaz || me.reserves !== 20 || selected === MOBILE_STRONGHOLD, - waitingForFremen ? 'advisor-setup-wait' : undefined, + waitingForFremen || waitingForEcaz ? 'advisor-setup-wait' : undefined, )} )} @@ -2124,7 +2134,9 @@ export function GameTable({ <> Player decision

- {g.decision.kind === 'caladanReinforcement' + {g.decision.kind === 'ixRicheseTechnology' + ? 'Ixian Technology · Richese lot' + : g.decision.kind === 'caladanReinforcement' ? 'Caladan victory reinforcement' : g.decision.kind === 'grummanCollection' ? 'Grumman Collection' @@ -2373,6 +2385,8 @@ export function GameTable({ act={act} busy={busy} /> + ) : g.decision.kind === 'ixRicheseTechnology' ? ( + ) : g.decision.kind === 'richeseBlackMarket' || g.decision.kind === 'richeseDeclaration' || g.decision.kind === 'richeseCache' || diff --git a/components/ix-richese-technology.tsx b/components/ix-richese-technology.tsx new file mode 100644 index 00000000..6832fe54 --- /dev/null +++ b/components/ix-richese-technology.tsx @@ -0,0 +1,51 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { Button } from './ui/button'; + +export function IxRicheseTechnology({ + game, + act, + busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const offer = game.ixRicheseTechnology; + if ( + !offer || + offer.player !== game.me || + game.decision?.kind !== 'ixRicheseTechnology' || + game.decision.player !== game.me || + game.decision.event !== offer.event + ) + return null; + return ( +
+

+ Richese is offering a{' '} + {offer.source === 'cache' ? 'cache' : 'Black Market'} card. Bidding will + begin after you decline Technology for this lot. +

+

{offer.exchangeBlocked}

+

+ Declining leaves your once-per-round Technology use available for later + lots. Your hand and the offered card stay unchanged. +

+ +
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 29b93f60..3f06aca6 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -4,6 +4,19 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid ## Evidence and source of truth +- [Expansion-faction development games](EXPANSION_FACTIONS_PROTOTYPE.md) connect + genuine setup for all seven expansion selections, using existing faction + controls, all four AI profiles and saved continuation. Ecaz now places its six + starting forces in Imperial Basin and keeps fourteen in reserve. The optional + three-card Ecaz variant remains separate and unfinished. Advanced Ixians have + an explicit saved per-lot decline for Richese offers; the actual exchange still + awaits its replacement-custody ruling. Complete-game samples + finish five of six games; combined Advanced reaches turn three, then stops + on the existing CHOAM movement/Ambassador arrival guard. That interaction is + the next integration gap. Setup evidence does not open public starts or + establish full faction compliance. Saved-game checks passed after a necessary + restart for a stale server module cache. + - [Diplomat defense copying](DIPLOMAT_DEFENSE.md) connects one committed Worthless card to an opposing base Shield or Snooper, with optional controls, all four AI profiles, native trained-leader self-protection and mandatory discard. Saved diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d6ee8709..fdbaa2cf 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -11,6 +11,9 @@ The [three-milestone pilot](EFFICIENCY_PILOT.md) records measurement limits. Use the [Ix prototype workflow](IX_PROTOTYPE.md) to connect a fresh local ready lobby to genuine expansion setup with a private backup and preserved sessions. It does not open normal mode gates or require restarting the server. +The [expansion-faction profile](EXPANSION_FACTIONS_PROTOTYPE.md) extends genuine +setup to selected faction sets without optional modules, including Ecaz starting +placement. Remaining combined-play guards still apply. ## Architecture map diff --git a/docs/EXPANSION_FACTIONS_PROTOTYPE.md b/docs/EXPANSION_FACTIONS_PROTOTYPE.md new file mode 100644 index 00000000..bb03b7ac --- /dev/null +++ b/docs/EXPANSION_FACTIONS_PROTOTYPE.md @@ -0,0 +1,228 @@ +# Expansion-faction development profile + +13 September 2026. This checkpoint connects genuine setup for any nonempty +selection of the Ixians & Tleilaxu, CHOAM & Richese, and Ecaz & Moritani +expansions. It is a local development profile for faction play. Optional +variants, normal public expansion starts, and publication remain gated. + +## Source and deck boundary + +The source audit used the archived publisher-authored rulebooks at +`/home/quintine/.local/share/dune/sources/ecaz-moritani.pdf` and +`/home/quintine/.local/share/dune/sources/choam-richese-rulebook-en.pdf`, with +their extracted text. A targeted official web check reproduced the CHOAM & +Richese component text from GF9's index. The current Ecaz & Moritani PDF URL +returned HTTP 403, so the findings below use the archived publisher file and +retain its public URL rather than claiming a fresh download. No publisher or +designer clarification of Ecaz's starting-sector allocation was found in the +bounded follow-up search. + +The Ecaz & Moritani rulebook calls Homeworlds, Nexus Cards, New Treachery +Cards, and Discovery Tokens variants that may be added together or separately, +regardless of the factions selected. Choosing Ecaz or Moritani therefore does +not add Recruits, Reinforcements, or Harass & Withdraw. Those three cards remain +an independent, unimplemented variant. [GF9 Ecaz & Moritani, printed +pp. 4, 9, 11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9) + +The CHOAM & Richese rulebook says its Poison Tooth and Artillery Strike replace +the versions from Ixians & Tleilaxu, or are added when that expansion is absent. +Its two updated Karama cards replace the two core cards. These are physical +replacements, so they do not duplicate identities or increase the combined Ix +deck. [GF9 CHOAM & Richese, printed +p. 4](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=4) + +| Selected expansion sets | Ordinary Treachery Deck | +| --- | ---: | +| Ecaz & Moritani only | 33 | +| CHOAM & Richese only | 35 | +| Ixians & Tleilaxu only | 47 | +| Ixians & Tleilaxu plus either or both later sets | 47 | +| CHOAM & Richese plus Ecaz & Moritani, without Ix | 35 | + +Richese's ten technology cards form a separate faction cache and do not count +toward the ordinary deck or the Richese hand at setup. When Richese is seated, +the setup creates that cache and its three concealed No-Field tokens with +values zero, three, and five. [GF9 CHOAM & Richese, printed +pp. 5–6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=5) + +The ordinary Spice Deck gains Sandtrout when Ixians & Tleilaxu is selected. +The seven Ecaz & Moritani Spice Cards belong to the separate Discovery Tokens +variant and are absent from this profile. + +## Connected setup + +`initializeFactionExpansionsGameForAudit` accepts an already constructed Basic +or Advanced lobby, two through six distinct ready players, and any roster whose +faction sets are among the selected nonempty expansion list. It uses the shared +prediction, traitor, force-placement, starting-card, and first-Storm pipeline. +It rejects an already dealt or modified lobby and does not add optional modules. +The ordinary browser lobby still creates Basic games only; Advanced profile +setup is currently an offline development/test path rather than a visible mode +selection. + +Faction-specific setup uses the existing physical inventories and decisions: + +- Ixians place three cyborgs and three suboids in the unplaced Hidden Mobile + Stronghold, then make their private starting-card choice. Tleilaxu receives + three Face Dancers after the other factions finish traitor selection. +- Advanced CHOAM receives the additional Auditor disc before traitors are + dealt. Richese receives its separate cache and concealed No-Field inventory. +- Ecaz receives its Ecaz Ambassador plus five random non-Ecaz Ambassadors and + allocates six starting forces among the three printed sectors of Imperial + Basin, leaving fourteen in reserve. The printed constraint specifies the + territory and total, without a one-stack restriction or a separate setup + sector procedure. The prototype therefore accepts any nonnegative allocation + totaling six within sectors 9, 10, and 11. This is the implementation's + literal territory-level reading, not a separate publisher ruling about + sector allocation; a targeted publisher/designer search found no more + specific clarification. +- Moritani receives six concealed Terror tokens and places six forces in an + unoccupied territory after every other faction completes setup, leaving + fourteen in reserve. Ecaz or Moritani also creates the one shared Duke Vidal + disc. Moritani-last is explicit on printed p. 5. The Ecaz setup on printed + p. 7 states its location and inventory but no relative priority. The + Fremen–Ecaz–Advanced-Bene-Gesserit order is therefore engine sequencing + needed to resolve those choices, not a claimed publisher priority among the + three factions. + +These setup values come from the faction sections of the +[GF9 Ecaz & Moritani rulebook, printed pp. 5 and +7](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5) +and the [GF9 CHOAM & Richese rulebook, printed pp. 5 and +7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=5). +The earlier [Ix prototype](IX_PROTOTYPE.md) records its setup and 47-card +inventory evidence. + +## Start a local faction prototype + +Create a new local Basic room with the intended expansion sets and factions, +then mark every seat ready. Preserve the room cookie and current lobby version. +Start only that fresh lobby with: + +```sh +node --import tsx tools/start-prototype.ts \ + --profile factions \ + --db PATH_TO_LOCAL_D1_SQLITE \ + --room EIGHT_CHARACTER_CODE \ + --version CURRENT_LOBBY_VERSION \ + --out /private/new-prototype-checkpoint +``` + +The output directory must be new and outside the repository. The tool first +backs up all saved rooms, then uses an atomic state-and-version comparison to +write the selected lobby. It preserves sessions, recovery records, other rooms, +and the public start gate. Refresh the existing invitation to continue through +the ordinary private setup controls. Reusing the command on the started game is +rejected. + +## Known incomplete play + +This entry point makes the faction setup real and reviewable; it does not mark +the expansion rules complete. The three optional Ecaz Treachery Cards have +metadata and source guides but no runtime. Richese's cache and auction lifecycle +are present, but cache custody does not establish complete runtime or combined +interaction coverage for every technology card. Ecaz, Moritani, CHOAM, Richese, +Ixian, and Tleilaxu powers retain narrower supported and pending contracts in +the current in-game reference and their feature documents. In particular, see +the [Ecaz Treachery audit](ECAZ_TREACHERY_RULES.md), +[Ecaz Ambassadors](ECAZ_AMBASSADORS_RULES.md), +[Moritani Terror](MORITANI_TERROR_RULES.md), +[Richese implementation plan](RICHESE_IMPLEMENTATION_PLAN.md), +[CHOAM remaining rules](CHOAM_REMAINING_RULES.md), and +[Ix prototype](IX_PROTOTYPE.md). Those records and later feature-specific +runtime documents govern over older inventory snapshots. When an earlier +source/readiness audit says that a component or action was absent, the later +dated runtime document overrides that historical statement only for the slice +it actually connects. + +Advanced Ixian Technology on a Richese cache or Black Market lot has a specific +combined-source gap: the rules do not assign the substituted card's seller, +sale income, or zero-bid destination. The development profile therefore does +not implement that exchange. A valid Richese cache or Black Market declaration +first creates a saved `ixRicheseTechnology` decision for Ixians, before the lot +opens, bidding begins, or Atreides receives an inspection. The only supported +answer is an explicit `decline: true` with the exact current event. It then +opens the originally declared lot without using Technology, moving either card, +or marking the once-per-turn power used. Technology remains available for a +later Richese or supported normal lot. The saved declaration binds the round, +turn, source, card custody, auction method and any direction or claim; public +views do not expose a concealed Black Market card through this pause. + +This continuation prevents the known combined-game deadlock while preserving +the unresolved exchange. It is not a ruling that Technology cannot affect a +Richese lot, and it does not establish substitute ownership, sale income or +zero-bid custody. See the [Richese auction contract](RICHESE_AUCTION_RULES.md). + +No Homeworld, Nexus, Leader Skill, Discovery, Tech Token, or Advanced +Stronghold Card state is admitted by this profile. Selecting expansion factions +does not imply any of those modules, and this checkpoint supplies no evidence +for their combinations. + +`tests/expansion-factions-prototype.test.ts` covers every nonempty expansion +selection in Basic and Advanced at all four AI profiles, two-through-six-player +rosters, private views, deck and component custody, JSON continuation, stale +actions, and dirty lobby rejection. It includes a direct 2/2/2 Ecaz allocation, +the Fremen–Ecaz–Advanced-Bene-Gesserit sequence, Moritani's final placement, +and rejection of missing or forged placement state. + +`tests/ecaz-setup-controls.test.tsx` checks the human allocation control and +bot path. `tests/expansion-factions-recovery.test.ts` exercises the mixed +six-faction setup through the migrated SQLite room API across reloads, plus +optimistic concurrency, unrelated room/session preservation, and repeated-start +rejection. The focused union reported 74 passing tests and typecheck passed; +the final checkpoint report records the required broad checks. Browser setup +and refresh restored the owned Ecaz allocation and private cards. This bounded setup evidence establishes a development path, not +complete games with every printed effect or public mode acceptance. + +## Integrated sample games and browser evidence + +Six deterministic samples used genuine faction setup, all four AI levels, +unchanged physical inventories and JSON continuations every 37 accepted actions. +No pieces were staged or phases skipped in these samples. + +| Profile | Accepted actions | JSON continuations | Result notes | +| --- | ---: | ---: | --- | +| CHOAM & Richese, Basic | 1,054 | 28 | Finished at turn ten with no rejected candidates. | +| CHOAM & Richese, Advanced | 225 | 6 | Finished at turn two with no rejected candidates. | +| Ecaz & Moritani, Basic | 91 | 2 | Finished at turn one after four guarded Ambassador candidates were rejected and legal alternatives were used. | +| Ecaz & Moritani, Advanced | 523 | 14 | Finished at turn five after 32 guarded arrival candidates were rejected and legal alternatives were used. | +| All three expansion sets, Basic | 476 | 12 | Finished at turn two with no rejected candidates. | +| All three expansion sets, Advanced | 614 | 16 | Stopped at turn three on an existing CHOAM movement/Ambassador arrival guard. | + +The first combined Advanced run stopped at action 70 on the old Ixian/Richese +special-lot guard. With the decline continuation, the same seed declined three +Richese lots and reached eleven ordinary Technology decisions before the separate +arrival stop. This is evidence of the connected auction path, not a completed +combined Advanced game. The current movement stop and rejected arrival candidates +remain integration work; public modes stay gated. + +An initial CHOAM Basic custody audit mistakenly counted the already sold current +normal-auction card both in its winner's hand and in its sale receipt. Correcting +that private harness alias accounting allowed the full sample to run. The first +failure logs were retained; production card ownership was not changed to fit the +harness. + +Browser room `WEBF27AU` used genuine six-expansion-faction setup. The human Ecaz +seat chose its private traitor, saw an incomplete four-force allocation rejected, +submitted two forces in each Imperial Basin sector, and refreshed to fourteen +reserves, twelve spice, its dealt Crysknife and unchanged traitor at opening Storm. +Ixian private dealing and Moritani's final placement completed in the same game. + +A separate fresh browser lobby `BMHNSASJ` was configured for Advanced by the local +QA helper before genuine initialization. Thirty-seven accepted bot-selected +setup and phase actions reached the Ixian cache-offer decision; no pieces were +staged and no phases skipped. Refresh restored the human Ixian choice, clicking +its explicit decline opened the unchanged Ornithopter Once Around lot, and another +refresh retained the legal bid controls, private Harvester and traitor. The saved +hand, spice, offered card, ordinary pool count and unused Technology all matched +the pending-offer snapshot. This developer configuration does not add a public +Advanced lobby selector. + +The outage recovery audit preserved all 597 opening rooms and their seat/recovery +records. A stale development-server module cache caused room requests to fail +although the home page responded. A fresh 598-room backup preceded a necessary +restart; all 598 rooms matched immediately afterward and the private QA seat +restored. No database reset or recurring maintenance automation was used. Final +room totals, required checks and source identity are recorded in the checkpoint +commit and private reports under +`/home/quintine/.local/share/dune/checkpoints/20260913-expansion-factions/`. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 7c8489a4..85ceae16 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,41 @@ # Dune implementation status +## 13 September 2026 — Expansion faction setup and saved Ixian auction decline + +The [faction development profile](EXPANSION_FACTIONS_PROTOTYPE.md) connects +all seven nonempty expansion selections to genuine Basic/Advanced setup and +existing faction controls, AI and saved continuation. Ecaz now allocates six +starting forces within Imperial Basin, keeping fourteen in reserve; Fremen, +Advanced Bene Gesserit and Moritani placement ordering is preserved with its +printed and inferred boundaries documented. The optional Ecaz Treachery variant +stays separate, ordinary decks contain 33/35/47 cards, and Richese keeps its +separate ten-card cache. Public starts remain gated. + +Combined play exposed an existing Ixian Technology/Richese special-lot deadlock. +A saved explicit per-lot decline now opens the original cache or Black Market lot +without using Technology, changing its card custody or revealing a concealed +card. Exact event, round, declaration and custody validation protects view, +normalization and actions, including Gift/Truthtrance interruptions. Competing +uses of the reserved offered card are rejected before creating responses. +The actual exchange still needs its recorded replacement-custody interpretation. + +Focused setup, controls, legal AI, private JSON/SQLite continuation and concurrent +write checks pass, with independent review of rules, custody and persistence. +Five of six genuine mixed-profile samples finish. Combined Advanced progresses +past three special-lot declines to turn three, then stops on an existing CHOAM +movement/Ambassador arrival guard. Guarded arrival candidates also occur in the +Ecaz samples. These are explicit remaining integration limits, not full expansion +acceptance. The detailed feature document retains exact sample metrics. + +The browser verified genuine Ecaz traitor/force setup and refresh, then a separate +Advanced development game's saved Ixian decline, unchanged offered card, legal +bidding and private hand restoration. After the reported outage, all 597 opening +games and original seat/recovery records matched the backup. A stale server module +cache required a restart after a fresh 598-room backup; every room matched before +QA resumed. Required broad checks, final preservation totals and source fingerprint +are recorded in the checkpoint commit and private report. No database reset, +recurring automation or publication was performed. + ## 13 September 2026 — Diplomat base-defense copying prototype [Diplomat](DIPLOMAT_DEFENSE.md) now optionally counts one committed Worthless as diff --git a/docs/RICHESE_AUCTION_RULES.md b/docs/RICHESE_AUCTION_RULES.md index 1156c68b..53285d0e 100644 --- a/docs/RICHESE_AUCTION_RULES.md +++ b/docs/RICHESE_AUCTION_RULES.md @@ -149,3 +149,35 @@ Root’s global run passed 1,106 rules and 81 recovery tests, production build, Desktop QA confirmed all ten readable enlarged guides and working direct catalog topic links. Room `63NJ822S` completed first-position declaration, cache offer, Once Around self-purchase for 3 and automatic payment, leaving 17 spice, one card and two normal lots. A Silent bid of 4 remained private; fresh browser tab 23 restored two-of-three submitted status and the viewer’s own bid of 4. A final synthetic zero submission produced the storm-order tie winner Harkonnen, paid 4 and awarded its bonus, with Richese at 24 spice. Root records the exact screenshots and synthetic/manual distinction in `docs/VISUAL_PLAYTEST.md`. Remaining boundaries are unchanged: cache-cancellation count, empty-cache arithmetic, seller-positive Black Market bidding, advanced Ixian Technology special-lot custody, remaining card effects and actual Juice of Sapho play. Full starts remain gated. The hand’s disabled-effect fix and pre-offer guard explanation are implemented. The final browser recheck in QA room63NJ822S version9 confirmed the printed category, disabled unsupported Play card control, visible reason and available inspector. This is bounded desktop/persistence evidence, not mobile QA, a full Richese game, every advanced interaction or durable hosted-worker certification. + + +## 13 September: saved per-lot Technology decline + +Genuine combined Advanced faction play exposed the previous pre-offer guard as +a deadlock when Ixians retained their optional Technology use. A validated Richese +cache or Black Market offer now creates a saved decision for Ixians before opening +that lot. The development continuation supports an explicit **decline for this lot**; +it does not implement a card exchange or adjudicate the pending seller, income or +unsold-card questions described above. + +The exact offered card stays in its original hand or cache. The pending receipt +binds the current round, turn, offer terms, owner, independent event and physical +custody. The public decision contains no card identity; the owner-facing control +explains the unfinished exchange and uses the matching event. Declining opens the +same lot once, preserves the normal pool count, and leaves `ixTechnologyTurn` +unchanged. Ixians can still use Technology on a later normal lot or separately +decline another Richese lot. All four AI profiles have this legal continuation. + +This opt-out is the ordinary exercise of an optional ability, not a publisher +prohibition on exchanging special lots. The pending offer is kept private until +the normal lot-opening point; this saved staging order is an implementation +boundary rather than a separate official timing clarification. Public starts and +full combined compliance remain gated. + +The focused engine and UI tests are +`tests/ix-richese-technology.test.ts`, +`tests/ix-richese-technology-controls.test.tsx`, and +`tests/ix-richese-technology-recovery.test.ts`. Final checkpoint verification is +recorded in the implementation log and source-bound private report. Historical +absence claims about ordinary Richese technology cards above are superseded by +their later runtime contracts, including [Juice of Sapho](JUICE_OF_SAPHO_RUNTIME.md). diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 77f4b03c..909bc22c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -6,6 +6,8 @@ Updated 13 September 2026. This is the authoritative navigation index for existi | Topic | Decision to preserve | Detailed authority and evidence | | --- | --- | --- | +| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) | +| Ixian Technology on Richese lots | Replacement-card ownership, income and unsold disposition remain unresolved. A saved explicit per-lot decline permits an unchanged offer to proceed and leaves Technology available later. This development continuation does not implement the exchange or settle its custody. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline) | | Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) | | Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) | | Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing; multiple-pile collection remains guarded. | [Source and connected prototype](SANDMASTER_MOVEMENT.md) | diff --git a/game/bots.ts b/game/bots.ts index 08a26241..79205a45 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -9,6 +9,7 @@ import { greatMakerBotActions } from './great-maker-options'; import { nexusChoamTradeBotActions } from './nexus-choam-trade-options'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options'; import { nexusMoritaniBotActions } from './nexus-moritani-options'; +import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup'; import { choamPowerAction, choamPowerBotPlay } from './choam-power-options'; import { nexusCardBotActions } from './nexus-card-options'; import { nexusTraitorBotActions } from './nexus-traitor-options'; @@ -1190,12 +1191,15 @@ function policyActions(g: GameView): Action[] { }, }, ]; + if (placingForces && me.faction === 'ecaz' && me.reserves === 20 && g.setupStage === 'forces' && g.setupPending.includes(me.id)) + return [{ type: 'ecazSetup', placements: quoteEcazStartingForces({ [ECAZ_START_LOCATIONS[0]]: ECAZ_START_FORCES }) }]; if ( placingForces && g.advanced && me.faction === 'beneGesserit' && !me.advisorSetup && me.reserves === 20 && + (!g.setupStage || g.setupPending.includes(me.id)) && g.players.every((p) => p.faction !== 'fremen' || p.reserves === 10) ) return destinations(g, true) @@ -1658,6 +1662,8 @@ function policyActions(g: GameView): Action[] { const card = cards.find((c) => c.kind === 'worthless') ?? cards[0]; return card ? [{ type: 'decision', card: card.id }] : []; } + if (d.kind === 'ixRicheseTechnology') + return [{ type: 'decision', event: d.event, decline: true }]; if (d.kind === 'richeseBlackMarket') return [ { type: 'decision', event: g.richeseBidding!.event, decline: true }, diff --git a/game/cards.ts b/game/cards.ts index 11584728..bca2f93e 100644 --- a/game/cards.ts +++ b/game/cards.ts @@ -62,8 +62,10 @@ export function choamDeck(): Card[] { } /** Deck assembly does not open the separate faction/module release gates. */ export function treacheryDeck(expansions: readonly string[] = []): Card[] { - if (expansions.some((id) => id !== 'ix' && id !== 'choam')) + if (expansions.some((id) => !['ix', 'choam', 'ecaz'].includes(id))) throw new Error('This expansion treachery deck is not implemented yet.'); + // Ecaz/Moritani factions are independent of the optional three-card variant. + // Selecting those factions does not activate its still-unimplemented effects. return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : expansions.includes('choam') ? choamDeck() : [])]; } export type Leader = { diff --git a/game/ecaz-setup.ts b/game/ecaz-setup.ts new file mode 100644 index 00000000..dd5b0013 --- /dev/null +++ b/game/ecaz-setup.ts @@ -0,0 +1,44 @@ +import { location, territory } from './board'; + +export const ECAZ_START_FORCES = 6; +export const ECAZ_START_LOCATIONS = Object.freeze( + territory('imperial_basin').sectors.map((sector) => + location('imperial_basin', sector), + ), +); + +/** The printed territory and total constrain the allocation; return detached positive groups. */ +export function quoteEcazStartingForces( + placements: unknown, +): Record { + if ( + !placements || + typeof placements !== 'object' || + Array.isArray(placements) + ) + throw new Error( + 'Choose the Imperial Basin sectors for six Ecaz starting forces.', + ); + const entries = Object.entries(placements); + if ( + !entries.every( + ([key, amount]) => + ECAZ_START_LOCATIONS.includes(key) && + typeof amount === 'number' && + Number.isSafeInteger(amount) && + amount >= 0 && + amount <= ECAZ_START_FORCES, + ) + ) + throw new Error( + 'Ecaz starting forces require whole nonnegative amounts in explicit Imperial Basin sectors.', + ); + if ( + entries.reduce((total, [, amount]) => total + amount, 0) !== + ECAZ_START_FORCES + ) + throw new Error( + 'Place exactly six Ecaz starting forces in Imperial Basin.', + ); + return Object.fromEntries(entries.filter(([, amount]) => amount > 0)); +} diff --git a/game/engine.ts b/game/engine.ts index f8b0c817..fee3f1ba 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -15,6 +15,7 @@ import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipmen import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement'; import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker'; import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense'; +import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup'; import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions'; import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm'; import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight'; @@ -710,6 +711,7 @@ export type Decision = | { kind: 'ixSetup'; player: string } | { kind: 'ixAuction'; player: string } | { kind: 'ixTechnology'; player: string } + | { kind: 'ixRicheseTechnology'; player: string; event: string; source: 'cache' | 'blackMarket' } | { kind: 'ixAllyCard'; player: string } | { kind: 'mobileStronghold'; player: string; placement: boolean } | { @@ -1124,6 +1126,22 @@ export type Game = { richesePeekKnown?: boolean; richeseOfferedCard?: Card | null; richeseClaim?: string | null; + /** Private pre-lot offer. The public Ixian choice contains no Black Market card identity. */ + pendingIxRicheseTechnology?: { + event: string; + player: string; + owner: string; + round: string; + turn: number; + source: 'cache' | 'blackMarket'; + card: Card; + method: RicheseAuction['method']; + direction: 'clockwise' | 'counterclockwise' | null; + claim: string | null; + frame: string; + signature: string; + }; + ixRicheseTechnologyEvent?: string; currentAuctionSale?: { winner: string; amount: number; @@ -2870,6 +2888,8 @@ function savedTransferResponses(g: Game) { ]; } function transferCardBlock(g: Game, owner: Player, card: Card) { + if (g.pendingIxRicheseTechnology?.owner === owner.id && g.pendingIxRicheseTechnology.card.id === card.id) + return 'This card is reserved for the pending Richese lot and Ixian choice.'; if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id)) return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.'; @@ -3322,6 +3342,8 @@ function draw(g: Game) { return g.deck.shift(); } function discard(g: Game, p: Player, id: string, role?: Omit) { + requireRule(g.pendingIxRicheseTechnology?.card.id !== id, + 'The card reserved for the pending Richese lot cannot be discarded.'); requireRule( !g.pendingTreacheryDiscard, 'Finish the committed discard continuation before another discard.', @@ -5290,6 +5312,8 @@ function setupPending(g: Game): string[] { .map((p) => p.id); const fremen = byFaction(g, 'fremen'); if (fremen?.reserves === 20) return [fremen.id]; + const ecaz = byFaction(g, 'ecaz'); + if (ecaz?.reserves === 20) return [ecaz.id]; const bg = byFaction(g, 'beneGesserit'); return g.advanced && bg && !bg.advisorSetup ? [bg.id] : []; } @@ -5371,6 +5395,33 @@ export function initializeIxGameForAudit(state: Game): Game { 'The Ix prototype requires exactly the Ixians & Tleilaxu expansion.'); return initializeSetupGameForAudit(state, false, false, true); } +/** Genuine faction-only development setup. Optional variants and public starts remain gated. */ +export function initializeFactionExpansionsGameForAudit(state: Game): Game { + requireRule(typeof state.advanced === 'boolean', 'Choose Basic or Advanced rules for the faction prototype.'); + requireRule(Array.isArray(state.expansions) && state.expansions.length > 0 && + state.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) && + new Set(state.expansions).size === state.expansions.length, + 'The faction prototype needs a nonempty selection of distinct known expansions.'); + requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills && + !state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker && + !state.techTokens && !state.strongholdCards, + 'The faction prototype excludes optional modules, including Leader Skills and Discoveries.'); + requireRule(state.richeseCache === undefined && state.richeseRemoved === undefined && + state.ecazAmbassadors === undefined && state.moritaniTerror === undefined && state.dukeVidal === undefined && + state.mobileStronghold === undefined && state.ixSetupCards === undefined && + state.players.every(p => { + const original = leaders(p.faction); + return p.noField === undefined && p.noFieldEvent === undefined && p.faceDancers === undefined && + p.revived === 0 && p.freeForcesRevived === 0 && !p.leaderRevived && p.revivalCycle === 0 && + p.battleLosses === 0 && p.ally === null && p.bribes === 0 && + p.leaders.length === original.length && original.every(expected => { + const actual = p.leaders.find(l => l.id === expected.id); + return actual && Object.keys(actual).length === Object.keys(expected).length && + Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value); + }); + }), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.'); + return initializeSetupGameForAudit(state, false, false, false, false, false, false, true); +} /** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */ export function initializeDiscoveryGameForAudit(state: Game): Game { requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker, @@ -5384,7 +5435,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { g.leaderSkills = createLeaderSkills(random); return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam'); } -function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false): Game { +function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false): Game { nexusCardsIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); @@ -5406,8 +5457,9 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f 'The audit initializer requires two through six distinct ready players and an existing host.', ); requireRule( - (homeworlds || nexus || ix || choam || g.expansions.length === 0) && - (choam || !g.expansions.includes('choam')) && + (homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) && + (choam || factions || !g.expansions.includes('choam')) && + (factions || !g.expansions.includes('ecaz')) && (leaderSkills || !g.leaderSkills) && (discovery || !g.discoveryEnabled) && (nexus || !g.nexusCards) && @@ -5416,10 +5468,12 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f (homeworlds || !g.homeworlds) && g.players.every((p) => FACTIONS.some( - (f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam')), + (f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam') || (factions && g.expansions.includes(f.expansion))), ), ), - choam + factions + ? 'The faction prototype supports base factions and the selected expansion factions without optional modules.' + : choam ? 'The Leader Skills prototype supports base, CHOAM and Richese factions without other expansions or optional modules.' : ix ? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.' @@ -5451,7 +5505,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f !p.prediction && !p.advisorSetup && (!p.elites || - ((homeworlds || nexus || ix) && + ((homeworlds || nexus || ix || factions) && p.faction === 'ixians' && p.elites.reserves === 7 && p.elites.tanks === 0 && @@ -5470,6 +5524,10 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f initializeSetup(g); return normalizeAutomaticGame(g); } +function ecazStartingForcesComplete(p: Player): boolean { + if (p.reserves !== 20 - ECAZ_START_FORCES) return false; + try { quoteEcazStartingForces(p.forces); return true; } catch { return false; } +} function otherSetupComplete(g: Game) { return ( !g.setupStage && @@ -5478,6 +5536,7 @@ function otherSetupComplete(g: Game) { (p) => (p.faction === 'tleilaxu' || p.traitors.length > 0) && (p.faction !== 'fremen' || p.reserves === 10) && + (p.faction !== 'ecaz' || ecazStartingForcesComplete(p)) && (p.faction !== 'beneGesserit' || (p.prediction && (!g.advanced || p.advisorSetup))), ) @@ -6528,10 +6587,47 @@ function richeseOfferBlock(g: Game): string | null { ? 'Ixian Technology on Richese lots is awaiting a ruling on replacement-card custody.' : null; } +function ixRicheseTechnologyFrame(g: Game): string { + return JSON.stringify({ round: g.richeseBidding, order: g.order, positions: normalizedPlayerPositions(g) }); +} +function ixRicheseTechnologySignature(pending: NonNullable): string { + return JSON.stringify({ ...pending, signature: undefined }); +} +function ixRicheseTechnologyIntegrity(g: Game) { + const pending = g.pendingIxRicheseTechnology; + const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'ixRicheseTechnology'); + if (pending === undefined && g.ixRicheseTechnologyEvent === undefined && !decisions.length) return; + const round = g.richeseBidding, ixians = byFaction(g, 'ixians'), owner = byFaction(g, 'richese'); + requireRule(pending && g.status === 'playing' && g.phase === 3 && g.advanced && + ixians && g.ixTechnologyTurn !== g.turn && owner && round && + pending.player === ixians.id && pending.owner === owner.id && pending.round === round.event && + pending.turn === g.turn && round.turn === g.turn && round.owner === owner.id && + typeof pending.event === 'string' && pending.event.length > 0 && pending.event === g.ixRicheseTechnologyEvent && + ['cache', 'blackMarket'].includes(pending.source) && + round.stage === (pending.source === 'cache' ? 'cacheOffer' : 'blackMarketOffer') && + !g.auction && !g.richeseAuction && !g.ixAuction && !g.currentAuctionSale && !g.richeseOfferedCard && + pending.frame === ixRicheseTechnologyFrame(g) && pending.signature === ixRicheseTechnologySignature(pending), + 'The saved Ixian Richese-lot choice lost or changed its original offer, round or event.'); + requireRule(['normal', 'onceAround', 'silent'].includes(pending.method) && + (pending.source !== 'cache' || pending.method !== 'normal') && + (pending.direction === null || ['clockwise', 'counterclockwise'].includes(pending.direction)) && + (pending.method !== 'onceAround' || pending.direction !== null) && + (pending.source === 'cache' ? pending.claim === null : pending.claim === null || + typeof pending.claim === 'string' && pending.claim === pending.claim.trim() && pending.claim.length <= 300), + 'The saved Ixian Richese-lot choice has invalid auction terms.'); + const held = (pending.source === 'cache' ? g.richeseCache : owner.hand)?.filter(c => c.id === pending.card?.id); + requireRule(held?.length === 1 && JSON.stringify(held[0]) === JSON.stringify(pending.card) && + physicalTreacheryCards(g).filter(c => c.id === pending.card.id).length === 1, + 'The pending Richese offer must retain its exact unique card in the original custody.'); + requireRule(decisions.length === 1 && decisions[0].player === pending.player && decisions[0].event === pending.event && + decisions[0].source === pending.source && Object.keys(decisions[0]).every(key => ['kind', 'player', 'event', 'source'].includes(key)), + 'The saved Ixian Richese-lot choice lost or duplicated its original decision.'); +} function beginRicheseLot( g: Game, source: 'cache' | 'blackMarket', action: Action, + technologyDeclined = false, ) { const round = g.richeseBidding!, owner = getPlayer(g, round.owner); @@ -6558,8 +6654,25 @@ function beginRicheseLot( action.method !== 'onceAround' || action.direction !== undefined, 'Choose a physical bidding direction.', ); - const blocked = richeseOfferBlock(g); - requireRule(!blocked, blocked ?? 'This Richese lot is unavailable.'); + if (richeseOfferBlock(g) && !technologyDeclined) { + requireRule(!g.pendingIxRicheseTechnology && g.ixRicheseTechnologyEvent === undefined, + 'Finish the existing Ixian choice before declaring another Richese offer.'); + const event = crypto.randomUUID(), player = byFaction(g, 'ixians')!.id; + const pending: NonNullable = { + event, player, owner: owner.id, round: round.event, turn: g.turn, source, card: structuredClone(card), + method: action.method as RicheseAuction['method'], + direction: (action.direction as 'clockwise' | 'counterclockwise' | undefined) ?? null, + claim: source === 'blackMarket' && typeof action.claim === 'string' ? action.claim.trim().slice(0, 300) : null, + frame: ixRicheseTechnologyFrame(g), signature: '', + }; + pending.signature = ixRicheseTechnologySignature(pending); + g.pendingIxRicheseTechnology = pending; + g.ixRicheseTechnologyEvent = event; + g.decision = { kind: 'ixRicheseTechnology', player, event, source }; + g.active = player; + log(g, `${getPlayer(g, player).name} must choose whether to continue without exchanging this Richese ${source === 'cache' ? 'cache' : 'Black Market'} lot. The exchange is not yet implemented; Technology remains available for later lots.`); + return; + } const positions = normalizedPlayerPositions(g); let order = [...g.order]; if (action.method === 'onceAround') { @@ -7172,6 +7285,8 @@ function nextAuction(g: Game) { } /** Physical custody only: promise feasibility remains conservatively checked at commit. */ function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null { + if (g.pendingIxRicheseTechnology?.card.id === card.id) + return 'This card is reserved for the pending Richese lot and Ixian choice.'; if (g.battle?.lateDefense?.[p.id] === card.id) return 'This Portable Snooper is already played and reserved for battle cleanup.'; if (giftReserved(g, p.id, card.id)) @@ -18533,6 +18648,8 @@ export function prepareSpecialKaramaIntent( 'Resolve the previous purchase income first.', ); const acquire = stringField(action.acquire); + requireRule(g.pendingIxRicheseTechnology?.card.id !== acquire, + 'The card reserved for the pending Richese lot cannot be acquired by another effect.'); const chosen = g.richeseCache?.find((c) => c.id === acquire); requireRule( chosen && richeseCardDefinition(chosen), @@ -18724,6 +18841,11 @@ export function prepareSpecialKaramaIntent( g.pendingRicheseGift?.intent.owner !== target.id, 'Resolve the reserved Richese gift before randomly exchanging that hand.', ); + requireRule( + g.pendingIxRicheseTechnology?.source !== 'blackMarket' || + g.pendingIxRicheseTechnology.owner !== target.id, + 'Resolve the pending Black Market Ixian choice before randomly exchanging that hand.', + ); const count = integer( action.amount, 1, @@ -19117,6 +19239,7 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); @@ -19283,6 +19406,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); + ixRicheseTechnologyIntegrity(g); return g; } function finishActionContinuations(g: Game) { @@ -19377,6 +19501,7 @@ function settleAutomaticContinuations(g: Game) { } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { + ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); @@ -19417,6 +19542,7 @@ export function normalizeAutomaticGame(state: Game): Game { homeworldSubstitutionIntegrity(g); homeworldDefenseIntegrity(g); homeworldShipmentIntegrity(g); + ixRicheseTechnologyIntegrity(g); return g; } @@ -19805,6 +19931,19 @@ function applyActionInner( ); g.decision = null; requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.'); + if (decision.kind === 'ixRicheseTechnology') { + const pending = g.pendingIxRicheseTechnology!; + requireRule(action.event === pending.event && action.decline === true && + Object.keys(action).every(key => ['type', 'event', 'decline'].includes(key)), + 'Ixian exchange custody for Richese lots is not implemented. Explicitly continue without this exchange.'); + delete g.pendingIxRicheseTechnology; + delete g.ixRicheseTechnologyEvent; + log(g, `${p.name} continued without exchanging this Richese lot; Technology remains available for later lots.`); + beginRicheseLot(g, pending.source, { type: 'decision', card: pending.card.id, method: pending.method, + ...(pending.direction !== null ? { direction: pending.direction } : {}), + ...(pending.claim !== null ? { claim: pending.claim } : {}) }, true); + return g; + } if (decision.kind === 'homeworldRevivalDeployment') { decideHomeworldRevivalReturn(g, p, action); return g; @@ -21080,7 +21219,7 @@ function applyActionInner( ? ['traitor'] : g.setupStage === 'leaderSkills' ? ['leaderSkill'] - : ['fremenSetup', 'advisorSetup']; + : ['fremenSetup', 'ecazSetup', 'advisorSetup']; requireRule( expected.includes(t) && setupPending(g).includes(id), `Wait for the current setup step: ${g.setupStage}.`, @@ -21130,6 +21269,18 @@ function applyActionInner( ); p.traitors = [String(action.leader)]; p.traitorChoices = []; + } else if (t === 'ecazSetup') { + requireRule(g.setupStage === 'forces' && p.faction === 'ecaz' && p.reserves === 20 && + Object.keys(p.forces).length === 0 && Object.keys(action).every(key => ['type', 'placements'].includes(key)), + 'Place Ecaz starting forces once during the current force setup.'); + const placements = nexusRule(() => quoteEcazStartingForces(action.placements)); + for (const [key, amount] of Object.entries(placements)) { + const loc = splitLocation(key); + place(p, loc.territory, loc.sector, amount); + } + p.reserves -= ECAZ_START_FORCES; + log(g, `${p.name} placed six starting forces in Imperial Basin and retained fourteen in reserves.`, + { faction: p.faction, name: 'Starting forces' }); } else if (t === 'fremenSetup') { requireRule( p.faction === 'fremen' && p.reserves === 20, @@ -22823,6 +22974,7 @@ function applyActionInner( throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string) { + ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); discoveryFlightIntegrity(state); discoveryIntegrity(state); @@ -23636,6 +23788,13 @@ export function viewGame(state: Game, id: string) { } : null, richeseSpecialKarama: richeseSpecialKaramaView(g, me), + ixRicheseTechnology: g.pendingIxRicheseTechnology ? { + event: g.pendingIxRicheseTechnology.event, + player: g.pendingIxRicheseTechnology.player, + owner: g.pendingIxRicheseTechnology.owner, + source: g.pendingIxRicheseTechnology.source, + exchangeBlocked: 'Ixian exchange custody for Richese lots is not implemented. You can explicitly continue without this exchange; Technology remains available for later lots.', + } : null, richeseBidding: g.richeseBidding?.turn === g.turn ? { @@ -23644,7 +23803,7 @@ export function viewGame(state: Game, id: string) { stage: g.richeseBidding.stage, position: g.richeseBidding.position, normalCount: g.richeseBidding.normalCount, - offerBlocked: richeseOfferBlock(g), + offerBlocked: null, cache: g.richeseBidding.owner === id ? (g.richeseCache ?? []) : null, } diff --git a/game/reference.ts b/game/reference.ts index ae4897ae..427be491 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3591,6 +3591,30 @@ export const RULE_TOPICS: RuleTopic[] = [ { area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] }, ], }, + { + id: 'expansion-faction-games', + title: 'Expansion faction development games', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Genuine setup connects selected expansion factions to development tables. Complete faction rules and combined-game acceptance remain unfinished.', + steps: [ + 'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.', + 'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.', + 'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.', + 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: Recruits, Reinforcements and Harass & Withdraw form a separate unfinished optional variant.', + 'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.', + 'Remaining abilities and combined timing can still encounter explicit development guards. Setup and saved-continuation evidence does not certify complete expansion play.', + ], + related: ['setup', 'ix-modules', 'choam-modules', 'ecaz-modules', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Genuine setup for seven expansion selections reuses existing faction effects and corrects Ecaz starting placement. Saved per-lot Ixian decline resumes Richese bidding without changing card custody or consuming Technology. Optional variants and unfinished interactions remain separate.', evidence: ['game/engine.ts', 'game/cards.ts', 'game/ecaz-setup.ts', 'tools/prototype-room.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Existing faction choices and private hands are reused. Ecaz has an owned sector-allocation control and setup waiting messages; Ixians have an explicit per-lot decline control. Public starts and full acceptance remain gated.', evidence: ['components/ecaz-setup.tsx', 'components/ix-richese-technology.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four profiles use genuine setup choices and the shared Ecaz allocation quote, and can decline this Richese lot without using Technology. Combined gameplay strategy, guarded interactions and full-game reliability remain unfinished.', evidence: ['game/bots.ts', 'game/ecaz-setup.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'The faction profile records ordinary deck composition, independent variants, Ecaz territory allocation and deterministic setup-order interpretations, with unfinished play explicit.', evidence: ['docs/EXPANSION_FACTIONS_PROTOTYPE.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained.', evidence: ['tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] }, + ], + }, ...[ [ 'ix-modules', diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index 1d53480c..d6642531 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -38,6 +38,7 @@ const DECISIONS = { ixSetup: true, ixAuction: true, ixTechnology: true, + ixRicheseTechnology: true, ixAllyCard: true, mobileStronghold: true, ixSubstitution: true, diff --git a/tests/ecaz-cards.test.ts b/tests/ecaz-cards.test.ts index abe09917..f4067c1d 100644 --- a/tests/ecaz-cards.test.ts +++ b/tests/ecaz-cards.test.ts @@ -37,13 +37,34 @@ void test('the verified Ecaz variant has one physical special of each identity a assert.equal(new Set(combined.map((card) => card.id)).size, 50); assert.equal(treacheryDeck().length, 33); assert.equal(treacheryDeck(['ix']).length, 47); - for (const unsupported of ['ecaz', ECAZ_TREACHERY_VARIANT.id]) + assert.equal(treacheryDeck(['ecaz']).length, 33); + for (const unsupported of [ECAZ_TREACHERY_VARIANT.id, 'unknown']) assert.throws(() => treacheryDeck([unsupported]), /not implemented/); assert.equal(ECAZ_TREACHERY_VARIANT.independentOfFactions, true); assert.equal(ECAZ_TREACHERY_VARIANT.independentOfOtherVariants, true); assert.equal(ECAZ_TREACHERY_VARIANT.activation, 'not-implemented'); }); +void test('Ecaz faction selection preserves each ordinary deck and never includes its separate unfinished variant', () => { + for (const other of [[], ['ix'], ['choam'], ['ix', 'choam']]) { + const cards = treacheryDeck([...other, 'ecaz']); + assert.deepEqual(cards, treacheryDeck(other)); + assert.equal(new Set(cards.map((card) => card.id)).size, cards.length); + assert.equal( + cards.some((card) => card.id.startsWith('ecaz-')), + false, + ); + assert.equal( + cards.length, + other.includes('ix') ? 47 : other.includes('choam') ? 35 : 33, + ); + } + assert.throws( + () => treacheryDeck(['ecaz', ECAZ_TREACHERY_VARIANT.id]), + /not implemented/, + ); +}); + void test('factory instances and their mutable cards are independent of canonical definitions', () => { const first = ecazTreacheryCards(); first[0].name = 'Changed'; diff --git a/tests/ecaz-setup-controls.test.tsx b/tests/ecaz-setup-controls.test.tsx new file mode 100644 index 00000000..b9fe34b3 --- /dev/null +++ b/tests/ecaz-setup-controls.test.tsx @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { EcazSetup } from '../components/ecaz-setup'; +import { + applyAction, + createGame, + initializeFactionExpansionsGameForAudit, + joinGame, + newPlayer, + viewGame, + type Game, +} from '../game/engine'; +import { ECAZ_START_LOCATIONS } from '../game/ecaz-setup'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; + +function pending(advanced = false) { + let game = createGame('ECAZSETU', newPlayer('e', 'Ecaz', 'ecaz'), advanced, [ + 'ecaz', + ]); + joinGame(game, newPlayer('g', 'Guild', 'guild')); + for (const player of game.players) + game = applyAction(game, player.id, { type: 'ready' }); + game = initializeFactionExpansionsGameForAudit(game); + while (game.setupStage === 'traitors') { + const player = game.players.find((p) => p.traitorChoices.length)!; + game = applyAction(game, player.id, { + type: 'traitor', + leader: player.traitorChoices[0], + }); + } + assert.equal(game.setupStage, 'forces'); + assert.deepEqual( + game.players.map((p) => p.hand.length), + [0, 0], + ); + assert.deepEqual(game.setupStage && viewGame(game, 'e').setupPending, ['e']); + return game; +} + +function markup(game: Game, viewer = 'e', busy = false) { + return renderToStaticMarkup( + createElement(EcazSetup, { game: viewGame(game, viewer), act() {}, busy }), + ); +} + +void test('Ecaz setup presents three sector allocations only to its pending owner, before cards are dealt', () => { + for (const advanced of [false, true]) { + const game = pending(advanced); + const html = markup(game); + assert.match(html, /Ecaz starting forces/); + assert.match(html, /fourteen forces remain in reserves/); + for (const location of ECAZ_START_LOCATIONS) + assert.ok(html.includes(`ecaz-start-${location}`)); + assert.match(html, /6 \/ 6 forces assigned/); + assert.match(html, /Place Ecaz starting forces/); + assert.equal(markup(game, 'g'), ''); + assert.equal( + ( + markup(game, 'e', true).match( + /<(?:input|button)\b[^>]*\sdisabled=""/g, + ) ?? [] + ).length, + 4, + ); + const placed = applyAction(JSON.parse(JSON.stringify(game)), 'e', { + type: 'ecazSetup', + placements: Object.fromEntries( + ECAZ_START_LOCATIONS.map((location) => [location, 2]), + ), + }); + assert.equal(markup(placed), ''); + assert.equal(placed.players[0].reserves, 14); + assert.deepEqual( + placed.players.map((p) => p.hand.length), + [1, 1], + ); + } +}); + +void test('every AI profile supplies a legal Ecaz allocation and other seats wait for placement', () => { + for (const difficulty of DIFFICULTIES) { + const game = pending(true); + const view = viewGame(game, 'e'); + view.players[0].bot = difficulty; + const actions = botActions(view); + assert.equal(actions.length, 1); + assert.equal(actions[0].type, 'ecazSetup'); + const result = applyAction(game, 'e', actions[0]); + assert.equal(result.players[0].reserves, 14); + assert.equal( + Object.values(result.players[0].forces).reduce( + (sum, count) => sum + count, + 0, + ), + 6, + ); + const other = viewGame(game, 'g'); + other.players[1].bot = difficulty; + assert.deepEqual(botActions(other), []); + } +}); diff --git a/tests/expansion-factions-prototype.test.ts b/tests/expansion-factions-prototype.test.ts new file mode 100644 index 00000000..435b7b96 --- /dev/null +++ b/tests/expansion-factions-prototype.test.ts @@ -0,0 +1,594 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAction, + createGame, + initializeFactionExpansionsGameForAudit, + initializeHomeworldGameForAudit, + initializeNexusGameForAudit, + joinGame, + newPlayer, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; +import { FACTIONS, faction, type FactionId } from '../game/catalog'; +import { CHOAM_AUDITOR_ID, spiceDeck, treacheryDeck } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { traitorDeck } from '../game/traitors'; +import { MOBILE_LOCATION } from '../game/board'; +import { + ECAZ_START_FORCES, + ECAZ_START_LOCATIONS, + quoteEcazStartingForces, +} from '../game/ecaz-setup'; + +const combinations = [ + ['ix'], + ['choam'], + ['ecaz'], + ['ix', 'choam'], + ['ix', 'ecaz'], + ['choam', 'ecaz'], + ['ix', 'choam', 'ecaz'], +]; +const sum = (forces: Record) => + Object.values(forces).reduce((a, b) => a + b, 0); +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +function rosterFor(expansions: string[]): FactionId[] { + const selected = FACTIONS.filter((f) => expansions.includes(f.expansion)).map( + (f) => f.id, + ); + return [ + ...selected, + ...( + ['fremen', 'beneGesserit', 'atreides', 'harkonnen'] as FactionId[] + ).slice(0, 6 - selected.length), + ]; +} +function lobby( + expansions = ['choam'], + advanced = false, + roster = rosterFor(expansions), +): Game { + let g = createGame( + 'FACTIONS', + newPlayer(roster[0], roster[0], roster[0]), + advanced, + expansions, + ); + for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id)); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + return g; +} +function privateViews(g: Game) { + const saved = reload(g); + for (const p of g.players) { + const view = viewGame(g, p.id); + assert.deepEqual(viewGame(saved, p.id), view); + assert.equal('deck' in view, false); + assert.equal('traitorReserve' in view, false); + for (const other of view.players.filter((other) => other.id !== p.id)) { + assert.equal(other.hand, undefined); + assert.equal(other.traitors, undefined); + assert.equal(other.traitorChoices, undefined); + assert.equal(other.faceDancers, undefined); + assert.equal(other.spice, undefined); + } + if (g.ixSetupCards && p.faction !== 'ixians') + assert.equal(view.ixTechnology, null); + if (g.players.some((other) => other.noField)) { + assert.equal(!!view.richeseNoField?.private, p.faction === 'richese'); + if (p.faction !== 'richese') + for (const token of g.players.find( + (other) => other.faction === 'richese', + )!.noField!.tokens) + assert.equal(JSON.stringify(view).includes(token.id), false); + } + if (g.moritaniTerror) { + assert.equal( + view.moritaniTerror!.tokens.length, + p.faction === 'moritani' ? 6 : 0, + ); + if (p.faction === 'moritani') + assert.ok( + view.moritaniTerror!.tokens.every((token) => 'kind' in token), + ); + } + } + return saved; +} +function inventory(g: Game) { + const expected = [ + ...treacheryDeck(g.expansions), + ...(g.players.some((p) => p.faction === 'richese') ? richeseCards() : []), + ]; + const cards = [ + ...g.deck, + ...g.discard, + ...g.players.flatMap((p) => p.hand), + ...(g.ixSetupCards ?? []), + ...(g.richeseCache ?? []), + ]; + assert.deepEqual( + cards.map((c) => c.id).sort(), + expected.map((c) => c.id).sort(), + ); + assert.equal(new Set(cards.map((c) => c.id)).size, cards.length); + assert.ok(cards.every((c) => !c.id.startsWith('ecaz-'))); + assert.equal( + g.spiceDeck.length + g.spiceDiscard.flat().length, + spiceDeck(g.expansions.includes('ix')).length, + ); + for (const p of g.players) + assert.equal(p.reserves + p.tanks + sum(p.forces), 20, p.id); + if (g.setupStage !== 'prediction') { + const traitors = [ + ...(g.traitorReserve ?? []), + ...g.players.flatMap((p) => [ + ...p.traitors, + ...p.traitorChoices, + ...(p.faceDancers ?? []).map((c) => c.leader), + ]), + ]; + assert.deepEqual( + traitors.slice().sort(), + traitorDeck(g.players, g.expansions.includes('ix')).sort(), + ); + assert.equal(new Set(traitors).size, traitors.length); + } +} +function setup(state: Game, difficulty: Difficulty) { + let g = state; + const history: string[] = []; + for (let step = 0; g.status === 'setup' && step < 40; step++) { + inventory(g); + g = privateViews(g); + let next: Game | undefined; + for (const p of g.players) { + const view = viewGame(g, p.id); + view.players.find((player) => player.id === p.id)!.bot = difficulty; + const before = structuredClone(view); + const actions = botActions(view); + assert.deepEqual(view, before); + for (const action of actions) { + const candidate = applyAction(g, p.id, action); + next ??= candidate; + } + if (next) { + const action = actions[0]; + history.push( + action.type === 'decision' ? g.decision!.kind : action.type, + ); + if (g.decision?.kind === 'moritaniSetup') { + assert.equal(g.setupStage, undefined); + assert.ok( + g.players.every( + (player) => + player.hand.length === (player.faction === 'harkonnen' ? 2 : 1), + ), + ); + assert.ok( + g.players.every( + (player) => player.faction !== 'ecaz' || player.reserves === 14, + ), + ); + } + break; + } + } + assert.ok( + next, + `${g.expansions.join(',')}/${difficulty}: stalled ${g.setupStage}/${g.decision?.kind}/${viewGame(g, g.host).setupPending.join(',')}`, + ); + g = next; + } + assert.equal(g.status, 'playing'); + assert.equal(g.turn, 1); + assert.equal(g.phase, 0); + inventory(g); + privateViews(g); + return { game: g, history }; +} +function reject(g: Game, operation = initializeFactionExpansionsGameForAudit) { + const before = structuredClone(g); + assert.throws(() => operation(g)); + assert.deepEqual(g, before); +} +function rejectAction(g: Game, player: string, action: Action) { + const before = structuredClone(g); + assert.throws(() => applyAction(g, player, action)); + assert.deepEqual(g, before); +} + +for (const expansions of combinations) + void test(`genuine ${expansions.join('+')} faction setup supports Basic and Advanced with every AI profile and private JSON continuation`, () => { + for (const advanced of [false, true]) + for (const difficulty of DIFFICULTIES) { + const initial = lobby(expansions, advanced), + before = structuredClone(initial); + const started = initializeFactionExpansionsGameForAudit(initial); + assert.deepEqual(initial, before); + assert.equal(started.leaderSkills, undefined); + assert.equal( + started.deck.length, + expansions.includes('ix') + ? 47 + : expansions.includes('choam') + ? 35 + : 33, + ); + const { game: g, history } = setup(started, difficulty); + assert.deepEqual(g.expansions, expansions); + for (const p of g.players) { + assert.equal(p.spice, faction(p.faction).spice); + assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1); + } + const ecaz = g.players.find((p) => p.faction === 'ecaz'); + if (ecaz) { + assert.equal(ecaz.reserves, 14); + assert.equal(sum(ecaz.forces), 6); + assert.ok( + Object.keys(ecaz.forces).every((key) => + ECAZ_START_LOCATIONS.includes(key), + ), + ); + assert.equal(g.ecazAmbassadors!.tokens.length, 11); + assert.equal( + g.ecazAmbassadors!.tokens.filter((t) => t.zone === 'supply').length, + 6, + ); + assert.equal(g.ecazAmbassadors!.cohort.length, 5); + if (g.players.some((p) => p.faction === 'fremen')) + assert.ok( + history.indexOf('fremenSetup') < history.indexOf('ecazSetup'), + ); + if (advanced && g.players.some((p) => p.faction === 'beneGesserit')) + assert.ok( + history.indexOf('ecazSetup') < history.indexOf('advisorSetup'), + ); + } + const moritani = g.players.find((p) => p.faction === 'moritani'); + if (moritani) { + assert.equal(moritani.reserves, 14); + assert.equal(sum(moritani.forces), 6); + assert.equal(g.moritaniTerror!.tokens.length, 6); + assert.equal(history.at(-1), 'moritaniSetup'); + assert.ok(g.dukeVidal); + assert.ok( + g.players.every( + (p) => !p.leaders.some((l) => l.id === g.dukeVidal!.leader.id), + ), + ); + } + const richese = g.players.find((p) => p.faction === 'richese'); + if (richese) { + assert.equal(g.richeseCache!.length, 10); + assert.deepEqual( + richese.noField!.tokens.map((t) => t.value).sort((a, b) => a - b), + [0, 3, 5], + ); + assert.equal(richese.reserves, 20); + } + const choam = g.players.find((p) => p.faction === 'choam'); + if (choam) + assert.equal( + choam.leaders.some((l) => l.id === CHOAM_AUDITOR_ID), + advanced, + ); + const ix = g.players.find((p) => p.faction === 'ixians'); + if (ix) { + assert.deepEqual(ix.forces, { [MOBILE_LOCATION]: 6 }); + assert.deepEqual(ix.elites!.forces, { [MOBILE_LOCATION]: 3 }); + assert.equal(ix.elites!.reserves, 4); + assert.equal(g.mobileStronghold!.location, null); + } + const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu'); + if (tleilaxu) assert.equal(tleilaxu.faceDancers!.length, 3); + reject(g); + } + }); + +void test('faction prototype handles two through six seats and preserves the public start gates', () => { + const roster: FactionId[] = [ + 'choam', + 'richese', + 'atreides', + 'harkonnen', + 'guild', + 'emperor', + ]; + for (const count of [2, 3, 4, 5, 6]) + for (const advanced of [false, true]) { + const g = lobby(['choam'], advanced, roster.slice(0, count)); + rejectAction(g, g.host, { type: 'start' }); + assert.equal( + setup(initializeFactionExpansionsGameForAudit(g), 'Medium').game.players + .length, + count, + ); + } +}); + +void test('Ecaz starting-force quote accepts exact six-force Basin allocations and rejects invalid or ambiguous input purely', () => { + assert.equal(ECAZ_START_FORCES, 6); + assert.deepEqual(ECAZ_START_LOCATIONS, [ + 'imperial_basin:9', + 'imperial_basin:10', + 'imperial_basin:11', + ]); + const input = { + 'imperial_basin:9': 2, + 'imperial_basin:10': 4, + 'imperial_basin:11': 0, + }; + assert.deepEqual(quoteEcazStartingForces(input), { + 'imperial_basin:9': 2, + 'imperial_basin:10': 4, + }); + for (const bad of [ + null, + [], + {}, + { 'imperial_basin:9': 5 }, + { 'imperial_basin:9': 7 }, + { 'imperial_basin:9': 5.5, 'imperial_basin:10': 0.5 }, + { 'imperial_basin:9': -1, 'imperial_basin:10': 7 }, + { 'imperial_basin:09': 6 }, + { imperial_basin: 6 }, + { 'arrakeen:10': 6 }, + { 'imperial_basin:9': '6' }, + ]) { + const before = structuredClone(bad); + assert.throws(() => quoteEcazStartingForces(bad)); + assert.deepEqual(bad, before); + } +}); + +void test('Ecaz splits six forces once between Fremen and Advanced BG, then excludes Imperial Basin from Moritani final placement', () => { + let g = initializeFactionExpansionsGameForAudit( + lobby(['ecaz'], true, ['ecaz', 'moritani', 'fremen', 'beneGesserit']), + ); + const actionsFor = (id: string) => { + const view = viewGame(g, id); + view.players.find((p) => p.id === id)!.bot = 'Medium'; + return botActions(view); + }; + for ( + let step = 0; + !( + g.setupStage === 'forces' && + viewGame(g, g.host).setupPending[0] === 'ecaz' + ) && step < 20; + step++ + ) { + const actor = g.players.find((p) => actionsFor(p.id).length > 0)!; + assert.ok(actor); + g = applyAction(reload(g), actor.id, actionsFor(actor.id)[0]); + } + assert.deepEqual(viewGame(g, g.host).setupPending, ['ecaz']); + assert.equal(g.players.find((p) => p.faction === 'fremen')!.reserves, 10); + assert.ok(g.players.every((p) => p.hand.length === 0)); + const split = { + type: 'ecazSetup', + placements: { + 'imperial_basin:9': 2, + 'imperial_basin:10': 2, + 'imperial_basin:11': 2, + }, + }; + rejectAction(g, 'beneGesserit', { + type: 'advisorSetup', + territory: 'polar_sink', + sector: 0, + }); + rejectAction(g, 'fremen', split); + rejectAction(g, 'ecaz', { + type: 'ecazSetup', + placements: { 'imperial_basin:9': 5 }, + }); + rejectAction(g, 'ecaz', { ...split, elitePlacements: {} }); + g = applyAction(privateViews(g), 'ecaz', split); + assert.deepEqual(g.players[0].forces, split.placements); + assert.equal(g.players[0].reserves, 14); + assert.deepEqual(viewGame(g, g.host).setupPending, ['beneGesserit']); + rejectAction(g, 'ecaz', split); + g = applyAction(privateViews(g), 'beneGesserit', { + type: 'advisorSetup', + territory: 'polar_sink', + sector: 0, + }); + assert.deepEqual(g.decision, { kind: 'moritaniSetup', player: 'moritani' }); + assert.ok(g.players.every((p) => p.hand.length === 1)); + rejectAction(g, 'moritani', { + type: 'decision', + territory: 'imperial_basin', + sector: 9, + }); + rejectAction(g, 'moritani', { + type: 'decision', + territory: 'polar_sink', + sector: 0, + }); + for (const [reserves, forces] of [ + [20, {}], + [14, { 'arrakeen:10': 6 }], + ] as const) { + const incomplete = reload(g); + incomplete.players[0].reserves = reserves; + incomplete.players[0].forces = forces; + rejectAction(incomplete, 'moritani', { + type: 'decision', + territory: 'carthag', + sector: 11, + }); + incomplete.decision = null; + const before = structuredClone(incomplete), + normalized = normalizeAutomaticGame(incomplete); + assert.deepEqual(incomplete, before); + assert.equal(normalized.status, 'setup'); + assert.equal(normalized.decision, null); + } + g = applyAction(privateViews(g), 'moritani', { + type: 'decision', + territory: 'arrakeen', + sector: 10, + }); + assert.equal(g.status, 'playing'); + assert.equal(g.players[1].reserves, 14); + assert.deepEqual(g.players[1].forces, { 'arrakeen:10': 6 }); + rejectAction(g, 'ecaz', split); + inventory(g); +}); + +void test('a stale or partially played lobby cannot redeal faction inventories, leaders, or optional modules', () => { + const mutations: Array<(g: Game) => void> = [ + (g) => { + g.expansions = []; + }, + (g) => { + g.expansions = ['ecaz-treachery']; + }, + (g) => { + g.expansions.push('choam'); + }, + (g) => { + g.players[0].ready = false; + }, + (g) => { + g.host = 'missing'; + }, + (g) => { + g.turn = 2; + }, + (g) => { + g.advanced = 'advanced' as unknown as boolean; + }, + (g) => { + g.status = 'setup'; + }, + (g) => { + g.players[0].spice = 1; + }, + (g) => { + g.players[0].reserves = 19; + }, + (g) => { + g.richeseCache = []; + }, + (g) => { + g.richeseRemoved = []; + }, + (g) => { + g.ecazAmbassadors = { tokens: [], cohort: [], placement: null }; + }, + (g) => { + g.moritaniTerror = { tokens: [] }; + }, + (g) => { + g.dukeVidal = {} as NonNullable; + }, + (g) => { + g.mobileStronghold = { location: null }; + }, + (g) => { + g.ixSetupCards = []; + }, + (g) => { + g.players[1].noField = {} as NonNullable< + Game['players'][number]['noField'] + >; + }, + (g) => { + g.players[1].noFieldEvent = 'old'; + }, + (g) => { + g.players[0].faceDancers = []; + }, + (g) => { + g.players[0].leaders[0].capturedBy = g.players[1].id; + }, + (g) => { + g.players[0].leaders[0].gholaBy = g.players[1].id; + }, + (g) => { + g.players[0].leaders[0].dead = true; + }, + (g) => { + g.players[0].leaders[0].deaths = 1; + }, + (g) => { + g.players[0].leaders[0].usedAt = 'arrakeen'; + }, + (g) => { + g.players[0].leaders[0].strength++; + }, + (g) => { + g.players[0].revived = 1; + }, + (g) => { + g.players[0].freeForcesRevived = 1; + }, + (g) => { + g.players[0].leaderRevived = true; + }, + (g) => { + g.players[0].revivalCycle = 1; + }, + (g) => { + g.homeworlds = { custody: null }; + }, + (g) => { + g.nexusCards = { cards: null, phase: null }; + }, + (g) => { + g.leaderSkills = {} as NonNullable; + }, + (g) => { + g.discoveryEnabled = true; + }, + (g) => { + g.discoveries = {} as NonNullable; + }, + (g) => { + g.techTokens = {} as NonNullable; + }, + (g) => { + g.strongholdCards = {} as NonNullable; + }, + ]; + for (const mutate of mutations) { + const g = lobby(); + mutate(g); + reject(g); + } + const absentFaction = lobby(['choam']); + absentFaction.players[0] = { + ...newPlayer('ecaz', 'Ecaz', 'ecaz'), + ready: true, + }; + absentFaction.host = 'ecaz'; + reject(absentFaction); + const tooMany = lobby(['ix']); + tooMany.players.push({ + ...newPlayer('guild', 'Guild', 'guild'), + ready: true, + }); + reject(tooMany); + const alone = lobby(['choam'], false, ['choam']); + reject(alone); +}); + +void test('accepting the Ecaz deck selector does not open existing Homeworld or Nexus expansion gates', () => { + for (const expansions of [['choam'], ['ecaz'], ['ix', 'ecaz']]) { + const home = lobby(expansions, true); + home.homeworlds = { custody: null }; + reject(home, initializeHomeworldGameForAudit); + const nexus = lobby(expansions, true); + nexus.nexusCards = { cards: null, phase: null }; + reject(nexus, initializeNexusGameForAudit); + } +}); diff --git a/tests/expansion-factions-recovery.test.ts b/tests/expansion-factions-recovery.test.ts new file mode 100644 index 00000000..4e01061b --- /dev/null +++ b/tests/expansion-factions-recovery.test.ts @@ -0,0 +1,415 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import { botActions } from '../game/bots'; +import { treacheryDeck } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import type { Action, Game, GameView } from '../game/engine'; +import type * as Rooms from '../db/rooms'; +import { startPrototypeRoom } from '../tools/prototype-room'; +import { unitStore } from './fixture-nexus-room-store'; + +const clock: Rooms.RoomsClock = { + now: () => 12_345, + sleep: async () => {}, +}; + +const MIXED_ROSTER: readonly FactionId[] = [ + 'ixians', + 'tleilaxu', + 'choam', + 'richese', + 'ecaz', + 'moritani', +]; + +type Store = ReturnType; +type RoomRow = { + code: string; + state: string; + version: number; + updated_at: number; +}; + +let receiptSequence = 0; +function entry() { + receiptSequence++; + return { + operationId: `00000000-0000-4000-8000-${receiptSequence + .toString(16) + .padStart(12, '0')}`, + sessionToken: receiptSequence.toString(16).padStart(64, '0'), + }; +} + +function roomRow(db: DatabaseSync, code: string): RoomRow { + const row = db + .prepare('SELECT code,state,version,updated_at FROM rooms WHERE code = ?') + .get(code) as RoomRow | undefined; + assert.ok(row); + return row; +} + +function storageSnapshot(db: DatabaseSync) { + return { + rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(), + seats: db.prepare('SELECT * FROM seats ORDER BY room_code,player_id').all(), + receipts: db + .prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id') + .all(), + }; +} + +async function readyRoom( + store: Store, + expansions: string[], + factions: readonly FactionId[], + advanced = false, +) { + const made = await store.rooms.createRoom( + `${factions[0]} host`, + factions[0], + advanced, + expansions, + entry(), + ); + const code = made.view.code; + const tokens = [made.token]; + for (const faction of factions.slice(1)) { + const joined = await store.rooms.joinRoom( + code, + `${faction} seat`, + faction, + entry(), + ); + assert.ok(joined.token); + tokens.push(joined.token); + } + const auths = await Promise.all( + tokens.map((token) => store.rooms.authenticate(code, token)), + ); + for (const auth of auths) { + const current = await store.rooms.readRoom(code); + await store.rooms.act( + code, + auth, + current.version, + { type: 'ready' }, + clock, + ); + } + const source = await store.rooms.readRoom(code); + const raw = roomRow(store.sqlite, code); + assert.equal(source.version, raw.version); + assert.equal((JSON.parse(raw.state) as Game).version, raw.version); + assert.equal(source.status, 'lobby'); + assert.ok(source.players.every((player) => player.ready)); + return { code, tokens, auths, source }; +} + +function ownPlayer(view: GameView) { + return view.players.find((player) => player.id === view.me)!; +} + +async function assertPrivateSetupViews( + rooms: Store['rooms'], + code: string, + auths: Rooms.SeatAuth[], + game: Game, +) { + const views = await Promise.all( + auths.map((auth) => rooms.readSeatView(code, auth)), + ); + for (const [index, view] of views.entries()) { + const owner = game.players[index]; + assert.equal(view.me, owner.id); + assert.deepEqual(ownPlayer(view).hand, owner.hand); + assert.deepEqual(ownPlayer(view).traitors, owner.traitors); + assert.deepEqual(ownPlayer(view).traitorChoices, owner.traitorChoices); + assert.deepEqual(ownPlayer(view).faceDancers, owner.faceDancers); + assert.equal(Object.hasOwn(view, 'deck'), false); + for (const other of view.players.filter( + (player) => player.id !== view.me, + )) { + assert.equal(other.hand, undefined); + assert.equal(other.traitors, undefined); + assert.equal(other.traitorChoices, undefined); + assert.equal(other.faceDancers, undefined); + } + } + + const richeseIndex = game.players.findIndex( + (player) => player.faction === 'richese', + ); + const richese = game.players[richeseIndex]; + if (richese.noField) { + assert.deepEqual( + views[richeseIndex].richeseNoField?.private, + richese.noField, + ); + const secretIds = richese.noField.tokens.map((token) => token.id); + for (const [index, view] of views.entries()) { + if (index === richeseIndex) continue; + assert.equal(view.richeseNoField?.private, null); + const projected = JSON.stringify(view); + for (const id of secretIds) assert.equal(projected.includes(id), false); + } + } + + const moritaniIndex = game.players.findIndex( + (player) => player.faction === 'moritani', + ); + if (game.moritaniTerror && moritaniIndex >= 0) { + assert.deepEqual(views[moritaniIndex].moritaniTerror, game.moritaniTerror); + for (const [index, view] of views.entries()) { + if (index === moritaniIndex) continue; + assert.deepEqual(view.moritaniTerror?.tokens, []); + } + } + + if (game.ixSetupCards) { + const ixianIndex = game.players.findIndex( + (player) => player.faction === 'ixians', + ); + assert.deepEqual(views[ixianIndex].ixTechnology?.setup, game.ixSetupCards); + for (const [index, view] of views.entries()) + if (index !== ixianIndex) assert.equal(view.ixTechnology, null); + } +} + +function setupAction(view: GameView): Action | null { + const copy = structuredClone(view); + ownPlayer(copy).bot = 'Medium'; + return botActions(copy)[0] ?? null; +} + +async function finishPersistedSetup( + store: Store, + code: string, + auths: Rooms.SeatAuth[], +) { + let sawPrivateIxChoice = false; + for (let step = 0; step < 64; step++) { + const game = await store.restart().readRoom(code); + const raw = roomRow(store.sqlite, code); + assert.equal(game.version, raw.version); + assert.equal((JSON.parse(raw.state) as Game).version, raw.version); + await assertPrivateSetupViews(store.restart(), code, auths, game); + if (game.ixSetupCards) sawPrivateIxChoice = true; + if (game.status === 'playing') return { game, sawPrivateIxChoice }; + + let progressed = false; + for (const auth of auths) { + const rooms = store.restart(); + const view = await rooms.readSeatView(code, auth); + const action = setupAction(view); + if (!action) continue; + await rooms.act(code, auth, view.version, action, clock); + progressed = true; + break; + } + assert.ok( + progressed, + `Persisted faction setup stalled at ${game.setupStage}/${game.decision?.kind ?? 'no decision'}.`, + ); + } + assert.fail('Persisted faction setup exceeded its bounded action count.'); +} + +void test('faction prototype preserves room entry state and completes a private mixed setup after reloads', async (t) => { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const main = await readyRoom( + store, + ['ix', 'choam', 'ecaz'], + MIXED_ROSTER, + true, + ); + const neighbor = await store.rooms.createRoom( + 'Unchanged neighboring room', + 'atreides', + false, + [], + entry(), + ); + const neighborRow = roomRow(store.sqlite, neighbor.view.code); + const seatsBefore = storageSnapshot(store.sqlite).seats; + const receiptsBefore = storageSnapshot(store.sqlite).receipts; + + const started = startPrototypeRoom( + store.sqlite, + main.code, + main.source.version, + 'factions', + ); + assert.equal(started.version, main.source.version + 1); + assert.equal(started.status, 'setup'); + assert.equal(started.players, 6); + const startedRow = roomRow(store.sqlite, main.code); + assert.equal(startedRow.version, started.version); + assert.equal((JSON.parse(startedRow.state) as Game).version, started.version); + assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore); + assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore); + assert.deepEqual(roomRow(store.sqlite, neighbor.view.code), neighborRow); + + const { game, sawPrivateIxChoice } = await finishPersistedSetup( + store, + main.code, + main.auths, + ); + assert.equal(sawPrivateIxChoice, true); + assert.equal(game.status, 'playing'); + assert.deepEqual(game.expansions, ['ix', 'choam', 'ecaz']); + assert.equal(game.setupStage, undefined); + assert.equal(game.ixSetupCards, null); + assert.equal(game.richeseCache?.length, 10); + assert.equal(game.richeseRemoved?.length, 0); + assert.equal(game.ecazAmbassadors?.tokens.length, 11); + assert.equal(game.ecazAmbassadors?.cohort.length, 5); + assert.equal(game.moritaniTerror?.tokens.length, 6); + assert.equal(game.dukeVidal?.leader.id, 'duke-vidal'); + assert.equal( + game.players.find((player) => player.faction === 'tleilaxu')?.faceDancers + ?.length, + 3, + ); + const ecaz = game.players.find((player) => player.faction === 'ecaz')!; + assert.equal(ecaz.reserves, 14); + assert.equal( + Object.entries(ecaz.forces) + .filter(([key]) => key.startsWith('imperial_basin:')) + .reduce((sum, [, amount]) => sum + amount, 0), + 6, + ); + const expectedDeck = treacheryDeck(['ix', 'choam', 'ecaz']); + const heldDeck = [ + ...game.deck, + ...game.players.flatMap((player) => player.hand), + ]; + assert.equal(expectedDeck.length, 47); + assert.equal(heldDeck.length, 47); + assert.deepEqual( + heldDeck.map((card) => card.id).sort(), + expectedDeck.map((card) => card.id).sort(), + ); + assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore); + assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore); + assert.deepEqual(roomRow(store.sqlite, neighbor.view.code), neighborRow); +}); + +void test('faction prototype rejects stale, wrong-profile, optional-module and redeal starts without writes', async (t) => { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const main = await readyRoom(store, ['choam', 'ecaz'], ['choam', 'ecaz']); + + let before = storageSnapshot(store.sqlite); + assert.throws( + () => + startPrototypeRoom( + store.sqlite, + main.code, + main.source.version - 1, + 'factions', + ), + /missing or changed/, + ); + assert.deepEqual(storageSnapshot(store.sqlite), before); + + assert.throws( + () => + startPrototypeRoom(store.sqlite, main.code, main.source.version, 'ix'), + /requires exactly/, + ); + assert.deepEqual(storageSnapshot(store.sqlite), before); + + const unsupported = await readyRoom(store, ['ecaz'], ['ecaz', 'atreides']); + const unsupportedRow = roomRow(store.sqlite, unsupported.code); + const unsupportedState = JSON.parse(unsupportedRow.state) as Game; + unsupportedState.discoveryEnabled = true; + store.sqlite + .prepare('UPDATE rooms SET state = ? WHERE code = ?') + .run(JSON.stringify(unsupportedState), unsupported.code); + before = storageSnapshot(store.sqlite); + assert.throws( + () => + startPrototypeRoom( + store.sqlite, + unsupported.code, + unsupported.source.version, + 'factions', + ), + /excludes optional modules/, + ); + assert.deepEqual(storageSnapshot(store.sqlite), before); + + const result = startPrototypeRoom( + store.sqlite, + main.code, + main.source.version, + 'factions', + ); + before = storageSnapshot(store.sqlite); + assert.throws( + () => + startPrototypeRoom(store.sqlite, main.code, result.version, 'factions'), + /fresh lobby|overwrite|redeal/, + ); + assert.deepEqual(storageSnapshot(store.sqlite), before); +}); + +void test('faction prototype start CAS preserves a concurrent fresh-lobby winner', async (t) => { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const main = await readyRoom(store, ['ecaz'], ['ecaz', 'atreides']); + const seatsBefore = storageSnapshot(store.sqlite).seats; + const receiptsBefore = storageSnapshot(store.sqlite).receipts; + let raced = false; + const racing = { + prepare(sql: string) { + const statement = store.sqlite.prepare(sql); + if (!sql.startsWith('UPDATE rooms SET state = ?, version = ?')) + return statement; + return { + run: (...values: (string | number | null)[]) => { + if (!raced) { + raced = true; + const current = roomRow(store.sqlite, main.code); + const winner = JSON.parse(current.state) as Game; + winner.version = current.version + 1; + winner.log.push({ + seq: (winner.log.at(-1)?.seq ?? 0) + 1, + text: 'Concurrent fresh-lobby edit won the version fence.', + }); + store.sqlite + .prepare( + 'UPDATE rooms SET state = ?, version = ?, updated_at = ? WHERE code = ?', + ) + .run( + JSON.stringify(winner), + winner.version, + current.updated_at + 1, + main.code, + ); + } + return statement.run(...values); + }, + }; + }, + } as unknown as DatabaseSync; + + assert.throws( + () => + startPrototypeRoom(racing, main.code, main.source.version, 'factions'), + /changed during setup/, + ); + assert.equal(raced, true); + const saved = roomRow(store.sqlite, main.code); + const winner = JSON.parse(saved.state) as Game; + assert.equal(saved.version, main.source.version + 1); + assert.equal(winner.version, saved.version); + assert.equal(winner.status, 'lobby'); + assert.equal(winner.setupStage, undefined); + assert.match(winner.log.at(-1)!.text, /Concurrent fresh-lobby edit/); + assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore); + assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore); +}); diff --git a/tests/ix-deck.test.ts b/tests/ix-deck.test.ts index cf9166b4..088492b1 100644 --- a/tests/ix-deck.test.ts +++ b/tests/ix-deck.test.ts @@ -109,7 +109,7 @@ void test('the Ix expansion has fourteen distinct cards and combines into a fort baseDeck().some((c) => c.name === 'Basilia Weapon'), false, ); - for (const expansion of ['ecaz', 'unknown']) + for (const expansion of ['ecaz-treachery', 'unknown']) assert.throws(() => treacheryDeck([expansion]), /not implemented/); }); void test('CHOAM & Richese adds or replaces the same two battle cards without duplicating physical custody', () => { diff --git a/tests/ix-richese-technology-controls.test.tsx b/tests/ix-richese-technology-controls.test.tsx new file mode 100644 index 00000000..25558427 --- /dev/null +++ b/tests/ix-richese-technology-controls.test.tsx @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { IxRicheseTechnology } from '../components/ix-richese-technology'; +import { RicheseAuctionDecision } from '../components/richese-auctions'; +import { + applyAction, + createGame, + initializeFactionExpansionsGameForAudit, + joinGame, + newPlayer, + viewGame, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; + +function until(state: Game, kind: string) { + let game = state; + for (let step = 0; step < 100; step++) { + if (game.decision?.kind === kind) return game; + let next: Game | undefined; + for (const player of game.players) { + const action = botActions(viewGame(game, player.id))[0]; + if (action) { + next = applyAction(game, player.id, action); + break; + } + } + assert.ok(next, `No action before ${kind}`); + game = next; + } + throw new Error(`Did not reach ${kind}`); +} + +function offer(source: 'cache' | 'blackMarket') { + let game = createGame( + 'IXRICHUI', + newPlayer('r', 'Richese', 'richese'), + true, + ['ix', 'choam'], + ); + joinGame(game, newPlayer('i', 'Ixians', 'ixians')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + for (const player of game.players) { + player.ready = true; + player.bot = 'Medium'; + } + game = until( + initializeFactionExpansionsGameForAudit(game), + 'richeseBlackMarket', + ); + if (source === 'cache') { + game = applyAction(game, 'r', { + type: 'decision', + event: game.richeseBidding!.event, + decline: true, + }); + game = applyAction(game, 'r', { + type: 'decision', + event: game.richeseBidding!.event, + position: 'first', + }); + game = until(game, 'richeseCache'); + } + const markup = renderToStaticMarkup( + createElement(RicheseAuctionDecision, { + game: viewGame(game, 'r'), + act() {}, + busy: false, + }), + ); + assert.match(markup, /Offer (?:cache|Black Market) card/); + assert.doesNotMatch(markup, /]*\sdisabled=""[^>]*>Offer /); + const card = + source === 'cache' ? game.richeseCache![0] : game.players[0].hand[0]; + game = applyAction(game, 'r', { + type: 'decision', + event: game.richeseBidding!.event, + card: card.id, + method: 'silent', + ...(source === 'blackMarket' ? { claim: 'Private card claim' } : {}), + }); + assert.equal(game.decision?.kind, 'ixRicheseTechnology'); + return { game, card }; +} + +function render(game: Game, viewer = 'i', busy = false) { + return renderToStaticMarkup( + createElement(IxRicheseTechnology, { + game: viewGame(game, viewer), + act() {}, + busy, + }), + ); +} + +void test('Ixian special-lot decline is owned, available after reload and disabled during submission', () => { + for (const source of ['cache', 'blackMarket'] as const) { + const { game, card } = offer(source); + const restored = JSON.parse(JSON.stringify(game)) as Game; + const html = render(restored); + assert.match(html, /Decline Technology for this lot/); + assert.match(html, /once-per-round Technology use available/); + assert.match(html, /custody/); + assert.doesNotMatch(html, new RegExp(card.id)); + if (source === 'blackMarket') assert.ok(!html.includes(card.name)); + assert.equal(render(restored, 'r'), ''); + assert.equal(render(restored, 'a'), ''); + assert.match(render(restored, 'i', true), /]*\sdisabled=""/); + const completed = applyAction(restored, 'i', { + type: 'decision', + event: viewGame(restored, 'i').ixRicheseTechnology!.event, + decline: true, + }); + assert.equal(render(completed), ''); + assert.equal(completed.ixTechnologyTurn, game.ixTechnologyTurn); + assert.equal(completed.richeseAuction?.cardId, card.id); + } +}); + +void test('all four AI levels decline only this lot using the private owner view', () => { + for (const source of ['cache', 'blackMarket'] as const) { + const { game, card } = offer(source); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'i'); + view.players.find((player) => player.id === 'i')!.bot = difficulty; + const actions = botActions(view); + assert.deepEqual(actions, [ + { + type: 'decision', + event: view.ixRicheseTechnology!.event, + decline: true, + }, + ]); + const result = applyAction(game, 'i', actions[0]); + assert.equal(result.richeseAuction?.cardId, card.id); + assert.equal(result.ixTechnologyTurn, game.ixTechnologyTurn); + } + } +}); diff --git a/tests/ix-richese-technology-recovery.test.ts b/tests/ix-richese-technology-recovery.test.ts new file mode 100644 index 00000000..9f6f5f71 --- /dev/null +++ b/tests/ix-richese-technology-recovery.test.ts @@ -0,0 +1,428 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { botActions } from '../game/bots'; +import { treacheryDeck, type Card } from '../game/cards'; +import { + normalizeAutomaticGame, + viewGame, + type Action, + type Decision, + type Game, +} from '../game/engine'; +import { richeseCards } from '../game/richese-cards'; +import type * as Rooms from '../db/rooms'; +import { unitStore } from './fixture-nexus-room-store'; + +const clock: Rooms.RoomsClock = { + now: () => 22_000, + sleep: async () => {}, +}; + +type Store = ReturnType; +type Source = 'cache' | 'blackMarket'; + +function player(g: Game, id: string) { + return g.players.find((candidate) => candidate.id === id)!; +} + +function take(g: Game, predicate: (card: Card) => boolean) { + const index = g.deck.findIndex(predicate); + assert.ok(index >= 0); + return g.deck.splice(index, 1)[0]; +} + +function physicalCards(g: Game) { + return [ + ...g.deck, + ...g.discard, + ...(g.richeseCache ?? []), + ...(g.richeseRemoved ?? []), + ...g.players.flatMap((seat) => seat.hand), + ...(g.auction?.cards.slice(g.auction.index) ?? []), + ...(g.ixAuction?.cards ?? []), + ] + .map((card) => card.id) + .sort(); +} + +function storageRow(store: Store, code: string) { + const row = store.sqlite + .prepare('SELECT state,version FROM rooms WHERE code = ?') + .get(code) as { state: string; version: number } | undefined; + assert.ok(row); + return row; +} + +function snapshot(store: Store, code: string) { + const row = storageRow(store, code); + return { state: row.state, version: row.version }; +} + +function barrier() { + let release!: () => void; + const wait = new Promise((resolve) => { + release = resolve; + }); + let arrivals = 0; + return async () => { + if (++arrivals === 2) release(); + await wait; + }; +} + +async function fixture(t: test.TestContext) { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const made = await store.rooms.createRoom('Richese seller', 'richese', true, [ + 'ix', + 'choam', + ]); + const code = made.view.code; + const ixJoined = await store.rooms.joinRoom(code, 'Ixian choice', 'ixians'); + const observerJoined = await store.rooms.joinRoom( + code, + 'Outside observer', + 'emperor', + ); + assert.ok(ixJoined.token); + assert.ok(observerJoined.token); + const auths = await Promise.all( + [made.token, ixJoined.token, observerJoined.token].map((token) => + store.rooms.authenticate(code, token), + ), + ); + const [richeseAuth, ixAuth, observerAuth] = auths; + const initial = await store.rooms.readRoom(code); + Object.assign(initial, { + status: 'playing', + advanced: true, + turn: 2, + phase: 2, + order: auths.map((auth) => auth.playerId), + active: null, + ready: [], + response: null, + decision: null, + phaseOpening: null, + deck: treacheryDeck(['ix', 'choam']), + discard: [], + richeseCache: richeseCards(), + richeseRemoved: [], + richeseBidding: null, + richeseAuction: null, + richeseFunding: {}, + }); + for (const seat of initial.players) { + seat.hand = []; + seat.spice = 20; + seat.traitors = [seat.leaders[0].id]; + seat.traitorChoices = []; + } + const blackMarketCard = take( + initial, + (card) => card.effect !== 'karama' && card.kind !== 'worthless', + ); + player(initial, richeseAuth.playerId).hand.push(blackMarketCard); + player(initial, ixAuth.playerId).hand.push( + take(initial, (card) => card.effect !== 'karama'), + ); + store.sqlite + .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(initial), initial.version, code); + + async function act( + auth: Rooms.SeatAuth, + action: Action, + rooms = store.rooms, + version?: number, + ) { + const currentVersion = version ?? (await rooms.readRoom(code)).version; + return rooms.act(code, auth, currentVersion, action, clock); + } + for (const auth of auths) await act(auth, { type: 'ready' }); + let opened = await store.rooms.readRoom(code); + for (const auth of auths) { + if (!opened.phaseOpening) break; + await act(auth, { type: 'ready' }); + opened = await store.rooms.readRoom(code); + } + store.writes.length = 0; + opened = await store.restart().readRoom(code); + assert.equal(opened.phase, 3); + assert.equal(opened.decision?.kind, 'richeseBlackMarket'); + return { + ...store, + code, + auths, + richeseAuth, + ixAuth, + observerAuth, + blackMarketCard, + act, + }; +} + +async function openIxChoice( + f: Awaited>, + source: Source, +) { + let game = await f.rooms.readRoom(f.code); + if (source === 'cache') { + await f.act(f.richeseAuth, { + type: 'decision', + event: game.richeseBidding!.event, + decline: true, + }); + game = await f.rooms.readRoom(f.code); + assert.equal(game.decision?.kind, 'richeseDeclaration'); + await f.act(f.richeseAuth, { + type: 'decision', + event: game.richeseBidding!.event, + position: 'first', + }); + game = await f.rooms.readRoom(f.code); + while (game.response) { + const auth = f.auths.find( + (candidate) => !game.response!.passed.includes(candidate.playerId), + ); + assert.ok(auth); + await f.act(auth, { type: 'passResponse' }); + game = await f.rooms.readRoom(f.code); + } + assert.equal(game.decision?.kind, 'richeseCache'); + } + const offered = + source === 'cache' ? game.richeseCache![3] : f.blackMarketCard; + const roundEvent = game.richeseBidding!.event; + await f.act(f.richeseAuth, { + type: 'decision', + event: roundEvent, + card: offered.id, + method: 'onceAround', + direction: source === 'cache' ? 'clockwise' : 'counterclockwise', + ...(source === 'blackMarket' ? { claim: ' concealed claim ' } : {}), + }); + game = await f.restart().readRoom(f.code); + assert.deepEqual(game.decision, { + kind: 'ixRicheseTechnology', + player: f.ixAuth.playerId, + event: game.pendingIxRicheseTechnology!.event, + source, + }); + assert.equal(game.pendingIxRicheseTechnology?.round, roundEvent); + assert.equal(game.pendingIxRicheseTechnology?.card.id, offered.id); + assert.equal( + game.pendingIxRicheseTechnology?.direction, + source === 'cache' ? 'clockwise' : 'counterclockwise', + ); + assert.equal( + game.pendingIxRicheseTechnology?.claim, + source === 'blackMarket' ? 'concealed claim' : null, + ); + return { game, offered }; +} + +function decline(game: Game): Action { + return { + type: 'decision', + event: game.pendingIxRicheseTechnology!.event, + decline: true, + }; +} + +void test('restored Ixian declines resume the exact cache and Black Market lots once without spending Technology or exposing cards', async (t) => { + for (const source of ['cache', 'blackMarket'] as const) { + await t.test(source, async (t) => { + const f = await fixture(t); + const { game: pending, offered } = await openIxChoice(f, source); + const pendingVersion = pending.version; + const inventory = physicalCards(pending); + const counts = { + deck: pending.deck.length, + cache: pending.richeseCache?.length, + richeseHand: player(pending, f.richeseAuth.playerId).hand.length, + ixHand: player(pending, f.ixAuth.playerId).hand.length, + auctionPool: pending.auction?.cards.length ?? 0, + ixPool: pending.ixAuction?.cards.length ?? 0, + }; + assert.equal(pending.ixTechnologyTurn, undefined); + + for (const auth of f.auths) { + const view = await f.restart().readSeatView(f.code, auth); + assert.deepEqual(view.ixRicheseTechnology, { + event: pending.pendingIxRicheseTechnology!.event, + player: f.ixAuth.playerId, + owner: f.richeseAuth.playerId, + source, + exchangeBlocked: + 'Ixian exchange custody for Richese lots is not implemented. You can explicitly continue without this exchange; Technology remains available for later lots.', + }); + assert.equal(Object.hasOwn(view, 'pendingIxRicheseTechnology'), false); + assert.equal(Object.hasOwn(view, 'ixRicheseTechnologyEvent'), false); + if (auth.playerId !== f.richeseAuth.playerId) + assert.equal(JSON.stringify(view).includes(offered.id), false); + } + const ixView = await f.rooms.readSeatView(f.code, f.ixAuth); + const botView = structuredClone(ixView); + botView.players.find((seat) => seat.id === f.ixAuth.playerId)!.bot = + 'Medium'; + assert.deepEqual(botActions(botView), [decline(pending)]); + + if (source === 'blackMarket') { + const beforeRejected = snapshot(f, f.code); + await assert.rejects( + f.rooms.act( + f.code, + f.observerAuth, + pendingVersion, + decline(pending), + clock, + ), + /pending decision/, + ); + for (const action of [ + { + type: 'decision', + event: pending.pendingIxRicheseTechnology!.event + '-stale', + decline: true, + }, + { + type: 'decision', + event: pending.pendingIxRicheseTechnology!.event, + decline: false, + }, + { + ...decline(pending), + card: pending.pendingIxRicheseTechnology!.card.id, + }, + ]) + await assert.rejects( + f.rooms.act(f.code, f.ixAuth, pendingVersion, action, clock), + /Explicitly continue/, + ); + assert.deepEqual(snapshot(f, f.code), beforeRejected); + } + + await f.act(f.ixAuth, decline(pending), f.restart(), pendingVersion); + const resumed = await f.restart().readRoom(f.code); + assert.equal(resumed.version, pendingVersion + 1); + assert.equal(resumed.pendingIxRicheseTechnology, undefined); + assert.equal(resumed.ixRicheseTechnologyEvent, undefined); + assert.equal(resumed.ixTechnologyTurn, undefined); + assert.equal(resumed.richeseAuction?.source, source); + assert.equal(resumed.richeseAuction?.cardId, offered.id); + assert.equal(resumed.richeseAuction?.method, 'onceAround'); + assert.deepEqual(physicalCards(resumed), inventory); + assert.deepEqual( + { + deck: resumed.deck.length, + cache: resumed.richeseCache?.length, + richeseHand: player(resumed, f.richeseAuth.playerId).hand.length, + ixHand: player(resumed, f.ixAuth.playerId).hand.length, + auctionPool: resumed.auction?.cards.length ?? 0, + ixPool: resumed.ixAuction?.cards.length ?? 0, + }, + counts, + ); + const outside = await f.restart().readSeatView(f.code, f.observerAuth); + if (source === 'blackMarket') { + assert.equal(outside.richeseAuction?.card, null); + assert.equal(outside.richeseAuction?.cardId, null); + assert.equal(JSON.stringify(outside).includes(offered.id), false); + } + + const saved = snapshot(f, f.code); + await assert.rejects( + f.rooms.act(f.code, f.ixAuth, pendingVersion, decline(pending), clock), + /table changed/, + ); + await assert.rejects( + f.rooms.act(f.code, f.ixAuth, resumed.version, decline(pending), clock), + ); + assert.deepEqual(snapshot(f, f.code), saved); + }); + } +}); + +void test('forged or missing saved Ixian lot decisions fail before projection, action and normalization without writes', async (t) => { + const f = await fixture(t); + const { game: pending } = await openIxChoice(f, 'blackMarket'); + const genuine = structuredClone(pending); + const corruptions: Array<(game: Game) => void> = [ + (game) => { + game.decision = null; + }, + (game) => { + delete game.pendingIxRicheseTechnology; + }, + (game) => { + if (game.decision?.kind === 'ixRicheseTechnology') + game.decision.event += '-forged'; + }, + (game) => { + game.ixRicheseTechnologyEvent += '-forged'; + }, + (game) => { + const duplicate = structuredClone(game.decision) as Decision; + game.pendingExchange = { response: null, decision: duplicate }; + }, + ]; + + for (const corrupt of corruptions) { + const game = structuredClone(genuine); + corrupt(game); + f.sqlite + .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(game), game.version, f.code); + const before = snapshot(f, f.code); + const original = structuredClone(game); + assert.throws(() => viewGame(game, f.ixAuth.playerId), /saved Ixian/); + assert.deepEqual(game, original); + assert.throws(() => normalizeAutomaticGame(game), /saved Ixian/); + assert.deepEqual(game, original); + await assert.rejects(f.rooms.readSeatView(f.code, f.ixAuth), /saved Ixian/); + await assert.rejects( + f.rooms.act(f.code, f.ixAuth, game.version, decline(genuine), clock), + /saved Ixian/, + ); + assert.deepEqual(snapshot(f, f.code), before); + } +}); + +void test('concurrent authenticated declines persist one Richese lot continuation behind the room CAS', async (t) => { + const f = await fixture(t); + const { game: pending, offered } = await openIxChoice(f, 'blackMarket'); + const inventory = physicalCards(pending); + f.writes.length = 0; + f.hooks.beforeWrite = barrier(); + const attempts = await Promise.allSettled([ + f.rooms.act(f.code, f.ixAuth, pending.version, decline(pending), clock), + f.restart().act(f.code, f.ixAuth, pending.version, decline(pending), clock), + ]); + delete f.hooks.beforeWrite; + assert.equal( + attempts.filter((attempt) => attempt.status === 'fulfilled').length, + 1, + ); + assert.equal( + attempts.filter((attempt) => attempt.status === 'rejected').length, + 1, + ); + assert.deepEqual( + f.writes.map((write) => write.changes).sort((a, b) => a - b), + [0, 1], + ); + const resumed = await f.restart().readRoom(f.code); + assert.equal(resumed.version, pending.version + 1); + assert.equal(resumed.richeseAuction?.cardId, offered.id); + assert.equal(resumed.richeseAuction?.source, 'blackMarket'); + assert.equal(resumed.pendingIxRicheseTechnology, undefined); + assert.equal(resumed.ixTechnologyTurn, undefined); + assert.deepEqual(physicalCards(resumed), inventory); + assert.equal( + resumed.log.filter((entry) => + entry.text.includes('continued without exchanging this Richese lot'), + ).length, + 1, + ); +}); diff --git a/tests/ix-richese-technology.test.ts b/tests/ix-richese-technology.test.ts new file mode 100644 index 00000000..3ebd353d --- /dev/null +++ b/tests/ix-richese-technology.test.ts @@ -0,0 +1,522 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAction, + createGame, + initializeFactionExpansionsGameForAudit, + joinGame, + newPlayer, + normalizeAutomaticGame, + prepareSpecialKaramaIntent, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; + +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +function fixture(advanced = true, harkonnen = false) { + let g = createGame('IXRICH', newPlayer('r', 'Richese', 'richese'), advanced, [ + 'ix', + 'choam', + ]); + joinGame(g, newPlayer('i', 'Ixians', 'ixians')); + joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + if (harkonnen) joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + g = initializeFactionExpansionsGameForAudit(g); + for (let step = 0; g.status === 'setup' && step < 20; step++) { + let next: Game | undefined; + for (const p of g.players) { + const view = viewGame(g, p.id); + view.players.find((p) => p.id === view.me)!.bot = 'Medium'; + const action = botActions(view)[0]; + if (action) { + next = applyAction(g, p.id, action); + break; + } + } + assert.ok(next); + g = next; + } + assert.equal(g.status, 'playing'); + // Focus the genuine initialized inventory on the Charity-to-Bidding transition. + Object.assign(g, { + phase: 2, + ready: [], + phaseOpening: null, + stormPending: null, + response: null, + decision: null, + }); + for (const p of g.players) { + g.deck.push(...p.hand); + p.hand = []; + p.spice = 20; + } + for (const [index, kind] of ['shield', 'snooper', 'projectile'].entries()) { + const card = g.deck.findIndex((c) => c.kind === kind); + assert.ok(card >= 0); + g.players[index].hand.push(...g.deck.splice(card, 1)); + } + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + assert.equal(g.phase, 3); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + return g; +} +function richese(g: Game, fields: Omit) { + return applyAction(g, 'r', { + type: 'decision', + event: g.richeseBidding!.event, + ...fields, + }); +} +function offer(source: 'cache' | 'blackMarket', method = 'silent') { + let g = fixture(); + if (source === 'cache') { + g = richese(g, { decline: true }); + g = richese(g, { position: 'first' }); + } + const card = source === 'cache' ? g.richeseCache![0] : g.players[0].hand[0]; + const before = structuredClone(g); + g = richese(g, { + card: card.id, + method, + direction: 'counterclockwise', + claim: ' a private draft claim ', + }); + return { g, card, before }; +} +function decline(g: Game) { + return applyAction(reload(g), 'i', { + type: 'decision', + event: g.pendingIxRicheseTechnology!.event, + decline: true, + }); +} +function stock(g: Game) { + return { + deck: g.deck, + discard: g.discard, + hands: g.players.map((p) => p.hand), + spice: g.players.map((p) => p.spice), + cache: g.richeseCache, + removed: g.richeseRemoved, + normalCount: g.richeseBidding!.normalCount, + blackMarketSold: g.richeseBidding!.blackMarketSold, + ixTechnologyTurn: g.ixTechnologyTurn, + }; +} +function reject(g: Game, player: string, action: Action) { + const before = structuredClone(g); + assert.throws(() => applyAction(g, player, action)); + assert.deepEqual(g, before); +} + +for (const source of ['cache', 'blackMarket'] as const) + void test(`${source} offer pauses before bidding or inspection and saved explicit Ixian decline preserves all custody, funds and Technology`, () => { + for (const method of source === 'cache' + ? ['silent', 'onceAround'] + : ['silent', 'onceAround', 'normal']) { + const { g, card, before } = offer(source, method); + assert.equal(g.decision?.kind, 'ixRicheseTechnology'); + assert.equal(g.decision?.player, 'i'); + assert.equal(g.response, null); + assert.equal(g.richeseAuction, null); + assert.equal(g.auction, null); + assert.deepEqual(stock(g), stock(before)); + assert.deepEqual(normalizeAutomaticGame(reload(g)), g); + const event = g.pendingIxRicheseTechnology!.event; + for (const p of g.players) + assert.deepEqual(viewGame(reload(g), p.id), viewGame(g, p.id)); + const resumed = decline(g); + assert.equal(resumed.pendingIxRicheseTechnology, undefined); + assert.equal(resumed.ixRicheseTechnologyEvent, undefined); + assert.deepEqual(stock(resumed), stock(g)); + assert.equal(resumed.richeseAuction!.cardId, card.id); + assert.equal(resumed.richeseAuction!.source, source); + assert.equal(resumed.richeseAuction!.method, method); + assert.equal( + resumed.richeseClaim, + source === 'blackMarket' ? 'a private draft claim' : null, + ); + assert.equal(viewGame(resumed, 'a').richeseAuction!.card?.id, card.id); + reject(resumed, 'i', { type: 'decision', event, decline: true }); + } + }); + +void test('Black Market pending projections conceal card identity, terms and receipt from Ixians and Atreides before inspection', () => { + const { g, card } = offer('blackMarket'); + for (const id of ['i', 'a']) { + const view = viewGame(g, id), + json = JSON.stringify(view); + assert.equal(json.includes(card.id), false); + assert.equal(json.includes('a private draft claim'), false); + assert.equal('pendingIxRicheseTechnology' in view, false); + assert.equal('ixRicheseTechnologyEvent' in view, false); + assert.deepEqual(view.ixRicheseTechnology, { + event: g.pendingIxRicheseTechnology!.event, + player: 'i', + owner: 'r', + source: 'blackMarket', + exchangeBlocked: view.ixRicheseTechnology!.exchangeBlocked, + }); + assert.equal(view.richeseBidding!.offerBlocked, null); + } + assert.equal(JSON.stringify(g.log).includes(card.id), false); + assert.equal(JSON.stringify(g.log).includes('a private draft claim'), false); +}); + +void test('decline requires the current Ixian owner and exact explicit action; exchange and stale offers remain guarded atomically', () => { + const { g } = offer('cache'), + event = g.pendingIxRicheseTechnology!.event; + for (const action of [ + { type: 'decision', event }, + { type: 'decision', event, decline: false }, + { type: 'decision', event, card: g.players[1].hand[0].id }, + { type: 'decision', event, decline: true, card: 'extra' }, + { type: 'decision', event: 'expired', decline: true }, + { type: 'ready' }, + ]) + reject(g, 'i', action); + reject(g, 'r', { type: 'decision', event, decline: true }); + reject(g, 'a', { type: 'decision', event, decline: true }); + reject(g, 'r', { + type: 'decision', + event: g.richeseBidding!.event, + card: g.richeseCache![1].id, + method: 'silent', + }); + reject(g, 'a', { type: 'richeseBid', event, amount: 1 }); +}); + +void test('saved receipt, independent marker, unique decision and exact physical offer cannot be forged or lost before view, action or normalization', () => { + const { g } = offer('blackMarket'); + const mutations: Array<(g: Game) => void> = [ + (g) => { + delete g.pendingIxRicheseTechnology; + }, + (g) => { + delete g.ixRicheseTechnologyEvent; + }, + (g) => { + g.decision = null; + }, + (g) => { + g.decision!.player = 'r'; + }, + (g) => { + ( + g.decision as Extract< + NonNullable, + { kind: 'ixRicheseTechnology' } + > + ).event = 'stale'; + }, + (g) => { + g.pendingIxRicheseTechnology!.event = 'stale'; + }, + (g) => { + g.pendingIxRicheseTechnology!.source = 'cache'; + }, + (g) => { + g.pendingIxRicheseTechnology!.method = 'normal'; + }, + (g) => { + g.pendingIxRicheseTechnology!.claim = 'changed'; + }, + (g) => { + g.pendingIxRicheseTechnology!.card.name = 'changed'; + }, + (g) => { + g.pendingIxRicheseTechnology!.round = 'stale'; + }, + (g) => { + g.turn++; + }, + (g) => { + g.richeseBidding!.normalCount = 4; + }, + (g) => { + g.richeseBidding!.event = 'replaced'; + }, + (g) => { + g.ixTechnologyTurn = g.turn; + }, + (g) => { + g.deck.push(g.players[0].hand[0]); + }, + (g) => { + g.deck.push(...g.players[0].hand.splice(0, 1)); + }, + (g) => { + g.players[0].hand[0].name = 'changed physical card'; + }, + (g) => { + g.pendingExchange = { + response: null, + decision: structuredClone(g.decision), + }; + }, + ]; + for (const mutate of mutations) { + const bad = reload(g); + mutate(bad); + const before = structuredClone(bad); + assert.throws(() => viewGame(bad, 'i')); + assert.throws(() => normalizeAutomaticGame(bad)); + assert.throws(() => + applyAction(bad, 'i', { + type: 'decision', + event: g.pendingIxRicheseTechnology!.event, + decline: true, + }), + ); + assert.deepEqual(bad, before); + } +}); + +void test('Black Market decline does not use Technology; a later cache has a fresh choice and normal lots still offer their original exchange', () => { + let { g } = offer('blackMarket'); + const first = g.pendingIxRicheseTechnology!.event; + g = decline(g); + for (const p of g.players) + g = applyAction(g, p.id, { + type: 'richeseBid', + event: g.richeseAuction!.event, + amount: 0, + }); + assert.equal(g.decision?.kind, 'richeseDeclaration'); + g = richese(g, { position: 'first' }); + g = richese(g, { card: g.richeseCache![0].id, method: 'silent' }); + assert.equal(g.decision?.kind, 'ixRicheseTechnology'); + assert.notEqual(g.pendingIxRicheseTechnology!.event, first); + reject(g, 'i', { type: 'decision', event: first, decline: true }); + g = decline(g); + for (const p of g.players) + g = applyAction(g, p.id, { + type: 'richeseBid', + event: g.richeseAuction!.event, + amount: 0, + }); + assert.equal(g.decision?.kind, 'richeseUnbid'); + g = richese(g, { keep: false }); + assert.equal(g.decision?.kind, 'ixAuction'); + const cards = g.ixAuction!.cards!; + g = applyAction(g, 'i', { + type: 'decision', + card: cards[0].id, + position: 'bottom', + }); + assert.equal(g.decision?.kind, 'ixTechnology'); + const replacement = g.players[1].hand[0], + taken = g.auction!.cards[g.auction!.index]; + g = applyAction(g, 'i', { type: 'decision', card: replacement.id }); + assert.equal(g.ixTechnologyTurn, g.turn); + assert.ok(g.players[1].hand.some((c) => c.id === taken.id)); + assert.equal(g.auction!.cards[g.auction!.index].id, replacement.id); +}); + +void test('Basic offers and Advanced already-used Technology keep the original direct special-lot path', () => { + for (const advanced of [false, true]) { + let g = fixture(advanced); + if (advanced) { + g.ixTechnologyTurn = g.turn; + g = richese(g, { decline: true }); + } + g = richese(g, { position: 'first' }); + g = richese(g, { card: g.richeseCache![0].id, method: 'silent' }); + assert.ok(g.richeseAuction); + assert.equal(g.pendingIxRicheseTechnology, undefined); + assert.equal(viewGame(g, 'i').ixRicheseTechnology, null); + } +}); + +void test('an independent Richese gift can suspend the Ixian lot choice and restore the same private commitment after a saved response', () => { + let g = fixture(); + g.players[0].ally = 'i'; + g.players[1].ally = 'r'; + const gift = g.richeseCache!.splice(0, 1)[0]; + g.players[0].hand.push(gift); + const karamaIndex = g.deck.findIndex((c) => c.effect === 'karama'); + assert.ok(karamaIndex >= 0); + g.players[2].hand.push(...g.deck.splice(karamaIndex, 1)); + g = richese(g, { card: g.players[0].hand[0].id, method: 'silent' }); + const commitment = structuredClone(g.pendingIxRicheseTechnology); + g = applyAction(g, 'r', { type: 'richeseGift', card: gift.id }); + assert.equal(g.decision, null); + assert.equal(g.response?.kind, 'richeseGift'); + assert.equal( + g.pendingRicheseGift!.resume.decision?.kind, + 'ixRicheseTechnology', + ); + assert.deepEqual(g.pendingIxRicheseTechnology, commitment); + for (const id of ['r', 'i', 'a']) + assert.deepEqual(viewGame(reload(g), id), viewGame(g, id)); + g = applyAction(reload(g), 'a', { type: 'passResponse' }); + assert.equal(g.decision?.kind, 'ixRicheseTechnology'); + assert.deepEqual(g.pendingIxRicheseTechnology, commitment); + assert.ok(g.players[1].hand.some((c) => c.id === gift.id)); + g = decline(g); + assert.equal(g.richeseAuction!.cardId, commitment!.card.id); + assert.equal(g.ixTechnologyTurn, undefined); +}); + +void test('the pending physical card cannot begin a competing gift, paid Box search, or cache acquisition response', () => { + for (const source of ['cache', 'blackMarket'] as const) { + let g = fixture(); + g.players[0].ally = 'i'; + g.players[1].ally = 'r'; + const karamaIndex = g.deck.findIndex((c) => c.effect === 'karama'); + const karama = g.deck.splice(karamaIndex, 1)[0]; + g.players[0].hand.push(karama); + if (source === 'cache') { + g = richese(g, { decline: true }); + g = richese(g, { position: 'first' }); + for (const p of g.players) + if (g.response && !g.response.passed.includes(p.id)) + g = applyAction(g, p.id, { type: 'passResponse' }); + g = richese(g, { card: g.richeseCache![0].id, method: 'silent' }); + reject(g, 'r', { + type: 'card', + mode: 'special', + card: karama.id, + acquire: g.pendingIxRicheseTechnology!.card.id, + }); + } else { + const index = g.richeseCache!.findIndex( + (c) => c.effect === 'nullentropyBox', + ); + const card = g.richeseCache!.splice(index, 1)[0]; + g.players[0].hand.push(card); + g.discard.push(...g.deck.splice(0, 1)); + g = richese(g, { card: card.id, method: 'silent' }); + reject(g, 'r', { type: 'richeseGift', card: card.id }); + reject(g, 'r', { type: 'card', card: card.id }); + assert.equal( + viewGame(g, 'r').richeseGift?.cards.some((c) => c.id === card.id), + false, + ); + } + assert.equal(g.response, null); + assert.equal(g.pendingNullentropy, undefined); + assert.equal(g.pendingRicheseGift, undefined); + assert.equal(g.decision?.kind, 'ixRicheseTechnology'); + } +}); + +void test('spending the last Ixian hand card in an independent Truthtrance leaves the saved per-lot decline available', () => { + let g = fixture(); + g.deck.push(...g.players[1].hand.splice(0)); + const index = g.deck.findIndex((c) => c.effect === 'truthtrance'); + const truth = g.deck.splice(index, 1)[0]; + g.players[1].hand.push(truth); + g = richese(g, { card: g.players[0].hand[0].id, method: 'silent' }); + const receipt = structuredClone(g.pendingIxRicheseTechnology); + g = applyAction(g, 'i', { type: 'card', card: truth.id }); + while (g.truthtrance?.stage === 'priority') { + const player = g.players.find( + (p) => !g.truthtrance!.passed.includes(p.id), + )!; + g = applyAction(g, player.id, { type: 'truthPass' }); + } + g = applyAction(g, 'i', { + type: 'truthAsk', + question: { + kind: 'fact', + target: 'a', + fact: { kind: 'hand', name: g.players[2].hand[0].name }, + }, + }); + g = applyAction(reload(g), 'a', { type: 'truthAnswer', answer: 'yes' }); + assert.equal(g.players[1].hand.length, 0); + assert.deepEqual(g.pendingIxRicheseTechnology, receipt); + assert.equal(g.decision?.kind, 'ixRicheseTechnology'); + g = decline(g); + assert.equal(g.richeseAuction!.cardId, receipt!.card.id); + assert.equal(g.ixTechnologyTurn, undefined); +}); + +void test('Harkonnen random exchange rejects the reserved Black Market hand before RNG in live and suspended Ixian choices', (t) => { + const g = fixture(true, true); + const karama = g.deck.splice( + g.deck.findIndex((c) => c.effect === 'karama'), + 1, + )[0]; + g.players[3].hand.push(karama); + g.players[0].hand.push( + g.deck.splice( + g.deck.findIndex((c) => c.kind === 'worthless'), + 1, + )[0], + ); + const box = g.richeseCache!.splice( + g.richeseCache!.findIndex((c) => c.effect === 'nullentropyBox'), + 1, + )[0]; + g.players[1].hand.push(box); + g.discard.push( + g.deck.splice( + g.deck.findIndex((c) => c.kind === 'worthless'), + 1, + )[0], + ); + g.discard.push( + g.deck.splice( + g.deck.findIndex((c) => c.kind === 'shield'), + 1, + )[0], + ); + const original = reload(g); + const games: Game[] = []; + for (const card of original.players[0].hand) { + const live = richese(original, { card: card.id, method: 'silent' }); + const suspended = applyAction(live, 'i', { type: 'card', card: box.id }); + assert.equal( + suspended.pendingNullentropy!.resume.decision?.kind, + 'ixRicheseTechnology', + ); + assert.ok(viewGame(suspended, 'i').ixRicheseTechnology); + games.push(live, suspended); + } + let cache = richese(original, { decline: true }); + cache = richese(cache, { position: 'first' }); + cache = applyAction(cache, 'h', { type: 'passResponse' }); + cache = richese(cache, { card: cache.richeseCache![0].id, method: 'silent' }); + const random = t.mock.method(crypto, 'getRandomValues', () => { + throw new Error('Unexpected random selection'); + }); + const uuid = t.mock.method(crypto, 'randomUUID', () => { + throw new Error('Unexpected event allocation'); + }); + for (const state of games) + for (const amount of [1, 2]) { + const action = { + type: 'card', + mode: 'special', + card: karama.id, + target: 'r', + amount, + }; + const before = structuredClone(state); + assert.throws( + () => prepareSpecialKaramaIntent(state, 'h', action), + /pending Black Market Ixian choice/, + ); + assert.throws(() => applyAction(state, 'h', action)); + assert.deepEqual(state, before); + } + // A cache lot reserves the separate cache card, not Richese's unrelated hand. + assert.equal( + prepareSpecialKaramaIntent(cache, 'h', { + type: 'card', + mode: 'special', + card: karama.id, + target: 'r', + amount: 1, + }).kind, + 'harkonnen', + ); + assert.equal(random.mock.callCount(), 0); + assert.equal(uuid.mock.callCount(), 0); +}); diff --git a/tests/richese-engine-review.test.ts b/tests/richese-engine-review.test.ts index ddb82922..901df1b9 100644 --- a/tests/richese-engine-review.test.ts +++ b/tests/richese-engine-review.test.ts @@ -320,34 +320,42 @@ void test('normal paid auctions after a cache sale route income to Emperor witho assert.equal(g.auction!.index, 1); }); -void test('advanced unused Ixian Technology explicitly fences special lots when Ixians hold a replacement', () => { +void test('advanced unused Ixian Technology pauses special lots for an explicit per-lot decline', () => { let g = fixture(true); g.players[2].faction = 'ixians'; g.players[2].hand = [g.deck.shift()!]; - reject( - g, - 'r', - { - type: 'decision', - event: g.richeseBidding!.event, - card: g.players[0].hand[0].id, - method: 'silent', - }, - /Ixian Technology/, - ); + const blackMarket = applyAction(g, 'r', { + type: 'decision', + event: g.richeseBidding!.event, + card: g.players[0].hand[0].id, + method: 'silent', + }); + assert.equal(blackMarket.decision?.kind, 'ixRicheseTechnology'); + assert.equal(blackMarket.richeseAuction, null); + const resumed = applyAction(blackMarket, g.players[2].id, { + type: 'decision', + event: blackMarket.pendingIxRicheseTechnology!.event, + decline: true, + }); + assert.equal(resumed.richeseAuction?.source, 'blackMarket'); + assert.equal(resumed.ixTechnologyTurn, g.ixTechnologyTurn); g = decision(g, { decline: true }); g = decision(g, { position: 'first' }); - reject( - g, - 'r', - { - type: 'decision', - event: g.richeseBidding!.event, - card: g.richeseCache![0].id, - method: 'silent', - }, - /Ixian Technology/, - ); + const cache = applyAction(g, 'r', { + type: 'decision', + event: g.richeseBidding!.event, + card: g.richeseCache![0].id, + method: 'silent', + }); + assert.equal(cache.decision?.kind, 'ixRicheseTechnology'); + assert.equal(cache.richeseAuction, null); + const resumedCache = applyAction(cache, g.players[2].id, { + type: 'decision', + event: cache.pendingIxRicheseTechnology!.event, + decline: true, + }); + assert.equal(resumedCache.richeseAuction?.source, 'cache'); + assert.equal(resumedCache.ixTechnologyTurn, g.ixTechnologyTurn); g.ixTechnologyTurn = g.turn; g = decision(g, { card: g.richeseCache![0].id, method: 'silent' }); assert.equal(g.richeseAuction!.source, 'cache'); diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index 4986577f..237e31aa 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -3,10 +3,22 @@ import { initializeIxGameForAudit, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, + initializeFactionExpansionsGameForAudit, viewGame, type Game, } from '../game/engine'; +export const PROTOTYPE_PROFILES = [ + 'ix', + 'discovery', + 'leader-skills', + 'factions', +] as const; +export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number]; +export function isPrototypeProfile(value: string): value is PrototypeProfile { + return PROTOTYPE_PROFILES.some((profile) => profile === value); +} + /** Local development only. This cannot create a seat, change its owner, or * redeal a started game. The caller backs up the database before invoking it. */ export function startIxPrototypeRoom( @@ -20,7 +32,7 @@ export function startPrototypeRoom( db: DatabaseSync, code: string, expectedVersion: number, - profile: 'ix' | 'discovery' | 'leader-skills', + profile: PrototypeProfile, ) { if ( !/^[A-Z0-9]{8}$/.test(code) || @@ -38,17 +50,19 @@ export function startPrototypeRoom( const initial = JSON.parse(row.state) as Game; if (initial.code !== code) throw new Error('The room identity is inconsistent.'); - if (!['ix', 'discovery', 'leader-skills'].includes(profile)) + if (!isPrototypeProfile(profile)) throw new Error('Unknown prototype profile.'); const game = profile === 'ix' ? initializeIxGameForAudit(initial) - : profile === 'leader-skills' - ? initializeLeaderSkillsGameForAudit(initial) - : initializeDiscoveryGameForAudit({ - ...initial, - discoveryEnabled: true, - }); + : profile === 'factions' + ? initializeFactionExpansionsGameForAudit(initial) + : profile === 'leader-skills' + ? initializeLeaderSkillsGameForAudit(initial) + : initializeDiscoveryGameForAudit({ + ...initial, + discoveryEnabled: true, + }); // Exercise the same player projection before accepting the new saved state. for (const player of game.players) viewGame(game, player.id); const version = expectedVersion + 1; diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index b3b021ce..3392b8b0 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { captureSavedGames } from './saved-game-verification'; -import { startPrototypeRoom } from './prototype-room'; +import { isPrototypeProfile, startPrototypeRoom } from './prototype-room'; import { sourceSnapshot } from './verification'; async function main() { @@ -20,12 +20,12 @@ async function main() { }); if (values.help) { console.log( - 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', + 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Factions supports selected expansion factions without optional modules; the separate Ecaz Treachery variant stays disabled. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', ); return; } if ( - !['ix', 'discovery', 'leader-skills'].includes(values.profile) || + !isPrototypeProfile(values.profile) || !values.db || !values.room || !values.out || @@ -46,12 +46,7 @@ async function main() { throw new Error('The requested lobby version is absent from the backup.'); const db = new DatabaseSync(values.db); try { - const result = startPrototypeRoom( - db, - values.room, - version, - values.profile as 'ix' | 'discovery' | 'leader-skills', - ); + const result = startPrototypeRoom(db, values.room, version, values.profile); writeFileSync( resolve(values.out, 'prototype.json'), JSON.stringify(