Implement Nexus card ownership, private draws and full card reference

This commit is contained in:
Quintine committed 2026-09-10 09:29:54 +10:00
1 parent 61689514a0
commit d2a1d63517
20 files changed
+2477 -5

No files matched your search

+6 -2
View File
@@ -9,6 +9,7 @@ import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
import { GrummanCollection } from './grumman-collection';
import { TupileIntelligence } from './tupile-intelligence';
import { NexusCards } from './nexus-cards';
import { TerrorBoardMarkers } from './terror-board-markers';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
@@ -1415,7 +1416,8 @@ export function GameTable({
{g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'}
</div>
<PrivateBattlePlan game={g} />
<TupileIntelligence game={g} act={act} busy={busy} />
<NexusCards game={g} act={act} busy={busy} />
{!g.nexusCards?.waiting.length && <TupileIntelligence game={g} act={act} busy={busy} />}
{g.biddingEnd && <BiddingEnd game={g} act={act} busy={busy} />}
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
<JunctionTransport key={me.id === g.junctionTransport.owner ? g.junctionTransport.offerEvent : g.junctionTransport.event} game={g} act={act} busy={busy} />
@@ -1436,7 +1438,9 @@ export function GameTable({
sector={sector}
onSectorChange={setSector}
/>
{g.truthtrance ? (
{g.nexusCards?.waiting.length ? (
<p className="muted">The next phase begins when the remaining Nexus card choices are finished.</p>
) : g.truthtrance ? (
<Truthtrance
key={`${g.truthtrance.queue[0]?.card}-${g.truthtrance.stage}`}
game={g}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { faction, type FactionId } from '@/game/catalog';
import type { Action, GameView } from '@/game/engine';
import { HOMEWORLD_CARDS } from '@/game/homeworld-cards';
import { nexusCardMode, type NexusCardMode } from '@/game/nexus-cards';
import type { NexusCardChoice } from '@/game/nexus-card-phase';
import { nexusCardAction } from '@/game/nexus-card-options';
import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES, nexusCardReference } from '@/game/nexus-card-reference';
import { Button } from './ui/button';
import { Dialog, DialogClose, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './ui/dialog';
const panels: NexusCardMode[] = ['betrayal', 'cunning', 'secretAlly'];
/** Only an authorized projected card may be passed as a private held card. */
export function NexusCardFace({ card, mode, enlarged = false }: {
card: FactionId; mode?: NexusCardMode; enlarged?: boolean;
}) {
const identity = faction(card);
const reference = nexusCardReference(card);
const world = HOMEWORLD_CARDS.find((candidate) => candidate.faction === card)!;
return (
<article aria-label={`${identity.name} Nexus card`} className="min-w-0 overflow-hidden rounded-xl border border-[#a88b60] bg-[#20271f] text-[#f2e8d3] shadow-lg">
<header className="px-4 py-3" style={{ borderTop: `4px solid ${identity.color}` }}>
<p className="m-0 text-xs uppercase tracking-widest">Nexus card</p>
<h3 className="m-0 break-words font-serif text-2xl">{identity.name}</h3>
</header>
<Image src={`/art/homeworlds/${world.id}-v1.png`} alt="" width={1536} height={768} unoptimized loading="lazy" className="block max-h-32 w-full object-cover" />
<div className="divide-y divide-[#a88b60]/40 px-4">
{panels.map((panel) => (
<section key={panel} aria-label={NEXUS_PANEL_NAMES[panel]} className="space-y-1 py-3">
<h4 className="m-0 flex flex-wrap items-center gap-2 font-serif text-lg">
{NEXUS_PANEL_NAMES[panel]}
{mode === panel && <span className="rounded border border-[#e4c887] px-2 py-0.5 font-sans text-xs text-[#ffe5a8]">Your applicable mode</span>}
</h4>
<p className="m-0 text-xs text-[#c9baa0]">
{panel === 'cunning' ? `${identity.name} player only` : panel === 'betrayal' ? `Another player controls ${identity.name}` : `${identity.name} is not in this game`}
</p>
<p className={enlarged ? 'm-0 text-xl! leading-8!' : 'm-0 text-base! leading-7!'}>{reference[panel]}</p>
</section>
))}
</div>
{!enlarged && (
<div className="px-4 pb-4">
<Dialog>
<DialogTrigger render={<Button variant="outline" className="min-h-11 w-full whitespace-normal" />} aria-label={`Inspect ${identity.name} Nexus card`}>
Inspect card
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none" />
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-[#a88b60] bg-[#171d18] text-[#f2e8d3] shadow-2xl outline-none motion-reduce:animate-none">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#a88b60]/40 px-4 py-3">
<DialogTitle>{identity.name} Nexus card</DialogTitle>
<DialogClose render={<Button variant="outline" className="min-h-11 min-w-16" />}>Close</DialogClose>
</div>
<DialogDescription className="sr-only">All three Nexus effects and their faction requirements. Card effects are not playable yet.</DialogDescription>
<div className="min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-5"><NexusCardFace card={card} mode={mode} enlarged /></div>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
</div>
)}
<footer className="border-t border-[#a88b60]/40 px-4 py-2 text-xs text-[#c9baa0]">Ecaz &amp; Moritani · Original Nexus effects</footer>
</article>
);
}
export function NexusCardGallery() {
return <div aria-label="All twelve Nexus cards" className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">{NEXUS_CARD_REFERENCE.map((card) => <NexusCardFace key={card.faction} card={card.faction} />)}</div>;
}
/** The server supplies current choices. The client binds the exact offered
* turn and held identity; it neither infers an opponent's card nor draws one. */
export function nexusCardChoiceAction(game: GameView, choice: NexusCardChoice, ownRedraws: 0 | 1 | 2 = 0): Action | null {
const offer = game.nexusCards;
if (!offer || game.status !== 'playing' || game.phase !== 1 || offer.turn !== game.turn ||
!offer.waiting.includes(game.me) || !offer.choices.includes(choice) || ![0, 1, 2].includes(ownRedraws)) return null;
const owner = game.players.find((player) => player.id === game.me);
if (!owner || (choice === 'draw' && offer.card !== null) ||
(choice === 'replace' && offer.card === null)) return null;
return nexusCardAction(game, choice, ownRedraws);
}
export function NexusCards({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) {
const [ownRedraws, setOwnRedraws] = useState<0 | 1 | 2>(0);
const offer = game.nexusCards;
if (!offer) return null;
const owner = game.players.find((player) => player.id === game.me);
if (!owner) return null;
const mode = offer.card ? nexusCardMode(offer.card, owner.faction, game.players.map((player) => player.faction)) : undefined;
const labels: Record<NexusCardChoice, string> = {
draw: 'Draw a Nexus card', replace: 'Discard and replace',
keep: offer.card ? 'Keep this card' : 'Skip this draw',
};
const waiting = offer.waiting.filter((id) => id !== game.me)
.map((id) => game.players.find((player) => player.id === id)?.name).filter(Boolean);
return (
<section aria-label="Nexus cards" className="space-y-4 rounded-lg border border-[#a88b60]/50 p-3">
<div>
<h3>Nexus cards</h3>
<p className="fine">{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held</p>
<p>Drawing and keeping cards is supported; their effects are not playable yet.</p>
</div>
<div aria-label="Your private Nexus card" className="max-w-xl">
{offer.card ? <NexusCardFace card={offer.card} mode={mode} /> : <p>You do not hold a Nexus card.</p>}
</div>
{(offer.choices.includes('draw') || offer.choices.includes('replace')) && (
<label className="block max-w-xl space-y-1">
<span>If I draw my faction</span>
<select aria-label="If I draw my faction" className="min-h-11 w-full" value={ownRedraws} disabled={busy}
onChange={(event) => setOwnRedraws(Number(event.target.value) as 0 | 1 | 2)}>
<option value={0}>Keep it</option>
<option value={1}>Redraw once</option>
<option value={2}>Redraw whenever it appears</option>
</select>
<span className="fine block">Your choice applies privately during the draw, in Basic or Advanced play.</span>
</label>
)}
<div className="flex flex-wrap gap-2">
{[...offer.choices].sort((a, b) => Number(a === 'keep') - Number(b === 'keep')).map((choice) => {
const action = nexusCardChoiceAction(game, choice, ownRedraws);
return <Button key={choice} className="game-action min-h-11 whitespace-normal" disabled={busy || !action} onClick={() => { if (!busy && action) act(action); }}>{labels[choice]}</Button>;
})}
</div>
{!!waiting.length && <p className="fine" aria-live="polite">Waiting for Nexus choices: {waiting.join(', ')}.</p>}
<details className="space-y-3">
<summary className="min-h-11 cursor-pointer py-2">View all 12 Nexus cards</summary>
<p className="fine">This reference includes every faction. Other players’ held card identities stay secret.</p>
<NexusCardGallery />
</details>
</section>
);
}
+2
View File
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { StrongholdCardGallery } from './stronghold-cards';
import { HomeworldCardGallery } from './homeworld-cards';
import { NexusCardGallery } from './nexus-cards';
import { createStrongholdCards } from '@/game/stronghold-cards';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState, useEffect, useRef } from 'react';
@@ -187,6 +188,7 @@ function RulesReferenceContent() {
/>
)}
{topic.id === 'homeworlds' && <HomeworldCardGallery />}
{topic.id === 'nexus-cards' && <NexusCardGallery />}
{topic.id === 'richese-cards' && (
<section
aria-label="Richese card collection"
+2
View File
@@ -1,5 +1,7 @@
# Component inventory and verification ledger
**Nexus update, 10 September 2026:** all twelve physical Nexus identities and all thirty-six effect panels are verified against the original printed components. The separate inventory preserves one card per faction, independent of the selected roster; held cards remain private. The internal rules gallery and owner panel provide complete original-language effect text, existing bundled faction artwork and enlarged inspection. Common draws, replacement, preselected own-faction redraw, alliance forfeiture and saved phase progress are integrated. Individual effects and complete module games remain unfinished and gated. See [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md) and [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) for current verification and limits.
**Homeworld update, 9 September 2026:** all 13 cards and 26 physical faces are verified, including native population/battle values and occupied income symbols. The internal gallery supplies both faces, complete original gameplay prose, enlarged inspection and thirteen original bundled illustrations. Separate typed custody and population helpers are tested; actual Homeworld gameplay, occupation and effect integration remain unfinished and gated. See [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md) and [HOMEWORLD_COMPONENT_AUDIT.md](HOMEWORLD_COMPONENT_AUDIT.md). Earlier missing-Homeworld statements below are historical.
**Auditor update, 7 September 2026:** the separate strength-2 Advanced CHOAM disc and matching traitor are now represented, with one original portrait alongside Duke Prad Vidal in the special registry. Ordinary leader inventory remains 60. Root inspected Auditor at normal and enlarged sizes on desktop and phone; its inspector supplies complete original gameplay prose. Private audited-card faces, payment controls, setup identity, capture/foreign-ghola exclusions and first native revival have focused tests. Repeat/sixth-disc revival cycles and full module combinations remain unresolved. See [CHOAM_AUDITOR.md](CHOAM_AUDITOR.md); earlier missing-Auditor artwork/runtime statements below are historical.
+2
View File
@@ -1,5 +1,7 @@
# Dune implementation status
**Nexus ownership and complete component reference checkpoint, 10 September 2026:** [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) records the twelve-card physical deck, whole-phase Basic/Advanced draw opportunity, private replacement policy, all three alliance-forfeiture paths and complete inspectable 36-panel reference. Review fixed real Storm-entry initialization, edited progress/replay acceptance, one-way alliances and the private redraw prompt's public timing leak. Pre-draw preferences resolve without extra confirmations. **Types, lint, all 3,795 offline tests, production build and 40 HTTP/session tests pass.** Three-seat keyboard draw/redraw/refresh and all twelve normal/enlarged desktop/phone cards passed; independent review inspected 48 renders. All **3,173** preexisting room versions/hashes remain unchanged. All individual Nexus effects, Discoveries and complete expansion games remain unfinished; no release gate opened. Existing server reused, no restart due, no automation recreated.
**Tupile private intelligence and occupation evidence checkpoint, 10 September 2026:** [HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md) records current low-population contact checks, one historical spice/category answer per opposing faction, private controls and shared legal bot requests. Genuine setup binds qualification history and lifetime usage; legacy history stays unknown. Ordered battle observations retain brief sole occupation while simultaneous destruction does not invent it. Deterministic source identities make direct actions and restored continuations agree. **3,755 offline tests, types, lint, production build and 40 HTTP/session tests pass.** Three-seat desktop/mobile keyboard shipment, private disclosure, later balance change and refresh passed. The hourly restart and final comparison preserved all 3,112 preexisting room versions/hashes. Occupation entitlement/expiry/income, complete module games and the full expansion goal remain unfinished; no release gate opened. The removed automation stays removed.
**Grumman Collection and stacked Terror checkpoint, 10 September 2026:** [GRUMMAN_STACKED_TERROR_RUNTIME.md](GRUMMAN_STACKED_TERROR_RUNTIME.md) records one hidden-token addition with four bank spice, separate Mentat placement, private stacked-token selection, shared controls/AI and durable discard/alliance continuations. Review fixed selected-choice rewinds and restoration of a consumed Sabotage token during its genuine discard child. **3,712 offline cases, types, lint, production build and 40 HTTP/session cases pass.** Desktop/phone keyboard Collection, shipment, token selection, Robbery payment, public stack count and three-seat private refresh passed. All 3,081 preexisting room versions/hashes remained unchanged. Removal custody, occupation, unfinished effects and complete expansion games remain unresolved; no release gate opened. The existing server was reused and the removed automation stays removed.
+77
View File
@@ -0,0 +1,77 @@
# Nexus cards: primary-source contract
Source audit, 10 September 2026. This document establishes the printed component inventory and implementation contract; it does not claim runtime support or lift expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Authority and acquisition
- [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), printed pp.4, 9, 11 and 16: component count, independent variants, common lifecycle, Atreides example and two Nexus FAQ answers. Official-domain indexed text was inspected, but direct retrieval returned 403 during this audit. The coordinator successfully fetched the [publisher-authored PDF mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) to `/tmp/dune-e3-nexus-rules.pdf` and `.txt`. This is a mirror of the publisher's rules, not a separate rules authority.
- [Photograph of all twelve printed Nexus cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), uploaded by Jaakko / zaksalo on 2 October 2023. The original 3024×4032 photograph was downloaded and visually inspected, including all three panels on every card. The authority is the photographed publisher-authored component text, not the uploader's commentary. [Original image](https://cf.geekdo-images.com/zYJXVuckZugNx0kwjdcUAQ__original/img/pRh3dPJSueYj0aLYTbYidMxVXaQ=/0x0/filters:format(jpeg)/pic7767032.jpg); temporary inspection copy `/tmp/dune-nexus-cards.jpg`.
- Atreides' physical face independently matches the example on printed p.11. All twelve faces carry the 2022 Legendary notice and the E3 expansion mark. This is the original E3 module, not a later edition or fan revision.
Search also encountered tournament compilations that change the draw procedure and add effects. They were not used as authority. The former `dunecards.com` reference currently resolves to an unrelated collectibles storefront and is not a usable component source.
## Publisher's common rules
The module contains twelve cards and can accompany any faction selection, independently of the other variants. At the end of Spice Blow and Nexus, if a Nexus happened and an alliance exists, unallied players may draw, or replace their held card. Draws follow alliance negotiations. Cards stay secret until use and are discarded afterward; joining an alliance also requires discarding the held card. Exhaustion recycles shuffled discards. Drawing one's own faction allows an immediate optional replacement in Basic and Advanced.
Each card has mutually exclusive modes: **Cunning** for its native player, **Betrayal** when another player controls that faction, and **Secret Ally** when the faction is absent. The printed FAQ requires Bene Gesserit Secret Ally before Prescience; cards retained by Tleilaxu through Harkonnen Secret Ally are Face Dancers. [GF9, pp.4, 9, 11, 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11)
## Executable lifecycle composition
These are implementation consequences of the common rule, rather than additional quotations:
1. Keep a separate twelve-identity deck, discard and at-most-one held card per player. This is not a Treachery Card: no Treachery hand-limit slot, auction, ordinary discard hook, CHOAM sale, or Ecaz poison receipt follows from holding or discarding one.
2. Initialize only when this variant is selected. Include cards for absent factions; removing them would eliminate Secret Ally. No initial deal is prescribed. Use the twelve photographed factions exactly: Atreides, Bene Gesserit, Emperor, Fremen, Harkonnen, Spacing Guild, Ixians, Tleilaxu, CHOAM, Richese, Ecaz, Moritani.
3. Preserve a phase-level `nexusOccurred` fact separately from the transient negotiation flag. The explicit draw trigger is the end of the whole phase. Advanced blow A alone must not issue a draw, and blow B must not erase an earlier qualifying Nexus. Evaluate the final alliances when opening the opportunity.
4. A player with no card can draw or decline; one with a card can keep it or discard-and-replace. These are one opportunity, not repeated replacements. The printed own-faction redraw permission is implemented by a private preference selected **before** the draw: keep any card, redraw one's faction once, or redraw whenever one's faction appears. The server performs that policy atomically without a later confirmation. This automation prevents a public waiting state from identifying an own-faction draw. It is not an anytime exchange of a previously kept card, and it does not filter that faction out of the physical deck.
5. One event must survive the complete draw policy, reload and uncertain responses. Store the actual final card and physical intermediate discards before projecting the result. Randomness is not rerun when reading, normalizing, reconnecting or retrying the same committed action. Own-faction and other-faction draws complete the same public opportunity; no private redraw decision remains to reveal the drawn identity through public phase progress.
6. All actual alliance producers must discard both incoming partners' held cards as part of the accepted alliance transaction: ordinary Nexus, Ecaz Ambassador and Moritani's alliance offer. An offer, refusal or canceled formation does not discard. Leaving an alliance does not independently generate a draw.
7. Secret Ally selects the card's effect; it does not create a real allied player, change the stronghold victory target, unlock generic ally transfers or provide every ability of the absent faction.
8. Native roster identity determines the mode. A stolen leader, alliance benefit, Ghola or temporary battle controller does not change one's faction.
9. Public views may expose the module's inventory and readable reference for all twelve faces. Held identities, own-faction redraw eligibility and source deck order remain private. A public action list must not reveal which opponents hold a relevant Betrayal. Card play and its ensuing action need a saved original-effect commitment so a discarded card is not consumed twice.
The text does not specify a Nexus-dealing seat order. A deterministic storm-order serializer can be documented as implementation ordering under the game's general precedence convention, not attributed to a special Nexus instruction. It must not replace secret dealing with publicly revealed batches.
## All twelve printed effect contracts
Every row below is a semantic transcription of the photographed face, not a reproduction of its prose. Alternative effects within a panel consume the same single Nexus card. Timings below come from that panel; where a panel does not state an explicit phase, do not invent one from a similarly named faction ability.
| Card | Betrayal: opposing native faction seated | Cunning: native player | Secret Ally: faction absent |
| --- | --- | --- | --- |
| **Atreides** | Stop Atreides' attempt to inspect one battle-plan element. | Obtain a second element from the battle opponent. | In one's battle, choose one opposing plan element to inspect: leader, weapon, defense or dial. |
| **Bene Gesserit** | Stop Voice. | During one's Shipment and Movement action, convert any selected advisor groups to fighters; each territory must finish with a single stance. | Use Voice against one's battle opponent. The official FAQ places this before Prescience. |
| **Emperor** | During Bidding, require Emperor to cover the ally's purchase, at least the necessary spice; alternatively suppress Sardaukar while battle plans are being made. | Before making a battle plan, treat five own forces as Sardaukar when that battle contains none. | On a Treachery purchase, retain the spice after demonstrating the required funds; alternatively revive three extra forces free beyond the normal limit during Revival. |
| **Fremen** | Suppress worm riding for this turn, or suppress the two-territory movement advantage during Shipment and Movement. | A worm appearing in a territory without forces permits a group from one desert territory to ride elsewhere, respecting storm and occupancy. | Protect one's forces from a worm, or revive three forces free during Revival. |
| **Harkonnen** | Cancel a revealed Harkonnen traitor, shuffle that identity into the Traitor Deck, and give Harkonnen a replacement draw during Mentat Pause. | At any time, draw a Traitor Card into the hand, then choose one held Traitor Card to shuffle back. | During Mentat Pause draw two Traitor Cards, then choose two held cards to shuffle back. Tleilaxu's retained card is a Face Dancer under the official FAQ. |
| **Spacing Guild** | Take one entire shipment payment otherwise owed to Guild or bank, including one's own payment. The face expressly overrides income for occupying Junction. | Immediately after one's Shipment and Movement, make a second shipment. That group cannot move unless Hajr permits it. | Use Guild shipping prices; cross-shipping or returning to reserves is allowed as the shipping action. |
| **Ixians** | Prevent either the Bidding advantage or Technology advantage; one card cannot cancel both. | Before making one's battle plan, give every Suboid full unsupported strength in all battles for the remainder of this turn. | Discard a just-purchased Bidding Treachery Card and take the next Treachery deck card. |
| **Tleilaxu** | Cancel a revealed Face Dancer. It stays revealed until all Face Dancers have been revealed. | Set all revealed Face Dancers aside, secretly replace them, then shuffle the set-aside identities into the Traitor Deck. | During Revival, revive an own leader free and up to five forces at one spice each. |
| **CHOAM** | Make CHOAM discard a random held card without earning spice for that discard. | Spend any Treachery Card to obtain a selected Worthless special effect. | During Collection, trade a Worthless card for two spice; alternatively after winning a battle privately inspect a random opposing held card that was not used in that battle. |
| **Richese** | Stop a Richese self-purchase by discarding its intended Richese card without payment; alternatively divert payment for a Richese sale to the bank. | Combine two No-Fields in one shipment, still priced as one force; reveal one immediately and put it in front of the shield. | Replace normal shipping with a reserve shipment of up to five forces priced as one. |
| **Ecaz** | Before Shipment and Movement, where Ecaz and ally share a territory, make the ally return its forces from one such territory to reserves. | Take Duke Vidal for the current turn even from capture, Tanks or Ghola custody, overriding Moritani; set him aside at turn end. | Make one player disclose whether any of one's leaders are among their traitors, without identifying those leaders. |
| **Moritani** | Return a board Terror token to Moritani without revealing it. | When placing a Terror token, permit any Arrakis territory, including an existing Terror location. | After losing a battle with a winner, retain one played Treachery Card that could have been retained after winning. |
Source for the entire table: [the twelve original printed components](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani). All card identities appear once; the photograph is arranged Moritani/Tleilaxu/Richese/Ixians, Atreides/Bene Gesserit/CHOAM/Emperor, Guild/Ecaz/Harkonnen/Fremen.
## Effect integration boundaries
**Atreides is the first complete effect family to implement.** It has independent rulebook and physical-face evidence, and the engine already has private plan-element inspection. The Nexus effect needs its own cause and receipt rather than temporarily changing the inspecting faction. The second element must differ from the already inspected element. Preserve the original disclosed value if an allowed later battle edit occurs; follow the existing plan commitment rules instead of refreshing an old observation. The card specifies an element, not the entire hand or battle plan. A weapon/defense request uses actual played-card classification, including the published Reinforcements/Harass exclusions, rather than leaking a card merely because it occupies that slot.
**Cancellation needs the original attempted effect.** Betrayal is not a blanket Karama flag. For Atreides, identify the inspection attempt being prevented; for Voice, the command; for a traitor/Face Dancer, the actual revealed identity; for economy, the original invoice and settlement. Do not refund or replay a completed payment merely because the next input happens to be a Nexus play. Effects with no printed phase still need a safe explicit action boundary and cannot bypass another player's committed private choice.
**Physical custody remains authoritative.** Replacement Traitor Cards, Face Dancers, discarded Treachery Cards, reserve groups and Duke Vidal are existing components. Draw first then return cards in the order stated; do not shuffle a just-set-aside Face Dancer early enough to redraw it as its own replacement. The Nexus card does not generate an additional Duke disc or additional physical Sardaukar. Virtual Sardaukar strength belongs to battle modifiers, not a change to elite counter custody. A canceled Harkonnen traitor owes a later replacement even after the original battle is finished.
**Delayed effects outlive card custody.** Turn-long Suboid strength, Fremen riding prevention, Harkonnen's Mentat replacement, Guild's extra shipment and Ecaz's temporary Duke claim need saved receipts. Discarding the Nexus card cannot erase those commitments, and drawing another cannot replay them. Moritani retention must join the existing winner/loser card-retention sequence without retaining mandatory-discard Tooth or Artillery merely because a weapon slot was used.
**Existing unresolved rules stay local.** Guild Betrayal's explicit Junction override is not a ruling for ordinary low-Junction contribution rounding. Moritani Betrayal's explicit return-to-supply instruction is not evidence that high-Grumman's differently worded removal returns a token. Free Nexus revival effects need source identity and original newly revived groups for Homeworld interactions; they are not ordinary free revival by default. Do not silently resolve the pending Southern Hemisphere threshold/splitting questions or Ix revival shortage question through a Nexus adapter.
## Remaining source questions and release boundaries
- The common trigger is the end of the full Spice Blow and Nexus phase. The later shorthand about the next Nexus is read with that opening condition; it does not establish an extra draw after each Advanced blow. Keep that implementation decision explicit in runtime documentation.
- No retrieved card or E3 FAQ gives a general rule that every Nexus effect is Karama-immune, or that playing one suspends all ordinary responses. Homeworld immunity on the preceding page is limited to Homeworld advantages/penalties. Audit cancellation of an underlying native advantage separately from cancellation of the Nexus card before enabling each family.
- Duration is explicit for several panels but not all. Ixian Betrayal's scope over multiple auction lots, the optional continuation after canceling native Prescience, and exact multi-party cancellation priority require the corresponding existing faction timing contract. Do not make an entire-phase cancellation merely from the word “advantage.”
- Emperor forced ally payment needs a precise invoice allocation when the ally has some funds and Emperor is short. Its own purchase-refund alternative also needs original payer contributions. Tleilaxu's combined leader/force revival requires an audit of partial selection and interaction with ordinary free rates; the card does not say that all five force revivals are free.
- Richese's two-No-Field effect needs its own physical counter and immediate-reveal transaction; the normal single-marker action cannot represent it by adding private values. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects.
- A complete twelve-card reference/inventory and common lifecycle are substantial first work, but do not alone make all thirty-six panels playable. Keep the public module release gated until every face's supported timing, secrecy, custody, bots, UI, rejection, multiplayer CAS and saved continuation paths are verified. If development supports only Atreides' family first, other faces must remain accurately represented and explicitly unavailable in the audit mode; they must not silently act as blanks in a released module.
No user question was sent during this audit. No tournament rule, fan effect, public predeal reveal, extra Nexus identity or Discoveries prerequisite has been adopted.
+47
View File
@@ -0,0 +1,47 @@
# Nexus cards: common 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).
## Implemented scope
- [nexus-cards.ts](../game/nexus-cards.ts) owns the twelve physical identities, deck/discard/one-card hands, shuffle/recycling, alliance forfeiture, owner-only projection, and native/opposing/absent faction mode selection. The deck includes all twelve factions regardless of the seated roster.
- [nexus-card-phase.ts](../game/nexus-card-phase.ts) records a whole Spice Blow and Nexus phase, whether a real Nexus occurred, the final eligible unallied owners, and completed choices. Its consistency signature binds the original phase progress; it is not a cryptographic defense against wholesale fabrication of a saved game.
- The engine remembers Nexus occurrence across Advanced A/B blows and offers cards only when the entire phase ends with an alliance present. Earlier worm responses, alliance negotiations, rides and entry reactions finish before the closing card choices. The next phase waits for the eligible owners; a stale turn, card identity or already-finished opportunity cannot draw again. Genuine first-turn/no-Nexus/no-alliance cases remain distinct.
- Accepted alliance formation discards the incoming partners' held Nexus cards through the common physical custody helper. Failed offers and canceled alliance attempts do not forfeit a card. Existing card effects remain unavailable.
- [nexus-card-options.ts](../game/nexus-card-options.ts) provides the shared action shape and bot choices. All four bot profiles use the common ownership controls; future strategic selection of actual Nexus effects is separate work.
## Private pre-draw preference
An owner chooses **draw**, **discard and replace**, or **keep/skip**, as offered by the server. Before drawing, the optional selector specifies what to do if the drawn card matches the owner's faction:
| Preference | `ownRedraws` | Behavior |
| --- | --- | --- |
| Keep it | `0` | Retain the drawn card, including one's faction. |
| Redraw once | `1` | Replace an own-faction result once, then retain the next result. |
| Redraw whenever it appears | `2` | Continue replacing an own-faction result within the bounded twelve-card physical inventory until a different faction is drawn. |
The value `2` is a policy encoding, not an authorization to redraw arbitrary cards twice. A keep/skip action always sends `0`. Every action binds the current turn and the owner's original held identity, including `null` before a first draw. All policy draws and discards commit within one action. No owner-only confirmation is left pending afterward, so public readiness or phase advancement does not reveal that someone drew their own faction. The policy is available in Basic and Advanced.
Original intermediate draws remain physical transactions. Recycling can draw the same own-faction card again; the runtime preserves that possibility instead of silently excluding it. A lost HTTP response or reconnect must restore the committed final card without new randomness.
## Inspectable cards and privacy
[nexus-card-reference.ts](../game/nexus-card-reference.ts) contains faithful English summaries of every original panel, independently checked against all twelve physical faces. [nexus-cards.tsx](../components/nexus-cards.tsx) shows the owner's card with its applicable mode, all three readable panels, the existing faction-associated Homeworld artwork, and an accessible enlarged dialog. A collapsed gallery contains the full twelve-card reference, which is public rules information and does not identify opponents' held cards.
The panel also shows deck/discard/held counts and the names of players still choosing. It obtains those from the projected offer and public roster. No opponent hand, spice balance, traitor pool, hidden battle plan or deck sequence is consulted. Choice buttons disable during submission. The panel says that card effects are not playable and has no effect-play button. Historical art and source text do not imply live support for the corresponding advantages.
## Verification record
- **Focused UI/reference:** 9 control cases passed, covering all twelve identities and all thirty-six panels, material physical-card numbers and timing, exact action binding, all three private draw policies in both modes, stale and nonowner actions, mode selection, disabled/busy controls, public waiting information, and hidden-field getter traps.
- **Focused lint:** type-aware lint passed for the reference, component and controls test.
- **Pure inventory and phase:** 18 cases pass, including twelve-card conservation across two through six seats, absent-faction roles, malformed identities/RNG, reciprocal alliances, signed phase progress, own-card recycling and identity-independent public completion.
- **Engine:** 10 cases pass through genuine setup and Storm entry, Basic and Advanced A/B phases, first-turn skipped worms, no-alliance/no-worm phases, a summoned-before-blow Nexus, all three actual alliance producers, preselected redraw policies, stale/corrupt requests, private projections and all four bot profiles. The Storm-to-Spice path opens with `initialize=false`; review fixed tracker initialization at that real entry rather than assuming only normal phase initialization.
- **Production SQLite recovery:** three cases pass with real room/session code and disposable databases: competing keep-own/redraw-own requests produce one committed result; a lost response restores the same private card without reroll or extra writes; malformed inventory and changed/missing closing progress reject before projection or storage. An unrelated room remains unchanged.
- **Final integrated checks:** `npm run check` passes types, lint and **3,795/3,795 offline tests**, with no skips or cancellations (93.9 seconds for the tests). This adds 40 cases to the preceding checkpoint. `npm run build` and **40/40 HTTP/session checks** pass. Final logs are `/tmp/dune-nexus-final-check.log`, `/tmp/dune-nexus-final-build.log` and `/tmp/dune-nexus-final-http.log`.
- **Three-seat browser:** isolated room `43U78HGQ` reached its genuine closing opportunity at version 3. Harkonnen selected “Redraw whenever it appears” and used the keyboard to draw. The physical Harkonnen card went to discard and Ecaz became the one held card; version 4 advanced to Charity with ten deck cards, one discard and one held card. All three private sessions restored after refresh; only Harkonnen receives Ecaz's identity. Desktop and 390-pixel phone layouts had no overflow or page errors.
- **Complete component review:** the internal rules gallery opened all twelve normal and enlarged faces by keyboard. Each phone dialog scrolled to its final panel and restored focus on closing. Independent inspection of all **48 screenshots** (normal, enlarged desktop, and paired phone top/bottom for every card) found no readability, clipping, truncation or rules-text discrepancies. This verifies the 36 displayed panels, not their unimplemented effects. Artifacts: `/tmp/dune-nexus-gallery/results.json` and sibling PNGs.
- **Saved-game preservation:** the pre-work baseline contains **3,173** rooms; every original version and SHA-256 state hash is unchanged after browser and HTTP work. The database contains 3,234 rooms: that baseline, the isolated Nexus room and 60 rooms from two HTTP runs. Read-only three-seat restoration also passed for Tupile `ZMA84DY8` v7, Grumman `HU6YUC7Z` v10, Caladan `LV2TNQ88` v5 and revival `463GUCY3` v7. The existing development server was reused; no restart was due during this checkpoint, no database was reset, and the removed automation stays removed.
## Remaining release gates
Every effect family still needs its own original-action timing, cancellation, payment/discard/force or leader custody, hidden-information, bot, UI and recovery integration. Atreides' inspected elements are a distinct upcoming family; they are not enabled merely because the panel is displayed. Existing unresolved Homeworld, revival, No-Field and occupation interpretations remain isolated. Original-format saved games without this optional module are not retroactively dealt cards, and arbitrary saved JSON is not a substitute for a genuine setup/draw history.
+2
View File
@@ -1,3 +1,4 @@
import { nexusCardBotActions } from './nexus-card-options';
import { tupileIntelligenceActions } from './tupile-intelligence-options';
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
@@ -3296,6 +3297,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
if (g.automaticContinuationPending) return [];
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
const intelligence = tupileIntelligenceActions(g);
if (intelligence.length) return intelligence;
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
+121 -3
View File
@@ -1,3 +1,5 @@
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, type NexusState } from './nexus-cards';
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
@@ -1244,6 +1246,8 @@ export type Game = {
sandtrout?: boolean;
techTokens?: TechState | null;
strongholdCards?: StrongholdState | null;
/** Optional independent module; complete effect coverage is still release-gated. */
nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null;
/** Null/absent disables the module; custody is installed at force placement. */
homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null;
homeworldOccupationHistory?: HomeworldOccupationHistory;
@@ -1389,6 +1393,97 @@ export type Game = {
aid: Record<string, { recipient: string; amount: number }>;
};
export class RuleError extends Error {}
function nexusRule<T>(work: () => T): T {
try { return work(); } catch (error) {
throw new RuleError(error instanceof Error ? error.message : 'Invalid Nexus card operation.');
}
}
function nexusCardsIntegrity(g: Game) {
const nexus = g.nexusCards;
if (!nexus) return;
requireRule(Object.keys(nexus).sort().join(',') === 'cards,phase', 'Invalid Nexus module fields.');
if (g.status === 'lobby') {
requireRule(nexus.cards === null && nexus.phase === null, 'Nexus cards are dealt only after setup begins.');
return;
}
requireRule(nexus.cards, 'The Nexus inventory is missing.');
nexusRule(() => validateNexusCards(nexus.cards!, g.players));
requireRule(g.players.every(p => !p.ally || nexus.cards!.hands[p.id] === null),
'Allied players cannot retain Nexus cards.');
const phase = nexus.phase;
if (!phase) {
requireRule(g.turn === 1 && g.phase === 0, 'The Nexus phase record is missing.');
return;
}
nexusRule(() => validateNexusCardPhase(phase, g.players, nexus.cards!));
requireRule(phase.turn <= g.turn && phase.turn >= g.turn - 1 &&
(phase.stage === 'complete' || (phase.turn === g.turn && g.phase === 1)) &&
(g.phase !== 1 || phase.turn === g.turn), 'The Nexus record does not match its phase.');
if (phase.stage === 'drawing')
requireRule(!g.decision && !g.response && !g.phaseOpening && !g.spiceSequence && !g.spiceWindow &&
!g.spiceResolution && !g.summonedWorm && !g.wormRides.length && !g.choamMarket &&
!g.truthtrance && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Finish the preceding phase interaction before drawing Nexus cards.');
}
function markNexusOccurred(g: Game) {
if (!g.nexusCards) return;
const phase = g.nexusCards.phase;
requireRule(phase?.turn === g.turn && phase.stage === 'spice', 'A Nexus needs an open Spice Blow phase.');
g.nexusCards.phase = nexusRule(() => markNexusCardOccurred(phase));
}
function discardAllianceNexusCards(g: Game, ...players: Player[]) {
if (!g.nexusCards?.cards) return;
for (const player of players) {
if (!g.nexusCards.cards.hands[player.id]) continue;
g.nexusCards.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, player.id, g.players));
log(g, `${player.name} discarded their held Nexus Card to enter the alliance.`,
{ faction: player.faction, name: 'Nexus alliance discard' });
}
}
function closeNexusCards(g: Game): boolean {
const nexus = g.nexusCards;
if (g.phase !== 1 || !nexus) return false;
requireRule(nexus.phase?.turn === g.turn && nexus.cards, 'The Nexus phase record is missing.');
if (nexus.phase.stage === 'spice') {
nexus.phase = nexusRule(() => closeNexusCardPhase(nexus.phase!, g.players));
if (nexus.phase.stage === 'drawing')
log(g, 'Spice Blow and Nexus is ending. Unallied factions may draw a secret Nexus Card, replace their held card, or keep their current position. Alliances are settled.');
}
return nexus.phase.stage === 'drawing';
}
function decideNexusCard(g: Game, p: Player, action: Action) {
const nexus = g.nexusCards;
requireRule(g.status === 'playing' && g.phase === 1 && nexus?.cards && nexus.phase?.stage === 'drawing' &&
action.turn === nexus.phase.turn && action.card === nexus.cards.hands[p.id] &&
Object.keys(action).sort().join(',') === 'card,choice,ownRedraws,turn,type' &&
[0, 1, 2].includes(action.ownRedraws as number), 'Choose your current Nexus card opportunity.');
const choice = action.choice as NexusCardChoice;
requireRule(nexusCardChoices(nexus.phase, nexus.cards, p.id).includes(choice), 'That Nexus choice is unavailable.');
requireRule(choice !== 'keep' || action.ownRedraws === 0, 'Keeping your card does not draw a replacement.');
if (choice === 'draw') nexus.cards = nexusRule(() => drawNexusCard(nexus.cards!, p.id, g.players, random));
else if (choice === 'replace')
nexus.cards = nexusRule(() => replaceNexusCard(nexus.cards!, p.id, g.players, random));
if (choice !== 'keep') {
// The player preselects this optional policy knowing their own faction's
// printed effect. No private redraw prompt leaks its identity through
// whether the public phase waits or advances. At most two redraws are
// possible: twelve cards and at most six held cards leave other cards in
// the recycled deck after drawing the same own card for a second time.
for (let remaining = action.ownRedraws as number;
remaining > 0 && nexus.cards.hands[p.id] === p.faction; remaining--)
nexus.cards = nexusRule(() => replaceNexusCard(nexus.cards!, p.id, g.players, random));
}
nexus.phase = nexusRule(() => finishNexusCardChoice(nexus.phase!, g.players, nexus.cards!, p.id, choice));
log(g, `${p.name} ${choice === 'keep' ? 'finished their Nexus card choice' : choice === 'draw' ? 'drew a secret Nexus Card' : 'discarded a Nexus Card and drew a secret replacement'}.`);
if (nexus.phase.stage === 'complete') completePhase(g);
}
function projectedNexusCards(g: Game, id: string) {
const nexus = g.nexusCards;
if (!nexus?.cards) return null;
return { ...projectNexusCards(nexus.cards, id, g.players), turn: nexus.phase?.turn ?? null,
choices: nexusCardChoices(nexus.phase, nexus.cards, id),
waiting: nexus.phase?.stage === 'drawing' ? nexus.phase.eligible.filter(owner => !nexus.phase!.done.includes(owner)) : [] };
}
function homeworldRule<T>(quote: () => T): T {
try {
return quote();
@@ -3908,6 +4003,7 @@ function start(g: Game) {
);
requireRule(!g.advanced, 'Advanced rules are still being implemented.');
requireRule(!g.homeworlds, 'Homeworld gameplay is still being implemented.');
requireRule(!g.nexusCards, 'Nexus card effects are still being implemented.');
requireRule(
g.players.every((p) => faction(p.faction).expansion === 'base'),
'Expansion factions are still being implemented.',
@@ -3919,6 +4015,7 @@ function start(g: Game) {
initializeSetup(g);
}
function initializeSetup(g: Game) {
if (g.nexusCards) g.nexusCards = { cards: createNexusCards(g.players, random), phase: null };
const choam = byFaction(g, 'choam');
if (choam && g.advanced && !choam.leaders.some(isAuditorLeader))
choam.leaders.push(createAuditorLeader());
@@ -4093,7 +4190,14 @@ export function initializeHomeworldGameForAudit(state: Game): Game {
);
return initializeSetupGameForAudit(state, true);
}
function initializeSetupGameForAudit(state: Game, homeworlds: boolean): Game {
/** Offline-only seam for the independent Nexus module; public starts remain gated. */
export function initializeNexusGameForAudit(state: Game): Game {
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
'Enable Nexus cards in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, false, true);
}
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false): Game {
nexusCardsIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
homeworldSubstitutionIntegrity(state);
@@ -4114,13 +4218,14 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean): Game {
'The audit initializer requires two through six distinct ready players and an existing host.',
);
requireRule(
(homeworlds || g.expansions.length === 0) &&
(homeworlds || nexus || g.expansions.length === 0) &&
(nexus || !g.nexusCards) &&
!g.techTokens &&
!g.strongholdCards &&
(homeworlds || !g.homeworlds) &&
g.players.every((p) =>
FACTIONS.some(
(f) => f.id === p.faction && (homeworlds || f.expansion === 'base'),
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base'),
),
),
homeworlds
@@ -4495,6 +4600,7 @@ function afterWorm(g: Game) {
Object.assign(g, pending.resume);
g.summonedWorm = null;
g.wormRides = [...g.wormRides, ...rides];
markNexusOccurred(g);
g.ready = [];
if (g.spiceWindow?.territory === pending.territory) {
g.spiceWindow.harvested = true;
@@ -4582,6 +4688,7 @@ function continueSpice(g: Game, injected?: SpiceCard) {
);
} else {
g.nexus = true;
markNexusOccurred(g);
g.spiceDiscard[sequence.pile].push(card);
if (previous && 'worm' in previous && g.advanced) {
const fremen = byFaction(g, 'fremen');
@@ -8520,6 +8627,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
target.allySinceTurn = member.allySinceTurn;
}
g.allianceOffers = quote.allianceOffers;
discardAllianceNexusCards(g, owner, entrant);
g.ready = [];
log(
g,
@@ -8959,6 +9067,7 @@ function formTerrorAlliance(g: Game, owner: Player, entrant: Player) {
owner.ally = entrant.id;
entrant.ally = owner.id;
owner.allySinceTurn = entrant.allySinceTurn = g.turn;
discardAllianceNexusCards(g, owner, entrant);
g.ready = [];
}
function decideTerror(g: Game, p: Player, action: Action) {
@@ -9923,6 +10032,7 @@ function finishEcazPlacement(g: Game) {
completePhase(g);
}
function completePhase(g: Game) {
if (closeNexusCards(g)) return;
if (g.phase === 7 && g.grummanCollection?.turn === g.turn && g.grummanCollection.stage === 'waiting') {
g.grummanCollection.stage = 'complete';
g.grummanCollection.outcome = 'expired';
@@ -9941,6 +10051,8 @@ function completePhase(g: Game) {
}
/** Open the same public window for every Ix table, independently of who holds Amal. */
function openPhase(g: Game, initialize = true) {
if (g.phase === 1 && g.nexusCards && g.nexusCards.phase?.turn !== g.turn)
g.nexusCards.phase = createNexusCardPhase(g.turn);
if (initialize && g.phase === 4 && g.homeworlds) {
g.homeworldRevival = homeworldRule(() => snapshotHomeworldRevival(g));
if (g.homeworldRevival?.tleilaxu.low)
@@ -11225,6 +11337,7 @@ function gholaOptions(g: Game, p: Player) {
};
}
function marketGholaIntegrity(g: Game) {
nexusCardsIntegrity(g);
homeworldHistoryIntegrity(g);
grummanCollectionIntegrity(g);
homeworldVictoryReturnIntegrity(g);
@@ -16409,6 +16522,8 @@ function applyActionInner(
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
if (t === 'advanceBots') return g;
if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; }
requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.');
requireRule(
!(
t === 'card' &&
@@ -18134,6 +18249,7 @@ function applyActionInner(
p.ally = other.id;
other.ally = id;
p.allySinceTurn = other.allySinceTurn = g.turn;
discardAllianceNexusCards(g, p, other);
delete g.allianceOffers[id];
delete g.allianceOffers[other.id];
log(g, `${p.name} and ${other.name} formed an alliance.`);
@@ -19848,6 +19964,7 @@ export function viewGame(state: Game, id: string) {
: null,
techTokens: g.techTokens ?? null,
strongholdCards: g.strongholdCards ?? null,
nexusCards: projectedNexusCards(g, id),
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me),
@@ -19898,6 +20015,7 @@ export function viewGame(state: Game, id: string) {
spicePile: g.spiceSequence?.pile ?? 0,
beforeSpiceDraw:
g.phase === 1 &&
g.nexusCards?.phase?.stage !== 'drawing' &&
!g.spiceSequence &&
!g.spiceResolution &&
!g.spiceWindow &&
+18
View File
@@ -0,0 +1,18 @@
import type { Action, GameView } from './engine';
import type { NexusCardChoice } from './nexus-card-phase';
export function nexusCardAction(game: GameView, choice: NexusCardChoice, ownRedraws: 0 | 1 | 2 = 0): Action | null {
const offer = game.nexusCards;
if (game.status !== 'playing' || game.phase !== 1 || offer?.turn !== game.turn ||
!offer.waiting.includes(game.me) || !offer.choices.includes(choice) || ![0, 1, 2].includes(ownRedraws)) return null;
return { type: 'nexusCardChoice', turn: offer.turn, card: offer.card, choice, ownRedraws: choice === 'keep' ? 0 : ownRedraws };
}
/** Common legal ownership policy. Choosing among card effects will be handled
* by the faction-aware effect policy once those effects are integrated. */
export function nexusCardBotActions(game: GameView): Action[] {
const choices = game.nexusCards?.choices ?? [];
const choice = choices.includes('draw') ? 'draw' : choices.includes('keep') ? 'keep' : null;
const action = choice && nexusCardAction(game, choice);
return action ? [action] : [];
}
+197
View File
@@ -0,0 +1,197 @@
import {
validateNexusCards,
validateNexusPlayers,
type NexusPlayer,
type NexusState,
} from './nexus-cards';
/** One closing opportunity for the whole Spice Blow and Nexus phase, including
* both Advanced spice piles. Pre-draw own-faction policies resolve privately
* within one action, so public progress never identifies a drawn card. */
export type NexusCardPhase = {
turn: number;
occurred: boolean;
stage: 'spice' | 'drawing' | 'complete';
eligible: string[];
done: string[];
signature: string;
};
export type NexusCardChoice = 'draw' | 'replace' | 'keep';
function requireNexus(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function signature(phase: Omit<NexusCardPhase, 'signature'>): string {
return JSON.stringify([
'nexusCardPhase',
phase.turn,
phase.occurred,
phase.stage,
phase.eligible,
phase.done,
]);
}
function validateFrame(phase: NexusCardPhase): void {
requireNexus(
phase &&
typeof phase === 'object' &&
!Array.isArray(phase) &&
Object.keys(phase).sort().join(',') ===
'done,eligible,occurred,signature,stage,turn' &&
Number.isSafeInteger(phase.turn) &&
phase.turn > 0 &&
typeof phase.occurred === 'boolean' &&
['spice', 'drawing', 'complete'].includes(phase.stage),
'Invalid saved Nexus phase.',
);
for (const list of [phase.eligible, phase.done])
requireNexus(
Array.isArray(list) &&
new Set(list).size === list.length &&
list.every((id) => typeof id === 'string' && id.trim().length > 0),
'Invalid Nexus decision owners.',
);
requireNexus(
phase.done.every((id) => phase.eligible.includes(id)),
'Nexus choices must belong to an eligible player.',
);
requireNexus(
phase.signature === signature(phase),
'The saved Nexus phase has lost its original progress.',
);
if (phase.stage === 'spice')
requireNexus(
!phase.eligible.length && !phase.done.length,
'Nexus draws cannot precede the closing opportunity.',
);
if (!phase.occurred)
requireNexus(
!phase.eligible.length,
'Nexus draws require an actual Nexus this phase.',
);
if (phase.stage === 'complete')
requireNexus(
phase.eligible.length === phase.done.length,
'A completed Nexus opportunity cannot retain a choice.',
);
if (phase.stage === 'drawing')
requireNexus(
phase.eligible.some((id) => !phase.done.includes(id)),
'A Nexus draw window needs an unfinished recipient.',
);
}
function signed(phase: Omit<NexusCardPhase, 'signature'>): NexusCardPhase {
return { ...phase, signature: signature(phase) };
}
export function createNexusCardPhase(turn: number): NexusCardPhase {
requireNexus(
Number.isSafeInteger(turn) && turn > 0,
'Invalid Nexus phase turn.',
);
return signed({
turn,
occurred: false,
stage: 'spice',
eligible: [],
done: [],
});
}
export function markNexusCardOccurred(phase: NexusCardPhase): NexusCardPhase {
validateFrame(phase);
requireNexus(
phase.stage === 'spice',
'A Nexus occurrence needs its open Spice Blow phase.',
);
return signed({ ...structuredClone(phase), occurred: true });
}
export function validateNexusCardPhase(
phase: NexusCardPhase,
players: readonly NexusPlayer[],
cards: NexusState,
): void {
validateFrame(phase);
validateNexusCards(cards, players);
requireNexus(
phase.eligible.every((id) => players.some((player) => player.id === id)),
'Invalid Nexus decision owners.',
);
if (phase.stage === 'drawing') {
const unallied = players
.filter((player) => !player.ally)
.map((player) => player.id);
requireNexus(
players.some((player) => !!player.ally) &&
phase.eligible.length === unallied.length &&
unallied.every((id) => phase.eligible.includes(id)),
'A Nexus draw window needs every unallied recipient and a settled alliance.',
);
}
}
export function closeNexusCardPhase(
phase: NexusCardPhase,
players: readonly NexusPlayer[],
): NexusCardPhase {
validateFrame(phase);
validateNexusPlayers(players);
requireNexus(
phase.stage === 'spice',
'This Nexus phase already reached its closing opportunity.',
);
const eligible =
phase.occurred && players.some((player) => !!player.ally)
? players.filter((player) => !player.ally).map((player) => player.id)
: [];
return signed({
...structuredClone(phase),
eligible,
stage: eligible.length ? 'drawing' : 'complete',
});
}
export function nexusCardChoices(
phase: NexusCardPhase | null,
cards: NexusState,
player: string,
): NexusCardChoice[] {
if (!phase) return [];
validateFrame(phase);
if (
phase.stage !== 'drawing' ||
!phase.eligible.includes(player) ||
phase.done.includes(player)
)
return [];
return cards.hands[player] ? ['keep', 'replace'] : ['keep', 'draw'];
}
/** Called only after the selected physical operation (and any preauthorized
* own-faction replacements) committed. No private card-dependent progress. */
export function finishNexusCardChoice(
phase: NexusCardPhase,
players: readonly NexusPlayer[],
cards: NexusState,
player: string,
choice: NexusCardChoice,
): NexusCardPhase {
validateNexusCardPhase(phase, players, cards);
requireNexus(
['draw', 'replace', 'keep'].includes(choice),
'Choose a valid Nexus card operation.',
);
requireNexus(
phase.stage === 'drawing' &&
phase.eligible.includes(player) &&
!phase.done.includes(player),
'Your Nexus choice is already complete or unavailable.',
);
requireNexus(
choice === 'keep' || cards.hands[player] !== null,
'A completed Nexus draw must have its physical card.',
);
const done = [...phase.done, player];
const next = signed({
...structuredClone(phase),
done,
stage: done.length === phase.eligible.length ? 'complete' : 'drawing',
});
validateNexusCardPhase(next, players, cards);
return next;
}
+96
View File
@@ -0,0 +1,96 @@
import type { FactionId } from './catalog';
import type { NexusCardMode } from './nexus-cards';
/** Faithful English rules summaries of all 36 original printed panels, rather
* than the changed effects in tournament compilations. See NEXUS_CARD_RULES.md.
* Reference availability does not enable an effect in the runtime. */
export type NexusCardReference = {
faction: FactionId;
betrayal: string;
cunning: string;
secretAlly: string;
};
export const NEXUS_PANEL_NAMES: Record<NexusCardMode, string> = {
betrayal: 'Betrayal', cunning: 'Cunning', secretAlly: 'Secret Ally',
};
export const NEXUS_CARD_SOURCE = 'https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani';
export const NEXUS_CARD_REFERENCE: readonly NexusCardReference[] = [
{
faction: 'atreides',
betrayal: 'Stop Atreides from inspecting one element of a Battle Plan with their battle advantage.',
cunning: 'Require your battle opponent to disclose a second element of their Battle Plan.',
secretAlly: 'In your battle, inspect one opposing Battle Plan element of your choice: leader, weapon, defense or number dialed.',
},
{
faction: 'beneGesserit',
betrayal: 'Prevent the Bene Gesserit from using Voice.',
cunning: 'During your Shipment and Movement turn, you may convert any selected groups of advisors into fighters. Every territory must finish with all your forces in the same stance.',
secretAlly: 'In your battle, use Voice to require or forbid a Treachery Card in your opponent’s plan.',
},
{
faction: 'emperor',
betrayal: 'During Bidding, require Emperor to pay for the ally’s Treachery purchase, covering at least the necessary spice. Alternatively, while Battle Plans are being made, suppress Emperor’s Sardaukar advantage.',
cunning: 'Before making your Battle Plan, you may treat five of your forces as Sardaukar if you have no Sardaukar in that battle.',
secretAlly: 'On a Treachery purchase, retain your spice after showing that you hold the required amount. Alternatively, during Revival, revive three extra forces free beyond the revival limit.',
},
{
faction: 'fremen',
betrayal: 'Stop Fremen worm riding for this turn, or prevent their two-territory movement during Shipment and Movement.',
cunning: 'When a sandworm appears in a territory containing no forces, your forces from one desert territory may ride to any territory, respecting storm and occupancy restrictions.',
secretAlly: 'Avoid losing your forces to a sandworm, or revive three forces free during Revival.',
},
{
faction: 'harkonnen',
betrayal: 'Cancel a revealed Harkonnen traitor and shuffle that card into the Traitor Deck. Harkonnen draws a replacement during Mentat Pause.',
cunning: 'At any time, draw a Traitor Card into your hand, then choose one of your held Traitor Cards and shuffle it into the Traitor Deck.',
secretAlly: 'During Mentat Pause, draw two Traitor Cards into your hand, then choose two held Traitor Cards and shuffle them into the Traitor Deck.',
},
{
faction: 'guild',
betrayal: 'Take one whole shipment payment otherwise going to the Spacing Guild or Spice Bank, including your own payment. This overrides the income of a faction occupying Junction.',
cunning: 'Immediately after your Shipment and Movement, make a second shipment. Its forces cannot move unless you play Hajr.',
secretAlly: 'Use Guild shipping prices. Your shipping action may instead cross-ship on the planet or return forces to your reserves.',
},
{
faction: 'ixians',
betrayal: 'Prevent either the Ixian Bidding advantage or the Technology advantage. This card cannot prevent both.',
cunning: 'Before making your Battle Plan, you may give all your Suboids full strength without spice support in every battle for the rest of this turn.',
secretAlly: 'Discard a Treachery Card you have just purchased during Bidding, then draw the next Treachery deck card.',
},
{
faction: 'tleilaxu',
betrayal: 'Cancel a revealed Face Dancer. That card stays revealed until Tleilaxu has revealed every Face Dancer.',
cunning: 'Set your revealed Face Dancers aside and secretly draw replacements. Then shuffle the set-aside cards into the Traitor Deck.',
secretAlly: 'During Revival, revive one of your leaders free and up to five of your forces for one spice each.',
},
{
faction: 'choam',
betrayal: 'Make CHOAM discard a random card from its hand without receiving spice for that discard.',
cunning: 'Discard any Treachery Card to obtain a Worthless Card special effect of your choice.',
secretAlly: 'During Spice Collection, discard a Worthless Card for two spice. Alternatively, after winning a battle, inspect a random card in your opponent’s hand that was not used in that battle.',
},
{
faction: 'richese',
betrayal: 'Stop Richese from buying a Richese Treachery Card: discard that card without payment. Alternatively, send the payment for a card Richese sells to the Spice Bank instead.',
cunning: 'Use two No-Field tokens in a single shipment, still charged as one force. Immediately reveal one token and place it in front of your shield.',
secretAlly: 'Instead of normal shipping, send up to five forces from reserves for the shipping price of one force.',
},
{
faction: 'ecaz',
betrayal: 'Before Shipment and Movement, choose a territory shared by Ecaz and its ally. Require the ally to return its forces from that territory to reserves.',
cunning: 'Take Duke Vidal for this turn, including from capture, the Tanks or Ghola custody, overriding Moritani. Set him aside normally at the end of the turn.',
secretAlly: 'Require one player to disclose whether any of your leaders are among their traitors, without identifying which leaders.',
},
{
faction: 'moritani',
betrayal: 'Return a Terror token from the board to Moritani without revealing it.',
cunning: 'When placing a Terror token, you may choose any Arrakis territory, including one that already has a Terror token.',
secretAlly: 'After losing a battle that has a winner, keep one Treachery Card you played which you could have kept if you had won.',
},
];
export function nexusCardReference(id: FactionId): NexusCardReference {
const card = NEXUS_CARD_REFERENCE.find((candidate) => candidate.faction === id);
if (!card) throw new Error('Unknown Nexus reference card.');
return card;
}
+280
View File
@@ -0,0 +1,280 @@
import { FACTIONS, type FactionId } from './catalog';
/** Physical inventory is independent of seated factions and enabled modules. */
export const NEXUS_FACTIONS: readonly FactionId[] = Object.freeze(
FACTIONS.map((faction) => faction.id),
);
export type NexusPlayer = {
id: string;
faction: FactionId;
ally?: string | null;
};
export type NexusState = {
version: 1;
deck: FactionId[];
discard: FactionId[];
hands: Record<string, FactionId | null>;
};
export type NexusCardMode = 'cunning' | 'betrayal' | 'secretAlly';
export type NexusCardsView = {
card: FactionId | null;
deckCount: number;
discardCount: number;
held: Record<string, boolean>;
};
export class NexusCardsError extends Error {
constructor(message: string) {
super(message);
this.name = 'NexusCardsError';
}
}
function requireCards(condition: unknown, message: string): asserts condition {
if (!condition) throw new NexusCardsError(message);
}
const faction = (value: unknown): value is FactionId =>
NEXUS_FACTIONS.includes(value as FactionId);
const identifier = (value: unknown): value is string =>
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value);
function plain(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype: unknown = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
export function validateNexusPlayers(players: readonly NexusPlayer[]): void {
requireCards(
Array.isArray(players) && players.length >= 2 && players.length <= 6,
'Nexus custody needs two through six seated players.',
);
const ids = new Set<string>(),
factions = new Set<FactionId>();
for (const player of players) {
requireCards(
plain(player) &&
identifier(player.id) &&
faction(player.faction) &&
!ids.has(player.id) &&
!factions.has(player.faction),
'Nexus custody needs distinct valid seated identities and factions.',
);
ids.add(player.id);
factions.add(player.faction);
}
for (const player of players)
requireCards(
player.ally === undefined ||
player.ally === null ||
(identifier(player.ally) &&
player.ally !== player.id &&
ids.has(player.ally)),
'A Nexus player has an invalid seated alliance partner.',
);
for (const player of players)
requireCards(
player.ally === undefined ||
player.ally === null ||
players.find((partner) => partner.id === player.ally)!.ally ===
player.id,
'Nexus alliances must identify reciprocal seated partners.',
);
}
/** No alias zones, unknown card, missing seat, duplicated identity or extra
* private fields may be reconstructed or silently normalized on read. */
export function validateNexusCards(
state: NexusState,
players: readonly NexusPlayer[],
): void {
validateNexusPlayers(players);
requireCards(
plain(state) &&
Object.keys(state).length === 4 &&
['version', 'deck', 'discard', 'hands'].every((key) =>
Object.hasOwn(state, key),
) &&
state.version === 1 &&
Array.isArray(state.deck) &&
Array.isArray(state.discard) &&
plain(state.hands),
'The saved Nexus inventory has an invalid shape.',
);
requireCards(
Object.keys(state.hands).length === players.length &&
players.every((player) => Object.hasOwn(state.hands, player.id)),
'Nexus hands must match the complete seated roster.',
);
const physical: FactionId[] = [];
for (const pile of [state.deck, state.discard])
for (const card of pile) {
requireCards(
faction(card),
'The Nexus inventory contains an unknown physical card.',
);
physical.push(card);
}
for (const player of players) {
const card = state.hands[player.id];
requireCards(
card === null || faction(card),
'Each Nexus hand holds at most one physical card.',
);
if (card !== null) physical.push(card);
}
requireCards(
physical.length === NEXUS_FACTIONS.length &&
new Set(physical).size === NEXUS_FACTIONS.length,
'The Nexus inventory must conserve exactly one card for each of the twelve factions.',
);
}
function shuffle(
cards: readonly FactionId[],
random: () => number,
): FactionId[] {
requireCards(
typeof random === 'function',
'Nexus shuffling needs server randomness.',
);
const result = [...cards];
for (let index = result.length - 1; index > 0; index--) {
const value = random();
requireCards(
Number.isFinite(value) && value >= 0 && value < 1,
'Nexus random draws must be finite from zero inclusive to one exclusive.',
);
const selected = Math.floor(value * (index + 1));
[result[index], result[selected]] = [result[selected], result[index]];
}
return result;
}
export function createNexusCards(
players: readonly NexusPlayer[],
random: () => number,
): NexusState {
validateNexusPlayers(players);
return {
version: 1,
deck: shuffle(NEXUS_FACTIONS, random),
discard: [],
hands: Object.fromEntries(players.map((player) => [player.id, null])),
};
}
function owner(
state: NexusState,
player: string,
players: readonly NexusPlayer[],
): NexusPlayer {
validateNexusCards(state, players);
const seat = players.find((candidate) => candidate.id === player);
requireCards(seat && identifier(player), 'Choose a seated Nexus card owner.');
return seat;
}
function requireUnallied(player: NexusPlayer): void {
requireCards(
player.ally === undefined || player.ally === null,
'An allied player cannot draw or replace a Nexus card.',
);
}
function drawInto(
state: NexusState,
player: string,
random: () => number,
): void {
if (!state.deck.length) {
requireCards(
state.discard.length > 0,
'There is no physical Nexus card to draw.',
);
state.deck = shuffle(state.discard, random);
state.discard = [];
}
state.hands[player] = state.deck.shift()!;
}
/** The engine authorizes the phase opportunity or immediate own-faction redraw.
* Drawing one's faction never discards it automatically in either rules mode. */
export function drawNexusCard(
state: NexusState,
player: string,
players: readonly NexusPlayer[],
random: () => number,
): NexusState {
requireUnallied(owner(state, player, players));
requireCards(
state.hands[player] === null,
'Discard a held Nexus card before drawing its replacement.',
);
const result = structuredClone(state);
drawInto(result, player, random);
return result;
}
export function replaceNexusCard(
state: NexusState,
player: string,
players: readonly NexusPlayer[],
random: () => number,
): NexusState {
requireUnallied(owner(state, player, players));
requireCards(
state.hands[player] !== null,
'Hold a Nexus card before replacing it.',
);
const result = structuredClone(state);
result.discard.push(result.hands[player]!);
result.hands[player] = null;
drawInto(result, player, random);
return result;
}
/** Used cards and cards forfeited upon joining any alliance take the same
* physical route. The caller separately proves which discard is mandatory. */
export function discardNexusCard(
state: NexusState,
player: string,
players: readonly NexusPlayer[],
): NexusState {
owner(state, player, players);
requireCards(
state.hands[player] !== null,
'Hold a Nexus card before discarding it.',
);
const result = structuredClone(state);
result.discard.push(result.hands[player]!);
result.hands[player] = null;
return result;
}
export function nexusCardMode(
card: FactionId,
ownerFaction: FactionId,
seatedFactions: readonly FactionId[],
): NexusCardMode {
requireCards(
faction(card) &&
faction(ownerFaction) &&
Array.isArray(seatedFactions) &&
seatedFactions.every(faction) &&
new Set(seatedFactions).size === seatedFactions.length &&
seatedFactions.includes(ownerFaction),
'Nexus effects need a known card and its actual seated faction roster.',
);
return card === ownerFaction
? 'cunning'
: seatedFactions.includes(card)
? 'betrayal'
: 'secretAlly';
}
export function projectNexusCards(
state: NexusState,
viewer: string,
players: readonly NexusPlayer[],
): NexusCardsView {
owner(state, viewer, players);
return {
card: state.hands[viewer],
deckCount: state.deck.length,
discardCount: state.discard.length,
held: Object.fromEntries(
players.map((player) => [player.id, state.hands[player.id] !== null]),
),
};
}
+27
View File
@@ -3,6 +3,7 @@ import { FACTION_RULES } from './faction-reference';
import { FACTIONS, PHASES } from './catalog';
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { NEXUS_CARD_REFERENCE } from './nexus-card-reference';
export const RULE_CHECKLIST_AREAS = [
'Implementation',
@@ -3333,6 +3334,32 @@ export const RULE_TOPICS: RuleTopic[] = [
},
],
},
{
id: 'nexus-cards',
title: 'Nexus Cards',
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. Ownership and private draw choices are integrated; individual effects 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.',
'Keep your card secret until it is used. You may hold at most one Nexus Card. It is separate from your Treachery hand and does not occupy a Treachery hand slot.',
'Whenever you draw your own faction card, you may immediately discard it and draw again, in either Basic or Advanced play. A replacement is discarded before the new draw; when the deck is empty, shuffle its discard pile to refill it.',
'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.',
'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
'The development table supports custody, closing choices, replacement, immediate redraw, alliance discard and private card inspection. Effect execution and full module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical ownership and whole-phase draw lifecycle; all individual card effects still require integration.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private draw, keep, replace and immediate own-faction redraw; readable card inspection. Effect play controls remain unfinished.', evidence: ['components/nexus-cards.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles draw when empty and keep their held card. Effect strategy and module strength calibration are unfinished.', evidence: ['game/nexus-card-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve printed faces are recorded. Effect interaction guidance remains in development.', evidence: ['docs/NEXUS_CARD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused custody and phase regressions; complete module games remain a release requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts'] },
],
},
...[
[
'ix-modules',
+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeNexusGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck, type SpiceCard } from '../game/cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
export const nexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
export const nexusPlayer = (g: Game, id: string) =>
g.players.find((p) => p.id === id)!;
/** Final seat identities precede the genuine base setup. The only module seam
* enables the unfinished Nexus module through its explicit audit initializer. */
export function nexusFixture(
options: {
seatIds?: [string, string, string];
advanced?: boolean;
hostFaction?: 'fremen' | 'moritani' | 'ecaz';
} = {},
): Game {
const [f, a, h] = options.seatIds ?? ['f', 'a', 'h'];
let g = createGame(
'NEXUSAUDIT',
newPlayer(
f,
options.hostFaction ?? 'fremen',
options.hostFaction ?? 'fremen',
),
options.advanced ?? false,
[],
);
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g.nexusCards = { cards: null, phase: null };
g = initializeNexusGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 60; i++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
g = next;
}
assert.equal(g.status, 'playing');
assert.equal(g.homeworlds, undefined);
for (const p of g.players) g.deck.push(...p.hand.splice(0));
nexusInventory(g);
return g;
}
export function nexusReady(state: Game): Game {
let g = state;
for (const p of state.players)
if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
export function nexusAllow(state: Game): Game {
let g = state;
for (let i = 0; g.response && i < 20; i++) {
const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = applyAction(g, id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
/** Later turns stage only a settled Storm boundary after a completed phase.
* The tracker for the next Spice Blow is always created by actual Storm play. */
export function enterNexusSpice(state: Game, nextTurn = false): Game {
let g = nexusReload(state);
if (nextTurn) {
assert.equal(g.nexusCards?.phase?.stage, 'complete');
Object.assign(g, {
turn: g.turn + 1,
phase: 0,
ready: [],
stormPending: null,
stormDialers: g.players.slice(0, 2).map((p) => p.id),
stormDials: {},
});
}
assert.equal(g.phase, 0);
for (const id of g.stormDialers)
g = applyAction(g, id, { type: 'stormDial', amount: g.turn === 1 ? 0 : 1 });
g = nexusAllow(nexusReady(g));
assert.equal(g.phase, 1);
assert.equal(g.nexusCards?.phase?.stage, 'spice');
return g;
}
/** Reorder existing physical spice cards without adding/removing their census. */
export function orderNexusSpice(g: Game, kinds: ('land' | 'worm')[]): void {
const all = [...g.spiceDeck, ...g.spiceDiscard.flat()];
const front: SpiceCard[] = [];
for (const kind of kinds) {
const index = all.findIndex((card) =>
kind === 'land' ? 'territory' in card : 'worm' in card,
);
assert.ok(index >= 0);
front.push(all.splice(index, 1)[0]);
}
g.spiceDeck = [...front, ...all];
g.spiceDiscard = [[], []];
}
export function finishNexusSpice(state: Game): Game {
let g = state;
for (
let i = 0;
g.phase === 1 && g.nexusCards?.phase?.stage === 'spice' && i < 30;
i++
) {
if (g.response) g = nexusAllow(g);
else if (g.decision?.kind === 'wormRide')
g = applyAction(g, g.decision.player, {
type: 'decision',
accept: false,
});
else {
assert.equal(g.decision, null);
g = nexusReady(g);
}
}
assert.notEqual(g.nexusCards?.phase?.stage, 'spice');
return g;
}
export function nexusTurnTwo(
options: {
seatIds?: [string, string, string];
advanced?: boolean;
hostFaction?: 'fremen' | 'moritani' | 'ecaz';
} = {},
): Game {
const first = enterNexusSpice(nexusFixture(options));
orderNexusSpice(first, ['land', 'land']);
return enterNexusSpice(finishNexusSpice(first), true);
}
export function nexusInventory(g: Game): void {
assert.ok(g.nexusCards?.cards);
validateNexusCards(g.nexusCards.cards, g.players);
assert.deepEqual(
[
...g.nexusCards.cards.deck,
...g.nexusCards.cards.discard,
...Object.values(g.nexusCards.cards.hands).filter(
(card) => card !== null,
),
].sort(),
[...NEXUS_FACTIONS].sort(),
);
assert.deepEqual(
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort(),
baseDeck()
.map((c) => c.id)
.sort(),
);
for (const p of g.players)
assert.equal(
p.reserves +
p.tanks +
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
20,
);
}
export function nexusReject(
g: Game,
id: string,
action: Action,
pattern: RegExp = /Nexus/,
): void {
const before = nexusReload(g);
assert.throws(() => applyAction(g, id, action), pattern);
assert.deepEqual(g, before);
}
+158
View File
@@ -0,0 +1,158 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FACTIONS, type FactionId } from '../game/catalog';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { NEXUS_CARD_REFERENCE, NEXUS_PANEL_NAMES, nexusCardReference } from '../game/nexus-card-reference';
// Match Vinext's actual next/image alias in this isolated Node test process;
// render the production image implementation, not a component mock.
const imageAlias = registerHooks({ resolve(specifier, context, nextResolve) {
return nextResolve(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
} });
const { NexusCardFace, NexusCardGallery, NexusCards, nexusCardChoiceAction } = await import('../components/nexus-cards');
imageAlias.deregister();
/** Only public seats and the server's private owner projection feed the UI. */
function fixture(card: FactionId | null = null): GameView {
const g = createGame('NEXUSCONTROLS', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 1, turn: 2, active: 'e' });
const view = viewGame(g, 'a');
view.nexusCards = { card, deckCount: card ? 9 : 10, discardCount: 0,
held: { a: !!card, h: true, e: true }, turn: 2,
choices: card ? ['keep', 'replace'] : ['keep', 'draw'], waiting: ['a', 'h'] };
return view;
}
function markup(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(NexusCards, { game, busy, act() {} }));
}
void test('the original inventory renders twelve identities, all36 readable panels and inspect controls', () => {
assert.equal(NEXUS_CARD_REFERENCE.length, 12);
assert.deepEqual(NEXUS_CARD_REFERENCE.map((card) => card.faction).sort(), FACTIONS.map((faction) => faction.id).sort());
const html = renderToStaticMarkup(createElement(NexusCardGallery));
for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const)
assert.equal(html.split(`aria-label="${NEXUS_PANEL_NAMES[mode]}"`).length - 1, 12);
assert.equal((html.match(/aria-label="Inspect [^"]+ Nexus card"/g) ?? []).length, 12);
for (const card of NEXUS_CARD_REFERENCE) {
for (const mode of ['betrayal', 'cunning', 'secretAlly'] as const) {
assert.ok(card[mode].length > 30);
assert.ok(html.includes(card[mode]));
}
}
});
void test('reference preserves materially different physical effects and original numerical limits', () => {
assert.match(nexusCardReference('guild').betrayal, /whole shipment payment.*own payment.*Junction/);
assert.match(nexusCardReference('guild').cunning, /second shipment.*cannot move unless.*Hajr/);
assert.match(nexusCardReference('richese').cunning, /two No-Field.*one force.*reveal one/);
assert.match(nexusCardReference('tleilaxu').secretAlly, /leader.*free.*five.*one spice each/);
assert.match(nexusCardReference('emperor').secretAlly, /three extra forces free beyond/);
assert.match(nexusCardReference('ecaz').cunning, /capture.*Tanks.*Ghola.*Moritani.*end of the turn/);
assert.match(nexusCardReference('moritani').betrayal, /Return.*without revealing/);
assert.match(nexusCardReference('harkonnen').betrayal, /replacement during Mentat Pause/);
assert.match(nexusCardReference('choam').secretAlly, /not used in that battle/);
assert.match(nexusCardReference('ixians').betrayal, /cannot prevent both/);
assert.doesNotMatch(JSON.stringify(NEXUS_CARD_REFERENCE), /INTERNAL AUDIT|ALTERNATE SUPPLY CHAIN|entire Battle Plan/);
});
void test('draw, replacement and keep actions bind the exact offered turn and card without mutation', () => {
for (const card of [null, 'fremen'] as const) {
const view = fixture(card);
const before = structuredClone(view);
for (const choice of view.nexusCards!.choices)
assert.deepEqual(nexusCardChoiceAction(view, choice), { type: 'nexusCardChoice', turn: 2, card, choice, ownRedraws: 0 });
assert.equal(nexusCardChoiceAction(view, card ? 'draw' : 'replace'), null);
assert.deepEqual(view, before);
}
});
void test('private pre-draw policy applies in Basic and Advanced without a revealing post-draw prompt', () => {
for (const advanced of [false, true]) {
for (const card of [null, 'atreides', 'fremen'] as const) {
const view = fixture(card);
view.advanced = advanced;
const choice = card ? 'replace' : 'draw';
for (const ownRedraws of [0, 1, 2] as const)
assert.deepEqual(nexusCardChoiceAction(view, choice, ownRedraws), { type: 'nexusCardChoice', turn: 2, card, choice, ownRedraws });
assert.equal(nexusCardChoiceAction(view, choice, 3 as never), null);
assert.equal(nexusCardChoiceAction(view, 'keep', 2)!.ownRedraws, 0);
assert.match(markup(view), /aria-label="If I draw my faction"/);
assert.match(markup(view), /Basic or Advanced play/);
assert.match(markup(view), /Redraw whenever it appears/);
view.nexusCards!.choices = [];
view.nexusCards!.waiting = ['h'];
assert.doesNotMatch(markup(view), /If I draw my faction|You drew your own faction|Redraw my faction card/);
}
}
});
void test('settled, stale, nonplaying and nonowner opportunities cannot generate an action', () => {
for (const edit of [
(view: GameView) => { view.nexusCards!.waiting = ['h']; },
(view: GameView) => { view.nexusCards!.choices = []; },
(view: GameView) => { view.nexusCards!.turn = 1; },
(view: GameView) => { view.nexusCards!.turn = null; },
(view: GameView) => { view.phase = 2; },
(view: GameView) => { view.status = 'finished'; },
(view: GameView) => { view.me = 'observer'; },
]) {
const view = fixture();
edit(view);
assert.equal(nexusCardChoiceAction(view, 'draw'), null);
assert.equal(nexusCardChoiceAction(view, 'keep'), null);
}
});
void test('the owned face highlights its mode from the public roster and all three requirements remain readable', () => {
for (const [card, mode] of [['atreides', 'Cunning'], ['harkonnen', 'Betrayal'], ['fremen', 'Secret Ally']] as const) {
const html = markup(fixture(card));
const privateFace = html.slice(html.indexOf('aria-label="Your private Nexus card"'), html.indexOf('<details'));
assert.match(privateFace, new RegExp(`${mode}<span[^>]*>Your applicable mode`));
assert.equal((privateFace.match(/Your applicable mode/g) ?? []).length, 1);
assert.match(privateFace, /player only/);
assert.match(privateFace, /Another player controls/);
assert.match(privateFace, /is not in this game/);
}
});
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
const view = fixture();
const html = markup(view, true);
assert.match(html, /10 in deck · 0 discarded · 2 held/);
assert.match(html, /Waiting for Nexus choices: Harkonnen/);
assert.match(html, /You do not hold a Nexus card/);
assert.match(html, /effects are not playable yet/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Draw a Nexus card/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Skip this draw/);
assert.doesNotMatch(html, />Play |Activate effect|Use Betrayal/);
const enlarged = renderToStaticMarkup(createElement(NexusCardFace, { card: 'atreides', enlarged: true }));
assert.match(enlarged, /text-xl! leading-8!/);
assert.doesNotMatch(enlarged, /<button/);
});
void test('rendering and legal choices never inspect other hands, source deck order, spice or secret battle fields', () => {
const view = fixture('fremen');
const expected = markup(view);
for (const player of view.players)
for (const field of ['hand', 'traitors', 'spice', 'prediction'])
Object.defineProperty(player, field, { get() { throw new Error(`Private ${field} inspected`); } });
for (const field of ['deck', 'discard', 'battle', 'hands', 'noField'])
Object.defineProperty(view, field, { get() { throw new Error(`Source ${field} inspected`); } });
Object.defineProperty(view.nexusCards!, 'hands', { get() { throw new Error('Opposing held identities inspected'); } });
assert.equal(markup(view), expected);
assert.ok(nexusCardChoiceAction(view, 'replace'));
assert.ok(nexusCardChoiceAction(view, 'keep'));
});
void test('disabled module reads no player or card source and renders no inventory', () => {
const view = fixture();
view.nexusCards = null;
Object.defineProperty(view, 'players', { get() { throw new Error('Unnecessary player access'); } });
assert.equal(markup(view), '');
assert.equal(nexusCardChoiceAction(view, 'draw'), null);
});
+372
View File
@@ -0,0 +1,372 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { placeAmbassador } from '../game/ecaz-ambassadors';
import { placeTerror } from '../game/moritani-terror';
import {
enterNexusSpice,
finishNexusSpice,
nexusFixture,
nexusAllow,
nexusInventory,
nexusPlayer,
nexusReady,
nexusReject,
nexusReload,
nexusTurnTwo,
orderNexusSpice,
} from './fixture-nexus-cards';
function alliance(g: Game, one = 'f', two = 'a'): Game {
g = applyAction(g, one, { type: 'alliance', target: two });
return applyAction(g, two, { type: 'alliance', target: one });
}
function negotiations(g: Game): Game {
g = nexusReady(g);
assert.ok(g.spiceWindow);
g = nexusReady(g);
assert.equal(g.nexus, true);
assert.equal(g.spiceWindow, null);
return g;
}
function choice(g: Game, id: string, value: string): Action {
return {
type: 'nexusCardChoice',
turn: g.turn,
card: g.nexusCards!.cards!.hands[id],
choice: value,
ownRedraws: 0,
};
}
function top(g: Game, ...cards: FactionId[]): void {
const deck = g.nexusCards!.cards!.deck;
assert.ok(cards.every((card) => deck.includes(card)));
g.nexusCards!.cards!.deck = [
...cards,
...deck.filter((card) => !cards.includes(card)),
];
}
function closing(advanced = false): Game {
let g = nexusTurnTwo({ advanced });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = alliance(negotiations(g));
return finishNexusSpice(g);
}
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced A/B' : 'Basic'} real phase draws once only after all spice passes and settled alliances`, () => {
let g = nexusTurnTwo({ advanced });
assert.equal(g.nexusCards!.cards!.deck.length, 12);
orderNexusSpice(g, ['worm', 'land', 'land']);
g = alliance(negotiations(g));
assert.equal(g.nexusCards!.phase!.occurred, true);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, []);
g = nexusReady(g);
if (advanced) {
assert.equal(g.spiceSequence!.pile, 1);
assert.ok(g.spiceWindow);
assert.equal(g.nexusCards!.cards!.deck.length, 12);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, []);
}
g = finishNexusSpice(g);
assert.equal(g.phase, 1);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
assert.equal(viewGame(g, 'h').beforeSpiceDraw, false);
top(g, 'ecaz');
const draw = choice(g, 'h', 'draw');
g = applyAction(nexusReload(g), 'h', draw);
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.phase!.stage, 'complete');
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
assert.equal(g.nexusCards!.cards!.deck.length, 11);
nexusReject(g, 'h', draw);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
nexusInventory(g);
});
void test('first-turn skipped worm, no worm, and actual Nexus without any alliance do not grant a card', () => {
let first = enterNexusSpice(nexusFixture());
orderNexusSpice(first, ['worm', 'land']);
first = finishNexusSpice(first);
assert.equal(first.nexusCards!.phase!.occurred, false);
assert.equal(first.phase, 2);
let noWorm = enterNexusSpice(first, true);
orderNexusSpice(noWorm, ['land']);
noWorm = finishNexusSpice(noWorm);
assert.deepEqual(noWorm.nexusCards!.phase!.eligible, []);
let noAlliance = enterNexusSpice(noWorm, true);
orderNexusSpice(noAlliance, ['worm', 'land']);
noAlliance = finishNexusSpice(noAlliance);
assert.equal(noAlliance.nexusCards!.phase!.occurred, true);
assert.deepEqual(noAlliance.nexusCards!.phase!.eligible, []);
assert.equal(noAlliance.nexusCards!.cards!.deck.length, 12);
nexusInventory(noAlliance);
});
void test('a real before-blow Fremen summoned Nexus resumes both Advanced piles and offers one final draw', () => {
let g = nexusTurnTwo({ advanced: true });
orderNexusSpice(g, ['land', 'land']);
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
nexusPlayer(g, 'f').hand.push(card);
const beforeSpice = nexusReload(g).spiceDeck;
g = nexusAllow(
applyAction(g, 'f', {
type: 'card',
mode: 'special',
card: card.id,
territory: 'the_great_flat',
}),
);
assert.equal(g.summonedBeforeBlow, true);
assert.equal(g.nexusCards!.phase!.occurred, true);
assert.deepEqual(g.spiceDeck, beforeSpice);
assert.equal(g.discard.filter((item) => item.id === card.id).length, 1);
g = alliance(negotiations(nexusReload(g)));
g = finishNexusSpice(g);
assert.equal(
g.spiceDiscard[0].filter((item) => 'territory' in item).length,
1,
);
assert.equal(
g.spiceDiscard[1].filter((item) => 'territory' in item).length,
1,
);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
top(g, 'ecaz');
g = applyAction(nexusReload(g), 'h', choice(g, 'h', 'draw'));
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.cards!.deck.length, 11);
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
nexusInventory(g);
});
void test('precommitted own-faction policy completes privately and later replacement discards once', () => {
const initial = closing();
top(initial, 'harkonnen', 'ecaz', 'moritani');
const keptOwn = applyAction(initial, 'h', choice(initial, 'h', 'draw'));
assert.equal(keptOwn.phase, 2);
assert.equal(keptOwn.nexusCards!.cards!.hands.h, 'harkonnen');
assert.deepEqual(viewGame(keptOwn, 'a').nexusCards!.waiting, []);
let g = applyAction(nexusReload(initial), 'h', {
...choice(initial, 'h', 'draw'),
ownRedraws: 1,
});
assert.equal(g.phase, 2);
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz');
assert.deepEqual(g.nexusCards!.cards!.discard, ['harkonnen']);
assert.deepEqual(viewGame(g, 'a').nexusCards!.waiting, []);
nexusReject(g, 'h', choice(initial, 'h', 'draw'));
const repeatedPolicy = applyAction(initial, 'h', {
...choice(initial, 'h', 'draw'),
ownRedraws: 2,
});
assert.deepEqual(repeatedPolicy.nexusCards, g.nexusCards);
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = finishNexusSpice(g);
assert.deepEqual(viewGame(g, 'h').nexusCards!.choices, ['keep', 'replace']);
const replace = choice(g, 'h', 'replace');
g = applyAction(nexusReload(g), 'h', replace);
assert.equal(g.nexusCards!.cards!.hands.h, 'moritani');
assert.deepEqual(g.nexusCards!.cards!.discard, ['harkonnen', 'ecaz']);
nexusReject(g, 'h', replace);
nexusInventory(g);
});
void test('closing choices fence stale owners/cards/turns and every other action; all four bots emit a legal choice', () => {
const g = closing();
for (const action of [
{ type: 'ready' },
{ type: 'alliance', target: 'f' },
{ type: 'card', card: 'invented' },
{ type: 'ship', amount: 1 },
{ type: 'decision', decline: true },
{ ...choice(g, 'h', 'draw'), turn: g.turn - 1 },
{ ...choice(g, 'h', 'draw'), card: 'atreides' },
{ ...choice(g, 'h', 'draw'), injected: true },
])
nexusReject(g, 'h', action);
nexusReject(g, 'f', choice(g, 'f', 'draw'));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'h');
view.players.find((p) => p.id === 'h')!.bot = difficulty;
const action = botActions(view)[0];
assert.equal(action?.type, 'nexusCardChoice');
assert.doesNotThrow(() => applyAction(g, 'h', action!));
}
const kept = applyAction(g, 'h', choice(g, 'h', 'keep'));
assert.equal(kept.phase, 2);
assert.equal(kept.nexusCards!.cards!.deck.length, 12);
});
void test('private card projection exposes only own identity; a real reciprocal Nexus join forfeits held card exactly once', () => {
let g = closing();
top(g, 'ecaz');
g = applyAction(g, 'h', choice(g, 'h', 'draw'));
for (const id of ['f', 'a', 'h']) {
const projected = viewGame(nexusReload(g), id).nexusCards!;
assert.equal(projected.card, id === 'h' ? 'ecaz' : null);
assert.deepEqual(projected.held, { f: false, a: false, h: true });
assert.deepEqual(Object.keys(projected).sort(), [
'card',
'choices',
'deckCount',
'discardCount',
'held',
'turn',
'waiting',
]);
if (id !== 'h')
assert.equal(JSON.stringify(projected).includes('ecaz'), false);
}
assert.equal(JSON.stringify(g.log).includes('ecaz'), false);
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = negotiations(g);
g = applyAction(g, 'a', { type: 'alliance' });
g = applyAction(g, 'h', { type: 'alliance', target: 'a' });
assert.equal(g.nexusCards!.cards!.hands.h, 'ecaz', 'offer is not acceptance');
g = applyAction(nexusReload(g), 'a', { type: 'alliance', target: 'h' });
assert.equal(g.nexusCards!.cards!.hands.h, null);
assert.deepEqual(g.nexusCards!.cards!.discard, ['ecaz']);
assert.equal(nexusPlayer(g, 'h').ally, 'a');
nexusInventory(g);
});
void test('malformed inventory and closing state reject all views, normalization and action without repair', () => {
const g = closing();
const corruptions: ((g: Game) => void)[] = [
(x) => {
x.nexusCards!.cards!.deck.pop();
},
(x) => {
x.nexusCards!.cards!.hands.h = x.nexusCards!.cards!.deck[0];
},
(x) => {
x.nexusCards!.phase!.done = ['f'];
},
(x) => {
x.nexusCards!.phase!.occurred = false;
},
(x) => {
x.nexusCards!.phase!.turn--;
},
(x) => {
x.nexusCards!.phase = null;
},
];
for (const corrupt of corruptions) {
const bad = nexusReload(g);
corrupt(bad);
const before = nexusReload(bad);
for (const p of bad.players)
assert.throws(() => viewGame(bad, p.id), /Nexus/);
assert.throws(() => normalizeAutomaticGame(bad), /Nexus/);
assert.throws(() => applyAction(bad, 'h', choice(g, 'h', 'draw')), /Nexus/);
assert.deepEqual(bad, before);
}
});
for (const hostFaction of ['moritani', 'ecaz'] as const)
void test(`${hostFaction} actual accepted entry alliance forfeits a genuinely drawn card, but declaration and refusal retain it`, () => {
// This explicit expansion roster enters the same genuine audit setup before
// any Nexus custody exists. Base Treachery inventory remains unchanged.
let g = nexusTurnTwo({ hostFaction });
orderNexusSpice(g, ['worm', 'land']);
g = alliance(negotiations(g), 'a', 'h');
g = finishNexusSpice(g);
top(g, 'richese');
g = applyAction(g, 'f', choice(g, 'f', 'draw'));
// Stage a later unused shipment, retaining the completed real phase receipt.
// The entrant first ends its old alliance in the next genuine Nexus.
g = enterNexusSpice(g, true);
orderNexusSpice(g, ['worm', 'land']);
g = negotiations(g);
g = applyAction(g, 'a', { type: 'alliance' });
g = finishNexusSpice(g);
Object.assign(g, {
phase: 5,
active: 'a',
order: ['a', 'f', 'h'],
movementRemaining: ['a', 'f', 'h'],
ready: [],
storm: 18,
});
for (const p of g.players) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0);
p.forces = {};
Object.assign(p, { spice: 20, shipped: false, moved: 0 });
}
if (hostFaction === 'moritani') {
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
g.moritaniTerror = placeTerror(
g.moritaniTerror!,
token.id,
'arrakeen',
g.turn,
);
} else {
const token = g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!;
g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors!, token.id, {
turn: g.turn,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
}
g = applyAction(g, 'a', {
type: 'ship',
amount: 2,
territory: 'arrakeen',
sector: 10,
});
const declare: Action =
hostFaction === 'moritani'
? { type: 'decision', alliance: true }
: {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
choice: 'alliance',
beneficiary: 'f',
};
g = applyAction(g, 'f', declare);
assert.equal(g.nexusCards!.cards!.hands.f, 'richese');
assert.equal(
g.response,
null,
'empty Treachery hands need no Karama response',
);
const reply: Action =
hostFaction === 'moritani'
? { type: 'decision', accept: true }
: { type: 'decision', event: g.pendingAmbassador!.event, accept: true };
const refused = applyAction(nexusReload(g), 'a', {
...reply,
accept: false,
});
assert.equal(refused.nexusCards!.cards!.hands.f, 'richese');
const accepted = applyAction(nexusReload(g), 'a', reply);
assert.equal(accepted.nexusCards!.cards!.hands.f, null);
assert.deepEqual(accepted.nexusCards!.cards!.discard, ['richese']);
assert.equal(nexusPlayer(accepted, 'f').ally, 'a');
assert.equal(nexusPlayer(accepted, 'a').shipped, true);
assert.equal(accepted.pendingTerrorEntry ?? null, null);
assert.equal(accepted.pendingAmbassador ?? null, null);
nexusInventory(accepted);
});
+238
View File
@@ -0,0 +1,238 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createNexusCardPhase,
markNexusCardOccurred,
closeNexusCardPhase,
finishNexusCardChoice,
nexusCardChoices,
validateNexusCardPhase,
type NexusCardPhase,
type NexusCardChoice,
} from '../game/nexus-card-phase';
import {
createNexusCards,
drawNexusCard,
replaceNexusCard,
projectNexusCards,
validateNexusPlayers,
type NexusPlayer,
type NexusState,
} from '../game/nexus-cards';
const players: NexusPlayer[] = [
{ id: 'a', faction: 'atreides', ally: null },
{ id: 'g', faction: 'guild', ally: 'h' },
{ id: 'h', faction: 'harkonnen', ally: 'g' },
{ id: 'e', faction: 'emperor', ally: null },
];
const cards = () => createNexusCards(players, () => 0.999);
const open = () =>
closeNexusCardPhase(markNexusCardOccurred(createNexusCardPhase(3)), players);
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
void test('only an actual Nexus and a settled alliance open the closing opportunity', () => {
const initial = createNexusCardPhase(3);
assert.deepEqual(nexusCardChoices(initial, cards(), 'a'), []);
assert.equal(closeNexusCardPhase(initial, players).stage, 'complete');
const unallied = players.map((p) => ({ ...p, ally: null }));
assert.equal(
closeNexusCardPhase(markNexusCardOccurred(initial), unallied).stage,
'complete',
);
const marked = markNexusCardOccurred(initial);
assert.equal(initial.occurred, false);
assert.deepEqual(markNexusCardOccurred(marked), marked);
const phase = closeNexusCardPhase(marked, players);
assert.deepEqual(phase.eligible, ['a', 'e']);
assert.equal(phase.stage, 'drawing');
assert.deepEqual(nexusCardChoices(phase, cards(), 'g'), []);
assert.throws(() => closeNexusCardPhase(phase, players));
assert.throws(() => markNexusCardOccurred(phase));
});
void test('each eligible owner may finish once in either order and the whole phase closes once', () => {
const initial = open();
const state = cards();
const first = finishNexusCardChoice(initial, players, state, 'e', 'keep');
assert.deepEqual(first.done, ['e']);
assert.equal(first.stage, 'drawing');
assert.deepEqual(nexusCardChoices(first, state, 'e'), []);
const drawn = drawNexusCard(state, 'a', players, () => 0);
const complete = finishNexusCardChoice(
reload(first),
players,
reload(drawn),
'a',
'draw',
);
assert.equal(complete.stage, 'complete');
assert.deepEqual(initial.done, []);
validateNexusCardPhase(reload(complete), players, drawn);
assert.throws(() =>
finishNexusCardChoice(complete, players, drawn, 'a', 'keep'),
);
assert.throws(() => closeNexusCardPhase(complete, players));
assert.throws(() => markNexusCardOccurred(complete));
});
void test('keeping empty or held hands is legal; replacement requires the physical transaction first', () => {
const initial = open();
const state = cards();
assert.deepEqual(nexusCardChoices(initial, state, 'a'), ['keep', 'draw']);
assert.throws(() =>
finishNexusCardChoice(initial, players, state, 'a', 'draw'),
);
const held = drawNexusCard(state, 'a', players, () => 0);
assert.deepEqual(nexusCardChoices(initial, held, 'a'), ['keep', 'replace']);
const replaced = replaceNexusCard(held, 'a', players, () => 0);
const done = finishNexusCardChoice(
initial,
players,
replaced,
'a',
'replace',
);
assert.deepEqual(done.done, ['a']);
assert.equal(held.hands.a, 'atreides');
assert.equal(replaced.hands.a, 'harkonnen');
assert.deepEqual(replaced.discard, ['atreides']);
assert.deepEqual(
finishNexusCardChoice(initial, players, held, 'a', 'keep').done,
['a'],
);
});
void test('own-faction and other draws have identical public progress and observer projections', () => {
const phase = open();
const ownDeck = cards();
const otherDeck = reload(ownDeck);
[otherDeck.deck[0], otherDeck.deck[1]] = [
otherDeck.deck[1],
otherDeck.deck[0],
];
const own = drawNexusCard(ownDeck, 'a', players, () => 0);
const other = drawNexusCard(otherDeck, 'a', players, () => 0);
assert.notEqual(own.hands.a, other.hands.a);
const ownDone = finishNexusCardChoice(phase, players, own, 'a', 'draw');
const otherDone = finishNexusCardChoice(phase, players, other, 'a', 'draw');
assert.deepEqual(ownDone, otherDone);
assert.deepEqual(
projectNexusCards(own, 'e', players),
projectNexusCards(other, 'e', players),
);
assert.deepEqual(nexusCardChoices(ownDone, own, 'a'), []);
});
void test('a recycled own card may repeat without creating a second phase opportunity', () => {
const phase = open();
const held = drawNexusCard(cards(), 'a', players, () => 0);
const exhausted: NexusState = { ...held, deck: [], discard: [...held.deck] };
let calls = 0;
const same = replaceNexusCard(exhausted, 'a', players, () =>
calls++ === 0 ? 0 : 0.999,
);
assert.equal(same.hands.a, 'atreides');
const different = replaceNexusCard(same, 'a', players, () => {
throw new Error('No shuffle needed');
});
assert.notEqual(different.hands.a, 'atreides');
const done = finishNexusCardChoice(phase, players, different, 'a', 'replace');
assert.deepEqual(done.done, ['a']);
assert.throws(() =>
finishNexusCardChoice(done, players, different, 'a', 'replace'),
);
});
void test('saved progress detects rewinds, altered recipients and unknown fields without mutation', () => {
const state = cards();
let complete = finishNexusCardChoice(open(), players, state, 'a', 'keep');
complete = finishNexusCardChoice(complete, players, state, 'e', 'keep');
const mutations: Array<(phase: NexusCardPhase) => void> = [
(p) => {
p.stage = 'drawing';
p.done = [];
},
(p) => {
p.turn++;
},
(p) => {
p.occurred = false;
},
(p) => {
p.eligible.reverse();
},
(p) => {
p.done.reverse();
},
(p) => {
p.eligible.push('outsider');
},
(p) => {
p.signature = '';
},
(p) => {
Object.assign(p, { redraw: 'a' });
},
];
for (const mutate of mutations) {
const corrupt = reload(complete);
mutate(corrupt);
const before = JSON.stringify(corrupt);
assert.throws(() => validateNexusCardPhase(corrupt, players, state));
assert.throws(() => nexusCardChoices(corrupt, state, 'a'));
assert.throws(() =>
finishNexusCardChoice(corrupt, players, state, 'a', 'keep'),
);
assert.equal(JSON.stringify(corrupt), before);
}
});
void test('unknown choices, premature finishes and foreign owners reject unchanged', () => {
const phase = open();
const state = cards();
const before = JSON.stringify({ phase, state });
for (const choice of ['redraw', 'skip', '', null, 1])
assert.throws(() =>
finishNexusCardChoice(
phase,
players,
state,
'a',
choice as NexusCardChoice,
),
);
for (const owner of ['g', 'outsider'])
assert.throws(() =>
finishNexusCardChoice(phase, players, state, owner, 'keep'),
);
assert.throws(() =>
finishNexusCardChoice(createNexusCardPhase(3), players, state, 'a', 'keep'),
);
assert.equal(JSON.stringify({ phase, state }), before);
});
void test('one-way, missing and overlapping alliance partners cannot qualify a Nexus window', () => {
const state = cards();
for (const seats of [
players.map((p) => (p.id === 'h' ? { ...p, ally: null } : p)),
players.map((p) => (p.id === 'g' ? { ...p, ally: 'outsider' } : p)),
players.map((p) => (p.id === 'a' ? { ...p, ally: 'g' } : p)),
]) {
const before = JSON.stringify(seats);
assert.throws(() => validateNexusPlayers(seats));
assert.throws(() =>
closeNexusCardPhase(
markNexusCardOccurred(createNexusCardPhase(3)),
seats,
),
);
assert.throws(() => validateNexusCardPhase(open(), seats, state));
assert.equal(JSON.stringify(seats), before);
}
const changed = players.map((p) => ({ ...p, ally: null }));
assert.throws(() => validateNexusCardPhase(open(), changed, state));
let complete = finishNexusCardChoice(open(), players, state, 'a', 'keep');
complete = finishNexusCardChoice(complete, players, state, 'e', 'keep');
validateNexusCardPhase(complete, changed, state);
});
+203
View File
@@ -0,0 +1,203 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, webcrypto } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import type * as Rooms from '../db/rooms';
import { nexusTurnTwo, nexusReady, orderNexusSpice, finishNexusSpice, nexusInventory } from './fixture-nexus-cards';
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
function unitStore(runBots = bots.runBots) {
const sqlite = new DatabaseSync(':memory:');
const hooks: { beforeWrite?: () => Promise<void> } = {};
const writes: { expected: number; changes: number }[] = [];
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((f) => f.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async run() {
const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
);
if (continuation)
writes.push({ expected: Number(this.values[3]), changes });
return { meta: { changes } };
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
sqlite.exec('BEGIN');
try {
const results = [];
for (const statement of statements) results.push(await statement.run());
sqlite.exec('COMMIT');
return results;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
function loadRooms() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
// Keep persisted JSON values in the test realm for strict structural assertions.
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine;
if (name === '@/game/bots') return { ...bots, runBots };
throw new Error('Unexpected module ' + name);
},
},
);
return exports as typeof Rooms;
}
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
}
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
async function persisted() {
const store = unitStore();
const made = await store.rooms.createRoom('Nexus SQL QA', 'fremen', false, []);
const code = made.view.code;
const credentials = [made.token];
for (const faction of ['atreides', 'harkonnen'] as const)
credentials.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(credentials.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string,string,string];
let initial = nexusTurnTwo({seatIds:ids});
orderNexusSpice(initial, ['worm','land']);
initial = nexusReady(nexusReady(initial));
initial = engine.applyAction(initial, ids[0], {type:'alliance',target:ids[1]});
initial = engine.applyAction(initial, ids[1], {type:'alliance',target:ids[0]});
initial = finishNexusSpice(initial);
initial.code = code;
initial.version = (await store.rooms.readRoom(code)).version;
const cards = initial.nexusCards!.cards!;
cards.deck = ['harkonnen','ecaz',...cards.deck.filter(card => !['harkonnen','ecaz'].includes(card))];
const save = (state: engine.Game) => store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(state),state.version,code);
save(initial);
const other = await store.rooms.createRoom('Untouched Nexus room','fremen',false,[]);
const otherBefore = await store.rooms.readRoom(other.view.code);
store.writes.length = 0;
return {...store,code,credentials,auths,ids,initial,save,otherCode:other.view.code,otherBefore};
}
type Fixture = Awaited<ReturnType<typeof persisted>>;
function snapshot(f: Fixture) {
const row = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(f.code)!;
return {version:row.version,hash:createHash('sha256').update(String(row.state)).digest('hex')};
}
function draw(f:Fixture, ownRedraws=0):engine.Action {
return {type:'nexusCardChoice',turn:f.initial.turn,card:null,choice:'draw',ownRedraws};
}
async function restored(f:Fixture,g:engine.Game) {
const before=snapshot(f), rooms=f.restart();
for (const [i,token] of f.credentials.entries()) {
const auth=await rooms.authenticate(f.code,token);
const view=await rooms.readSeatView(f.code,auth);
assert.deepEqual(view,engine.viewGame(g,f.ids[i]));
const offer=view.nexusCards!;
assert.equal(offer.card,g.nexusCards!.cards!.hands[f.ids[i]]);
assert.equal(Object.hasOwn(offer,'deck'),false);
assert.equal(Object.hasOwn(offer,'discard'),false);
assert.equal(Object.hasOwn(offer,'hands'),false);
assert.equal(JSON.stringify(offer).includes('signature'),false);
if(i!==2) assert.equal(offer.card,null);
}
assert.deepEqual(snapshot(f),before);
assert.deepEqual(await rooms.readRoom(f.otherCode),f.otherBefore);
nexusInventory(g);
}
void test('Nexus SQL concurrent keep-own and redraw-own policies commit one physical result and one phase advance',async()=>{
const f=await persisted();
try {
let arrived=0; let release!:()=>void;
const waiting=new Promise<void>(resolve=>{release=resolve;});
f.hooks.beforeWrite=async()=>{if(++arrived===2)release();await waiting;};
const outcomes=await Promise.allSettled([
f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f,0),clock),
f.restart().act(f.code,f.auths[2],f.initial.version,draw(f,2),clock),
]);
delete f.hooks.beforeWrite;
assert.equal(outcomes.filter(result=>result.status==='fulfilled').length,1);
assert.deepEqual(f.writes.map(write=>write.changes).sort((a,b)=>a-b),[0,1]);
const g=await f.rooms.readRoom(f.code);
assert.equal(g.version,f.initial.version+1);
assert.equal(g.phase,2);
assert.ok(['harkonnen','ecaz'].includes(g.nexusCards!.cards!.hands[f.ids[2]]!));
assert.equal(g.nexusCards!.phase!.stage,'complete');
const before=snapshot(f);
await assert.rejects(f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f),clock));
await assert.rejects(f.rooms.act(f.code,f.auths[2],g.version,draw(f),clock),/Nexus/);
assert.deepEqual(snapshot(f),before);
await restored(f,g);
} finally {f.sqlite.close();}
});
void test('Nexus SQL lost draw response restores the exact private card without reroll or additional writes',async()=>{
const f=await persisted();
try {
await f.rooms.act(f.code,f.auths[2],f.initial.version,draw(f,2),clock);
const g=await f.restart().readRoom(f.code);
assert.equal(g.nexusCards!.cards!.hands[f.ids[2]],'ecaz');
assert.deepEqual(g.nexusCards!.cards!.discard,['harkonnen']);
const before=snapshot(f);f.writes.length=0;
await restored(f,g);
await f.restart().continueRoomAutomatic(f.code,clock);
assert.deepEqual(snapshot(f),before);assert.equal(f.writes.length,0);
for(const entry of g.log.filter(entry=>entry.text.includes('Nexus Card')))
assert.doesNotMatch(entry.text,/Ecaz|Harkonnen.*card identity/);
} finally {f.sqlite.close();}
});
void test('Nexus SQL invalid inventory and edited closing progress reject before storage or private projection',async()=>{
const f=await persisted();
try {
for(const corrupt of [
(g:engine.Game)=>{g.nexusCards!.cards!.deck.pop();},
(g:engine.Game)=>{g.nexusCards!.phase!.done.push(f.ids[2]);},
(g:engine.Game)=>{g.nexusCards!.phase!.occurred=false;},
(g:engine.Game)=>{g.nexusCards!.phase=null;},
]) {
const g=structuredClone(f.initial);corrupt(g);f.save(g);
const before=snapshot(f);f.writes.length=0;
await assert.rejects(f.rooms.readSeatView(f.code,f.auths[2]),/Nexus/);
await assert.rejects(f.rooms.act(f.code,f.auths[2],g.version,draw(f),clock),/Nexus/);
assert.deepEqual(snapshot(f),before);assert.equal(f.writes.length,0);
}
} finally {f.sqlite.close();}
});
+305
View File
@@ -0,0 +1,305 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
NEXUS_FACTIONS,
createNexusCards,
validateNexusCards,
drawNexusCard,
replaceNexusCard,
discardNexusCard,
nexusCardMode,
projectNexusCards,
type NexusState,
type NexusPlayer,
} from '../game/nexus-cards';
import type { FactionId } from '../game/catalog';
const players: NexusPlayer[] = [
{ id: 'a', faction: 'atreides', ally: null },
{ id: 'g', faction: 'guild', ally: null },
];
const ordered = () => createNexusCards(players, () => 0.999);
function census(state: NexusState) {
assert.deepEqual(
[
...state.deck,
...state.discard,
...Object.values(state.hands).filter(
(card): card is FactionId => card !== null,
),
].sort(),
[...NEXUS_FACTIONS].sort(),
);
validateNexusCards(JSON.parse(JSON.stringify(state)) as NexusState, players);
}
void test('every setup contains all twelve physical faction cards independent of the two through six seated factions', () => {
assert.equal(NEXUS_FACTIONS.length, 12);
for (const count of [2, 3, 6]) {
const seats = NEXUS_FACTIONS.slice(0, count).map((faction, i) => ({
id: `seat-${i}`,
faction,
}));
let calls = 0;
const state = createNexusCards(seats, () => {
calls++;
return 0.999;
});
assert.equal(calls, 11);
assert.deepEqual(state.deck, NEXUS_FACTIONS);
assert.deepEqual(state.discard, []);
assert.deepEqual(
Object.values(state.hands),
Array.from({ length: count }, () => null),
);
validateNexusCards(state, seats);
}
assert.notDeepEqual(createNexusCards(players, () => 0).deck, ordered().deck);
assert.deepEqual(
createNexusCards(players, () => 0),
createNexusCards(players, () => 0),
);
});
void test('one secret card is drawn from the top without extra randomness; own-faction draw remains optional to replace', () => {
const initial = ordered();
const before = JSON.stringify(initial);
const state = drawNexusCard(initial, 'a', players, () => {
throw new Error('A nonempty deck is not shuffled');
});
assert.equal(state.hands.a, 'atreides');
assert.equal(
nexusCardMode(state.hands.a!, 'atreides', ['atreides', 'guild']),
'cunning',
);
assert.equal(state.deck.length, 11);
assert.equal(state.discard.length, 0);
assert.equal(JSON.stringify(initial), before);
assert.throws(() => drawNexusCard(state, 'a', players, () => 0));
assert.throws(() => replaceNexusCard(initial, 'a', players, () => 0));
census(state);
});
void test('replacement discards before drawing and can draw the same physical card from a recycled discard pile', () => {
const initial = drawNexusCard(ordered(), 'a', players, () => 0);
const replaced = replaceNexusCard(initial, 'a', players, () => {
throw new Error('Still a nonempty deck');
});
assert.equal(replaced.hands.a, 'harkonnen');
assert.deepEqual(replaced.discard, ['atreides']);
census(replaced);
const exhausted = { ...initial, deck: [], discard: [...initial.deck] };
census(exhausted);
let calls = 0;
// The just-discarded own card is the final element; the first Fisher-Yates
// swap moves it to the top and remaining no-op swaps leave it there.
const redrawn = replaceNexusCard(exhausted, 'a', players, () =>
calls++ === 0 ? 0 : 0.999,
);
assert.equal(calls, 11);
assert.equal(redrawn.hands.a, 'atreides');
assert.equal(redrawn.discard.length, 0);
assert.equal(redrawn.deck.length, 11);
assert.deepEqual(exhausted.hands, initial.hands);
census(redrawn);
});
void test('using or forfeiting a held card routes it to discard once; allied players cannot draw or replace', () => {
const held = drawNexusCard(ordered(), 'a', players, () => 0);
const allied: NexusPlayer[] = [
{ ...players[0], ally: 'g' },
{ ...players[1], ally: 'a' },
];
let calls = 0;
assert.throws(() =>
drawNexusCard(ordered(), 'g', allied, () => {
calls++;
return 0;
}),
);
assert.throws(() =>
replaceNexusCard(held, 'a', allied, () => {
calls++;
return 0;
}),
);
assert.equal(calls, 0);
const forfeited = discardNexusCard(held, 'a', allied);
assert.equal(forfeited.hands.a, null);
assert.deepEqual(forfeited.discard, ['atreides']);
assert.throws(() => discardNexusCard(forfeited, 'a', allied));
assert.deepEqual(discardNexusCard(held, 'a', players), forfeited);
census(forfeited);
});
void test('an empty deck recycles only discards, keeping another held identity outside the shuffle', () => {
const held = drawNexusCard(ordered(), 'g', players, () => 0);
const state = { ...held, deck: [], discard: [...held.deck] };
let calls = 0;
const drawn = drawNexusCard(state, 'a', players, () => {
calls++;
return 0.999;
});
assert.equal(calls, 10);
assert.equal(drawn.hands.g, 'atreides');
assert.equal(drawn.hands.a, 'harkonnen');
assert.equal(drawn.discard.length, 0);
assert.equal(drawn.deck.length, 10);
census(drawn);
});
void test('all physical cards use Cunning for their faction, Betrayal for another seated faction and Secret Ally otherwise', () => {
for (const card of NEXUS_FACTIONS) {
const other = NEXUS_FACTIONS.find((faction) => faction !== card)!;
assert.equal(nexusCardMode(card, card, [card, other]), 'cunning');
assert.equal(nexusCardMode(card, other, [card, other]), 'betrayal');
assert.equal(nexusCardMode(card, other, [other]), 'secretAlly');
}
for (const [card, own, seated] of [
['unknown', 'atreides', ['atreides']],
['guild', 'atreides', ['guild']],
['guild', 'atreides', ['atreides', 'atreides']],
['guild', 'unknown', ['unknown']],
] as const)
assert.throws(() =>
nexusCardMode(
card as FactionId,
own as FactionId,
seated as readonly FactionId[],
),
);
});
void test('malformed physical census, hand capacity, inherited dictionaries and changed seat keys reject unchanged', () => {
const mutations: ((s: NexusState) => void)[] = [
(s) => {
s.deck.pop();
},
(s) => {
s.discard.push(s.deck[0]);
},
(s) => {
s.hands.a = s.deck[0];
},
(s) => {
s.deck[0] = 'unknown' as FactionId;
},
(s) => {
delete s.hands.g;
},
(s) => {
s.hands.absent = null;
},
(s) => {
s.version = 2 as 1;
},
(s) => {
s.hands.a = [] as unknown as FactionId;
},
(s) => {
s.hands = Object.create({ a: null, g: null }) as NexusState['hands'];
},
(s) => {
Object.assign(s, { privateCard: 'guild' });
},
];
for (const mutate of mutations) {
const state = ordered();
mutate(state);
const before = JSON.stringify(state);
let calls = 0;
assert.throws(() => validateNexusCards(state, players));
assert.throws(() =>
drawNexusCard(state, 'a', players, () => {
calls++;
return 0;
}),
);
assert.equal(calls, 0);
assert.equal(JSON.stringify(state), before);
}
});
void test('unsafe or duplicate player identities reject before randomness and cannot pollute hand dictionaries', () => {
for (const seats of [
[],
[players[0]],
[...players, players[0]],
[players[0], { ...players[1], faction: 'atreides' as const }],
[players[0], { ...players[1], ally: 'missing' }],
...['__proto__', 'constructor', 'prototype', ''].map((id) => [
{ ...players[0], id },
players[1],
]),
]) {
let calls = 0;
assert.throws(() =>
createNexusCards(seats, () => {
calls++;
return 0;
}),
);
assert.equal(calls, 0);
}
const state = ordered();
assert.throws(() => drawNexusCard(state, '__proto__', players, () => 0));
assert.equal(Object.getPrototypeOf(state.hands), Object.prototype);
assert.equal(Object.hasOwn(Object.prototype, 'atreides'), false);
});
void test('invalid random draws fail on initialization or recycling without altering a committed card or consuming custody', () => {
const held = drawNexusCard(ordered(), 'a', players, () => 0);
const exhausted = { ...held, deck: [], discard: [...held.deck] };
const before = JSON.stringify(exhausted);
for (const value of [-0.1, 1, NaN, Infinity, -Infinity]) {
assert.throws(() => createNexusCards(players, () => value), /random draws/);
assert.throws(
() => replaceNexusCard(exhausted, 'a', players, () => value),
/random draws/,
);
assert.equal(JSON.stringify(exhausted), before);
}
});
void test('private projection reveals only the caller’s card and public counts, and frozen inputs preserve independent JSON custody', () => {
let state = drawNexusCard(ordered(), 'a', players, () => 0);
state = drawNexusCard(state, 'g', players, () => 0);
const privateView = projectNexusCards(state, 'a', players);
assert.deepEqual(privateView, {
card: 'atreides',
deckCount: 10,
discardCount: 0,
held: { a: true, g: true },
});
const alternate = structuredClone(state);
[alternate.hands.g, alternate.deck[0]] = [
alternate.deck[0],
alternate.hands.g!,
];
alternate.deck.reverse();
assert.deepEqual(projectNexusCards(alternate, 'a', players), privateView);
assert.notDeepEqual(
projectNexusCards(alternate, 'g', players),
projectNexusCards(state, 'g', players),
);
assert.throws(() => projectNexusCards(state, 'observer-not-seated', players));
for (const player of players)
for (const field of ['hand', 'spice', 'traitors', 'battlePlan'])
Object.defineProperty(player, field, {
configurable: true,
get() {
throw new Error(`Private ${field}`);
},
});
Object.freeze(state.deck);
Object.freeze(state.discard);
Object.freeze(state.hands);
Object.freeze(state);
const next = replaceNexusCard(state, 'a', players, () => 0);
census(next);
assert.notEqual(next.deck, state.deck);
assert.notEqual(next.discard, state.discard);
assert.notEqual(next.hands, state.hands);
privateView.held.g = false;
assert.equal(projectNexusCards(state, 'a', players).held.g, true);
});