From 4af6e08b3aec7bfb8ec32a31660219508f596f69 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:39:43 +1000 Subject: [PATCH] Prototype CHOAM Nexus Collection trade and inventory Discoveries An unallied player holding CHOAM Nexus while CHOAM is absent can now discard one Worthless card during Spice Collection for two bank spice. The server spends both physical cards once, exposes only private legal card choices, and persists an already-paid discard receipt. Human controls and all four AI profiles share the action. The after-victory inspection alternative remains missing; normal unfinished modes and publication stay gated. Independent review caught and fixed a queued Grumman Collection lock mismatch. Focused tests cover Basic/Advanced, actual card custody, private controls, all AI profiles, stale and wrong-mode rejection, Truthtrance and Grumman locks, JSON corruption/replay, competing production SQLite writes and private seat restoration. A separate bounded acquisition helper recorded all seven Discovery Spice Cards, eight tokens and exact physical card placements from publisher-authored sources. Discovery runtime remains missing. Validation: types, lint, 4,217 offline tests, production build and 40 HTTP tests passed. Durations: check 139298 ms; build 6617 ms; HTTP 24329 ms. Source fingerprint dfdaaf8bafe69acd31c9d0afc13f7a0d070da43deb544f091e95fe840b399493; private report under ~/.local/share/dune/checkpoints/20260913-choam-nexus-trade/verification/report.json. Browser reference search, Prototyped label and related Collection link checked. All 95 opening saved rooms unchanged; 30 HTTP test rooms added, 125 total. No server restart or database reset. Efficiency counter: 59,982,405 at batch start; 60,581,959 after broad verification, delta 599,554 aggregate tokens before final preservation/Git/usage checks. One newly playable function plus the acquired Discovery inventory; this is not full CHOAM or Nexus compliance. --- components/game-table.tsx | 2 + components/nexus-choam-trade.tsx | 36 ++++++++ docs/CURRENT_STATUS.md | 9 +- docs/DISCOVERY_COMPONENTS.md | 89 ++++++++++++++++++++ docs/EFFICIENCY_PILOT.md | 9 ++ docs/IMPLEMENTATION_STATUS.md | 17 ++++ docs/NEXUS_CHOAM_SECRET_ALLY.md | 55 ++++++++++++ docs/RULE_DECISIONS.md | 1 + game/bots.ts | 3 + game/engine.ts | 80 ++++++++++++++++++ game/nexus-choam-trade-options.ts | 15 ++++ game/nexus-choam-trade.ts | 55 ++++++++++++ game/reference.ts | 23 ++++++ tests/fixture-grumman-collection.ts | 17 +++- tests/fixture-nexus-choam-trade.ts | 37 +++++++++ tests/nexus-choam-trade-grumman.test.ts | 57 +++++++++++++ tests/nexus-choam-trade-recovery.test.ts | 60 ++++++++++++++ tests/nexus-choam-trade.test.ts | 101 +++++++++++++++++++++++ 18 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 components/nexus-choam-trade.tsx create mode 100644 docs/DISCOVERY_COMPONENTS.md create mode 100644 docs/NEXUS_CHOAM_SECRET_ALLY.md create mode 100644 game/nexus-choam-trade-options.ts create mode 100644 game/nexus-choam-trade.ts create mode 100644 tests/fixture-nexus-choam-trade.ts create mode 100644 tests/nexus-choam-trade-grumman.test.ts create mode 100644 tests/nexus-choam-trade-recovery.test.ts create mode 100644 tests/nexus-choam-trade.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 5654e709..f165a4e2 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -12,6 +12,7 @@ import { GrummanCollection } from './grumman-collection'; import { TupileIntelligence } from './tupile-intelligence'; import { NexusCards } from './nexus-cards'; import { NexusTraitors } from './nexus-traitors'; +import { NexusChoamTrade } from './nexus-choam-trade'; import { NexusTleilaxu } from './nexus-tleilaxu'; import { NexusSuboids } from './nexus-suboids'; import { NexusAdvisors } from './nexus-advisors'; @@ -1435,6 +1436,7 @@ export function GameTable({ + diff --git a/components/nexus-choam-trade.tsx b/components/nexus-choam-trade.tsx new file mode 100644 index 00000000..b3f933d9 --- /dev/null +++ b/components/nexus-choam-trade.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { nexusChoamTradeAction } from '@/game/nexus-choam-trade-options'; +import { CardInspector } from './card-inspector'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; +export function NexusChoamTrade({ game, act, busy }: Props) { + const [selected, select] = useState(''); + const offer = game.nexusChoamTrade; + if (!offer) return null; + const card = offer.cards.find(choice => choice.id === selected) ?? offer.cards[0]; + const action = card ? nexusChoamTradeAction(game, card.id) : null; + return ( +
+

CHOAM Nexus · Secret Ally

+

During Spice Collection, discard one Worthless card to receive two spice from the bank. This also spends your CHOAM Nexus card.

+ {offer.cards.length > 1 && ( + + )} + {card &&
{card.name}
} + {offer.blocked &&

{offer.blocked}

} + +

Optional: keep both cards by continuing the phase. This Nexus card’s battle inspection alternative is still in development.

+
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5e49a82c..55a50c82 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -4,6 +4,8 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid ## Evidence and source of truth +- Ix prototype checkpoint `5a76ae0`: types, lint, **4,211 offline tests**, build and **40 HTTP tests** passed. Genuine Basic/Advanced setup and two seeded complete simulations were exercised; a short browser game finished with private hand restoration. All 64 opening saved rooms were preserved; 31 QA rooms were added. This is bounded prototype evidence, not complete Ix compliance. + - [Rules implementation checklist](../game/reference.ts): `RULE_TOPICS`, topic `implementation-checklist`, and each feature's implementation, controls, AI, documentation and verification entries. Coverage is per stated boundary, not an implicit whole-game certificate. - [Latest verified gameplay checkpoint](IMPLEMENTATION_STATUS.md): Truthtrance hand inventory, 13 September 2026 (`7454456`): types, lint, **4,200 offline tests**, production build and **40 HTTP tests** passed, with a phone/browser refresh journey and preserved saved games. These are recorded results, not checks rerun for this dashboard. - Latest verification checkpoint `4a0b1ee`: genuine Advanced full-plan interaction tests; types, lint and **4,205 offline tests** passed. Private inspection restored after restart, with desktop/phone checks and all 64 saved games unchanged. No runtime change; prior build/HTTP evidence applies. @@ -48,8 +50,11 @@ Likewise, the [Truthtrance readiness audit](TRUTHTRANCE_NONBATTLE_READINESS_2026 The first connected batch is the [Ixians & Tleilaxu prototype](IX_PROTOTYPE.md): genuine setup, both expansion factions, full Ix deck and Sandtrout, shared human/AI controls and saved continuation. The checklist now distinguishes its Prototyped -development stage from Partial rules coverage. Discoveries remain missing; their -seven spice cards and eight token faces need a verified inventory and adapters. +development stage from Partial rules coverage. The next connected action is the [CHOAM Nexus Collection trade](NEXUS_CHOAM_SECRET_ALLY.md), +with private controls, all four AI profiles and a saved payment/discard continuation. +Its after-victory inspection remains missing. [Discovery components](DISCOVERY_COMPONENTS.md) +now records all seven spice cards, eight tokens and exact card placements; +Discovery runtime and controls remain missing. The user changed the development order on 13 September: get working prototypes of all remaining functions into the game, then use integration and play to find diff --git a/docs/DISCOVERY_COMPONENTS.md b/docs/DISCOVERY_COMPONENTS.md new file mode 100644 index 00000000..4f509a68 --- /dev/null +++ b/docs/DISCOVERY_COMPONENTS.md @@ -0,0 +1,89 @@ +# Discovery components + +This is a source-bound acquisition record for the *Ecaz & Moritani* Discovery +variant. It records identities and values needed by an implementation; it does +not reproduce card prose or artwork. + +## Sources and method + +- **EM rules:** Gale Force Nine's publisher-authored *Ecaz & Moritani* + rulebook, [retailer-hosted PDF mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf). + Page 4 establishes seven Spice Cards and eight Discovery tokens. Pages 12–13 + establish the common card procedure, Great Maker behavior, the two token + types, all eight token names and their effects. The + [publisher URL](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf) + returned HTTP 403 during this acquisition pass. +- **Printed card faces:** the clear English + [BoardGameGeek component scan](https://boardgamegeek.com/image/9022457/dune-ecaz-and-moritani) + ([image metadata](https://api.geekdo.com/api/images/9022457), + [original-resolution image](https://cf.geekdo-images.com/I9pgb8CJWnRp32GzKwyrug__original/img/tqGpry-Bh3D7ZYSZ3W60CaE6PCo=/0x0/filters:format(png)/pic9022457.png)) + shows all six printed Discovery territory cards with © 2022 Legendary + markings. This is a scan of publisher components hosted by a community user, + not a fan rules implementation. +- **Sector indexing:** territory names and token types are printed as text on + the card faces. Numeric sectors below are visual transcriptions of each + highlighted card wedge, checked against the GF9 board image on the original + designers' [Dune core page](https://futurepastimes.com/dune-board-game) + ([board image](https://images.squarespace-cdn.com/content/v1/5627edd5e4b0e42d8c948a6a/1658421042495-X385VWK17DPR8B5SX6E8/Dune%2BCore%2BGallery_0004_Background.jpg)). + Numbering starts with the printed Storm Start wedge in Meridian as sector 1 + and continues counterclockwise. No repository or fan implementation supplied + these identities. + +## Seven Discovery Spice Cards + +Every territory card places six spice in its spice destination. The special +blow removes any spice and forces already there, then places a random +face-down token of the printed type at the separate token destination. + +| Printed card | Spice destination | Discovery token destination | Token type | +| --- | --- | --- | --- | +| Hagga Basin | Hagga Basin, sector 12 | Gara Kulon, sector 8 | Hiereg | +| Rock Outcroppings | Rock Outcroppings, sector 13 | Meridian, sector 1 | Hiereg | +| Sihaya Ridge | Sihaya Ridge, sector 9 | Cielago East, sector 3 | Hiereg | +| Wind Pass North | Wind Pass North, sector 17 | Plastic Basin, sector 13 | Smuggler | +| Funeral Plain | Funeral Plain, sector 15 | Pasty Mesa, sector 7 | Smuggler | +| Old Gap | Old Gap, sector 10 | False Wall West, sector 17 | Smuggler | +| Great Maker | No territory or sector | No Discovery token | Special sandworm | + +Great Maker resolves the usual sandworm removal in the last spice territory, +then players vote in storm order on whether a Nexus occurs; a strict majority +is required. Afterward, Fremen may ride using any number of reserve forces, +subject to normal storm and occupancy restrictions. A Karama that kills Fremen +forces in the affected territory does not cancel that reserve ride. + +## Eight Discovery tokens + +The first five entries are persistent location tokens. A revealed location is +a territory nested inside its surrounding territory, admits at most two +factions, can be shipped into at stronghold cost, and protects occupants from +storm and sandworm effects. The last three resolve or become a carried benefit +and are removed as stated. + +| Printed token | Type | Verified functional identity | +| --- | --- | --- | +| Jacurutu Sietch | Hiereg location | Counts as a normal stronghold; its battle winner receives one spice per opposing undialed force sent to the Tanks. | +| Cistern | Hiereg location | An occupant gains two spice during Spice Collection. | +| Ecological Testing Station | Hiereg location | An occupant may alter storm movement by one in either direction during the Storm phase; Weather Control is unaffected. | +| Shrine | Hiereg location | An occupant may use Truthtrance as Karama or Karama as Truthtrance. | +| Orgiz Processing Station | Smuggler location | During Spice Collection, an occupant takes one spice from every spice blow that is collected. | +| Treachery Card Stash | Smuggler one-shot | Gain one Treachery Card; at the hand limit, gain first and then discard any card. Remove the token. | +| Spice Stash | Smuggler one-shot | Gain seven spice from the bank, then remove the token. | +| Ornithopter | Smuggler carried/one-shot | Take the token; on a later turn it gives movement three for one movement action, then is removed. | + +Face-down Hiereg tokens may be inspected privately by Fremen at any time; +face-down Smuggler tokens have the equivalent Spacing Guild privilege. Other +factions with non-advisor forces at the token may inspect it during Spice +Collection and choose whether to reveal it. + +## Explicit limits + +- All 15 requested identities (seven cards and eight tokens), the six card + placements, and the Hiereg/Smuggler classifications are resolved. +- The territory names are directly legible text. The sector numbers are a + visual reading of the printed highlighted wedges because the card sentence + names the destination territory but does not print its sector number. +- No second independent physical scan of the Great Maker face or the eight + token faces was found in this bounded pass. Their identities and behavior are + verified from the publisher-authored rulebook only. +- Card backs, token backs beyond the two type backs pictured in the rulebook, + dimensions, exact color, and artwork are not acquired or certified here. diff --git a/docs/EFFICIENCY_PILOT.md b/docs/EFFICIENCY_PILOT.md index ec5eca5e..e76c1168 100644 --- a/docs/EFFICIENCY_PILOT.md +++ b/docs/EFFICIENCY_PILOT.md @@ -82,3 +82,12 @@ leave the removed recurring automation absent. After `32791ed`, usage was 3% use Use [current status](CURRENT_STATUS.md), [rule decisions](RULE_DECISIONS.md) and [verification workflow](VERIFICATION_WORKFLOW.md) as short entry points. Historical logs remain references, not mandatory full-context input on every turn. + +The next rapid prototype batch begins at aggregate counter 59,982,405 and adds +one missing action: CHOAM Nexus Secret Ally Collection trade. A workhorse helper +acquires the complete Discovery inventory for the following batch; a reused +independent reviewer checks this trade and its queued-Collection lock. The owner +connects runtime, private controls, AI and SQLite recovery. The battle inspection +alternative stays explicitly missing instead of holding the independent trade +for an exhaustive battle-cleanup design. Final checks, counter endpoint and +checkpoint scope are recorded in the commit. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 05a6f459..85dbc69c 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,22 @@ # Dune implementation status +## 13 September 2026 — CHOAM Nexus Collection prototype and Discovery inventory + +Adds the missing absent-CHOAM Secret Ally Collection trade through the engine, +private physical-card selector/inspector, all four AI profiles and JSON discard +continuation. Both cards are spent for exactly two bank spice; stale actions, +competing SQL writes and saved replay cannot duplicate the payment. The separate +battle inspection remains missing. See [contract and scope](NEXUS_CHOAM_SECRET_ALLY.md). +Independent review found a queued Grumman Collection lock mismatch; the server +and private offer now share that automatic-continuation block. + +A bounded acquisition helper resolved [all Discovery component identities and +card placements](DISCOVERY_COMPONENTS.md). No Discovery runtime is claimed. +Focused trade tests pass; checkpoint-wide check/build/HTTP results are recorded +in the verified commit and private source-bound report. Existing mode and +publication gates remain closed. No server restart or saved-game reset is needed. + + ## 13 September 2026 — Genuine Ix expansion prototype Connected Basic/Advanced Ix setup with both expansion factions, the complete diff --git a/docs/NEXUS_CHOAM_SECRET_ALLY.md b/docs/NEXUS_CHOAM_SECRET_ALLY.md new file mode 100644 index 00000000..840d7f61 --- /dev/null +++ b/docs/NEXUS_CHOAM_SECRET_ALLY.md @@ -0,0 +1,55 @@ +# CHOAM Nexus Secret Ally prototype + +13 September 2026. The Spice Collection trade is **Prototyped** in the development +Nexus module. Its battle inspection alternative is still missing. This checkpoint +does not enable normal Nexus starts, certify the full card family, or open a +publication gate. + +## Source and behavior + +The printed CHOAM card's Secret Ally panel allows a Worthless card to be discarded +during Spice Collection for two spice, or a random unused opposing hand card to +be inspected after a victory. The [common source contract](NEXUS_CARD_RULES.md) +records the [photographed publisher component](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani) +and publisher lifecycle rules. [Native Cunning](NEXUS_CHOAM_RULES.md) has different +costs and effects and is not reused as a substitute for this panel. + +Only an unallied holder with CHOAM absent may use this trade, in Basic or Advanced. +The server quotes the holder's actual Worthless cards. The player selects one; +its printed identity and physical discard custody remain intact. Both that card +and the CHOAM Nexus card are spent, and two spice are paid from the bank exactly +once. No native faction identity, market sale, Cunning conversion, or Auditor +payment is introduced. No blanket Nexus Karama immunity is inferred: this trade +uses the absent-faction panel rather than a native CHOAM advantage. + +Collection readiness remains optional; continuing without trading keeps the +cards. The action cannot interrupt a decision, Truthtrance, committed discard, +exchange, or queued automatic Collection choice. It may still be used while its +owner is ready if Collection has not ended. Exhaustive simultaneous Collection +combinations remain part of the later integration pass. + +## Connected paths + +- `game/nexus-choam-trade.ts` provides the private quote and saved receipt checks. + `game/engine.ts` applies payment, Nexus/card custody and the semantic fresh + discard continuation. The completed history remains private server evidence. +- `game/nexus-choam-trade-options.ts` supplies the shared human/AI action. + `components/nexus-choam-trade.tsx` provides a card picker, inspector, costs and + availability reasons at the table. All four AI profiles use eligible trades. +- The existing JSON room state stores the receipt. No database migration or + server restart is required. Recovery validates the already-paid outcome and + retires the discard; it never credits spice a second time. + +## Focused evidence and remaining scope + +Run `npm test -- nexus-choam-trade`. Focused cases cover Basic/Advanced payment, +physical custody, all four AI profiles, private rendered controls, wrong-mode and +stale action rejection, Truthtrance locking, damaged saved receipts and JSON +continuation. Production SQLite tests exercise competing trades, seat restoration, +replay rejection and an already-paid discard resume. + +The after-victory inspection still needs an original battle receipt, exclusion of +the opponent's used cards, a private one-card sample and composition with winner +cleanup. Complete Nexus games, broader Homeworld interactions, strategic choice +between the two Secret Ally uses and visual acceptance of that full family remain +unfinished. Prototype evidence is deliberately narrower than complete compliance. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 6d88589c..3a630376 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -14,6 +14,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi | Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) | | Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) | | Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) | +| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | | Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) | diff --git a/game/bots.ts b/game/bots.ts index 33203eff..b500cb6b 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1,3 +1,4 @@ +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 { choamPowerAction, choamPowerBotPlay } from './choam-power-options'; @@ -3368,6 +3369,8 @@ export function botActions(g: GameView): Action[] { : nexusTraitorBotActions(g); if (g.automaticContinuationPending) return []; if (g.nexusCards?.waiting.length) return nexusCardBotActions(g); + const trade = nexusChoamTradeBotActions(g); + if (trade.length) return trade; const sardaukar = nexusSardaukarBotActions(g); if (sardaukar.length) return sardaukar; const advisorConversion = nexusAdvisorBotActions(g); diff --git a/game/engine.ts b/game/engine.ts index 4021a0f9..68ca1842 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade'; import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally'; import { shipmentAvailable } from './shipment-opportunity'; import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning'; @@ -883,6 +884,8 @@ type RicheseAllyOffer = { payer: string; }; export type Game = { + nexusChoamTrades?: NexusChoamTrade[]; + nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] }; treacheryDiscardSequence?: number; resolvedTreacheryDiscardSequence?: number; /** Committed discard receipts are evidence, not additional card custody. */ @@ -891,6 +894,7 @@ export type Game = { batch: FreshDiscardBatch; continuation: | { kind: 'ambassador'; entry: NonNullable } + | { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number } | { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number } | { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable } | { @@ -1482,6 +1486,61 @@ function nexusCardsIntegrity(g: Game) { !g.truthtrance && !g.pendingKarama && !g.pendingTreacheryDiscard, 'Finish the preceding phase interaction before drawing Nexus cards.'); } +function nexusChoamTradeIntegrity(g: Game) { + const history = g.nexusChoamTrades; + const continuation = g.pendingTreacheryDiscard?.continuation; + const pending = continuation?.kind === 'nexusChoamTrade' ? continuation : null; + if (history === undefined) { + requireRule(!g.nexusChoamTradeLast && !pending, 'The CHOAM Nexus trade has lost its saved history.'); + return; + } + requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, + 'The CHOAM Nexus trade requires its physical module and history.'); + const events = new Set(), turns = new Set(); + const physical = physicalTreacheryCards(g); + for (const [index, record] of history.entries()) { + nexusRule(() => validateNexusChoamTrade(g, record, physical)); + requireRule(!events.has(record.event) && !turns.has(record.turn) && + (record.stage === 'complete' || index === history.length - 1), + 'The CHOAM Nexus trade has a duplicated or unfinished prior payment.'); + events.add(record.event); turns.add(record.turn); + } + const last = history.at(-1)!; + requireRule(JSON.stringify(g.nexusChoamTradeLast) === JSON.stringify({event: last.event, stage: last.stage}) && + (last.stage === 'discard') === !!pending, + 'The CHOAM Nexus trade has lost or reopened its committed discard.'); + if (last.turn === g.turn) + requireRule(g.nexusCards.cards.discard.includes('choam'), 'The CHOAM Nexus trade has lost its spent Nexus card.'); + if (pending) requireRule(g.status === 'playing' && g.phase === 7 && last.turn === g.turn && + pending.event === last.event && pending.owner === last.owner && pending.card === last.card && + pending.spiceAfter === last.spiceBefore + 2 && getPlayer(g, last.owner).spice === pending.spiceAfter, + 'The CHOAM Nexus trade no longer matches its committed payment.'); +} +function currentNexusChoamTrade(g: Game, owner: string) { + return nexusRule(() => quoteNexusChoamTrade(g, owner, + g.phase === 7 && grummanCollectionAutomatic(g))); +} +function playNexusChoamTrade(g: Game, p: Player, action: Action) { + const offer = currentNexusChoamTrade(g, p.id); + requireRule(Object.keys(action).every(key => ['type', 'event', 'card'].includes(key)) && + offer && !offer.blocked && action.event === offer.event && + offer.cards.some(card => card.id === action.card), + offer?.blocked ?? 'Choose your current CHOAM Nexus trade and one held Worthless card.'); + requireRule(Number.isSafeInteger(p.spice) && p.spice >= 0 && Number.isSafeInteger(p.spice + 2), + 'The CHOAM Nexus trade needs a valid spice balance.'); + const record: NexusChoamTrade = { event: offer.event, turn: g.turn, owner: p.id, + card: action.card as string, spiceBefore: p.spice, stage: 'discard', signature: '' }; + record.signature = nexusChoamTradeSignature(record); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + const card = discard(g, p, record.card); + p.spice += 2; + (g.nexusChoamTrades ??= []).push(record); + g.nexusChoamTradeLast = { event: record.event, stage: record.stage }; + stageTreacheryDiscard(g, 'nexus:choamTrade', [{card, discardedBy: p.id, publicFace: true}], + {kind: 'nexusChoamTrade', event: record.event, owner: p.id, card: card.id, spiceAfter: p.spice}); + log(g, `${p.name} spent CHOAM Nexus Secret Ally and discarded ${card.name} during Spice Collection to receive 2 spice from the bank. Both cards are spent.`, + {faction: p.faction, name: 'CHOAM Nexus trade'}); +} function markNexusOccurred(g: Game) { if (!g.nexusCards) return; const phase = g.nexusCards.phase; @@ -4256,6 +4315,13 @@ function treacheryDiscardIntegrity(g: Game) { Array.isArray(c.optional) && new Set(c.optional).size === c.optional.length && c.optional.every((id) => getPlayer(g, c.player).hand.some((card) => card.id === id)), 'The mandatory winning card discard no longer matches its resolved battle.'); + } else if (continuation?.kind === 'nexusChoamTrade') { + const c = continuation; + nexusChoamTradeIntegrity(g); + requireRule(batch.cause === 'nexus:choamTrade' && batch.entries.length === 1 && + batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace && + batch.entries[0].card.id === c.card && batch.entries[0].card.kind === 'worthless', + 'The CHOAM Nexus trade has changed its discarded Worthless card.'); } else if (continuation?.kind === 'kaitainDiscard') { const c = continuation; requireRule(g.phase === 3 && g.biddingEnd?.event === c.event && @@ -4304,6 +4370,12 @@ function finishTreacheryDiscard(g: Game) { finishWinner(g, getPlayer(g, next.player), next.territory, next.optional); return; } + if (next.kind === 'nexusChoamTrade') { + const record = g.nexusChoamTrades!.at(-1)!; + record.stage = 'complete'; record.signature = nexusChoamTradeSignature(record); + g.nexusChoamTradeLast = { event: record.event, stage: record.stage }; + return; + } if (next.kind === 'kaitainDiscard') return; if (next.kind === 'ambassador') { const p = getPlayer(g, next.entry.beneficiary!); @@ -12157,6 +12229,7 @@ function resumeMarketGhola(g: Game) { if (!pending || pending.stage !== 'complete' || g.pendingTreacheryDiscard || g.pendingNullentropy || g.response || g.decision || g.pendingKarama || g.truthtrance || g.phaseOpening || g.pendingRicheseGift || g.pendingExchange) return; + nexusChoamTradeIntegrity(g); marketGholaIntegrity(g); g.response = structuredClone(pending.response); g.pendingChoamMarketGhola = null; @@ -17573,6 +17646,7 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + nexusChoamTradeIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); @@ -17723,6 +17797,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { reconcileShipmentPromises(g, { actor: id, action }); settleAutomaticContinuations(g); observeOccupation(g); + nexusChoamTradeIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); @@ -17817,6 +17892,7 @@ function settleAutomaticContinuations(g: Game) { } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { + nexusChoamTradeIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); @@ -17844,6 +17920,7 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileShipmentPromises(g); settleAutomaticContinuations(g); observeOccupation(g); + nexusChoamTradeIntegrity(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); @@ -17934,6 +18011,7 @@ function applyActionInner( requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.'); finishNexusTraitorReturn(g, p, action); return g; } + if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; } if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; } requireRule( !( @@ -21156,6 +21234,7 @@ function applyActionInner( throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string) { + nexusChoamTradeIntegrity(state); marketGholaIntegrity(state); homeworldRule(() => homeworldGameIntegrity(state)); homeworldBattleLossIntegrity(state); @@ -21447,6 +21526,7 @@ export function viewGame(state: Game, id: string) { techTokens: g.techTokens ?? null, strongholdCards: g.strongholdCards ?? null, nexusCards: projectedNexusCards(g, id), + nexusChoamTrade: currentNexusChoamTrade(g, id), nexusMoritani: nexusMoritaniOffer(g,id), nexusRichese: nexusRicheseOffer(g,id), nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id), diff --git a/game/nexus-choam-trade-options.ts b/game/nexus-choam-trade-options.ts new file mode 100644 index 00000000..e986cddb --- /dev/null +++ b/game/nexus-choam-trade-options.ts @@ -0,0 +1,15 @@ +import type { Action, GameView } from './engine'; + +/** Human and AI use the same private, server-quoted physical card choices. */ +export function nexusChoamTradeAction(game: GameView, card: string): Action | null { + const offer = game.nexusChoamTrade; + if (game.status !== 'playing' || game.phase !== 7 || + game.nexusCards?.card !== 'choam' || game.automaticContinuationPending || + !offer || offer.blocked || !offer.cards.some(choice => choice.id === card)) return null; + return { type: 'nexusChoamTrade', event: offer.event, card }; +} +export function nexusChoamTradeBotActions(game: GameView): Action[] { + const card = game.nexusChoamTrade?.cards[0]; + const action = card ? nexusChoamTradeAction(game, card.id) : null; + return action ? [action] : []; +} diff --git a/game/nexus-choam-trade.ts b/game/nexus-choam-trade.ts new file mode 100644 index 00000000..f80c02ad --- /dev/null +++ b/game/nexus-choam-trade.ts @@ -0,0 +1,55 @@ +import type { Card } from './cards'; +import type { Game } from './engine'; +import { nexusCardMode } from './nexus-cards'; + +export type NexusChoamTrade = { + event: string; + turn: number; + owner: string; + card: string; + spiceBefore: number; + stage: 'discard' | 'complete'; + signature: string; +}; +export function nexusChoamTradeSignature(record: NexusChoamTrade): string { + return JSON.stringify([record.event, record.turn, record.owner, record.card, + record.spiceBefore, record.stage]); +} +export function nexusChoamTradeEvent(turn: number, owner: string): string { + return JSON.stringify(['nexusChoamTrade', turn, owner]); +} +/** Private owner quote. Secret Ally does not change faction identity or invoke + * native CHOAM market prices, Cunning conversions, or Auditor rules. */ +export function quoteNexusChoamTrade(g: Game, owner: string, automaticPending: boolean) { + const p = g.players.find(player => player.id === owner); + if (!p || g.nexusCards?.cards?.hands[owner] !== 'choam' || + nexusCardMode('choam', p.faction, g.players.map(player => player.faction)) !== 'secretAlly') return null; + let blocked: string | null = null; + if (p.ally) blocked = 'Use a Nexus card while unallied.'; + else if (g.status !== 'playing' || g.phase !== 7) + blocked = 'Trade during Spice Collection.'; + else if (automaticPending || g.truthtrance || g.response || g.decision || g.phaseOpening || + g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || + g.pendingExchange || g.pendingAmbassador || g.pendingRicheseGift || + g.pendingRichesePurchaseIncome || g.nexusCards.phase?.stage === 'drawing' || + g.nexusTraitorExchanges?.some(record => record.stage === 'return')) + blocked = 'Finish the current interaction before trading.'; + if (!blocked && (!Number.isSafeInteger(p.spice) || p.spice < 0 || !Number.isSafeInteger(p.spice + 2))) + blocked = 'The trade needs a valid spice balance.'; + const cards = p.hand.filter(card => card.kind === 'worthless').map(card => ({ ...card })); + if (!blocked && !cards.length) blocked = 'You need a Worthless card to trade.'; + return { event: nexusChoamTradeEvent(g.turn, owner), blocked, cards, spice: 2 as const }; +} +export function validateNexusChoamTrade(g: Game, record: NexusChoamTrade, physical: readonly Card[]): void { + const p = record && g.players.find(player => player.id === record.owner); + if (!record || Object.keys(record).sort().join(',') !== 'card,event,owner,signature,spiceBefore,stage,turn' || + !p || g.players.some(player => player.faction === 'choam') || + !Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn || + !Number.isSafeInteger(record.spiceBefore) || record.spiceBefore < 0 || + !Number.isSafeInteger(record.spiceBefore + 2) || + !['discard', 'complete'].includes(record.stage) || + record.event !== nexusChoamTradeEvent(record.turn, record.owner) || + record.signature !== nexusChoamTradeSignature(record) || + !physical.some(card => card.id === record.card && card.kind === 'worthless')) + throw new Error('The CHOAM Nexus trade has lost its original owner, card or payment.'); +} diff --git a/game/reference.ts b/game/reference.ts index 8b34908d..da543d5a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3406,6 +3406,28 @@ export const RULE_TOPICS: RuleTopic[] = [ }, ], }, + { + id: 'nexus-choam-trade', + title: 'CHOAM Nexus: Collection trade', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'When CHOAM is absent, an unallied holder may spend the CHOAM Nexus card and one Worthless card for two bank spice during Spice Collection.', + steps: [ + 'Use the Secret Ally panel only when no player controls CHOAM. This works in Basic and Advanced play.', + 'During Spice Collection, choose one Worthless card from your own hand. The chosen card and your CHOAM Nexus card are discarded, and you receive two spice from the bank.', + 'Finish any current choice, Truthtrance or automatic continuation first. Continuing the phase without trading keeps your cards.', + 'The card also permits a private random unused opposing hand-card inspection after a victory. That alternative is still in development.', + ], + related: ['nexus-cards', 'collection'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Collection trade is playable with physical custody and a saved payment/discard receipt. Battle inspection remains missing.', evidence: ['game/nexus-choam-trade.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Private Worthless-card picker and inspector with explicit two-card cost and availability reasons.', evidence: ['components/nexus-choam-trade.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four profiles use the same private legal trade action. Strategy between the two panel uses awaits battle inspection.', evidence: ['game/nexus-choam-trade-options.ts', 'game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Source contract and connected Collection behavior are documented; remaining inspection and combination work is explicit.', evidence: ['docs/NEXUS_CHOAM_SECRET_ALLY.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused rules, private controls, AI, saved continuation and competing SQLite actions. Complete family acceptance remains open.', evidence: ['tests/nexus-choam-trade.test.ts', 'tests/nexus-choam-trade-recovery.test.ts'] }, + ], + }, { id: 'nexus-cards', title: 'Nexus Cards', @@ -3436,6 +3458,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.', 'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.', 'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups and other Emperor Nexus modes remain unavailable.', + 'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. Its after-victory inspection remains unfinished.', 'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.', 'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.', 'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.', diff --git a/tests/fixture-grumman-collection.ts b/tests/fixture-grumman-collection.ts index 7fa15eed..bafe2d94 100644 --- a/tests/fixture-grumman-collection.ts +++ b/tests/fixture-grumman-collection.ts @@ -3,6 +3,7 @@ import { applyAction, createGame, initializeHomeworldGameForAudit, + initializeNexusGameForAudit, joinGame, newPlayer, viewGame, @@ -13,6 +14,11 @@ import { baseDeck, type Card } from '../game/cards'; import { botActions } from '../game/bots'; import { homeworldGameIntegrity } from '../game/homeworld-game'; import { placeTerror, type TerrorKind } from '../game/moritani-terror'; +import { + enterNexusSpice, + finishNexusSpice, + orderNexusSpice, +} from './fixture-nexus-cards'; export const grummanPlayer = (g: Game, id: string) => g.players.find((p) => p.id === id)!; @@ -25,6 +31,7 @@ export function grummanCollectionFixture( options: { native?: number; advanced?: boolean; + nexus?: boolean; seatIds?: [string, string, string]; } = {}, ): Game { @@ -38,8 +45,11 @@ export function grummanCollectionFixture( joinGame(g, newPlayer(a, 'Atreides', 'atreides')); joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen')); g = applyAction(g, m, { type: 'homeworlds', enabled: true }); + if (options.nexus) g.nexusCards = { cards: null, phase: null }; for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); - g = initializeHomeworldGameForAudit(g); + g = options.nexus + ? initializeNexusGameForAudit(g) + : initializeHomeworldGameForAudit(g); for (let n = 0; g.status === 'setup' && n < 60; n++) { let next: Game | undefined; for (const p of g.players) { @@ -66,6 +76,11 @@ export function grummanCollectionFixture( moved: 0, }); } + if (options.nexus) { + g = enterNexusSpice(g); + orderNexusSpice(g, ['land', 'land']); + g = finishNexusSpice(g); + } const owner = grummanPlayer(g, m); owner.reserves = options.native ?? 8; owner.forces = { 'polar_sink:0': 20 - owner.reserves }; diff --git a/tests/fixture-nexus-choam-trade.ts b/tests/fixture-nexus-choam-trade.ts new file mode 100644 index 00000000..d0c78293 --- /dev/null +++ b/tests/fixture-nexus-choam-trade.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import type { Game } from '../game/engine'; +import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options'; +import { nexusChoamTradeSignature } from '../game/nexus-choam-trade'; +import { viewGame } from '../game/engine'; +import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors'; +import { holdNexusChoamCard } from './fixture-nexus-choam'; + +export function nexusChoamTradeFixture(advanced = false, seatIds: [string, string, string] = ['p', 'q', 'r']) { + const g = nexusTraitorFixture({ ownerFaction: 'atreides', advanced, phase: 7, seatIds }); + const [owner, target, observer] = seatIds; + const cards = g.nexusCards!.cards!, index = cards.deck.indexOf('choam'); + assert.ok(index >= 0); + cards.deck[index] = cards.hands[owner]!; + cards.hands[owner] = 'choam'; + const cost = holdNexusChoamCard(g, owner, 'worthless'); + const spare = holdNexusChoamCard(g, owner, 'worthless'); + const other = holdNexusChoamCard(g, owner, 'projectile'); + nexusTraitorInventory(g); + const action = nexusChoamTradeAction(viewGame(g, owner), cost.id)!; + assert.ok(action); + return { g, owner, target, observer, cost, spare, other, action }; +} +/** Restore the exact already-paid semantic discard boundary. The normal live + * action drains it automatically; recovery must never repeat its payment. */ +export function pausedNexusChoamTrade(completed: Game): Game { + const g = structuredClone(completed), record = g.nexusChoamTrades!.at(-1)!; + record.stage = 'discard'; record.signature = nexusChoamTradeSignature(record); + g.nexusChoamTradeLast = {event: record.event, stage: record.stage}; + const sequence = g.treacheryDiscardSequence!; + g.resolvedTreacheryDiscardSequence = sequence - 1; + g.pendingTreacheryDiscard = { sequence, batch: { + event: `discard:${g.turn}:${g.phase}:${sequence}`, turn: g.turn, phase: g.phase, + cause: 'nexus:choamTrade', entries: [{card: structuredClone(g.discard.find(card => card.id === record.card)!), discardedBy: record.owner, publicFace: true}], + }, continuation: {kind: 'nexusChoamTrade', event: record.event, owner: record.owner, card: record.card, spiceAfter: record.spiceBefore + 2} }; + return g; +} diff --git a/tests/nexus-choam-trade-grumman.test.ts b/tests/nexus-choam-trade-grumman.test.ts new file mode 100644 index 00000000..9d4eaa8d --- /dev/null +++ b/tests/nexus-choam-trade-grumman.test.ts @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, normalizeAutomaticGame, viewGame } from '../game/engine'; +import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options'; +import { + enterGrummanCollection, + grummanCollectionFixture, + grummanPlayer, + grummanReload, + holdGrummanCard, +} from './fixture-grumman-collection'; + +void test('CHOAM Secret Ally trade waits for the queued high-Grumman Collection continuation', () => { + let g = grummanCollectionFixture({ native: 7, nexus: true }); + const owner = g.players.find((player) => player.faction === 'atreides')!.id; + const nexus = g.nexusCards!.cards!; + const previous = nexus.hands[owner]; + const heldBy = g.players.find((player) => nexus.hands[player.id] === 'choam'); + if (heldBy) nexus.hands[heldBy.id] = previous; + else { + const deck = nexus.deck.indexOf('choam'); + const discard = nexus.discard.indexOf('choam'); + assert.ok(deck >= 0 || discard >= 0); + if (deck >= 0) { + if (previous) nexus.deck[deck] = previous; + else nexus.deck.splice(deck, 1); + } else if (previous) nexus.discard[discard] = previous; + else nexus.discard.splice(discard, 1); + } + nexus.hands[owner] = 'choam'; + const worthless = holdGrummanCard(g, owner, 'worthless'); + + g = enterGrummanCollection(g); + assert.equal(g.grummanCollection?.stage, 'waiting'); + assert.equal(g.decision, null); + const moritani = grummanPlayer(g, g.grummanCollection!.player); + moritani.reserves++; + moritani.forces['polar_sink:0']--; + + const view = viewGame(g, owner); + assert.equal(view.automaticContinuationPending, true); + assert.match(view.nexusChoamTrade!.blocked!, /current interaction/); + const action = { + type: 'nexusChoamTrade', + event: view.nexusChoamTrade!.event, + card: worthless.id, + }; + assert.equal(nexusChoamTradeAction(view, worthless.id), null); + const before = grummanReload(g); + assert.throws(() => applyAction(g, owner, action), /current interaction/); + assert.deepEqual(g, before); + + const opened = normalizeAutomaticGame(grummanReload(g)); + assert.equal(opened.grummanCollection?.stage, 'choice'); + assert.equal(opened.decision?.kind, 'grummanCollection'); + assert.equal(opened.decision?.player, moritani.id); +}); diff --git a/tests/nexus-choam-trade-recovery.test.ts b/tests/nexus-choam-trade-recovery.test.ts new file mode 100644 index 00000000..9d93273c --- /dev/null +++ b/tests/nexus-choam-trade-recovery.test.ts @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { unitStore } from './fixture-nexus-room-store'; +import { nexusChoamTradeFixture, pausedNexusChoamTrade } from './fixture-nexus-choam-trade'; +import { viewGame } from '../game/engine'; +import type { RoomsClock } from '../db/rooms'; +import { nexusTraitorInventory } from './fixture-nexus-traitors'; + +const clock: RoomsClock = {now: () => 10000, sleep: async () => {}}; +void test('SQLite competing trades settle once, restore private seats and resume an already-paid discard without another payout', async () => { + const store = unitStore(); + try { + const made = await store.rooms.createRoom('Nexus trade SQL', 'atreides', true, []); + const code = made.view.code; + const tokens = [made.token]; + for (const faction of ['guild', 'fremen'] as const) + tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); + const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + const ids = auths.map(auth => auth.playerId) as [string, string, string]; + const f = nexusChoamTradeFixture(true, ids); + f.g.code = code; f.g.version = (await store.rooms.readRoom(code)).version; + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?').run(JSON.stringify(f.g), f.g.version, code); + const seats = store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(); + let arrivals = 0, release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; }; + const timer = setTimeout(release, 2000); + let results: PromiseSettledResult[]; + try { + results = await Promise.allSettled([f.cost, f.spare].map(card => + store.restart().act(code, auths[0], f.g.version, {...f.action, card: card.id}, clock))); + } finally { clearTimeout(timer); delete store.hooks.beforeWrite; } + assert.equal(arrivals, 2); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + const done = await store.restart().readRoom(code); + assert.equal(done.players[0].spice, f.g.players[0].spice + 2); + assert.equal(done.nexusChoamTrades!.length, 1); + assert.equal(done.players[0].hand.length, f.g.players[0].hand.length - 1); + assert.equal(done.version, f.g.version + 1); + const before = JSON.stringify(done); + await assert.rejects(store.restart().act(code, auths[0], done.version, f.action, clock)); + assert.equal(JSON.stringify(await store.restart().readRoom(code)), before); + for (const [i, token] of tokens.entries()) { + const rooms = store.restart(), auth = await rooms.authenticate(code, token); + assert.deepEqual(await rooms.readSeatView(code, auth), viewGame(done, ids[i])); + } + const paused = pausedNexusChoamTrade(done); + store.sqlite.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(JSON.stringify(paused), code); + await store.restart().continueRoomAutomatic(code, clock); + const resumed = await store.restart().readRoom(code); + assert.equal(resumed.players[0].spice, done.players[0].spice); + assert.equal(resumed.nexusChoamTrades![0].stage, 'complete'); + assert.equal(resumed.pendingTreacheryDiscard, null); + const settled = JSON.stringify(resumed); + await store.restart().continueRoomAutomatic(code, clock); + assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled); + assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats); + nexusTraitorInventory(resumed); + } finally { store.sqlite.close(); } +}); diff --git a/tests/nexus-choam-trade.test.ts b/tests/nexus-choam-trade.test.ts new file mode 100644 index 00000000..ce9a7f21 --- /dev/null +++ b/tests/nexus-choam-trade.test.ts @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, normalizeAutomaticGame, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { quoteNexusChoamTrade } from '../game/nexus-choam-trade'; +import { NexusChoamTrade } from '../components/nexus-choam-trade'; +import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options'; +import { holdNexusChoamCard } from './fixture-nexus-choam'; +import { nexusChoamTradeFixture, pausedNexusChoamTrade } from './fixture-nexus-choam-trade'; +import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors'; +import { nexusReject, nexusReload, nexusReady } from './fixture-nexus-cards'; + +void test('Basic and Advanced Collection trade spends both physical cards and pays exactly two bank spice once', () => { + for (const advanced of [false, true]) { + const f = nexusChoamTradeFixture(advanced), before = nexusReload(f.g); + const g = applyAction(f.g, f.owner, f.action); + assert.deepEqual(f.g, before); + assert.equal(g.players[0].spice, before.players[0].spice + 2); + assert.deepEqual(g.players.slice(1), before.players.slice(1)); + assert.deepEqual(g.players[0].hand, before.players[0].hand.filter(card => card.id !== f.cost.id)); + assert.equal(g.discard.filter(card => card.id === f.cost.id).length, 1); + assert.equal(g.nexusCards!.cards!.hands[f.owner], null); + assert.ok(g.nexusCards!.cards!.discard.includes('choam')); + assert.equal(g.nexusChoamTrades!.length, 1); + assert.equal(g.nexusChoamTrades![0].stage, 'complete'); + assert.equal(g.response, null); + assert.equal(g.pendingTreacheryDiscard, null); + nexusReject(g, f.owner, f.action); + assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g); + assert.equal(nexusReady(g).phase, 8); + nexusTraitorInventory(g); + } +}); + +void test('private trade choices stay hidden and all four AI profiles use the same legal physical-card action', () => { + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const f = nexusChoamTradeFixture(true); + const v = viewGame(f.g, f.owner); + v.players.find(p => p.id === f.owner)!.bot = difficulty; + assert.deepEqual(botActions(v)[0], f.action); + assert.equal(nexusChoamTradeAction(v, f.other.id), null); + const opponent = viewGame(f.g, f.target); + assert.equal(opponent.nexusChoamTrade, null); + assert.equal(opponent.nexusCards?.card, null); + const ownHtml = renderToStaticMarkup(createElement(NexusChoamTrade, {game: v, busy: false, act() {}})); + assert.match(ownHtml, /Worthless card to trade/); + assert.ok(ownHtml.includes(f.cost.name) && ownHtml.includes(f.spare.name)); + assert.match(ownHtml, /Inspect card/); + assert.match(ownHtml, /for 2 spice/); + assert.equal(renderToStaticMarkup(createElement(NexusChoamTrade, {game: opponent, busy: false, act() {}})), ''); + assert.match(renderToStaticMarkup(createElement(NexusChoamTrade, {game: v, busy: true, act() {}})), /disabled=""/); + const done = applyAction(f.g, f.owner, botActions(v)[0]); + assert.equal(viewGame(done, f.target).nexusChoamTrade, null); + assert.equal(Object.hasOwn(viewGame(done, f.target), 'nexusChoamTrades'), false); + nexusTraitorInventory(done); + } +}); + +void test('trade rejects stale/forged/foreign/wrong-role requests and does not interrupt Truthtrance', () => { + const f = nexusChoamTradeFixture(); + for (const action of [{...f.action, event: 'stale'}, {...f.action, card: f.other.id}, + {...f.action, amount: 99}, {...f.action, card: 'missing'}]) nexusReject(f.g, f.owner, action); + nexusReject(f.g, f.target, f.action); + const phase = nexusReload(f.g); phase.phase = 8; + nexusReject(phase, f.owner, f.action, /Collection/); + const truth = holdNexusChoamCard(f.g, f.target, 'truthtrance'); + const pending = applyAction(f.g, f.target, {type: 'card', card: truth.id}); + assert.ok(pending.truthtrance); + assert.match(viewGame(pending, f.owner).nexusChoamTrade!.blocked!, /current interaction/); + nexusReject(pending, f.owner, f.action, /current interaction/); + const native = nexusTraitorFixture({ownerFaction: 'choam', phase: 7}); + const deck = native.nexusCards!.cards!; + deck.deck[deck.deck.indexOf('choam')] = deck.hands.p!; deck.hands.p = 'choam'; + assert.equal(quoteNexusChoamTrade(native, 'p', false), null); + const betrayal = nexusTraitorFixture({ownerFaction: 'atreides', opponentFaction: 'choam', phase: 7}); + const second = betrayal.nexusCards!.cards!; + second.deck[second.deck.indexOf('choam')] = second.hands.p!; second.hands.p = 'choam'; + assert.equal(quoteNexusChoamTrade(betrayal, 'p', false), null); +}); + +void test('paid discard resumes after JSON reload without replay and rejects damaged custody/payment/history', () => { + const f = nexusChoamTradeFixture(), done = applyAction(f.g, f.owner, f.action); + const paused = pausedNexusChoamTrade(done); + assert.ok(viewGame(paused, f.owner).automaticContinuationPending); + assert.deepEqual(normalizeAutomaticGame(nexusReload(paused)), done); + nexusReject(paused, f.owner, f.action, /committed discard/); + for (const damage of [ + (g: typeof done) => { g.players[0].spice++; }, + (g: typeof done) => { g.players[0].hand.push(f.cost); }, + (g: typeof done) => { g.nexusChoamTrades![0].card = f.spare.id; }, + (g: typeof done) => { delete g.nexusChoamTrades; }, + (g: typeof done) => { g.pendingTreacheryDiscard = null; g.resolvedTreacheryDiscardSequence = g.treacheryDiscardSequence; }, + ]) { + const bad = nexusReload(paused); damage(bad); const before = nexusReload(bad); + for (const work of [() => viewGame(bad, f.owner), () => normalizeAutomaticGame(bad), () => applyAction(bad, f.owner, {type: 'advanceBots'})]) + assert.throws(work, /trade|custody|history|discard/i); + assert.deepEqual(bad, before); + } +});