diff --git a/components/game-table.tsx b/components/game-table.tsx index 14f35714..cd5339b6 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -109,6 +109,7 @@ import { canUseAsKarama } from '@/game/karama'; import { LeaderPortrait } from './leader-portrait'; import { ShipmentQuote } from './shipment-quote'; import { reserveShipmentCost } from '@/game/shipment-price'; +import { nexusRicheseAction, nexusRicheseQuote } from '@/game/nexus-richese-options'; import { guildTransportQuote } from '@/game/transport-quote'; import { fremenReserveEntry, botGroundMoveAllowed } from '@/game/bot-mobility'; import { location as boardLocation } from '@/game/board'; @@ -270,6 +271,8 @@ export function GameTable({ : 'arrakeen'; const [sector, setSector] = useState(10); const [amount, setAmount] = useState(1); + const [richeseShipmentEvent, setRicheseShipmentEvent] = useState(''); + const useRicheseShipment = !!g.nexusRichese && richeseShipmentEvent === g.nexusRichese.event; const [bidDraft, setBidDraft] = useState({ auction: '', value: 1 }); const auctionKey = `${g.code}/${g.turn}/${g.auction?.remaining ?? 0}`; const minimumBid = (g.auction?.bid ?? 0) + 1; @@ -406,6 +409,8 @@ export function GameTable({ const payment = !me.ally || allyPayment === '' ? {} : { allyPayment: Number(allyPayment) }; const shipmentProblems: string[] = []; + if (useRicheseShipment && g.nexusRichese?.blocked) shipmentProblems.push(g.nexusRichese.blocked); + const shipmentMaximum = useRicheseShipment ? Math.min(me.reserves, 5, g.nexusRichese!.maxForces) : me.reserves; if (homeworldSources) { const homes = g.homeworlds!.worlds!.filter((w) => w.native === me.id); if ( @@ -440,10 +445,10 @@ export function GameTable({ `${territory(selected).name} is beyond the Fremen reinforcement area. Choose the Great Flat or a territory within two territories of it.`, ); const validShipmentAmount = - Number.isInteger(amount) && amount > 0 && amount <= me.reserves; + Number.isInteger(amount) && amount > 0 && amount <= shipmentMaximum; if (!validShipmentAmount) shipmentProblems.push( - `Choose a whole number from 1 to ${me.reserves} available reserves.`, + `Choose a whole number from 1 to ${shipmentMaximum} available reserves.`, ); if (me.elites && validShipmentAmount) { const minimum = Math.max(0, amount - (me.reserves - me.elites.reserves)); @@ -465,8 +470,12 @@ export function GameTable({ g.karamaShipping?.player === me.id, }; const shipmentCost = validShipmentAmount - ? reserveShipmentCost(shipmentRate, territory(selected).type, amount) + ? useRicheseShipment + ? (nexusRicheseQuote(g, selected, amount)?.cost ?? null) + : reserveShipmentCost(shipmentRate, territory(selected).type, amount) : null; + if (useRicheseShipment && shipmentCost === null && !g.nexusRichese?.blocked) + shipmentProblems.push('Choose a current Richese Secret Ally shipment of up to five physical forces.'); const selectedAllyPayment = shipmentCost === null ? 0 @@ -719,7 +728,7 @@ export function GameTable({ id="forces" type="number" min={includesNoField ? 0 : 1} - max={20} + max={useRicheseShipment && !me.shipped ? shipmentMaximum : 20} value={amount} onChange={(e) => setAmount(Number(e.target.value))} /> @@ -3664,6 +3673,19 @@ export function GameTable({ /> ))} + {!me.shipped && g.nexusRichese && ( +
+ +

Spend the Nexus to ship up to five physical forces from reserves at the price of one. This uses your ordinary shipment; movement remains available.

+ {g.nexusRichese.blocked &&

{g.nexusRichese.blocked}

} + {useRicheseShipment &&

Physical forces selected: {amount}. Forces charged: 1. Ordinary destination and faction restrictions still apply.

} +
+ )} {destination} {amountInput} {g.karamaShipping?.player === me.id && ( @@ -3689,6 +3711,7 @@ export function GameTable({ /> 0 } - onClick={() => - act({ - type: 'ship', - elite: eliteAmount, - ...payment, - territory: selected, - sector, - amount, + onClick={() => { + const shipment: Action = { + type: 'ship', elite: eliteAmount, ...payment, + territory: selected, sector, amount, ...(homeworldSources ? { homeworldSources } : {}), - }) - } + }; + const action = useRicheseShipment + ? nexusRicheseAction(g, richeseShipmentEvent, shipment) : shipment; + if (action) act(action); + }} > Ship from reserves diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx index bdf83bd5..3817f863 100644 --- a/components/nexus-cards.tsx +++ b/components/nexus-cards.tsx @@ -118,7 +118,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:

Nexus cards

{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held

-

Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.

+

Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Richese Secret Ally reserve shipping. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.

{game.nexusAtreides && offer.card === 'atreides' && (
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md index 32a74718..b23ce63c 100644 --- a/docs/COMPONENT_INVENTORY.md +++ b/docs/COMPONENT_INVENTORY.md @@ -1,5 +1,7 @@ # Component inventory and verification ledger +**Richese Nexus Secret Ally update, 10 September 2026:** the existing held card now has a shipment choice that moves up to five actual counters at the one-counter price. It creates no No-Field marker, changes no component identity and retains typed Homeworld reserves and native Ixian direct HMS entry. Controls distinguish physical forces from charged forces, with the original card inspector unchanged. [NEXUS_RICHESE_RUNTIME.md](NEXUS_RICHESE_RUNTIME.md) records scope, recovery and verification; native two-marker Cunning, Betrayal and complete module acceptance remain unfinished. + **Moritani Nexus Cunning update, 10 September 2026:** existing supply tokens now have an explicit Cunning choice for placement on the ordinary Arrakis map, including existing stacks. The physical token inspector and hidden public map marker remain shared; no extra token or artwork is created. Separate current and historical placement evidence supports later entry, revelation and discard recovery without exposing faces. [NEXUS_MORITANI_RUNTIME.md](NEXUS_MORITANI_RUNTIME.md) records 27 new regressions and desktop/phone verification. Enhanced relocation, mobile-stronghold placement, Grumman combinations and complete faction/module acceptance remain unresolved. **CHOAM Nexus Cunning update, 10 September 2026:** the existing physical Nexus can fund one of five supported Worthless effects with an actual held Treachery Card of any category. Shared controls show the cost card separately from the selected effect and retain its full inspector; the engine preserves its canonical identity through allowance, cancellation and legitimate payment loss. No duplicate Worthless piece or artwork is created. The printed family has six effects: Kull Wahad remains unavailable. [NEXUS_CHOAM_RUNTIME.md](NEXUS_CHOAM_RUNTIME.md) records thirty new regressions, browser inspection and private recovery; complete CHOAM and module acceptance remain gated. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 704c7312..2232ea54 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,7 @@ # Dune implementation status +**Richese Nexus Secret Ally checkpoint, 10 September 2026:** [NEXUS_RICHESE_RUNTIME.md](NEXUS_RICHESE_RUNTIME.md) records one-to-five actual reserve forces at the one-force price in the ordinary shipment pipeline. Native Guild/Fremen pricing, typed Homeworld reserves, native Ixian HMS entry, Guild prevention, BG accompaniment and real Terror entry retain their own rules. Private controls and all four bot levels share the quote; binding shipment promises recognize the affordable discounted source. Exact saved declarations and independent outcome markers protect pending and terminal history through corruption checks, competing SQL requests, Truthtrance and later turns. Review fixed the consumer's omitted native HMS destination. **Types, lint, all 4,099 offline tests, production build and 40 HTTP/session tests pass.** Thirty new cases and two three-seat browser rooms cover allowance/prevention, typed Emperor sources, desktop/phone controls and six private refreshes. The 02:48 UTC backed-up restart preserved all **3,548** opening games and restored twelve earlier private sessions. Final comparison leaves all opening versions/hashes unchanged, with two browser and thirty HTTP rooms added. Native Richese Cunning, Betrayal, foreign-Homeworld tariff integration and complete module/mode acceptance remain gated. The [Guild source audit](NEXUS_GUILD_RULES.md) prepares its distinct extra-shipment, borrowed-route and payment effects without claiming runtime completion. Automation remains removed. + **Moritani Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_MORITANI_RUNTIME.md](NEXUS_MORITANI_RUNTIME.md) records Basic/Advanced supply placement throughout the printed Arrakis map, including existing Terror stacks. Shared controls and all four bot profiles use the private offer; ordinary relocation stays unchanged. Native Karama cancellation preserves the token and spends the opportunity/Nexus. Independent review fixed arrival-signature downgrade, stale former-location reuse and physical-face rebinding; current placement permission and historical entry evidence remain separate. **Types, lint, all 4,069 offline tests, production build and 40 HTTP/session tests pass.** Twenty-seven new cases include real later shipments, Robbery/Sabotage/Sneak Attack, Truthtrance, prior Grumman outcomes and concurrent SQLite recovery. Two three-seat browser rooms passed phone placement, allowance/cancellation, map markers and all six private refreshes. All **3,516** opening room versions/hashes remain unchanged; two browser and thirty HTTP rooms were added. Relocation/HMS/Grumman interpretations, other unfinished Terror/Nexus effects and full module acceptance remain gated. The new [Ecaz source audit](NEXUS_ECAZ_RULES.md) records its leader-scope and disclosure boundaries. Existing server reused, no restart due; automation remains removed. **CHOAM Nexus Cunning checkpoint, 10 September 2026:** [NEXUS_CHOAM_RUNTIME.md](NEXUS_CHOAM_RUNTIME.md) records five native effects using one actual Treachery Card in Basic and Advanced. Shared controls inspect the real cost independently of the selected effect; all four bots use projected legality. Karama retains the cost and spent Nexus; actual intervening cash-in fizzles and resumes the original operation. Independent review fixed saved movement, revival and storm orders accepting valid-looking replacements. **Types, lint, all 4,042 offline tests, production build and 40 HTTP/session tests pass.** Thirty new cases include real paid Nullentropy, private views, physical disposal and concurrent SQLite outcomes. Two three-seat browser rooms passed phone declarations, allowance/cancellation and all six private refreshes. All **3,484** opening room versions/hashes remain unchanged; only two browser and thirty HTTP rooms were added. The printed family has six effects: Kull Wahad remains unavailable, as do unfinished Secret Ally/Betrayal and full module starts. Existing server reused, no restart due, automation remains removed. diff --git a/docs/NEXUS_CARD_RUNTIME.md b/docs/NEXUS_CARD_RUNTIME.md index 326cc3d2..bb420e91 100644 --- a/docs/NEXUS_CARD_RUNTIME.md +++ b/docs/NEXUS_CARD_RUNTIME.md @@ -1,5 +1,7 @@ # Nexus cards: common lifecycle checkpoint +The later [Richese Secret Ally checkpoint](NEXUS_RICHESE_RUNTIME.md) adds an ordinary physical reserve shipment priced as one force; its other effects and complete module acceptance remain gated. + **Historical foundation checkpoint.** Subsequent Atreides inspection integration is recorded in [Atreides runtime](NEXUS_ATREIDES_RUNTIME.md). The counts and effect limitations below describe the original lifecycle checkpoint. Integration record, 10 September 2026. **Types, lint, all 3,795 offline tests, the production build and 40 HTTP/session tests pass.** This checkpoint implements the optional Nexus card inventory and common draw/keep/replace lifecycle, plus a complete inspectable reference. It does **not** implement the thirty-six card effects, enable a complete public Nexus module, or certify Ecaz & Moritani or all expansions. Source facts, photographed component faces, and unresolved effect interactions are in [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md). diff --git a/docs/NEXUS_GUILD_RULES.md b/docs/NEXUS_GUILD_RULES.md new file mode 100644 index 00000000..6b55952c --- /dev/null +++ b/docs/NEXUS_GUILD_RULES.md @@ -0,0 +1,61 @@ +# Spacing Guild Nexus: shipment source contract + +Source audit, 10 September 2026. This documents the three printed panels and their integration boundaries. It does not implement them or certify complete Guild, Homeworld, Nexus or expansion compliance. [Common Nexus lifecycle](NEXUS_CARD_RULES.md) and existing release gates continue to apply. No user question was sent during this audit. + +## Primary evidence + +The Guild face was freshly inspected in the [original photograph of GF9's twelve Nexus cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), local inspection copy `/tmp/dune-nexus-cards.jpg`: + +- **Cunning, Guild only:** an immediate second shipment after the owner's Shipment and Movement. The second shipment's forces cannot move unless Hajr permits it. +- **Secret Ally, Guild absent:** Guild shipping prices, with cross-planet shipment or return to reserves permitted as the holder's shipment action. +- **Betrayal, opposing Guild seated:** take one entire shipping payment otherwise going to Guild or the bank, including one's own payment. The face expressly overrides a faction collecting for occupying Junction. + +The [base GF9 rulebook, p19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=19) identifies three native shipment forms: reserves to Arrakis, one Arrakis territory to another, or one territory to reserves. Guild uses half ordinary prices; returning costs one spice per two forces. Its flexible placement in the combined-turn order is Advanced. [Page 23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=23) rounds fractional shipment costs upward, confirms the return tariff, and preserves payment by allies. These publisher passages were freshly retrieved from the official search index; direct PDF opening returned 403. + +The [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) was freshly checked through indexed publisher text. Page 2 allows Hajr after entering Arrakeen/Carthag and grants the resulting ornithopter access. Page 8 requires Hajr during one's own turn under ordinary movement rules. Page 7 separates canceling one Guild income receipt, one discounted shipment, cross/return permission, and Advanced timing; cancellation stops one ability use. Page 2 routes each contributor's shipment funding directly to Guild or bank, with Guild's own contribution going to the bank. Page 7 routes independent Karama shipment payments to the bank. + +The [Ecaz & Moritani rulebook, pp10–11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10), inspected in the publisher-authored mirror `/tmp/dune-e3-nexus-rules.pdf`, calls Cunning an enhancement of a native advantage. It requires used Nexus cards to be discarded. Its Homeworld rules distinguish native Guild transport from other factions' routes, prohibit shipment to an ally's world, and exclude Homeworlds from territories. Homeworld advantages and penalties are immune to Karama. + +Search results concerning tournament house rules and the separate 2021 movie game were excluded. No retrieved publisher clarification specifically addresses Guild Nexus with Hajr, skipped movement, an independent Karama shipment, or Secret Ally Homeworld returns. + +## Cunning: supported sequence and composition + +**Keep one combined turn open.** Finish or decline the original shipment and ordinary movement opportunity, then resolve the Nexus second shipment before handing the turn to another faction. Declining ordinary movement is not a new prohibition on the card: the named Shipment and Movement turn has still reached its end. No inspected text requires moving a positive number of forces merely to reach that boundary. Likewise, do not implement the second shipment by reopening the original unused shipment slot after a pass; it is a distinct card-granted opportunity. These are phase-sequence compositions, not a dedicated FAQ answer about passing. + +**Allow native shipment forms.** The new shipment inherits native Guild reserve, cross-planet and return choices, actual tariff, legal source group, storm/stronghold rules and custody. Nothing restricts it to the first shipment's type or destination, or makes it free. A group moved during ordinary movement can be selected for the later cross-shipment; shipping is a separate action. Payment, physical arrival, technology triggers and entry reactions must settle before the second-shipment opportunity can finish. + +**Movement remains bounded.** The second shipment does not grant an ordinary movement. Ending the first ordinary movement by declining it cannot leave an ordinary move available for newly shipped forces. The express exception is Hajr. If Hajr is played after the second shipment, retain the same active player until its optional legal extra move finishes or is declined. A previously played but unspent Hajr move can remain available under ordinary allowance composition; a Hajr move already consumed before the second shipment cannot grant a third move. Track the actual remaining allowance, not merely whether Hajr appears in a played-card list. A later move checks current ornithopter access normally. + +The card does not establish how an independent Richese Ornithopter movement card combines with this exception or with earlier Hajr usage. Preserve the already documented [Hajr/Ornithopter boundary](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md) instead of assuming a third/fourth move. Native city ornithopter range is a different matter from that physical Treachery Card. + +**Separate native cancellations.** Cunning creates an enhanced native shipment allowance. Treating its declaration as a cancelable native enhancement is the common Cunning/general Karama composition. Cancellation consumes the attempted Nexus use but grants no second shipment and cannot undo the first shipment or completed movement. This is distinct from permitting a second shipment while canceling its discounted rate or its cross/return form. The base FAQ explicitly treats those native advantages separately; one generic cancellation must not silently choose all three outcomes. Existing rate/route cancellation limitations remain implementation work. Independent Karama shipment permission also has its own payment route; it must not silently authorize a second shipment after Cunning itself was canceled. + +**Homeworld Cunning routes are native routes.** Guild remains the native Guild faction, so an additional legal Guild shipment can use an already supported native Homeworld transport adapter. Preserve the route's typed forces and pricing, Homeworld alliance prohibition, BG limitations and actual arrival rules. The card does not turn a Homeworld into a territory or grant another faction the high-Junction sponsor ability. + +## Secret Ally: one modified shipping action + +The absence test is the seated native Guild faction, not whether a Guild player currently has board forces. Consume the held Nexus when declaring its use for the owner's shipping action. The grant supplies the printed rates and route choices; it does not add a second shipment, create a real alliance, provide flexible Advanced Guild timing, collect other players' payments, or convey every Guild/Homeworld benefit. + +For ordinary Arrakis routes, use the same rounded Guild tariff and one-territory source selection as native transport. The shipment still consumes the holder's normal shipping action. The ordinary movement afterward remains available and uses the holder's own movement rules. Keep physical elite/advisor/No-Field classification, source custody, storm restrictions and entry continuations with the actual player. A Fremen holder's ordinary free reserve reinforcement remains a distinct native option; do not overwrite its existing zero-price action merely because a Guild Nexus is held. + +The Guild is absent, so no native Guild income recipient or native Guild advantage owner exists. Do not manufacture a Guild-rate Karama response targeting a nonexistent player. The Nexus rule does not establish universal immunity for borrowed effects; this is a distinction between the printed card grant and the native cancellation targets. Ordinary actual arrival reactions can still apply. + +**Secret Ally with Homeworlds needs separate resolution.** E3 expressly limits Arrakis-to-Homeworld shipment to native Guild, except its named Homeworld transport advantage; Secret Ally expressly allows return to the holder's reserves. With reserves represented on a Homeworld, those provisions meet directly. Neither the face nor the inspected FAQ explains whether this grant overrides that faction-specific route restriction. Native Guild Cunning does not have that ambiguity. Do not silently extend the Secret Ally return to arbitrary foreign Homeworlds, or treat the entire Secret Ally panel as implemented while that material combination is unresolved. Richese No-Field discount combinations retain their existing separate pricing boundary. + +## Betrayal: payment replacement + +Bind one actual shipment payment and redirect that payment in full to the Nexus holder. This is payment replacement, not bank income added alongside the existing receiver. The express own-payment case allows paying the shipment's cost and receiving that payment; it does not permit financing the cost with the expected later receipt. A free shipment supplies no positive payment to take. + +The face expressly supersedes occupied Junction collection for that payment. Preserve the original charged amount before any native low-Junction division; do not hand the card holder only the Guild's reduced half while paying the remainder to an occupier. This follows the component's full-payment override, not the [unoccupied low-Junction runtime quote](HOMEWORLD_PAYMENT_INCOME_RULES.md). The existing contributor-rounding and occupation-lifecycle questions are not resolved generally by this one card. + +An implementation needs an original payment identity, exact charged contributions and a committed replacement recipient before ordinary Guild/bank/occupier settlement. Preserve hidden held Nexus identities until an actual play. The already pending private-reaction timing policy applies; this audit does not invent another pause, automatic response reveal, or duplicate user question. A zero-cost Guild Ambassador shipment cannot manufacture a Betrayal award. + +## Existing integration points and required state + +[engine.ts](../game/engine.ts) currently uses `p.shipped`, numeric `p.moved`, `movesAllowed`, `finishMovementTurn` and the `endMovement` action to manage the combined turn. `movesAllowed` currently treats a played Hajr as a two-move ceiling, which alone cannot distinguish an intentionally ended ordinary move from an unspent extra move after a new second shipment. A Cunning continuation needs that distinction before the queue advances. Advanced `movementTurn`/`chooseGuildTiming` and Sapho ordering must see one uninterrupted owner turn, not insert Guild into the queue twice. + +Preserve distinct settlement adapters: ordinary reserve shipment, `guildShip`, native Homeworld shipment, concealed No-Field and Junction-sponsored transport. [shipment-price.ts](../game/shipment-price.ts) already separates reserve prices, Guild transport tariffs, contribution bounds and Guild income. [guild-homeworld-shipment.ts](../game/guild-homeworld-shipment.ts) explicitly requires native Guild and should not be broadened indiscriminately for Secret Ally. [shipment-promises.ts](../game/shipment-promises.ts) also requires review: a second shipping action must not erase an earlier committed Truthtrance answer or reapply one shipment's promise to the wrong action. + +Suggested saved Cunning frame: original combined-turn event/owner/turn, the completed or declined original shipment and movement status, Hajr's remaining allowance, stage (`declaration`, `shipping`, `arrival`, `extraMove`, terminal outcome), and a bound second-shipment intent/receipt. Actual shipment custody and payment remain in their existing continuations. Do not clear a shipped flag as the sole source of the extra right, and do not use current hand membership as proof after spending the Nexus. Reconnect and uncertain responses must preserve one debit, one arrival and one consumed allowance. + +Verification should exercise first shipment followed by moved/declined ordinary movement, all three second-shipment forms, Hajr before/after and already consumed, cancellation of only the selected native advantage, paid BG/entry continuations, native Homeworld routes, private offers, stale requests and actual SQLite restoration. This audit performed source/link and bounded code review only; no runtime file or test was changed. diff --git a/docs/NEXUS_RICHESE_RULES.md b/docs/NEXUS_RICHESE_RULES.md new file mode 100644 index 00000000..60112668 --- /dev/null +++ b/docs/NEXUS_RICHESE_RULES.md @@ -0,0 +1,69 @@ +# Richese Nexus source contract + +Audited 10 September 2026. This separates the ordinary Secret Ally shipment from native Cunning's two-marker operation. It does not certify either implementation or open the Nexus release gate. See [common Nexus rules](NEXUS_CARD_RULES.md), [No-Field custody](RICHESE_NO_FIELD_RULES.md), and [allied No-Field distinctions](RICHESE_ALLIED_SHIPMENT_AUDIT.md). + +## Printed effects and source status + +The Richese panel was freshly read from the [original photographed GF9 cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), cached at `/tmp/dune-nexus-cards.jpg`. Secret Ally replaces a normal shipment: choose up to five actual reserve forces and ship them at the price of one. It creates no hidden marker and grants none of Richese's auctions or other advantages. Cunning uses two No-Field tokens for a single shipment, still priced as one force; one token is immediately revealed and placed faceup in front of the shield. + +Common eligibility requires **Richese absent**, not Guild absent, for Secret Ally. A native Guild player may hold it. A holder cannot retain a Nexus card when entering an alliance, and play consumes the card. E3 contains no Richese-specific Nexus FAQ. [GF9 E3 p.11 and p.16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11) + +Official indexed excerpts, the original component photograph, and cached E3 publisher-authored text were inspected. Direct retrieval of the official base/November FAQ PDFs returned 403. The older local base/E2 cache paths named by historical documents are no longer present in this workspace; those historical claims were checked against fresh publisher excerpts where used below. + +## Secret Ally: ordinary shipment composition + +Preserve the user's unused normal shipment opportunity, source custody, legal destination/sector, storm rules, occupancy, arrival stance and subsequent movement. Replace only the number used for pricing with one; transport the actual selected number. This is direct composition of the card with ordinary shipment rules, not a new cross-shipment permission. [GF9 base rules, pp.9 and 19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9) + +| Ordinary Arrakis route | Charged price for the whole selected group | +| --- | --- | +| Non-Guild, stronghold | 1 spice | +| Non-Guild, desert/rock/Polar Sink | 2 spice | +| Native Guild, either destination type | 1 spice after normal half-price rounding | +| Active ordinary Karama shipment rate | Price the one charged force at Guild rates; route payment to the bank | +| Fremen retaining native shipment geography | 0 spice; see below | + +Guild half-price rounding is upward, not fractional. Ordinary Karama can purchase Guild-rate shipment paid to the bank. These existing price modifiers can apply to the card's charged one; no inspected source fixes a contradictory Richese Secret Ally fee. [GF9 November 2020 FAQ, pp.5 and 7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7) + +Do **not** import the unresolved fixed-price wording for *allied native No-Field* into this different effect. That E2 FAQ concerns a real Richese supplier and an immediate-reveal marker for an ally. Secret Ally has no supplier or marker. Likewise, do not invent an allied Guild discount or new allied funding: the Nexus holder must be unallied. Keep any separately legal existing Karama benefit distinct. + +Use a positive physical group of 1–5, bounded by current typed reserves. Starred counters are physical forces, not two shipping units merely because battle strength can double. With the Homeworld module, preserve the exact selected native source pools and the existing ordinary/special subset constraints. The amount used for Terror entry, casualties, board presence and reserve subtraction is the **actual amount**, not the charged one. A five-force shipment is not a hidden one-force marker. This distinction follows the card's actual-forces wording; it is also the necessary custody boundary in the current model. + +**Fremen.** The native player sheet permits any/all reserves free within the Great Flat range; it has no ordinary three-force shipping cap. Three free revivals and three physical Fedaykin are different rules. [Publisher-authored faction sheets, Fremen](https://www.qugs.org/rules/r283355.pdf) + +The card says reserves, not off-planet reserves. Keeping Fremen's free price, native destination range and source classification while limiting the chosen card shipment to five is a defensible, nonexpansive composition. It adds no teleportation permission. It may be strategically unnecessary because ordinary Fremen already transport their reserves freely; that does not justify inventing a price or a new destination. The E2 allied No-Field exclusion for Fremen rests on that ability's explicit off-planet source and must not automatically exclude this card. No inspected FAQ separately rules on Richese Secret Ally with Fremen, so describe this as retained-native-rules composition, not an explicit named FAQ answer. + +## Payment, prevention and entry + +An ordinary non-Guild off-planet shipment pays Guild when seated, otherwise the bank. Guild's own shipment does not earn itself income. The reduced total is the actual transaction: do not credit Guild for five forces while debiting the buyer for one. [GF9 base rules, p.19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=19) + +With Richese absent, ordinary Karama cancellation cannot target a fictitious native Richese advantage owner. This follows the same ability-owner distinction used for other absent-faction Nexus effects; it is not a claim that Nexus cards erase independent responses. Advanced Guild special prevention can still stop a qualifying off-planet shipment. Retain the existing stopped-shipment usage policy and spend the already played Nexus card independently. The Fremen's retained on-planet reinforcement is outside the off-planet trigger. Ordinary Karama does not stop native Fremen reinforcement. [GF9 base special Karama, p.14](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=14), [November FAQ p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7) + +Retain actual shipment classification for Bene Gesserit companion/intrusion and Heighliner. Heighliner keys off qualifying off-planet shipment, with its Guild exception; reducing the price neither creates a second shipment nor changes an on-planet source to off-planet. [GF9 E1, p.5](https://www.gf9.com/Portals/0/Documents/GF9/IxianAndTleilaxuRulebook.pdf#page=5) + +Retain the native Ixian permission to ship directly into the present Hidden Mobile Stronghold. Its faction rule bars direct shipment by other factions, and the FAQ explicitly discusses an Ixian direct shipment with Bene Gesserit accompaniment. The Richese card changes the shipment quantity and price; it does not remove this native destination or grant it to another holder. Select the actual mobile stronghold location from the live board rather than only the printed territory catalog. Cyborgs and Suboids both ship normally and count as physical counters toward the five-force limit. [GF9 E1, pp.9–10](https://www.gf9.com/Portals/0/Documents/GF9/IxianAndTleilaxuRulebook.pdf#page=9) + +Preflight the complete typed arrival and currently representable reactions before spending. Persist the declared actual group and its price basis, then recheck custody/funding at settlement. A stopped declaration leaves the forces and unspent shipping money where they were. Accepted CAS/replay protection must cover the Nexus spend, shipment allowance and existing Guild decision together. + +## Homeworld sources and destinations + +Native reserves remain eligible source pools even when represented by Homeworld custody. Occupation does not turn a foreign garrison into the user's native reserves. Preserve Emperor's allowed combined Kaitain/Salusa source selection, counting all selected physical counters toward five. The card does not create permission to replenish a non-Guild player's own Homeworld or to enter an ally's Homeworld. Homeworld-to-Homeworld shipment already has its own legal routes and one-spice-per-force tariff, with Guild half price. [GF9 E3 p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10) + +A shipment from native reserves to a legal foreign Homeworld can defensibly compose that tariff with the charged-one rule, giving one spice for the whole group. This is not a retrieved Richese-specific FAQ. It needs the separate Homeworld transport adapter, source receipt, occupied-payment routing and arrival rules; merely extending the ordinary Arrakis territory selector is incorrect. A foreign occupied Homeworld **source** is not covered by the card's reserve wording. Returning from Arrakis or cross-shipping between board territories is also outside its stated source. These distinctions do not require blocking ordinary native-reserve-to-Arrakis use when Homeworlds are enabled. + +## Native Cunning: separate physical operation + +The E2 model has three distinct 0/3/5 components, one concealed deployed marker, shared own/allied nonconsecutive-use history, reserve-capped reveal, and a faceup used component. No-Field movement/reveal and allied immediate reveal are different transitions. [GF9 E2 p.6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=6) + +Cunning explicitly authorizes the two-component shipment exception. It does not authorize retaining two concealed board markers, duplicating a denomination, paying twice, or treating both groups as new free reserves. One selected component reveals immediately; the other remains concealed. The later reveal draws from then-available reserves, not a second already materialized copy of the immediate group. Preserve the private unrevealed identity/value and the public revealed denomination. + +A reveal is not another arrival; E3 explicitly distinguishes No-Field entry from later revelation for Terror. [GF9 E3 p.14](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=14) For this new same-shipment immediate reveal, do not assume the low-Grumman original entry count from the discounted price alone. It needs an explicit timing/count composition of the two components and the immediate physical group. + +Concrete Cunning boundaries requiring design or a selected interpretation are: how two same-shipment uses update the shared last-used history; whether an already deployed marker must reveal before the pair; how the immediate faceup component and the still-concealed one affect the next prohibited denomination; and composition with native high-Richese extra ordinary forces or an allied shipment. The present `RicheseNoField` shape has only `lastShipped` and one `deployed` record, so it cannot simply represent an atomic pair by calling single-deploy twice. These are Cunning custody issues, not reasons to withhold Secret Ally's ordinary actual-force shipment. + +## Existing integration points and verification + +`game/shipment-price.ts` already separates ordinary reserve prices, Guild cross-shipment prices and contributor-filtered Guild income. The ordinary engine chain is `validatePhysicalShipment` → `offerShipment` → `commitShipment`; its checks use native reserve withdrawal, typed counters, arrival stance, actual geography and shipment promises. Introduce a validated charged-count basis without replacing the physical amount. Keep current low-Junction income and occupied-payment routing after the reduced gross is computed. + +`game/homeworld-shipment.ts` and the Guild Homeworld adapter own non-Arrakis routes; they should not be bypassed with a forged static board destination. `game/richese-no-field.ts` owns physical markers and should not be initialized for absent-faction Secret Ally. + +Required focused evidence: 1/5 actual-force shipments with unchanged price; normal/elite mixtures; Guild present/absent/native-holder and active bank-rate payment; Fremen retained geography/free price; native Homeworld source custody; occupancy/storm rejection before costs; Guild allowance/prevention; real BG/Terror/Ambassador continuations using actual counts; all-seat projections; and persisted pending/complete/stopped histories with concurrent and rejected-action immutability. Complete Cunning and broader module acceptance remain separate work. diff --git a/docs/NEXUS_RICHESE_RUNTIME.md b/docs/NEXUS_RICHESE_RUNTIME.md new file mode 100644 index 00000000..3714203c --- /dev/null +++ b/docs/NEXUS_RICHESE_RUNTIME.md @@ -0,0 +1,43 @@ +# Richese Nexus Secret Ally runtime + +This checkpoint adds the [source-audited Secret Ally](NEXUS_RICHESE_RULES.md) to an unallied holder's unused ordinary reserve shipment when Richese is absent. Choose one to five actual forces and price the group as one force. The physical force count, starred identity, destination and arrival restrictions remain ordinary shipment rules. It creates no concealed No-Field and no second shipment. + +## Price and physical custody + +The shared [source quote](../game/nexus-richese.ts) separates `physicalAmount` from `pricedAmount: 1`. The whole group costs one spice to a stronghold or two elsewhere; native Guild and an active ordinary Karama rate keep their existing half-price rounding. Karama bank routing remains separate from the Nexus source. Fremen retain free reinforcement and their Great Flat range; this is retained-native-rules composition, not a named faction-specific FAQ ruling. + +Native Homeworld reserve pools remain usable through the existing typed withdrawal selection. Native Ixians retain their ordinary right to ship into the present Hidden Mobile Stronghold; the card does not grant that destination to other factions. A foreign Homeworld destination, board cross-shipment, return to reserves or physical No-Field is not offered by this adapter. These routes must not be fabricated through the Arrakis selector. The actual force amount governs withdrawal, board placement, Terror and other arrival callbacks. Only the price basis becomes one. + +The existing shipment consumes the Nexus when declared. Advanced Guild prevention retains its independent response; the stopped shipment uses the shipment allowance without moving forces or charging shipping spice, while the Nexus remains spent. Ordinary allowance uses the reduced actual payment for Guild income and preserves existing technology, BG and territory-entry rules. Unsupported competing arrival combinations retain their existing gates. + +## Saved declaration and binding promises + +The server binds the source to the original owner, turn, faction roster and exact shipment fields. The pending record includes the actual typed force allocation, selected native reserve pools, destination, sector, cost and arrival stance. Its separate latest-outcome marker prevents a deleted or reopened pending record from silently reverting to an ordinary shipment. These consistency signatures detect partial corruption; they are not cryptographic authentication of arbitrary replacement saves. + +The card is discarded when the validated declaration is made. A saved Guild interception keeps that same record and the actual force count until allowance or special prevention. Terminal records retain history without locking the player's later forces or resources. Ordinary shipment validation still checks current physical custody, geography and payment before commitment. + +A binding shipment answer searches the discounted shipment as a legal completion candidate. This matters when a player can afford five physical forces only by spending the held Nexus. The owner receives a concrete private completion action; the other seats do not receive the source offer or receipt. All four bot levels can use that action to honor an accepted Yes. + +## Controls and bots + +The [shipment panel](../components/game-table.tsx) starts with an unchecked “Use Richese Nexus Secret Ally” option. Selecting it limits the shipment group to five, displays the actual group separately from the one charged force, and uses the shared price for funding. It preserves the normal destination, typed-force and native Homeworld source controls. Server blocking reasons and transport-busy state remain visible. + +The [shared consumer helper](../game/nexus-richese-options.ts) fences the private held card, absent native faction, current owner, unused shipment, event and interruptions. Opponents receive no private offer. The action retains the ordinary `ship` fields and adds the current Nexus event; a marker, cross-shipment or foreign Homeworld cannot masquerade as this source. + +All four [bot profiles](../game/bots.ts) generate discounted candidates independently of ordinary shipment affordability, including five forces when the player can only afford the one-force price. They retain ordinary candidates and avoid spending the card when it saves no spice, including native Fremen free reinforcement. The [Homeworld payment filter](../game/homeworld-payment-options.ts) recognizes the actual source price before checking low-Junction contributor rounding. This establishes legal candidates, not separately calibrated Nexus strategy. + +## Verification and remaining boundaries + +Six new cases in [controls](../tests/nexus-richese-controls.test.ts) and [bots](../tests/nexus-richese-bots.test.ts) use the [genuine setup fixture](../tests/fixture-nexus-richese.ts). They exercise Basic/Advanced actual shipment, distinct price and physical count, unchecked controls, stale and unauthorized sources, observer privacy, native faction prices, affordable five-force candidates, ordinary fallback and the source-aware payment filter. The low-Junction consumer case explicitly stages a public projection only; it does not claim a new engine custody transition. Interactive checkbox verification is recorded below separately from server rendering and helper/action checks. + +The checkpoint adds **30 passing cases**: six pure source/receipt tests, six consumer cases, twelve engine cases, five production SQLite cases and one binding-promise regression. Engine coverage includes actual Truthtrance interruption, real next-turn movement/shipment continuity, native Ixian HMS entry, and genuine prior Mentat Terror placement followed by a five-force arrival under low Grumman. SQLite covers competing declarations, uncertain-response recovery, Guild prevention, original typed sources and malformed history with zero writes. + +Focused verification passed **6/6 new consumer cases**. A separate compatibility run passed **52/52 cases** covering shipment budgets and promises, native/Guild Homeworld controls, Nexus controls and reference checks. Type-aware lint passed for the changed consumer and test files. **Types, lint, all 4,099 offline tests, production build and 40 live HTTP tests passed.** Native Richese Cunning, Betrayal, the foreign-Homeworld tariff adapter and complete Nexus/module play remain unfinished. + +## Isolated browser acceptance + +Two fresh three-human-seat rooms used final session IDs before genuine fixture setup and exact state/version staging. MGJ3WQ6Y advanced from version 2 to 3: the unchecked ordinary five-force quote cost five spice and was unaffordable; selecting Secret Ally changed the maximum to five and the price to one. Emperor shipped three normal forces from Kaitain and two Sardaukar from Salusa, leaving fifteen reserves, three reserve Sardaukar, zero spice and movement available. + +In 3C2N43XF, the five-force declaration reached the Guild at version 3. The Guild used its actual special Karama control; version 4 retained all twenty shipper reserves and its one spice, consumed the shipment, and left the Nexus spent. No phantom physical marker appeared. + +Desktop 1440-pixel and phone 390-pixel screenshots were captured and visually inspected, including the typed source selection, price breakdown and Guild stop controls. Phone checks found no horizontal overflow. All six private sessions refreshed successfully with no page errors; opponents lacked the shipper's private hand, traitors and spice, and no internal shipment/history record was projected. The read-only restoration script is `/tmp/dune-richese-nexus-restore.cjs`. Staging the first room preserved all 3,548 other room states and versions; staging the second preserved all 3,549 other rooms. Final comparison after both browser rooms and thirty isolated HTTP rooms retained every version/hash of all 3,548 opening rooms; 3,580 rooms now persist. The 02:48 UTC controlled restart used a database backup, preserved all opening games and restored twelve earlier Moritani/CHOAM private sessions. Six new Richese private sessions subsequently restored. No publication or full module gate opened; the maintenance automation remains removed. diff --git a/game/bots.ts b/game/bots.ts index b514f8b3..bd4a43e3 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -52,6 +52,7 @@ import { ownedTech } from './tech-tokens'; import type { StrongholdProgress } from './victory-progress'; import { canUseAsKarama } from './karama'; import { reserveShipmentCost } from './shipment-price'; +import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options'; import { liveShipmentPromises, matchesShipment } from './shipment-promises'; import { botEntryAllowed, @@ -2925,6 +2926,18 @@ function policyActions(g: GameView): Action[] { me.reserves, level === 0 ? 1 : Math.max([1, 3, 4, 5][level], to.enemy + 2), ); + // Quote the discounted candidate independently of ordinary affordability. + const richeseAmount = Math.min(me.reserves, 5, g.nexusRichese?.maxForces ?? 0); + const richeseQuote = nexusRicheseQuote(g, to.t, richeseAmount); + if (richeseQuote && richeseAmount > 1 && richeseQuote.cost <= shipmentBudget && + richeseQuote.cost < reserveShipmentCost({ faction: me.faction, halfRate }, territory(to.t).type, richeseAmount)) { + const candidate = nexusRicheseAction(g, g.nexusRichese!.event, { + type: 'ship', territory: to.t, sector: to.s, amount: richeseAmount, + elite: level > 0 ? Math.min(richeseAmount, me.elites?.reserves ?? 0) : + Math.max(0, richeseAmount - (me.reserves - (me.elites?.reserves ?? 0))), + }); + if (candidate) actions.push(candidate); + } for (const amount of new Set([desired, Math.min(desired, 3), 1])) { if ( reserveShipmentCost( diff --git a/game/engine.ts b/game/engine.ts index d051c659..7e0c9ead 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese'; import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections'; import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange'; import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers'; @@ -845,6 +846,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & { pools: ReturnType['sources'] | ReturnType['boardSources']; }; type PendingShipment = { + nexusEvent?: string; homeworldSources?: NativeReserveSelections; source?: 'ambassador'; ambassadorEvent?: string; @@ -1282,6 +1284,8 @@ export type Game = { nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'}; nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[]; nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[]; + nexusRicheseHistory?: {receipt:NexusRicheseReceipt;stage:'pending'|'shipped'|'stopped';frame:string;halfRate:boolean;signature:string}[]; + nexusRicheseLast?: {event:string;stage:'pending'|'shipped'|'stopped'}; nexusMoritaniLocations?: Record; nexusMoritaniLast?: {event:string;stage:'pending'|'complete'|'canceled'}; nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'}; @@ -12097,6 +12101,7 @@ function marketGholaIntegrity(g: Game) { nexusSardaukarIntegrity(g); nexusChoamIntegrity(g); nexusMoritaniIntegrity(g); + nexusRicheseIntegrity(g); traitorDeclarationIntegrity(g); nexusInspectionIntegrity(g); homeworldHistoryIntegrity(g); @@ -12524,62 +12529,63 @@ function findShipmentCompletion( ) continue; for (const sector of territory(destination).sectors) { - const cost = reserveShipmentCost( - { - faction: p.faction, - halfRate: - p.faction === 'guild' || - byFaction(g, 'guild')?.id === p.ally || - g.karamaShipping?.player === p.id, - }, - territory(destination).type, - amount, - ); - try { - const allyPayment = contribution(g, p, cost); - const elite = eliteChoice( - amount, - p.reserves, - p.elites?.reserves ?? 0, - undefined, - ); - const sources = g.homeworlds?.custody - ? nativeReserveSources( - homeworldContext(g), - g.homeworlds.custody, - p.id, - { normal: amount - elite, elite }, - ) - : null; - const nativeSources = sources ? { homeworldSources: sources } : {}; - validatePhysicalShipment(g, { - ...nativeSources, - turn: g.turn, - player: p.id, - territory: destination, - sector, - amount, - elite, - cost, - allyPayment, - advisors: arrivalAsAdvisor(g, p, destination), - }); - return { - actions: [ - ...actions, - { - type: 'ship', - territory: destination, - sector, - amount, - elite, - allyPayment, - ...nativeSources, - }, - ], - }; - } catch (error) { - if (!(error instanceof RuleError)) throw error; + const offer = nexusRicheseOffer(g,p.id); + for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : [])]) { + const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; + const cost = nexus + ? nexusRule(() => quoteNexusRicheseShipment(price,territory(destination).type,amount)).cost + : reserveShipmentCost(price,territory(destination).type,amount); + try { + const allyPayment = contribution(g, p, cost); + const elite = eliteChoice( + amount, + p.reserves, + p.elites?.reserves ?? 0, + undefined, + ); + const sources = g.homeworlds?.custody + ? nativeReserveSources( + homeworldContext(g), + g.homeworlds.custody, + p.id, + { normal: amount - elite, elite }, + ) + : null; + const nativeSources = sources ? { homeworldSources: sources } : {}; + const shipment: PendingShipment = { + ...(nexus ? {nexusEvent:nexus} : {}), + ...nativeSources, + turn: g.turn, + player: p.id, + territory: destination, + sector, + amount, + elite, + cost, + allyPayment, + advisors: arrivalAsAdvisor(g, p, destination), + }; + const trial = nexus ? structuredClone(g) : g; + if (nexus) recordNexusRicheseShipment(trial,shipment); + validatePhysicalShipment(trial,shipment); + return { + actions: [ + ...actions, + { + type: 'ship', + ...(nexus ? {nexus} : {}), + territory: destination, + sector, + amount, + elite, + allyPayment, + ...nativeSources, + }, + ], + }; + } catch (error) { + if (!(error instanceof RuleError)) throw error; + } } if (p.faction === 'fremen' && byFaction(g, 'guild')?.id === p.ally) { const action: Action = { @@ -15767,6 +15773,7 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI } else commitHomeworldShipment(g, shipment); } function validatePhysicalShipment(g: Game, shipment: PendingShipment) { + if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment); requireRule( !(shipment.noField || shipment.alliedNoField) || shipment.homeworldSources === undefined, @@ -15835,17 +15842,10 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) { ), 'Fremen reinforcements must arrive within two territories of the Great Flat.', ); - const cost = reserveShipmentCost( - { - faction: p.faction, - halfRate: - p.faction === 'guild' || - byFaction(g, 'guild')?.id === p.ally || - g.karamaShipping?.player === p.id, - }, - territory(shipment.territory).type, - n, - ); + const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; + const cost = shipment.nexusEvent + ? nexusRule(() => quoteNexusRicheseShipment(price,territory(shipment.territory).type,n)).cost + : reserveShipmentCost(price,territory(shipment.territory).type,n); requireRule( Number.isSafeInteger(shipment.cost) && shipment.cost === cost, 'The declared shipment price is no longer current.', @@ -15911,7 +15911,7 @@ function offerShipment(g: Game, shipment: PendingShipment) { }; log( g, - `${p.name} declared a shipment of ${n} forces to ${territory(to).name}, sector ${s}.`, + `${p.name} declared a shipment of ${n} forces to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`, ); } else commitShipment(g, shipment); } @@ -15921,6 +15921,7 @@ function commitShipment(g: Game, shipment: PendingShipment) { return; } validatePhysicalShipment(g, shipment); + finishNexusRicheseShipment(g,shipment,'shipped'); finishShipmentPromises( g, getPlayer(g, shipment.player), @@ -16033,10 +16034,10 @@ function commitShipment(g: Game, shipment: PendingShipment) { ? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.` : shipment.noField ? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. It counts as one force; physical reserves remain unchanged until reveal.` - : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`, + : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`, shipment.noField || shipment.alliedNoField ? { faction: 'richese', name: 'No-Field shipment' } - : undefined, + : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined, ); const bg = byFaction(g, 'beneGesserit'); const followup = @@ -16131,6 +16132,92 @@ function choamWorthlessBlocked(g: Game, card: string) { g.choamWorthlessBlocked.cards.includes(card) ); } +function nexusRicheseOffer(g: Game, owner: string) { + const p = g.players.find(p => p.id === owner); + if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null; + let blocked: string | null = null; + if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.'; + else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || p.shipped) + blocked = 'Use Secret Ally for your unused reserve shipment before moving.'; + else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || + g.pendingNullentropy || g.pendingTreacheryDiscard || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing') + blocked = 'Finish the current interaction before declaring this shipment.'; + else if (p.reserves < 1) blocked = 'You need physical forces in reserves.'; + return {event:JSON.stringify(['nexusRichese',g.turn,owner]),blocked,maxForces:Math.min(5,p.reserves)}; +} +function shipmentHalfRate(g: Game, p: Player) { + return p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally || g.karamaShipping?.player === p.id; +} +function nexusRicheseSignature(record: NonNullable[number]) { + return JSON.stringify([record.receipt.signature,record.stage,record.frame,record.halfRate]); +} +function currentNexusRicheseShipment(g: Game, shipment: PendingShipment) { + const record = g.nexusRicheseHistory?.find(r => r.receipt.event === shipment.nexusEvent); + requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(shipment) && + record.receipt.owner === shipment.player && record.receipt.turn === g.turn && !getPlayer(g,shipment.player).ally && + record.signature === nexusRicheseSignature(record) && !shipment.noField && !shipment.alliedNoField && !shipment.source, + 'Richese Secret Ally has lost its original physical shipment declaration.'); + nexusRule(() => validateNexusRichese(g,record.receipt)); + return record; +} +function recordNexusRicheseShipment(g: Game, shipment: PendingShipment) { + const p = getPlayer(g,shipment.player); + const receipt = nexusRule(() => createNexusRichese(g,p.id)); + requireRule(shipment.nexusEvent === receipt.event, 'This Richese Secret Ally selection is stale.'); + const record = {receipt,stage:'pending' as const,frame:JSON.stringify(shipment),halfRate:shipmentHalfRate(g,p),signature:''}; + record.signature = nexusRicheseSignature(record); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players)); + (g.nexusRicheseHistory ??= []).push(record); + g.nexusRicheseLast = {event:receipt.event,stage:'pending'}; +} +function finishNexusRicheseShipment(g: Game, shipment: PendingShipment, stage: 'shipped'|'stopped') { + if (!shipment.nexusEvent) return; + const record = currentNexusRicheseShipment(g,shipment); + record.stage = stage; + record.signature = nexusRicheseSignature(record); + g.nexusRicheseLast = {event:record.receipt.event,stage}; +} +function nexusRicheseIntegrity(g: Game) { + const history = g.nexusRicheseHistory, pending = g.pendingShipment; + if (history === undefined) { + requireRule(!g.nexusRicheseLast && !pending?.nexusEvent, 'Richese Secret Ally has lost its saved shipment history.'); + return; + } + requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0, + 'Richese Secret Ally requires its original Nexus module and history.'); + const events = new Set(); + for (const [index,record] of history.entries()) { + requireRule(record && typeof record === 'object' && + Object.keys(record).sort().join(',') === 'frame,halfRate,receipt,signature,stage' && + ['pending','shipped','stopped'].includes(record.stage) && typeof record.frame === 'string' && + typeof record.halfRate === 'boolean' && record.signature === nexusRicheseSignature(record) && + !events.has(record.receipt?.event) && (record.stage !== 'pending' || index === history.length - 1), + 'Richese Secret Ally has a changed or duplicated shipment record.'); + nexusRule(() => validateNexusRichese(g,record.receipt)); + const frame = nexusRule(() => JSON.parse(record.frame)) as PendingShipment; + requireRule(frame && frame.nexusEvent === record.receipt.event && frame.player === record.receipt.owner && + frame.turn === record.receipt.turn && !frame.noField && !frame.alliedNoField && !frame.source && + Object.keys(frame).every(k => ['nexusEvent','turn','player','territory','sector','amount','elite','cost','allyPayment','advisors','homeworldSources'].includes(k)), + 'Richese Secret Ally has changed its original shipment source.'); + const quote = nexusRule(() => quoteNexusRicheseShipment({faction:getPlayer(g,frame.player).faction,halfRate:record.halfRate},territory(frame.territory).type,frame.amount)); + requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) && + frame.elite >= 0 && frame.elite <= frame.amount && typeof frame.advisors === 'boolean' && + territory(frame.territory).sectors.includes(frame.sector), 'Richese Secret Ally has changed its original physical allocation or price.'); + events.add(record.receipt.event); + } + const last = history.at(-1)!; + requireRule(JSON.stringify(g.nexusRicheseLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}) && + (last.stage === 'pending') === !!pending?.nexusEvent, + 'Richese Secret Ally has lost or reopened its latest shipment.'); + if (last.stage === 'pending') { + requireRule(pending && pending.nexusEvent === last.receipt.event && g.nexusCards.cards.discard.includes('richese'), + 'Richese Secret Ally has lost its pending shipment or spent Nexus card.'); + currentNexusRicheseShipment(g,pending); + const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); + requireRule(decisions.length > 0, 'Richese Secret Ally has lost its Guild interception decision.'); + for (const decision of decisions) validateGuildShipmentDecision(g,decision); + } +} function nexusMoritaniOffer(g: Game, owner: string, decision: Game['decision'] = g.decision) { const p = g.players.find(p => p.id === owner); if (!p || p.faction !== 'moritani' || g.nexusCards?.cards?.hands[p.id] !== 'moritani') return null; @@ -17058,6 +17145,7 @@ export function executeSpecialKaramaIntent( finishAmbassador(g); return; } + if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped'); shipper.shipped = true; // Provisional canceled-shipment settlement pending a primary-source // clarification: retain the attempted shipment's spice and rate card. @@ -19868,6 +19956,7 @@ function applyActionInner( return g; } if (t === 'homeworldShip' || t === 'guildHomeworldShip') { + requireRule(action.nexus === undefined, 'Richese Secret Ally applies only to an ordinary Arrakis reserve shipment.'); const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined; const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p); requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.'); @@ -19883,6 +19972,15 @@ function applyActionInner( g.phase === 5 && g.active === id && !p.shipped, 'You may ship once, before your movement.', ); + let nexusEvent: string | undefined; + if (action.nexus !== undefined) { + const offer = nexusRicheseOffer(g,id); + requireRule(offer && !offer.blocked && action.nexus === offer.event, + offer?.blocked ?? 'This Richese Secret Ally shipment is unavailable or stale.'); + requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)), + 'Richese Secret Ally requires an ordinary physical reserve shipment.'); + nexusEvent = offer.event; + } checkShipmentPromises(g, p, { territory: String(action.territory), amount: Number(action.amount), @@ -19929,17 +20027,10 @@ function applyActionInner( const elite = noField ? 0 : eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite); - const cost = reserveShipmentCost( - { - faction: p.faction, - halfRate: - p.faction === 'guild' || - byFaction(g, 'guild')?.id === p.ally || - g.karamaShipping?.player === id, - }, - territory(to).type, - n, - ); + const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)}; + const cost = nexusEvent + ? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost + : reserveShipmentCost(price,territory(to).type,n); if (p.faction === 'fremen') { requireRule( territory('the_great_flat').sectors.some((fs) => @@ -19957,6 +20048,7 @@ function applyActionInner( ); const allyPayment = contribution(g, p, cost, action.allyPayment); const shipment: PendingShipment = { + ...(nexusEvent ? {nexusEvent} : {}), turn: g.turn, player: id, territory: to, @@ -19974,6 +20066,7 @@ function applyActionInner( }), ...(noField ? { noField } : {}), }; + if (nexusEvent) recordNexusRicheseShipment(g,shipment); if (shipment.noField) { checkShipmentIncomeRounding(g, p, cost, allyPayment); g.pendingShipment = shipment; @@ -19986,6 +20079,7 @@ function applyActionInner( return g; } if (t === 'guildShip') { + requireRule(action.nexus === undefined, 'Choose ordinary reserve shipment to use Richese Secret Ally.'); const homeworldTarget = action.territory ?? 'reserves'; if (g.homeworlds?.custody && p.faction === 'guild' && (homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) { @@ -21044,6 +21138,7 @@ export function viewGame(state: Game, id: string) { strongholdCards: g.strongholdCards ?? null, nexusCards: projectedNexusCards(g, id), nexusMoritani: nexusMoritaniOffer(g,id), + nexusRichese: nexusRicheseOffer(g,id), nexusTraitors: projectedNexusTraitors(g, id), nexusTleilaxu: projectedNexusTleilaxu(g, id), nexusSuboids: projectedNexusSuboids(g, id), diff --git a/game/homeworld-payment-options.ts b/game/homeworld-payment-options.ts index ee68596b..481b33d0 100644 --- a/game/homeworld-payment-options.ts +++ b/game/homeworld-payment-options.ts @@ -2,6 +2,7 @@ import type { Action, GameView } from './engine'; import { quoteGuildPaymentRounding } from './homeworld-payment-income'; import { reserveShipmentCost, guildShipmentCost } from './shipment-price'; import { territory } from './board'; +import { nexusRicheseQuote } from './nexus-richese-options'; /** Own funding and public native population only; no donor balance or hidden card access. */ export function homeworldShipmentPaymentBlock(g: GameView, cost: number, allyPayment: number): string | null { @@ -21,7 +22,9 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): const me = g.players.find(p => p.id === g.me)!; const to = String(action.territory); const amount = action.noField ? 1 : Number(action.amount); - const cost = action.type === 'ship' + const nexusQuote = action.nexus === undefined ? null : nexusRicheseQuote(g, to, amount); + if (action.nexus !== undefined && (!nexusQuote || action.nexus !== g.nexusRichese?.event || action.type !== 'ship' || action.noField !== undefined)) return false; + const cost = nexusQuote ? nexusQuote.cost : action.type === 'ship' ? reserveShipmentCost({ faction: me.faction, halfRate: me.faction === 'guild' || g.players.some(p => p.faction === 'guild' && p.id === me.ally) || g.karamaShipping?.player === me.id }, territory(to).type, amount) : guildShipmentCost(to === 'reserves' ? 'reserves' : territory(to).type, amount); diff --git a/game/nexus-richese-options.ts b/game/nexus-richese-options.ts new file mode 100644 index 00000000..fd7ade50 --- /dev/null +++ b/game/nexus-richese-options.ts @@ -0,0 +1,85 @@ +import type { Action, GameView } from './engine'; +import { gameTerritories, MOBILE_STRONGHOLD } from './board'; +import { quoteNexusRicheseShipment } from './nexus-richese'; + +export function nexusRicheseCanAct(g: GameView): boolean { + const me = g.players.find((p) => p.id === g.me); + if ( + !me || + me.ally || + g.nexusCards?.card !== 'richese' || + g.players.some((p) => p.faction === 'richese') + ) + return false; + const offer = g.nexusRichese; + return !!( + offer && + !offer.blocked && + offer.event === JSON.stringify(['nexusRichese', g.turn, g.me]) && + g.status === 'playing' && + g.phase === 5 && + g.active === g.me && + !me.shipped && + !g.response && + !g.decision && + !g.truthtrance && + !g.phaseOpening && + !g.automaticContinuationPending && + !g.nexusCards.waiting.length && + !g.nexusTraitors?.pending + ); +} + +/** Price only the authorized reserve shipment; physical arrival stays unchanged. */ +export function nexusRicheseQuote( + g: GameView, + territory: string, + amount: number, +) { + if (!nexusRicheseCanAct(g)) return null; + const me = g.players.find((p) => p.id === g.me)!; + const destination = gameTerritories(g).find((t) => t.id === territory); + if ( + !destination || + (territory === MOBILE_STRONGHOLD && me.faction !== 'ixians') || + !Number.isSafeInteger(amount) || + amount < 1 || + amount > Math.min(5, me.reserves, g.nexusRichese!.maxForces) + ) + return null; + return quoteNexusRicheseShipment( + { + faction: me.faction, + halfRate: me.faction === 'guild' || g.karamaShipping?.player === g.me, + }, + destination.type, + amount, + ); +} + +export function nexusRicheseAction( + g: GameView, + event: string, + shipment: Action, +): Action | null { + if ( + !nexusRicheseCanAct(g) || + event !== g.nexusRichese!.event || + shipment.type !== 'ship' || + Object.keys(shipment).some( + (key) => + ![ + 'type', + 'territory', + 'sector', + 'amount', + 'elite', + 'homeworldSources', + 'allyPayment', + ].includes(key), + ) || + !nexusRicheseQuote(g, String(shipment.territory), Number(shipment.amount)) + ) + return null; + return { ...shipment, nexus: event }; +} diff --git a/game/nexus-richese.ts b/game/nexus-richese.ts new file mode 100644 index 00000000..e7a1f12d --- /dev/null +++ b/game/nexus-richese.ts @@ -0,0 +1,144 @@ +import { FACTIONS, type FactionId } from './catalog'; +import { reserveShipmentCost } from './shipment-price'; + +export type NexusRicheseContext = { + turn: number; + players: readonly { id: string; faction: FactionId }[]; +}; +export type NexusRicheseReceipt = { + version: 1; + event: string; + owner: string; + turn: number; + phase: 5; + mode: 'secretAlly'; + roster: { id: string; faction: FactionId }[]; + signature: string; +}; +export type NexusRicheseShipmentQuote = { + physicalAmount: number; + pricedAmount: 1; + cost: number; + source: 'nexusRichese'; +}; +function requireRichese(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} +const faction = (value: unknown): value is FactionId => + FACTIONS.some((candidate) => candidate.id === value); +const identifier = (value: unknown): value is string => + typeof value === 'string' && + value.trim().length > 0 && + !['__proto__', 'prototype', 'constructor'].includes(value); +const plain = (value: unknown): value is Record => + !!value && + typeof value === 'object' && + !Array.isArray(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)); + +/** Only the price basis changes. The engine validates actual typed reserve + * custody, destination, native Fremen geography, payment and arrival timing. */ +export function quoteNexusRicheseShipment( + player: { faction: FactionId; halfRate: boolean }, + territoryType: string, + amount: number, +): NexusRicheseShipmentQuote { + requireRichese( + player && + faction(player.faction) && + player.faction !== 'richese' && + typeof player.halfRate === 'boolean' && + ['stronghold', 'sand', 'rock', 'polar'].includes(territoryType) && + Number.isSafeInteger(amount) && + amount >= 1 && + amount <= 5, + 'Richese Secret Ally needs one to five physical reserve forces and a valid ordinary shipment price.', + ); + return { + physicalAmount: amount, + pricedAmount: 1, + cost: reserveShipmentCost(player, territoryType, 1), + source: 'nexusRichese', + }; +} +function roster(context: NexusRicheseContext) { + requireRichese( + context && + Number.isSafeInteger(context.turn) && + context.turn > 0 && + Array.isArray(context.players) && + context.players.length > 0 && + context.players.every( + (p) => p && identifier(p.id) && faction(p.faction), + ) && + new Set(context.players.map((p) => p.id)).size === + context.players.length && + new Set(context.players.map((p) => p.faction)).size === + context.players.length && + !context.players.some((p) => p.faction === 'richese'), + 'Richese Secret Ally needs a valid original faction roster with Richese absent.', + ); + return context.players + .map(({ id, faction }) => ({ id, faction })) + .sort((a, b) => a.id.localeCompare(b.id)); +} +function signature(receipt: NexusRicheseReceipt) { + return JSON.stringify([ + 'nexusRicheseReceipt', + receipt.version, + receipt.event, + receipt.owner, + receipt.turn, + receipt.phase, + receipt.mode, + receipt.roster.map((p) => [p.id, p.faction]), + ]); +} +/** Historical evidence stays valid after later card use, alliances and turns. + * Signatures establish consistency, not cryptographic authentication. */ +export function validateNexusRichese( + context: NexusRicheseContext, + receipt: NexusRicheseReceipt, +): void { + const seats = roster(context); + requireRichese( + plain(receipt) && + Object.keys(receipt).sort().join(',') === + 'event,mode,owner,phase,roster,signature,turn,version' && + receipt.version === 1 && + receipt.phase === 5 && + receipt.mode === 'secretAlly' && + Number.isSafeInteger(receipt.turn) && + receipt.turn > 0 && + receipt.turn <= context.turn && + identifier(receipt.owner) && + seats.some((p) => p.id === receipt.owner) && + receipt.event === + JSON.stringify(['nexusRichese', receipt.turn, receipt.owner]) && + Array.isArray(receipt.roster) && + receipt.roster.every( + (p) => plain(p) && Object.keys(p).sort().join(',') === 'faction,id', + ) && + JSON.stringify(receipt.roster) === JSON.stringify(seats) && + receipt.signature === signature(receipt), + 'Richese Secret Ally has lost its original owner, turn, phase or faction roster.', + ); +} +export function createNexusRichese( + context: NexusRicheseContext, + owner: string, +): NexusRicheseReceipt { + const receipt: NexusRicheseReceipt = { + version: 1, + event: JSON.stringify(['nexusRichese', context.turn, owner]), + owner, + turn: context.turn, + phase: 5, + mode: 'secretAlly', + roster: roster(context), + signature: '', + }; + receipt.signature = signature(receipt); + validateNexusRichese(context, receipt); + return receipt; +} diff --git a/game/reference.ts b/game/reference.ts index 4024f518..1daa1ace 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [ searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '), category: 'Advanced & expansions', coverage: 'Partial', - summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor, five CHOAM Cunning effects and Moritani Cunning supply placement are integrated. Other effects and complete module play remain in development.', + summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor, five CHOAM Cunning effects Moritani Cunning supply placement and Richese Secret Ally reserve shipping are integrated. Other effects and complete module play remain in development.', steps: [ 'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.', 'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.', @@ -3367,16 +3367,17 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', + 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.', 'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules; this does not implement Atomics, Extortion or complete Moritani games.', 'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.', ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects and Moritani supply placement beyond ordinary strongholds. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects Moritani supply placement beyond ordinary strongholds and Richese Secret Ally physical reserve shipping priced as one. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-options.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses, plus an explicit Richese shipment source and physical-force price breakdown. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-options.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts'] }, ], }, ...[ diff --git a/tests/fixture-nexus-richese.ts b/tests/fixture-nexus-richese.ts new file mode 100644 index 00000000..383fd4c3 --- /dev/null +++ b/tests/fixture-nexus-richese.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeNexusGameForAudit, + joinGame, + newPlayer, + viewGame, + type Game, + type Action, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import type { FactionId } from '../game/catalog'; +import { drawNexusCard } from '../game/nexus-cards'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; +import { nexusTraitorInventory } from './fixture-nexus-traitors'; +import { + enterNexusSpice, + finishNexusSpice, + orderNexusSpice, + nexusAllow, + nexusReload, +} from './fixture-nexus-cards'; + +export type NexusRicheseOwner = + | 'atreides' + | 'emperor' + | 'fremen' + | 'guild' + | 'beneGesserit' + | 'ixians' + | 'harkonnen'; +export type NexusRicheseFixtureOptions = { + seatIds?: [string, string, string]; + ownerFaction?: NexusRicheseOwner; + opponentFaction?: + | 'guild' + | 'harkonnen' + | 'moritani' + | 'beneGesserit' + | 'ixians'; + guild?: boolean; + advanced?: boolean; + homeworlds?: boolean; + spice?: number; + karama?: boolean; +}; +export type NexusRicheseFixture = { + g: Game; + owner: string; + target: string; + observer: string; + karama?: string; +}; + +/** Final roster, module choices and production seat IDs precede genuine setup. + * Conserved reserves are staged only after a real completed Nexus phase. */ +export function nexusRicheseFixture( + options: NexusRicheseFixtureOptions = {}, +): NexusRicheseFixture { + const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r']; + const ownerFaction = options.ownerFaction ?? 'atreides'; + const opponentFaction = + options.opponentFaction ?? + ((options.guild && ownerFaction !== 'guild') || ownerFaction === 'harkonnen' + ? 'guild' + : 'harkonnen'); + assert.notEqual(ownerFaction, opponentFaction); + const observerFaction = ( + ['fremen', 'atreides', 'emperor'] as FactionId[] + ).find((f) => f !== ownerFaction && f !== opponentFaction)!; + let g = createGame( + 'RICHESENEXUS', + newPlayer(owner, ownerFaction, ownerFaction), + options.advanced ?? false, + ownerFaction === 'ixians' || opponentFaction === 'ixians' ? ['ix'] : [], + ); + // The unfinished E3 public deck gate is separate from native Moritani audit + // setup. Fix the final faction before readiness and genuine initialization. + joinGame( + g, + newPlayer( + target, + opponentFaction, + opponentFaction === 'moritani' ? 'harkonnen' : opponentFaction, + ), + ); + if (opponentFaction === 'moritani') + g.players[1] = newPlayer(target, opponentFaction, opponentFaction); + joinGame(g, newPlayer(observer, observerFaction, observerFaction)); + if (options.homeworlds) + g = applyAction(g, owner, { type: 'homeworlds', enabled: true }); + g.nexusCards = { cards: null, phase: null }; + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + g = initializeNexusGameForAudit(g); + for (let step = 0; g.status === 'setup' && step < 100; step++) { + let next: Game | undefined; + for (const p of g.players) { + const v = viewGame(g, p.id); + v.players.find((s) => s.id === p.id)!.bot = 'Easy'; + const a = botActions(v)[0]; + if (a) { + next = applyAction(g, p.id, a); + break; + } + } + assert.ok( + next, + `Richese Nexus setup stalled at ${g.setupStage}/${g.decision?.kind}`, + ); + g = next; + } + assert.equal(g.status, 'playing'); + for (const p of g.players) g.deck.push(...p.hand.splice(0)); + if (g.expansions.includes('ix')) { + for (let step = 0; g.phase === 0 && step < 60; step++) g = fixtureStep(g); + assert.equal(g.phase, 1); + orderNexusSpice(g, ['land', 'land']); + for (let step = 0; g.phase === 1 && step < 60; step++) g = fixtureStep(g); + assert.equal(g.phase, 2); + } else { + g = enterNexusSpice(g); + orderNexusSpice(g, ['land', 'land']); + g = finishNexusSpice(g); + } + for (const p of g.players) { + const eliteTotal = p.elites + ? p.elites.reserves + + p.elites.tanks + + Object.values(p.elites.forces).reduce((s, n) => s + n, 0) + : 0; + Object.assign(p, { + forces: {}, + reserves: 20, + tanks: 0, + spice: 20, + shipped: false, + moved: 0, + }); + if (p.advisors) p.advisors = {}; + if (p.elites) + Object.assign(p.elites, { forces: {}, reserves: eliteTotal, tanks: 0 }); + } + const cards = g.nexusCards!.cards!; + cards.deck = ['richese', ...cards.deck.filter((c) => c !== 'richese')]; + g.nexusCards!.cards = drawNexusCard(cards, owner, g.players, () => 0); + const karama = options.karama + ? holdNexusRicheseCard(g, target, 'karama').id + : undefined; + g.players[0].spice = options.spice ?? 20; + Object.assign(g, { + phase: 5, + active: owner, + order: [owner, target, observer], + movementRemaining: [owner, target, observer], + ready: [], + response: null, + decision: null, + phaseOpening: null, + spice: {}, + storm: 18, + hajr: [], + }); + nexusRicheseInventory(g); + return { g, owner, target, observer, karama }; +} +export function holdNexusRicheseCard(g: Game, owner: string, kind: string) { + const i = g.deck.findIndex((c) => c.kind === kind || c.effect === kind); + assert.ok(i >= 0); + const card = g.deck.splice(i, 1)[0]; + g.players.find((p) => p.id === owner)!.hand.push(card); + return card; +} +function fixtureStep(g: Game): Game { + for (const p of g.players) { + const v = viewGame(g, p.id); + v.players.find((s) => s.id === p.id)!.bot = 'Easy'; + const action = botActions(v)[0]; + if (action) return applyAction(g, p.id, action); + } + throw new Error( + `Richese fixture progression stalled at ${g.phase}/${g.decision?.kind}`, + ); +} +export function nexusRicheseRequest( + f: NexusRicheseFixture, + amount = 5, + territory = 'arrakeen', + sector = 10, + g = f.g, +): Action { + return { + type: 'ship', + territory, + sector, + amount, + nexus: viewGame(g, f.owner).nexusRichese!.event, + }; +} +export function nexusRicheseInventory(g: Game) { + nexusTraitorInventory(g); + if (g.homeworlds) homeworldGameIntegrity(g); +} +export const nexusRicheseAllow = nexusAllow; +export const nexusRicheseReload = nexusReload; diff --git a/tests/nexus-richese-bots.test.ts b/tests/nexus-richese-bots.test.ts new file mode 100644 index 00000000..e115ad4a --- /dev/null +++ b/tests/nexus-richese-bots.test.ts @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options'; +import { + nexusRicheseFixture, + nexusRicheseAllow, + nexusRicheseInventory, +} from './fixture-nexus-richese'; + +void test('all profiles generate an affordable five-force Richese shipment when ordinary five-force shipping is unaffordable', () => { + for (const advanced of [false, true]) + for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const f = nexusRicheseFixture({ advanced, spice: 1 }); + const v = viewGame(f.g, f.owner); + v.players.find((p) => p.id === f.owner)!.bot = profile; + const before = structuredClone(v); + const action = botActions(v).find( + (a) => + a.type === 'ship' && + a.nexus === v.nexusRichese!.event && + a.amount === 5, + ); + assert.ok(action, profile); + assert.ok(botHomeworldShipmentPaymentAllowed(v, action)); + const next = nexusRicheseAllow(applyAction(f.g, f.owner, action)); + const me = next.players.find((p) => p.id === f.owner)!; + assert.equal(me.reserves, 15); + assert.equal(me.spice, 0); + assert.equal(me.shipped, true); + nexusRicheseInventory(next); + assert.deepEqual(v, before); + } +}); + +void test('Homeworld payment filter quotes the Nexus source and bots preserve ordinary fallback without wasting Fremen free shipping', () => { + const f = nexusRicheseFixture({ + advanced: true, + homeworlds: true, + guild: true, + spice: 1, + }); + const v = viewGame(f.g, f.owner); + v.players[0].bot = 'Hard'; + // Projection-only low-Junction seam exercises the payment consumer; engine custody stays untouched. + const junction = v.homeworlds!.worlds!.find((w) => w.card === 'junction')!; + junction.side = 'low'; + const action = { + type: 'ship', + territory: 'arrakeen', + sector: 10, + amount: 5, + nexus: v.nexusRichese!.event, + }; + assert.equal(botHomeworldShipmentPaymentAllowed(v, action), true); + assert.equal( + botHomeworldShipmentPaymentAllowed(v, { ...action, nexus: 'stale' }), + false, + ); + assert.equal( + botHomeworldShipmentPaymentAllowed(v, { ...action, amount: 6 }), + false, + ); + v.nexusRichese!.blocked = 'Unavailable'; + assert.ok(botActions(v).every((a) => a.nexus === undefined)); + const fremen = nexusRicheseFixture({ ownerFaction: 'fremen', spice: 0 }); + const fv = viewGame(fremen.g, fremen.owner); + fv.players[0].bot = 'Hard'; + const ordinary = botActions(fv).find((a) => a.type === 'ship'); + assert.ok(ordinary); + assert.equal(ordinary.nexus, undefined); + nexusRicheseInventory( + nexusRicheseAllow(applyAction(fremen.g, fremen.owner, ordinary)), + ); +}); diff --git a/tests/nexus-richese-controls.test.ts b/tests/nexus-richese-controls.test.ts new file mode 100644 index 00000000..ca47cb90 --- /dev/null +++ b/tests/nexus-richese-controls.test.ts @@ -0,0 +1,240 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame, type GameView } from '../game/engine'; +import { MOBILE_STRONGHOLD, gameTerritories } from '../game/board'; +import { ShipmentQuote } from '../components/shipment-quote'; +import { + nexusRicheseAction, + nexusRicheseCanAct, + nexusRicheseQuote, +} from '../game/nexus-richese-options'; +import { + nexusRicheseFixture, + nexusRicheseInventory, + nexusRicheseAllow, +} from './fixture-nexus-richese'; +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next( + specifier === 'next/image' + ? 'vinext/shims/image' + : specifier === 'next/link' + ? 'vinext/shims/link' + : specifier, + context, + ); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries( + [...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => [m[1], m[1]]), + ); + return { + format: 'module', + source: `export default ${JSON.stringify(classes)}`, + shortCircuit: true, + }; + }, +}); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +const html = (game: GameView, busy = false) => + renderToStaticMarkup( + createElement(GameTable, { + game, + busy, + send: async () => {}, + onExit() {}, + }), + ); +const shipment = { + type: 'ship', + territory: 'arrakeen', + sector: 10, + amount: 5, + elite: 0, +}; + +void test('actual Basic and Advanced shipment panel defaults to ordinary shipping; authorized five-force quote and action preserve physical custody', () => { + for (const advanced of [false, true]) { + const f = nexusRicheseFixture({ advanced, spice: 1 }); + const v = viewGame(f.g, f.owner), + before = structuredClone(v); + const markup = html(v); + assert.match(markup, /Use Richese Nexus Secret Ally/); + const checkbox = markup.match( + /]*type="checkbox"[^>]*>[\s\S]{0,150}Use Richese Nexus Secret Ally/, + )?.[0]; + assert.ok(checkbox); + assert.doesNotMatch(checkbox, /checked=""/); + assert.match(markup, /This uses your ordinary shipment/); + assert.match( + html(v, true).match( + /]*type="checkbox"[^>]*>[\s\S]{0,150}Use Richese Nexus Secret Ally/, + )![0], + /disabled=""/, + ); + const quote = nexusRicheseQuote(v, 'arrakeen', 5)!; + assert.equal(quote.cost, 1); + assert.equal(quote.physicalAmount, 5); + assert.equal(quote.pricedAmount, 1); + const price = renderToStaticMarkup( + createElement(ShipmentQuote, { + physicalForces: quote.physicalAmount, + quote: { + cost: quote.cost, + normalCost: 5, + ownPayment: 1, + pledgedPayment: 0, + }, + funding: { ownSpice: 1, pledgedSpice: 0 }, + }), + ); + assert.match(price, /Shipment cost: 1 spice/); + assert.match(price, /Normal cost: 5 spice/); + assert.match(price, /Physical forces selected<\/dt>]*>5<\/dd>/); + const action = nexusRicheseAction(v, v.nexusRichese!.event, shipment)!; + assert.deepEqual(action, { ...shipment, nexus: v.nexusRichese!.event }); + const settled = nexusRicheseAllow(applyAction(f.g, f.owner, action)); + const me = settled.players.find((p) => p.id === f.owner)!; + assert.equal(me.reserves, 15); + assert.equal(me.forces['arrakeen:10'], 5); + assert.equal(me.spice, 0); + assert.equal(me.shipped, true); + assert.equal(me.moved, 0); + assert.equal(me.noField, undefined); + nexusRicheseInventory(settled); + assert.deepEqual(v, before); + } +}); + +void test('shared Richese source fences reject stale, unavailable, oversized and marker routes and keep faction pricing', () => { + const f = nexusRicheseFixture(), + v = viewGame(f.g, f.owner), + event = v.nexusRichese!.event; + assert.equal(nexusRicheseAction(v, 'stale', shipment), null); + for (const change of [ + { amount: 0 }, + { amount: 6 }, + { amount: 1.5 }, + { noField: 'token' }, + { territory: 'homeworld:harkonnen' }, + { type: 'guildShip' }, + { nexus: event }, + ]) + assert.equal( + nexusRicheseAction(v, event, { ...shipment, ...change }), + null, + ); + for (const mutate of [ + (g: GameView) => { + g.turn++; + }, + (g: GameView) => { + g.phase = 6; + }, + (g: GameView) => { + g.active = f.target; + }, + (g: GameView) => { + g.players[0].shipped = true; + }, + (g: GameView) => { + g.players[0].ally = f.target; + }, + (g: GameView) => { + g.nexusCards!.card = null; + }, + (g: GameView) => { + g.automaticContinuationPending = true; + }, + (g: GameView) => { + g.nexusRichese!.blocked = 'Finish the current interaction.'; + }, + ]) { + const changed = structuredClone(v); + mutate(changed); + assert.equal(nexusRicheseAction(changed, event, shipment), null); + } + v.nexusRichese!.blocked = 'Finish the current interaction.'; + assert.match(html(v), /Finish the current interaction/); + for (const faction of ['guild', 'fremen'] as const) { + const own = nexusRicheseFixture({ ownerFaction: faction, spice: 1 }); + const view = viewGame(own.g, own.owner); + assert.equal( + nexusRicheseQuote(view, 'polar_sink', 5)!.cost, + faction === 'guild' ? 1 : 0, + ); + } +}); + +void test('Richese offer and helper never expose or inspect another player card or balance', () => { + const f = nexusRicheseFixture(); + for (const id of [f.target, f.observer]) { + const v = viewGame(f.g, id); + assert.equal(v.nexusRichese, null); + assert.doesNotMatch(html(v), /Use Richese Nexus Secret Ally/); + Object.defineProperty(v, 'nexusRichese', { + get() { + throw new Error('Private offer read'); + }, + }); + assert.equal(nexusRicheseCanAct(v), false); + assert.equal(nexusRicheseAction(v, 'unknown', shipment), null); + } + const v = viewGame(f.g, f.owner); + for (const p of v.players.filter((p) => p.id !== f.owner)) + for (const field of ['hand', 'spice', 'traitors']) + Object.defineProperty(p, field, { + get() { + throw new Error('Private opponent read'); + }, + }); + assert.equal(nexusRicheseQuote(v, 'arrakeen', 5)!.cost, 1); +}); + +void test('Richese price preserves a native Ixian shipment into the present mobile stronghold without granting another faction entry', () => { + for (const advanced of [false, true]) { + const f = nexusRicheseFixture({ + ownerFaction: 'ixians', + advanced, + spice: 1, + }); + const v = viewGame(f.g, f.owner); + const hms = gameTerritories(v).find((t) => t.id === MOBILE_STRONGHOLD)!; + assert.ok(hms); + const quote = nexusRicheseQuote(v, MOBILE_STRONGHOLD, 5)!; + assert.equal(quote.cost, 1); + const action = nexusRicheseAction(v, v.nexusRichese!.event, { + type: 'ship', + territory: MOBILE_STRONGHOLD, + sector: hms.sectors[0], + amount: 5, + elite: 2, + })!; + assert.ok(action); + const next = nexusRicheseAllow(applyAction(f.g, f.owner, action)); + const me = next.players.find((p) => p.id === f.owner)!; + assert.equal(me.reserves, 15); + assert.equal(me.spice, 0); + assert.equal(me.forces[`${MOBILE_STRONGHOLD}:${hms.sectors[0]}`], 5); + assert.equal( + me.elites!.forces[`${MOBILE_STRONGHOLD}:${hms.sectors[0]}`], + 2, + ); + nexusRicheseInventory(next); + const outsider = nexusRicheseFixture({ + opponentFaction: 'ixians', + advanced, + }); + const other = viewGame(outsider.g, outsider.owner); + assert.ok(gameTerritories(other).some((t) => t.id === MOBILE_STRONGHOLD)); + assert.equal(nexusRicheseQuote(other, MOBILE_STRONGHOLD, 5), null); + } +}); diff --git a/tests/nexus-richese-engine.test.ts b/tests/nexus-richese-engine.test.ts new file mode 100644 index 00000000..5f26dd33 --- /dev/null +++ b/tests/nexus-richese-engine.test.ts @@ -0,0 +1,475 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { botActions } from '../game/bots'; +import { orderNexusSpice } from './fixture-nexus-cards'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Game, + type Action, +} from '../game/engine'; +import { + nexusRicheseFixture, + nexusRicheseRequest, + nexusRicheseAllow, + nexusRicheseReload, + nexusRicheseInventory, + holdNexusRicheseCard, +} from './fixture-nexus-richese'; + +function reject(g: Game, id: string, action: Action) { + const before = nexusRicheseReload(g); + assert.throws(() => applyAction(g, id, action)); + assert.deepEqual(g, before); +} +function stable(g: Game) { + nexusRicheseInventory(g); + for (const p of g.players) + assert.deepEqual(viewGame(nexusRicheseReload(g), p.id), viewGame(g, p.id)); + assert.deepEqual( + normalizeAutomaticGame(nexusRicheseReload(g)), + nexusRicheseReload(g), + ); +} + +void test('Richese Secret Ally ships five actual counters at one-counter cost for ordinary, Guild and Fremen factions', () => { + for (const ownerFaction of ['atreides', 'guild', 'fremen'] as const) + for (const amount of [1, 5]) { + const f = nexusRicheseFixture({ ownerFaction, spice: 2 }); + const territory = + ownerFaction === 'fremen' ? 'the_great_flat' : 'red_chasm'; + const sector = ownerFaction === 'fremen' ? 15 : 7; + const action = nexusRicheseRequest(f, amount, territory, sector); + const g = nexusRicheseAllow(applyAction(f.g, f.owner, action)); + const p = g.players[0]; + assert.equal( + p.spice, + ownerFaction === 'fremen' ? 2 : ownerFaction === 'guild' ? 1 : 0, + ); + assert.equal(p.reserves, 20 - amount); + assert.equal(p.forces[`${territory}:${sector}`], amount); + assert.equal(p.shipped, true); + assert.equal(p.moved, 0); + assert.equal(g.nexusCards!.cards!.hands[f.owner], null); + assert.equal( + g.nexusCards!.cards!.discard.filter((c) => c === 'richese').length, + 1, + ); + reject(g, f.owner, action); + stable(g); + } +}); + +void test('Richese declaration rejects excess forces, stale events, foreign seats, wrong ranges and insufficient payment immutably', () => { + const f = nexusRicheseFixture({ spice: 1 }); + const action = nexusRicheseRequest(f); + for (const bad of [ + { ...action, amount: 0 }, + { ...action, amount: 6 }, + { ...action, nexus: 'old' }, + { ...action, territory: 'red_chasm', sector: 7 }, + { ...action, territory: 'arrakeen', sector: 9 }, + { ...action, homeworldSources: { unknown: { normal: 5, elite: 0 } } }, + ]) + reject(f.g, f.owner, bad); + reject(f.g, f.target, action); + for (const type of ['guildShip', 'homeworldShip', 'guildHomeworldShip']) + reject(f.g, f.owner, { ...action, type }); + const fremen = nexusRicheseFixture({ ownerFaction: 'fremen' }); + reject( + fremen.g, + fremen.owner, + nexusRicheseRequest(fremen, 5, 'arrakeen', 10), + ); + const storm = nexusRicheseReload(f.g); + storm.storm = 10; + reject(storm, f.owner, action); +}); + +void test('five typed Emperor forces withdraw from Kaitain and Salusa once while their single-force price stays one', () => { + const f = nexusRicheseFixture({ + ownerFaction: 'emperor', + advanced: true, + homeworlds: true, + spice: 1, + }); + const action = { + ...nexusRicheseRequest(f), + elite: 2, + homeworldSources: { + 'homeworld:emperor': { normal: 3, elite: 0 }, + 'homeworld:emperor:salusa': { normal: 0, elite: 2 }, + }, + }; + const g = applyAction(f.g, f.owner, action); + assert.equal(g.players[0].spice, 0); + assert.equal(g.players[0].forces['arrakeen:10'], 5); + assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2); + assert.equal(g.players[0].reserves, 15); + assert.equal(g.homeworlds!.custody!.salusa!.elite, 3); + stable(g); +}); + +void test('Guild interception preserves the complete five-force declaration and spends Nexus on either allowance or stop', () => { + for (const stop of [false, true]) { + const f = nexusRicheseFixture({ + advanced: true, + guild: true, + karama: true, + spice: 1, + }); + const action = nexusRicheseRequest(f); + let g = applyAction(f.g, f.owner, action); + assert.equal(g.decision?.kind, 'guildShipment'); + assert.equal(g.pendingShipment!.amount, 5); + assert.equal(g.pendingShipment!.cost, 1); + assert.equal(g.players[0].reserves, 20); + assert.equal(g.players[0].spice, 1); + assert.equal(g.nexusCards!.cards!.hands[f.owner], null); + stable(g); + g = applyAction( + nexusRicheseReload(g), + f.target, + stop + ? { type: 'card', mode: 'special', card: f.karama } + : { type: 'decision', allow: true }, + ); + g = nexusRicheseAllow(g); + assert.equal(g.players[0].reserves, stop ? 20 : 15); + assert.equal(g.players[0].spice, stop ? 1 : 0); + assert.equal(g.players[1].spice, stop ? 20 : 21); + assert.equal( + g.nexusCards!.cards!.discard.filter((c) => c === 'richese').length, + 1, + ); + reject(g, f.owner, action); + stable(g); + } +}); + +void test('independent Karama discounts the one-force price, routes payment to bank and preserves actual force count', () => { + const f = nexusRicheseFixture({ guild: true, spice: 1 }); + const card = holdNexusRicheseCard(f.g, f.owner, 'karama'); + let g = applyAction(f.g, f.owner, { + type: 'card', + card: card.id, + mode: 'shipment', + target: f.owner, + }); + g = applyAction(g, f.owner, nexusRicheseRequest(f, 5, 'red_chasm', 7, g)); + assert.equal(g.players[0].spice, 0); + assert.equal(g.players[0].forces['red_chasm:7'], 5); + assert.equal(g.players[1].spice, 20); + assert.equal(g.response, null); + assert.equal(g.discard.filter((c) => c.id === card.id).length, 1); + stable(g); +}); + +void test('the real shipment opens BG accompaniment with all five arrivals and leaves normal movement available afterward', () => { + const f = nexusRicheseFixture({ + advanced: true, + opponentFaction: 'beneGesserit', + }); + let g = applyAction(f.g, f.owner, nexusRicheseRequest(f)); + assert.equal(g.decision?.kind, 'advisor'); + assert.equal(g.players[0].forces['arrakeen:10'], 5); + g = applyAction(nexusRicheseReload(g), f.target, { + type: 'decision', + accept: true, + accompany: true, + }); + g = nexusRicheseAllow(g); + assert.equal(g.players[1].forces['arrakeen:10'], 1); + assert.equal(g.players[1].reserves, 19); + assert.equal(g.players[0].moved, 0); + assert.equal(g.active, f.owner); + stable(g); +}); + +void test('Richese held-card availability and original shipment evidence remain private across other seats and reload', () => { + const f = nexusRicheseFixture({ advanced: true, guild: true }); + for (const id of [f.target, f.observer]) + assert.equal(viewGame(f.g, id).nexusRichese, null); + let g = applyAction(f.g, f.owner, nexusRicheseRequest(f)); + for (const id of [f.target, f.observer]) { + const view = viewGame(g, id); + assert.equal(Object.hasOwn(view, 'nexusRicheseHistory'), false); + assert.equal(view.nexusRichese, null); + assert.equal(view.decision?.kind, 'guildShipment'); + if (view.decision?.kind === 'guildShipment') + assert.equal(view.decision.amount, 5); + } + g = applyAction(nexusRicheseReload(g), f.target, { + type: 'decision', + allow: true, + }); + g = nexusRicheseAllow(g); + stable(g); +}); + +void test('Richese pending source rejects event/history deletion, original decision edits and valid-looking typed reallocations', () => { + const f = nexusRicheseFixture({ + ownerFaction: 'emperor', + advanced: true, + homeworlds: true, + guild: true, + }); + const pending = applyAction(f.g, f.owner, { + ...nexusRicheseRequest(f), + elite: 2, + homeworldSources: { + 'homeworld:emperor': { normal: 3, elite: 0 }, + 'homeworld:emperor:salusa': { normal: 0, elite: 2 }, + }, + }); + for (const edit of [ + (g: Game) => { + delete g.pendingShipment!.nexusEvent; + }, + (g: Game) => { + delete g.nexusRicheseHistory; + }, + (g: Game) => { + delete g.nexusRicheseLast; + }, + (g: Game) => { + g.nexusRicheseHistory![0].signature = 'changed'; + }, + (g: Game) => { + g.nexusRicheseHistory![0].frame = '{}'; + }, + (g: Game) => { + g.pendingShipment!.elite = 1; + g.pendingShipment!.homeworldSources!['homeworld:emperor'].normal = 4; + g.pendingShipment!.homeworldSources!['homeworld:emperor:salusa'].elite = + 1; + }, + (g: Game) => { + if (g.decision?.kind === 'guildShipment') g.decision.amount = 4; + }, + (g: Game) => { + if (g.decision?.kind === 'guildShipment') g.decision.shipper = f.observer; + }, + (g: Game) => { + g.nexusRicheseLast!.stage = 'shipped'; + }, + ]) { + const bad = nexusRicheseReload(pending); + edit(bad); + const before = nexusRicheseReload(bad); + for (const p of bad.players) assert.throws(() => viewGame(bad, p.id)); + assert.throws(() => normalizeAutomaticGame(bad)); + assert.throws(() => + applyAction(bad, f.target, { type: 'decision', allow: true }), + ); + assert.deepEqual(bad, before); + } +}); + +void test('a genuine Truthtrance interruption preserves Richese pending shipment, paid quote and Guild interception', () => { + const f = nexusRicheseFixture({ advanced: true, guild: true, spice: 1 }); + const truth = holdNexusRicheseCard(f.g, f.observer, 'truthtrance'); + let g = applyAction(f.g, f.owner, nexusRicheseRequest(f)); + const shipment = structuredClone(g.pendingShipment), + decision = structuredClone(g.decision), + history = structuredClone(g.nexusRicheseHistory); + g = applyAction(g, f.observer, { type: 'card', card: truth.id }); + while (g.truthtrance?.stage === 'priority') + g = applyAction( + g, + g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id, + { type: 'truthPass' }, + ); + g = applyAction(g, f.observer, { + type: 'truthAsk', + question: { + kind: 'fact', + target: f.owner, + fact: { kind: 'hand', name: 'Shield' }, + }, + }); + g = applyAction(nexusRicheseReload(g), f.owner, { + type: 'truthAnswer', + answer: 'no', + }); + assert.deepEqual(g.pendingShipment, shipment); + assert.deepEqual(g.decision, decision); + assert.deepEqual(g.nexusRicheseHistory, history); + g = applyAction(g, f.target, { type: 'decision', allow: true }); + g = nexusRicheseAllow(g); + assert.equal(g.players[0].reserves, 15); + assert.equal(g.players[0].spice, 0); + assert.equal(g.discard.filter((c) => c.id === truth.id).length, 1); + stable(g); +}); + +void test('completed Richese history survives ordinary movement, genuine next-turn phases and a later full-price shipment', () => { + const f = nexusRicheseFixture(); + let g = applyAction(f.g, f.owner, nexusRicheseRequest(f)); + const history = structuredClone(g.nexusRicheseHistory); + g = applyAction(g, f.owner, { + type: 'move', + from: 'arrakeen:10', + territory: 'hagga_basin', + sector: 12, + amount: 5, + }); + assert.equal(g.players[0].forces['hagga_basin:12'], 5); + while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' }); + let ordered = false; + for ( + let step = 0; + !( + g.turn === 2 && + g.phase === 5 && + !g.response && + !g.decision && + !g.phaseOpening + ) && step < 180; + step++ + ) { + if (g.turn === 2 && g.phase === 1 && !ordered) { + orderNexusSpice(g, ['land', 'land']); + ordered = true; + } + let next: Game | undefined; + for (const p of g.players) { + const v = viewGame(g, p.id); + v.players.find((s) => s.id === p.id)!.bot = 'Easy'; + const action = botActions(v)[0]; + if (action) { + next = applyAction(g, p.id, action); + break; + } + } + assert.ok( + next, + `Next turn stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`, + ); + g = next; + } + assert.equal(g.turn, 2); + assert.equal(g.phase, 5); + while (g.active !== f.owner) + g = applyAction(g, g.active!, { type: 'endMovement' }); + assert.deepEqual(g.nexusRicheseHistory, history); + assert.equal(g.nexusCards!.cards!.hands[f.owner], null); + const before = g.players[0].spice, + reserves = g.players[0].reserves; + g = applyAction(g, f.owner, { + type: 'ship', + territory: 'red_chasm', + sector: 7, + amount: 1, + }); + assert.equal(g.players[0].spice, before - 2); + assert.equal(g.players[0].reserves, reserves - 1); + assert.deepEqual(g.nexusRicheseHistory, history); + stable(g); +}); + +void test('native Ixians may ship five typed counters into their real mobile stronghold but another faction cannot', () => { + for (const advanced of [false, true]) { + const f = nexusRicheseFixture({ + ownerFaction: 'ixians', + advanced, + spice: 1, + }); + assert.ok(f.g.mobileStronghold?.location); + const g = applyAction(f.g, f.owner, { + ...nexusRicheseRequest(f, 5, 'hidden_mobile_stronghold', 0), + elite: 2, + }); + assert.equal(g.players[0].forces['hidden_mobile_stronghold:0'], 5); + assert.equal(g.players[0].elites!.forces['hidden_mobile_stronghold:0'], 2); + assert.equal(g.players[0].spice, 0); + stable(g); + } + const other = nexusRicheseFixture({ opponentFaction: 'ixians' }); + assert.ok(other.g.mobileStronghold?.location); + reject( + other.g, + other.owner, + nexusRicheseRequest(other, 5, 'hidden_mobile_stronghold', 0), + ); +}); + +void test('a genuinely placed Terror token sees five arriving Richese-discounted counters under low Grumman', () => { + const f = nexusRicheseFixture({ + opponentFaction: 'moritani', + homeworlds: true, + }); + let g = f.g; + g.players[1].reserves = 7; + g.players[1].forces = { 'polar_sink:0': 13 }; + while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' }); + assert.equal(g.phase, 7); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + assert.equal(g.decision?.kind, 'moritaniPlacement'); + const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!; + g = nexusRicheseAllow( + applyAction(g, f.target, { + type: 'decision', + token: token.id, + territory: 'arrakeen', + }), + ); + let ordered = false; + for ( + let step = 0; + !( + g.turn === 2 && + g.phase === 5 && + !g.response && + !g.decision && + !g.phaseOpening + ) && step < 180; + step++ + ) { + if (g.turn === 2 && g.phase === 1 && !ordered) { + orderNexusSpice(g, ['land', 'land']); + ordered = true; + } + let next: Game | undefined; + for (const p of g.players) { + const v = viewGame(g, p.id); + v.players.find((s) => s.id === p.id)!.bot = 'Easy'; + const action = botActions(v)[0]; + if (action) { + next = applyAction(g, p.id, action); + break; + } + } + assert.ok( + next, + `Terror progression stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`, + ); + g = next; + } + assert.equal(g.turn, 2); + assert.equal(g.phase, 5); + while (g.active !== f.owner) + g = applyAction(g, g.active!, { type: 'endMovement' }); + assert.equal(g.players[1].reserves, 7); + const spice = g.players[0].spice; + g = applyAction(g, f.owner, nexusRicheseRequest(f, 5, 'arrakeen', 10, g)); + assert.equal(g.players[0].spice, spice - 1); + assert.equal(g.pendingTerrorEntry?.amount, 5); + assert.equal(g.players[0].forces['arrakeen:10'], 5); + const beforeRobbery = g.players[0].spice; + g = applyAction(nexusRicheseReload(g), f.target, { + type: 'decision', + reveal: true, + }); + g = applyAction(g, f.target, { type: 'decision', choice: 'spice' }); + assert.equal( + g.players[0].spice, + beforeRobbery - Math.ceil(beforeRobbery / 2), + ); + assert.equal( + g.moritaniTerror!.tokens.find((t) => t.id === token.id)!.status, + 'removed', + ); + stable(g); +}); diff --git a/tests/nexus-richese-promises.test.ts b/tests/nexus-richese-promises.test.ts new file mode 100644 index 00000000..aa45f0cb --- /dev/null +++ b/tests/nexus-richese-promises.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { nexusRicheseFixture, holdNexusRicheseCard, nexusRicheseRequest, nexusRicheseInventory } from './fixture-nexus-richese'; + +void test('a binding five-force Yes uses the held Richese discount as a private completion witness', () => { + const f = nexusRicheseFixture({spice:1}); + const card = holdNexusRicheseCard(f.g,f.target,'truthtrance'); + let g = applyAction(f.g,f.target,{type:'card',card:card.id}); + while (g.truthtrance?.stage === 'priority') { + const seat = g.players.find(p => !g.truthtrance!.passed.includes(p.id))!; + g = applyAction(g,seat.id,{type:'truthPass'}); + } + g = applyAction(g,f.target,{type:'truthAsk',question:{kind:'shipment',target:f.owner,territory:'arrakeen',minimum:5}}); + assert.deepEqual(viewGame(g,f.owner).truthShipmentAnswers,['yes','no']); + g = applyAction(g,f.owner,{type:'truthAnswer',answer:'yes'}); + for (const profile of ['Easy','Medium','Hard','Brutal'] as const) { + const v = viewGame(g,f.owner); + v.players.find(p => p.id === f.owner)!.bot = profile; + const action = botActions(v)[0]; + assert.equal(action.type,'ship'); + assert.equal(action.amount,5); + assert.equal(action.nexus,JSON.stringify(['nexusRichese',g.turn,f.owner])); + const done = applyAction(g,f.owner,action); + assert.equal(done.players[0].reserves,15); + assert.equal(done.players[0].spice,0); + assert.equal(done.shipmentPromises?.[0].fulfilled,true); + nexusRicheseInventory(done); + } + assert.throws(() => applyAction(g,f.owner,{type:'endMovement'})); + assert.throws(() => applyAction(g,f.owner,nexusRicheseRequest(f,4,'arrakeen',10,g))); +}); diff --git a/tests/nexus-richese-recovery.test.ts b/tests/nexus-richese-recovery.test.ts new file mode 100644 index 00000000..a86db7a3 --- /dev/null +++ b/tests/nexus-richese-recovery.test.ts @@ -0,0 +1,345 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { unitStore } from './fixture-nexus-room-store'; +import { + normalizeAutomaticGame, + viewGame, + type Game, + type Action, +} from '../game/engine'; +import type { RoomsClock } from '../db/rooms'; +import { + nexusRicheseFixture, + nexusRicheseRequest, + nexusRicheseInventory, + type NexusRicheseFixtureOptions, +} from './fixture-nexus-richese'; + +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; +async function fixture(options: NexusRicheseFixtureOptions = {}) { + const store = unitStore(); + const made = await store.rooms.createRoom( + 'Richese SQL', + options.ownerFaction ?? 'atreides', + options.advanced ?? true, + [], + ); + const code = made.view.code, + 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((a) => a.playerId) as [string, string, string]; + const f = nexusRicheseFixture({ + guild: true, + advanced: true, + karama: true, + ...options, + seatIds: ids, + }); + f.g.code = code; + f.g.version = (await store.rooms.readRoom(code)).version; + const save = (g: Game) => + store.sqlite + .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code); + save(f.g); + store.writes.length = 0; + return { ...store, ...f, code, tokens, auths, ids, save }; +} +type Fixture = Awaited>; +const row = (f: Fixture) => + f.sqlite + .prepare('SELECT state,version FROM rooms WHERE code = ?') + .get(f.code); +async function act(f: Fixture, seat: number, action: Action) { + const g = await f.restart().readRoom(f.code); + await f.restart().act(f.code, f.auths[seat], g.version, action, clock); + return f.restart().readRoom(f.code); +} +async function restored(f: Fixture, g: Game) { + const before = row(f), + rooms = f.restart(); + for (const [i, token] of f.tokens.entries()) { + const auth = await rooms.authenticate(f.code, token); + const v = await rooms.readSeatView(f.code, auth); + assert.deepEqual(v, viewGame(g, f.ids[i])); + assert.equal(Object.hasOwn(v, 'nexusRicheseHistory'), false); + if (i !== 0) assert.equal(v.nexusRichese, null); + } + await rooms.continueRoomAutomatic(f.code, clock); + assert.deepEqual(row(f), before); + nexusRicheseInventory(g); +} +async function compete(f: Fixture, version: number, actions: Action[]) { + let arrivals = 0, + release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await gate; + }; + const timer = setTimeout(release, 2000); + const results = await Promise.allSettled( + actions.map((action) => + f.restart().act(f.code, f.auths[0], version, action, clock), + ), + ); + clearTimeout(timer); + delete f.hooks.beforeWrite; + assert.equal(arrivals, 2); + assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1); + assert.deepEqual( + f.writes.map((w) => w.changes).sort((a, b) => a - b), + [0, 1], + ); +} + +void test('SQLite competing Richese declarations spend one Nexus and restore one actual pending shipment after a lost acknowledgment', async () => { + const f = await fixture({ spice: 1 }); + try { + const unrelated = await f.rooms.createRoom( + 'Unrelated saved table', + 'atreides', + false, + [], + ); + const otherBefore = f.sqlite + .prepare('SELECT state,version FROM rooms WHERE code = ?') + .get(unrelated.view.code); + await compete(f, f.g.version, [ + nexusRicheseRequest(f, 5), + nexusRicheseRequest(f, 4), + ]); + const pending = await f.restart().readRoom(f.code), + amount = pending.pendingShipment!.amount; + assert.ok(amount === 4 || amount === 5); + assert.equal(pending.pendingShipment!.cost, 1); + assert.equal(pending.players[0].reserves, 20); + assert.equal(pending.players[0].spice, 1); + assert.equal( + pending.nexusCards!.cards!.discard.filter((c) => c === 'richese').length, + 1, + ); + await restored(f, pending); + const done = await act(f, 1, { type: 'decision', allow: true }); + assert.equal(done.players[0].reserves, 20 - amount); + assert.equal(done.players[0].forces['arrakeen:10'], amount); + assert.equal(done.players[0].spice, 0); + assert.equal(done.players[1].spice, 21); + await restored(f, done); + const before = row(f); + await assert.rejects( + f + .restart() + .act(f.code, f.auths[0], done.version, nexusRicheseRequest(f), clock), + ); + assert.deepEqual(row(f), before); + assert.deepEqual( + f.sqlite + .prepare('SELECT state,version FROM rooms WHERE code = ?') + .get(unrelated.view.code), + otherBefore, + ); + } finally { + f.sqlite.close(); + } +}); + +void test('SQLite Guild special stop preserves physical reserves and payment while keeping Richese Nexus spent across reload', async () => { + const f = await fixture({ spice: 1 }); + try { + await act(f, 0, nexusRicheseRequest(f)); + const stopped = await act(f, 1, { + type: 'card', + mode: 'special', + card: f.karama, + }); + assert.equal(stopped.players[0].reserves, 20); + assert.equal(stopped.players[0].spice, 1); + assert.equal(stopped.players[0].forces['arrakeen:10'], undefined); + assert.equal(stopped.nexusCards!.cards!.hands[f.owner], null); + assert.equal(stopped.discard.filter((c) => c.id === f.karama).length, 1); + await restored(f, stopped); + const before = row(f); + await assert.rejects( + f + .restart() + .act( + f.code, + f.auths[1], + stopped.version, + { type: 'card', mode: 'special', card: f.karama }, + clock, + ), + ); + assert.deepEqual(row(f), before); + } finally { + f.sqlite.close(); + } +}); + +void test('SQLite rejects changed Richese paid intent and missing saved source without projection, normalization or action writes', async () => { + const f = await fixture({ spice: 1 }); + try { + const pending = await act(f, 0, nexusRicheseRequest(f)); + for (const edit of [ + (g: Game) => { + delete g.nexusRicheseHistory; + }, + (g: Game) => { + delete g.pendingShipment!.nexusEvent; + }, + (g: Game) => { + delete g.nexusRicheseLast; + }, + (g: Game) => { + g.nexusRicheseHistory![0].signature = 'changed'; + }, + (g: Game) => { + if (g.decision?.kind === 'guildShipment') g.decision.amount = 4; + }, + (g: Game) => { + if (g.decision?.kind === 'guildShipment') + g.decision.player = f.observer; + }, + (g: Game) => { + g.pendingShipment!.amount = 4; + }, + (g: Game) => { + g.pendingShipment!.cost = 0; + }, + (g: Game) => { + g.pendingShipment!.territory = 'carthag'; + g.pendingShipment!.sector = 11; + }, + (g: Game) => { + g.decision = null; + }, + ]) { + const bad = structuredClone(pending); + edit(bad); + f.save(bad); + f.writes.length = 0; + const before = row(f), + rooms = f.restart(); + for (const auth of f.auths) + await assert.rejects(rooms.readSeatView(f.code, auth)); + assert.throws(() => normalizeAutomaticGame(bad)); + // A waiting human shipment does not require automatic room recovery; the + // room adapter may return before dispatching the rejecting normalizer. + await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]); + await assert.rejects( + rooms.act( + f.code, + f.auths[1], + bad.version, + { type: 'decision', allow: true }, + clock, + ), + ); + assert.deepEqual(row(f), before); + assert.equal(f.writes.length, 0); + } + } finally { + f.sqlite.close(); + } +}); + +void test('SQLite typed Kaitain and Salusa shipment settles exactly once after a saved Guild interception', async () => { + const f = await fixture({ + ownerFaction: 'emperor', + homeworlds: true, + spice: 1, + }); + try { + const request = { + ...nexusRicheseRequest(f), + elite: 2, + homeworldSources: { + 'homeworld:emperor': { normal: 3, elite: 0 }, + 'homeworld:emperor:salusa': { normal: 0, elite: 2 }, + }, + }; + const pending = await act(f, 0, request); + await restored(f, pending); + const changed = structuredClone(pending); + changed.pendingShipment!.elite = 1; + changed.pendingShipment!.homeworldSources!['homeworld:emperor'].normal = 4; + changed.pendingShipment!.homeworldSources![ + 'homeworld:emperor:salusa' + ].elite = 1; + f.save(changed); + f.writes.length = 0; + const corruptRow = row(f); + for (const auth of f.auths) + await assert.rejects(f.restart().readSeatView(f.code, auth)); + await assert.rejects( + f + .restart() + .act( + f.code, + f.auths[1], + changed.version, + { type: 'decision', allow: true }, + clock, + ), + ); + assert.deepEqual(row(f), corruptRow); + assert.equal(f.writes.length, 0); + f.save(pending); + const done = await act(f, 1, { type: 'decision', allow: true }); + assert.equal(done.players[0].reserves, 15); + assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2); + assert.equal(done.homeworlds!.custody!.salusa!.elite, 3); + assert.equal(done.players[0].spice, 0); + await restored(f, done); + assert.equal( + done.nexusCards!.cards!.discard.filter((c) => c === 'richese').length, + 1, + ); + } finally { + f.sqlite.close(); + } +}); + +void test('SQLite restores a committed five-force shipment awaiting BG accompaniment without replaying its payment or arrival', async () => { + const f = await fixture({ opponentFaction: 'beneGesserit', spice: 1 }); + try { + const arrived = await act(f, 0, nexusRicheseRequest(f)); + assert.equal(arrived.decision?.kind, 'advisor'); + assert.equal(arrived.players[0].spice, 0); + assert.equal(arrived.players[0].forces['arrakeen:10'], 5); + await restored(f, arrived); + const done = await act(f, 1, { + type: 'decision', + accept: true, + accompany: true, + }); + assert.equal(done.players[0].reserves, 15); + assert.equal(done.players[0].forces['arrakeen:10'], 5); + assert.equal(done.players[0].spice, 0); + assert.equal(done.players[1].forces['arrakeen:10'], 1); + assert.equal(done.players[1].reserves, 19); + await restored(f, done); + const before = row(f); + await assert.rejects( + f + .restart() + .act( + f.code, + f.auths[1], + done.version, + { type: 'decision', accept: true }, + clock, + ), + ); + assert.deepEqual(row(f), before); + } finally { + f.sqlite.close(); + } +}); diff --git a/tests/nexus-richese.test.ts b/tests/nexus-richese.test.ts new file mode 100644 index 00000000..ee6724ec --- /dev/null +++ b/tests/nexus-richese.test.ts @@ -0,0 +1,267 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { FACTIONS, type FactionId } from '../game/catalog'; +import { + quoteNexusRicheseShipment, + createNexusRichese, + validateNexusRichese, + type NexusRicheseContext, + type NexusRicheseReceipt, +} from '../game/nexus-richese'; +const context: NexusRicheseContext = { + turn: 3, + players: [ + { id: 'p', faction: 'atreides' }, + { id: 'q', faction: 'guild' }, + { id: 'r', faction: 'fremen' }, + ], +}; +const clone = (x: T): T => JSON.parse(JSON.stringify(x)); + +void test('Richese Secret Ally prices one while retaining one through five actual physical forces for every supported faction and terrain', () => { + for (const faction of FACTIONS.map((f) => f.id).filter( + (f) => 'richese' !== f, + )) + for (const type of ['stronghold', 'sand', 'rock', 'polar']) + for (let amount = 1; amount <= 5; amount++) { + const player = { faction, halfRate: faction === 'guild' }, + before = clone(player); + const q = quoteNexusRicheseShipment(player, type, amount); + assert.deepEqual(q, { + physicalAmount: amount, + pricedAmount: 1, + cost: + faction === 'fremen' + ? 0 + : faction === 'guild' || type === 'stronghold' + ? 1 + : 2, + source: 'nexusRichese', + }); + assert.deepEqual(player, before); + } +}); +void test('an existing Karama half rate uses the one-force basis; suppressed Guild half rate and native Fremen free transport remain distinct', () => { + assert.equal( + quoteNexusRicheseShipment( + { faction: 'emperor', halfRate: false }, + 'sand', + 5, + ).cost, + 2, + ); + assert.equal( + quoteNexusRicheseShipment({ faction: 'emperor', halfRate: true }, 'sand', 5) + .cost, + 1, + ); + assert.equal( + quoteNexusRicheseShipment({ faction: 'guild', halfRate: false }, 'rock', 5) + .cost, + 2, + ); + assert.equal( + quoteNexusRicheseShipment( + { faction: 'guild', halfRate: true }, + 'stronghold', + 5, + ).cost, + 1, + ); + for (const halfRate of [false, true]) + assert.equal( + quoteNexusRicheseShipment({ faction: 'fremen', halfRate }, 'sand', 5) + .cost, + 0, + ); +}); +void test('quote refuses empty, fractional, excess or malformed physical groups and non-Arrakis price domains without mutating inputs', () => { + const player = Object.freeze({ + faction: 'emperor' as const, + halfRate: false, + }); + for (const amount of [ + 0, + -1, + 6, + 1.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER, + '3', + null, + undefined, + ]) + assert.throws(() => + quoteNexusRicheseShipment(player, 'sand', amount as number), + ); + for (const type of ['homeworld', 'reserves', 'unknown', '']) + assert.throws(() => quoteNexusRicheseShipment(player, type, 3)); + assert.throws(() => + quoteNexusRicheseShipment( + { faction: 'richese', halfRate: false }, + 'sand', + 3, + ), + ); + assert.throws(() => + quoteNexusRicheseShipment( + { faction: 'unknown' as FactionId, halfRate: false }, + 'sand', + 3, + ), + ); + assert.throws(() => + quoteNexusRicheseShipment( + { faction: 'emperor', halfRate: 'yes' as unknown as boolean }, + 'sand', + 3, + ), + ); +}); +void test('Richese receipt is bound to its real absent-faction owner and turn while canonicalizing public seat order', () => { + for (const p of context.players) { + const r = createNexusRichese(context, p.id); + assert.equal(r.event, JSON.stringify(['nexusRichese', 3, p.id])); + assert.equal(r.phase, 5); + assert.equal(r.mode, 'secretAlly'); + validateNexusRichese(context, clone(r)); + assert.deepEqual( + createNexusRichese( + { ...context, players: [...context.players].reverse() }, + p.id, + ), + r, + ); + assert.notEqual( + createNexusRichese({ ...context, turn: 4 }, p.id).event, + r.event, + ); + } + const copied = clone(context), + r = createNexusRichese(copied, 'p'); + copied.players[0].id = 'changed'; + assert.equal(r.roster[0].id, 'p'); + assert.throws(() => createNexusRichese(context, 'foreign')); + assert.throws(() => + createNexusRichese( + { + ...context, + players: [...context.players, { id: 's', faction: 'richese' }], + }, + 'p', + ), + ); +}); +void test('historical Richese receipt survives later turns and alliances without reading hands, funds, No-Field values or other secrets', () => { + const r = createNexusRichese(context, 'p'), + before = clone(r); + const fail = () => { + throw new Error('Private field read'); + }; + const players = context.players.map((p) => + Object.defineProperties( + { ...p }, + { + hand: { enumerable: true, get: fail }, + spice: { enumerable: true, get: fail }, + ally: { enumerable: true, get: fail }, + noField: { enumerable: true, get: fail }, + traitors: { enumerable: true, get: fail }, + }, + ), + ); + const later = Object.defineProperties( + { turn: 5, players }, + { nexusCards: { get: fail }, response: { get: fail } }, + ); + validateNexusRichese(later, r); + createNexusRichese(later, 'q'); + assert.deepEqual(r, before); + const pricePlayer = Object.defineProperties( + { faction: 'fremen' as const, halfRate: false }, + { spice: { get: fail }, reserves: { get: fail }, noField: { get: fail } }, + ); + assert.equal( + quoteNexusRicheseShipment(pricePlayer, 'sand', 5).physicalAmount, + 5, + ); +}); +void test('Richese historical validation rejects changed evidence, deleted fields and malformed roster without rewriting it', () => { + const original = createNexusRichese(context, 'p'); + const edits: ((r: NexusRicheseReceipt) => void)[] = [ + (r) => { + r.owner = 'q'; + }, + (r) => { + r.turn++; + }, + (r) => { + r.event = 'old'; + }, + (r) => { + r.phase = 4 as 5; + }, + (r) => { + r.mode = 'cunning' as 'secretAlly'; + }, + (r) => { + r.signature = 'changed'; + }, + (r) => { + r.roster.reverse(); + }, + (r) => { + r.roster[0].faction = 'emperor'; + }, + (r) => { + r.roster[0].id = 'other'; + }, + (r) => { + r.roster.pop(); + }, + (r) => { + Object.assign(r, { hiddenHand: [] }); + }, + (r) => { + Reflect.deleteProperty(r, 'version'); + }, + (r) => { + Reflect.deleteProperty(r, 'signature'); + }, + (r) => { + Object.assign(r.roster[0], { extra: true }); + }, + ]; + for (const edit of edits) { + const r = clone(original); + edit(r); + const before = clone(r); + assert.throws(() => validateNexusRichese(context, r)); + assert.deepEqual(r, before); + } + assert.throws(() => validateNexusRichese({ ...context, turn: 2 }, original)); + for (const id of ['__proto__', 'prototype', 'constructor', '']) + assert.throws(() => + createNexusRichese({ turn: 1, players: [{ id, faction: 'guild' }] }, id), + ); + for (const invalid of [ + { ...context, turn: 0 }, + { ...context, turn: 1.5 }, + { ...context, players: [] }, + { ...context, players: [context.players[0], context.players[0]] }, + { + ...context, + players: [ + { id: 'p', faction: 'atreides' }, + { id: 'q', faction: 'atreides' }, + ], + }, + ] as NexusRicheseContext[]) + assert.throws(() => createNexusRichese(invalid, 'p')); + const proto = Object.assign( + Object.create({ extra: true }) as NexusRicheseReceipt, + original, + ); + assert.throws(() => validateNexusRichese(context, proto)); +});