diff --git a/README.md b/README.md
index 3dbecf3f..e6b59414 100644
--- a/README.md
+++ b/README.md
@@ -202,6 +202,19 @@ physical Nexus and prevents only that attempt; all-pass keeps its native
effect and continuation. See the
[bounded verified runtime](docs/NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026).
+For local **Harkonnen Nexus Betrayal**, use
+`node --import tsx tools/start-prototype.ts --profile harkonnen-betrayal`
+on a fresh ready Basic/Advanced classic lobby with native Harkonnen and Nexus
+alone. Exact-version/private-backup safeguards and natural alliance-qualified
+Nexus rights apply; public starts and existing games are unchanged. After any
+original allied Karama counter allows an actual matching native traitor call,
+the declared-card acknowledgement offers **Pass** or privately eligible
+**Use Harkonnen Nexus**. Use returns that exact physical traitor immediately
+and preserves one automatic private actual Mentat replacement, not a draw-now
+exchange. This is a bounded provisional local timing/privacy convention;
+bounded verification is Partial/Development Verified; the universal reaction-policy question stays open.
+See the [source contract and excluded modes](docs/NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026).
+
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
counts separately from hands, auction cards and discards. See
[draw-pile counts](docs/DRAW_PILES.md).
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 4d381882..6013946c 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -83,6 +83,7 @@ import { NexusRicheseBetrayal } from './nexus-richese-betrayal';
import { NexusGuildBetrayal } from './nexus-guild-betrayal';
import { NexusIxianReplacement } from './nexus-ixian-replacement';
import { NexusIxianBetrayal } from './nexus-ixian-betrayal';
+import { NexusHarkonnenBetrayal } from './nexus-harkonnen-betrayal';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
@@ -310,8 +311,11 @@ export function GameTable({
const ixianBetrayalOwnsControls = !!g.nexusIxianBetrayalReaction &&
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
!g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
- const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls;
- const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls;
+ const harkonnenBetrayalOwnsControls = !!g.nexusHarkonnenBetrayalReaction &&
+ !g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
+ !g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
+ const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
+ const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls;
const canActivateKarama = (card: Card) =>
!g.karamaBlocked && canUseAsKaramaRole(g, me, card);
const traitorBattle = g.battle;
@@ -790,6 +794,13 @@ export function GameTable({
const act = (a: Action) => {
// Control of one's own seat is independent of every gameplay response.
if (a.type === 'setAutopilot') { void send(a); return; }
+ const harkonnenReaction = g.nexusHarkonnenBetrayalReaction;
+ if (harkonnenReaction && harkonnenBetrayalOwnsControls) {
+ if (reactionBusy || g.status !== 'playing' || !harkonnenReaction.canPass || harkonnenReaction.hasPassed ||
+ (a.type !== 'nexusHarkonnenBetrayalPass' && a.type !== 'nexusHarkonnenBetrayalUse') ||
+ a.event !== harkonnenReaction.event ||
+ (a.type === 'nexusHarkonnenBetrayalUse' && (!harkonnenReaction.canUse || harkonnenReaction.blocked))) return;
+ }
const ixianReaction = g.nexusIxianBetrayalReaction;
if (ixianReaction && ixianBetrayalOwnsControls) {
if (reactionBusy || g.status !== 'playing' || !ixianReaction.canPass || ixianReaction.hasPassed ||
@@ -1042,6 +1053,8 @@ export function GameTable({
faction(g.players.find((p) => p.id === id)!.faction).name,
)
.join(' + ') || 'No winner'
+ : harkonnenBetrayalOwnsControls
+ ? 'Declared Harkonnen traitor acknowledgement'
: ixianBetrayalOwnsControls
? 'Native Ixian advantage acknowledgement'
: g.guildBetrayalReaction
@@ -1117,6 +1130,16 @@ export function GameTable({
Preview rules and limits
)}
+ {g.nexusHarkonnenBetrayalPreview && (
+
+ Harkonnen Nexus Betrayal development preview · cancel an actually declared native Harkonnen
+ traitor call, return that physical card to the Traitor Deck, and draw one private replacement
+ automatically at this turn’s Mentat Pause. Classic Basic/Advanced, native Harkonnen, base
+ Treachery and Nexus only. Native allied Karama counters finish first. Not complete Harkonnen
+ or certified Nexus rules.{' '}
+ Preview rules and limits
+
+ )}
{g.status !== 'lobby' && (
{PHASES.map((p, i) => (
@@ -1765,6 +1788,8 @@ export function GameTable({
) : ixianBetrayalOwnsControls ? (
+ ) : harkonnenBetrayalOwnsControls ? (
+
) : g.nexusCards?.waiting.length ? (
The next phase begins when the remaining Nexus card choices are finished.
) : g.truthtrance ? (
diff --git a/components/nexus-harkonnen-betrayal.tsx b/components/nexus-harkonnen-betrayal.tsx
new file mode 100644
index 00000000..6261e96c
--- /dev/null
+++ b/components/nexus-harkonnen-betrayal.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { nexusCardMode } from '@/game/nexus-cards';
+import { faction } from '@/game/catalog';
+import { leaderStrengthLabel } from '@/game/cards';
+import { LeaderInspector } from './leader-inspector';
+import { NexusCardFace } from './nexus-cards';
+import { Button } from './ui/button';
+
+export function NexusHarkonnenBetrayal({ game, act, busy }: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const reaction = game.nexusHarkonnenBetrayalReaction;
+ if (!reaction) return null;
+ const own = game.players.find((player) => player.id === game.me);
+ const name = (id: string) => game.players.find((player) => player.id === id)?.name ?? id;
+ const declaredTraitor = game.allLeaders.find((leader) => leader.id === reaction.identity);
+ const traitor = declaredTraitor?.name ?? reaction.identity;
+ const declaredLeader = game.allLeaders.find((leader) => leader.id === reaction.leader)?.name ?? reaction.leader;
+ const canRespond = !!reaction.event && reaction.canPass && !reaction.hasPassed;
+ const canUse = canRespond && reaction.canUse && !reaction.blocked;
+ const priorityBlocked = game.automaticContinuationPending || !!game.truthtrance ||
+ !!game.response || !!game.decision || !!game.phaseOpening ||
+ !!game.nexusCards?.waiting.length || !!game.nexusTraitors?.pending;
+ const disabled = busy || game.status !== 'playing' || game.phase !== 6 || priorityBlocked ||
+ !!game.roomControl?.paused || !!game.roomControl?.closed || !!own?.autopilot;
+ const card = game.nexusCards?.card;
+ const ownMode = card && own
+ ? nexusCardMode(card, own.faction, game.players.map((player) => player.faction))
+ : undefined;
+
+ return (
+
+ Declared Harkonnen traitor acknowledgement
+ {name(reaction.provider)} has declared the {traitor} Traitor Card for {name(reaction.beneficiary)}
+ {' '}against {name(reaction.target)}’s {declaredLeader}. The traitor battle outcome has not happened.
+ {declaredTraitor && (
+
+ )}
+ Every publicly possible unallied responder acknowledges this declaration, regardless of their secret Nexus identity.
+ After all passes, the original traitor call continues once. Any native Karama counter finishes first;
+ this acknowledgement is not another Karama counter window.
+ {card && }
+ {canUse && Spend your own Harkonnen Nexus to cancel this declared traitor call.
+ The declared Traitor Card is shuffled into the Traitor Deck immediately; Harkonnen draws one private
+ replacement automatically during this turn’s Mentat Pause. No replacement selection or confirmation is needed.
+ {' '}Betrayal timing and preview limits
+
}
+ {!canRespond ? (
+ {reaction.hasPassed ? 'You have passed. Waiting for the remaining acknowledgements.' : 'Waiting for the required acknowledgements.'}
+ ) : <>
+ {reaction.blocked && {reaction.blocked} }
+ { if (!disabled) act({ type: 'nexusHarkonnenBetrayalPass', event: reaction.event }); }}>
+ Pass · allow the declared traitor call
+
+ {canUse && { if (!disabled) act({ type: 'nexusHarkonnenBetrayalUse', event: reaction.event }); }}>
+ Use Harkonnen Nexus · cancel this traitor call
+ }
+ >}
+
+ );
+}
diff --git a/db/admin-directory.ts b/db/admin-directory.ts
index f80b884e..7bdad9ec 100644
--- a/db/admin-directory.ts
+++ b/db/admin-directory.ts
@@ -36,6 +36,13 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
else if (g.response || g.phaseOpening) pending = { label: 'Shared response window', owners: [] };
else if (g.truthtrance) pending = { label: 'Truthtrance window', owners: [] };
else if (g.decision) pending = { label: 'Special decision', owners: seated(g.decision.player) ? [g.decision.player] : [] };
+ else if (g.pendingNexusHarkonnenBetrayal) {
+ const { required, passed } = g.pendingNexusHarkonnenBetrayal;
+ const unfinished = Array.isArray(required) && Array.isArray(passed)
+ ? [...new Set(required.filter(seated).filter(id => !passed.includes(id)))]
+ : [];
+ pending = { label: 'Nexus acknowledgement', owners: unfinished.length === 1 ? unfinished : [] };
+ }
else if (g.battle) pending = { label: 'Battle decisions', owners: [g.battle.attacker, g.battle.defender].filter(seated) };
else if (g.nexus) pending = { label: 'Nexus negotiations', owners: [] };
else if (g.phase === 0 && g.stormDialers?.length) pending = { label: 'Storm dials', owners: g.stormDialers.filter(id => g.stormDials?.[id] === undefined).filter(seated) };
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 7a00a998..6513c733 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -1,6 +1,6 @@
# Current development status
-Updated 1 October 2026. **The full twelve-faction goal is unfinished. Basic
+Updated 2 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable and the six classic factions have a user-authorized Advanced preview.
Expansion starts and full rules/publication acceptance remain gated.**
@@ -16,6 +16,33 @@ open mode gates.
## Current checkpoint and work
+Harkonnen Nexus Betrayal now connects actual personal and allied native traitor
+calls, exact immediate physical retirement, the surviving original battle once
+and one private actual Mentat replacement. Fresh local `harkonnen-betrayal`
+retains classic/base33/Nexus-only bounds, natural card rights and original
+allied counter preemption. Uniform acknowledgements are provisional; the
+previously asked universal reaction-policy question remains open.
+Independent review repaired an orphanable native allowance, added the public
+declared-Traitor inspector and corrected wrong-face bot fixture assumptions.
+Genuine sand battles/native declines replace an invalid Polar Sink fixture;
+real counter-free normalization is retained, not forced into a fictitious pause.
+Both final scoped reviews have no finding. Types/lint, **6,401 offline tests**,
+the **138-case affected union**, final guide **10/10** and build pass.
+Ten actual native cases and printed/BG preemption prove original stock and
+current Mentat continuation. Four privately backed-up same-setup CLI/390px
+rooms exercise human Use/Pass, actual public/own inspectors, partial refresh,
+administrator owner and original native HTTP battle/Collection/Mentat once.
+Saved proof retains all33 Treachery/all12 Nexus/Traitors, identity/circles,
+one actual private reserve-top draw and nineteen earlier QA games.
+The bounded path is **Partial development verified**, not full modes or
+deployment. Final local HTTP integration is **49/55**, not green: six requests
+returned the known Wrangler proxy 503. No automatic POST retry or weaker
+authorization is introduced. After the final isolated worker reload, all
+**70 original seats in twenty-three QA rooms** reauthenticate; saved proof
+preserves all four outcomes and nineteen earlier rooms. The actual expanded
+390px guide shows **Partial / Development: Verified** without page overflow.
+Shipping and exact-revision container evidence remain pending.
+
The authorized Advanced **leaderless Battle Plan announcement** is now
connected. Physical PDF page 11 requires disclosure before reveal when no
leader or Cheap Hero can be used; existing leader/card legality was already
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index c42f75dc..e61545f5 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,93 @@
# Dune implementation status
+## 2 October 2026 — original Harkonnen Nexus traitor retirement and Mentat replacement
+
+Four exclusive implementation owners connect fresh local `harkonnen-betrayal`:
+native personal/allied calls and original continuation, pure physical/current/
+historical obligations, private controls/four minimal legal policies, and
+authenticated SQLite/private-backed-up CLI entry. Parent integrates source
+guidance and raw administrator progress, all verification and shipping. Four is
+this wave's useful slice count, not a restoration of the removed five-agent minimum.
+
+The photographed printed face cancels one revealed Harkonnen traitor, returns
+that exact physical identity immediately and draws one replacement during
+Mentat Pause. The profile admits native Harkonnen plus unique classic 2..6-seat
+Basic/Advanced, base33 Treachery and Nexus alone. No initial Nexus deal,
+old-game retrofit, public start or optional/expansion overlay. Genuine
+alliance-qualified closing Nexus rights remain; effect fixtures need at least
+three seats, with four for two simultaneous unallied receivers.
+
+Original allied Harkonnen Karama/BG counters resolve first; native denial
+opens no Nexus gate or cost. A personal call gains no invented counter.
+After genuine allowance, publicly declare the actual canonical matching
+traitor and roles before outcome; every publicly possible unallied non-Harkonnen
+held-Nexus owner acknowledges uniformly. Private face controls only own Use.
+The local ordering/privacy convention is provisional, not publisher/user
+priority or an answer to the previously asked universal reaction question.
+Outside-profile Harkonnen Betrayal and arbitrary combined stacks remain gated.
+
+Use spends one physical Nexus, shuffles only that called Harkonnen traitor
+into the actual reserve, prevents only the original call and resumes the
+surviving battle once. All-pass retains both cards and original call. One
+successful-Use obligation survives to real current Mentat opening, where the
+actual reserve top is drawn privately once before optional work/victory.
+Final Collection readiness and Mentat opening are atomic; no fictitious
+persisted due-phase8 auto-recovery state is used. Nonrecursive history binds
+each Use even after later passing/recycling, without permanent retired-card
+custody or a second action/confirmation. Returned-card redraw remains legal.
+
+Initial integrated checks exposed missing typings, an invalid Polar Sink
+battle fixture, unwanted optional prescience commitments, incorrect
+two-receiver wrong-face assumptions and a test that mutated empty counter
+passes to the same empty array. Parent retains native source guards: choose
+conserved legal sand staging/native declines, explicit distinct singleton
+receiver faces and genuine live original counter evidence. Native counter-free
+automatic allowance remains legal rather than forcing a false pause.
+Independent review repaired an orphanable native allowance: it now requires
+its exact pending or closed acknowledgement. The public declared-Traitor
+inspector reuses the existing entitled component; original/current replacement
+identities remain correctly private. Both final scoped native and privacy/
+administrator reviews have no finding.
+
+Parent typecheck/lint and **6,401/6,401** offline tests pass; the affected
+eight-file union passed **138/138**. Final reference **10/10** and build pass.
+Actual program smoke exercises eight Basic/Advanced personal/allied Use/all-pass
+paths plus two irrelevant-face partial-pass/refresh paths, from original true
+call/counters through actual native battle/Collection/Mentat. Separate printed
+and Advanced BG Worthless preemption spend native fuel once and preserve the
+original Trait/Nexus stock, with no Nexus offer, cost or replacement.
+
+Four exact-version private-backed-up CLI rooms retain their actual admitted
+undealt setup, identities/circles/decks and native Nexus rights; no redeal:
+
+- `SHMPQ4FK` v14: Basic personal human Use; actual Lady Jessica Traitor
+ and own Harkonnen Nexus inspectors, immediate exact retirement and one
+ private actual reserve-top Mentat draw.
+- `ZAYDHGTU` v15: Advanced allied human Use; caller and Guild beneficiary
+ remain distinct, Thufir Hawat declaration prevented and replacement once.
+- `3ZLRKAYB` v19: Basic allied two-receiver wrong-face human Pass/all-pass;
+ Richese/Guild cards retained. Partial v11 refresh and raw administrator
+ projection agree on sole remaining Guild, without private source disclosure.
+- `GBFG2C75` v16: Advanced personal wrong-face human Pass; original
+ Soo-Soo Sook traitor outcome and native capture/Collection suffix retained.
+
+Original remaining native votes/cleanup/readiness continue through the real
+HTTP API to Mentat, not a scripted phase jump. Read-only saved proof retains
+all33 Treachery/all12 Nexus/Traitor identities, typed forces, original names/
+circles, exact returned identity and one private pre-Mentat reserve-top draw.
+Activation has no spice/force/Treachery cost; legitimate battle/income effects
+are preserved, not hidden in an incorrect unchanged-wallet assertion.
+Nineteen earlier QA states/versions remain unchanged. Actual 390px public
+declaration/own-card inspectors and Use/Pass controls are observed; panel width
+is 354px. After final isolated worker reload, all **70 original seats in
+twenty-three QA rooms** reauthenticate and all saved outcomes remain unchanged.
+The actual expanded guide displays **Partial / Development: Verified**.
+
+Current local HTTP integration is **49/55**, not green: six requests returned
+the known Wrangler proxy 503. No automatic write retry, authorization weakening
+or reset. Complete Harkonnen/Nexus games, universal reaction policy, combined
+modules, strategic calibration, public starts and live deployment remain open.
+
## 1 October 2026 — Advanced leaderless declaration before reveal
The supplied authorized Advanced PDF, physical page 11 **Leaders**, requires
diff --git a/docs/NEXUS_CARD_RUNTIME.md b/docs/NEXUS_CARD_RUNTIME.md
index 0e2a630a..8e269b8d 100644
--- a/docs/NEXUS_CARD_RUNTIME.md
+++ b/docs/NEXUS_CARD_RUNTIME.md
@@ -1,5 +1,16 @@
# Nexus cards: common lifecycle checkpoint
+**Later Harkonnen Betrayal checkpoint:** the separate fresh local
+`harkonnen-betrayal` profile connects actual personal and allied native
+traitor calls. Native allied counters precede provisional neutral
+acknowledgements; Use returns only the declared physical traitor immediately,
+prevents that call and preserves one actual private Mentat replacement.
+The previously asked universal reaction-policy question remains open.
+Verification is **Partial development verified** for frozen native/private/
+SQLite/CLI/phone evidence, including immediate retirement and actual Mentat draw;
+see the [bounded existing source contract](NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026).
+No historical checkpoint verifies this path or opens public/combined modes.
+
**Later Ixian Betrayal prototype:** the fresh local `ixian-betrayal` profile
connects both printed alternatives to original native attempts: Bidding
extra-card inspection/draw and Advanced Technology exchange. Native counters
diff --git a/docs/NEXUS_HARKONNEN_RULES.md b/docs/NEXUS_HARKONNEN_RULES.md
index 777ede2d..e8a96d20 100644
--- a/docs/NEXUS_HARKONNEN_RULES.md
+++ b/docs/NEXUS_HARKONNEN_RULES.md
@@ -2,6 +2,81 @@
Source audit, 10 September 2026. This is a rules contract, not a runtime completion claim. The [common Nexus lifecycle](NEXUS_CARD_RULES.md) and existing expansion release gates still apply. The pending reaction-privacy question remains unresolved; this audit does not authorize a Betrayal reaction policy.
+**1 October bounded local follow-up, verified 2 October:** `harkonnen-betrayal`
+connects a separate fresh classic/base-deck/Nexus-only Basic/Advanced preview.
+Native allied Harkonnen counters precede a uniform acknowledgement by public
+possible unallied held-Nexus owners before the original battle outcome.
+This is an explicitly provisional local ordering/privacy convention, not an
+answer to the previously asked universal reaction policy or a publisher ruling.
+The old audit's pending questions remain open outside this profile. No public
+start, old-game retrofit or combined-module gate is opened. Verification is
+**Partial**, with development stage **Verified** for the bounded evidence below.
+
+## Bounded Betrayal preview contract — 1 October 2026
+
+The profile requires native Harkonnen and unique 2..6 classic seats,
+the physical base33 Treachery Deck and twelve Nexus cards alone. Ix/CHOAM/Ecaz
+decks and factions, Homeworlds, Leader Skills, Discoveries, Semuta and other
+overlays are excluded. Natural alliance-qualified closing Nexus rights still
+apply; no initial deal is added. An effect needs at least three actual seats;
+two simultaneous unallied receivers need four. Two-seat ordinary continuation
+is valid without a fabricated card or alliance.
+
+Admit only an actual legally matching native personal or allied traitor call
+with canonical original battle/event, revealed plans, provider, combatant
+beneficiary, target and physical identity. Original allied Karama/BG counter
+allowance comes first; native cancellation opens no Nexus gate or cost.
+Personal calls gain no new native counter. Invalid, false, protected,
+non-Harkonnen, finished or changed sources reject rather than offering a retry.
+
+After genuine native allowance, the actual matched traitor is publicly declared
+before its outcome. Every publicly possible unallied non-Harkonnen held-Nexus
+owner acknowledges uniformly, independently of private face; only own eligibility
+exposes Use or a blocked reason. Public roles and that actual identity grant no
+other Harkonnen traitor, rival Nexus, reserve order, source or receipt entitlement.
+Other unsubmitted traitor choices stay private. Own Nexus inspection uses only
+the existing actual-card entitlement.
+
+Pass retains both cards; all required passes preserve the original call once.
+Use spends one physical Harkonnen Nexus, removes only the declared Harkonnen
+Traitor Card, shuffles it into the actual reserve immediately, prevents that call
+and resumes the surviving original battle once. Preserve a consistent historical
+declaration without implying current Harkonnen ownership after return. No battle
+rewind, automatic refund, other traitor cancellation, Treachery slot, spice,
+force or leader cost is added.
+
+One exact successful-Use obligation survives to actual current Mentat opening.
+The native transition draws the actual reserve top privately once before optional
+Mentat work/victory acceptance in this bounded profile, records drawn provenance
+and retires the due marker. It can legitimately redraw the retired identity
+after its shuffle. There is no extra click or selector and no artificial saved
+half-phase draw state: final Collection readiness and Mentat opening are atomic.
+Refresh/recovery cannot reshuffle, redraw or repeat the battle/phase suffix.
+Nonrecursive history binds each Use obligation rather than whichever later
+all-pass receipt is last; closed history does not freeze retired-card ownership.
+
+Both actions submit the offered event only. Pending gates retain native
+response/decision/Truthtrance priority and independent own-seat autopilot, while
+blocking ordinary votes and competing custody mutation. All four difficulties
+use canonical private minimal Use/Pass choices, not strategic calibration.
+Current-source corruption and foreign/stale/extra payloads reject immutably.
+
+Parent verification is **Partial development verified**: types/lint and
+**6,401/6,401** offline tests pass; the affected eight-file union passed
+**138/138**. Ten actual native personal/allied Basic/Advanced Use/all-pass/
+partial-refresh cases reach the real Mentat transition; printed and Advanced
+BG Worthless preemption retain original cards and spend native fuel once.
+Four privately backed-up original-setup CLI/390px rooms exercise human Use,
+wrong-face Pass, both public/own inspectors, partial refresh and actual native
+HTTP battle/Collection/Mentat continuation. Read-only proof retains all33
+Treachery/all12 Nexus/Traitor stock, identity/circles, exact retired identity,
+one private actual reserve-top replacement and nineteen earlier QA rooms.
+Administrator progress matches the sole public unfinished acknowledgement.
+Both final scoped native and privacy reviews have no finding.
+The printed face, separate universal reaction question, competing-module/
+Traitor-Deck priority and deployment remain distinct. This is not complete
+Harkonnen/Nexus play or a deployed claim.
+
## Primary authority
- [The original printed Nexus components](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani): the Harkonnen face was freshly inspected in the previously acquired 3024×4032 photograph, `/tmp/dune-nexus-cards.jpg`. Its three panels establish the effects below. The photographed publisher text is the authority, not the uploader's commentary.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 41f53030..f50f8556 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -122,6 +122,7 @@ independent decisions are not reconciled by this cutover.
| Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) |
| Ixian Nexus Secret Ally replacement | Fresh classic/base-deck/Nexus-only Basic or Advanced local `ixian-replacement` profile binds one genuine normal paid/printed Karama purchase. Discard exactly that purchased physical card before privately drawing the actual deck top; full-hand and exhausted-deck same-card redraw are legal. Keep original payment, income and native auction suffix once; no refund, re-auction or new native Ixian Karama response. Every publicly possible supported unallied held-Nexus buyer gets the same neutral choice irrespective of hidden face; only the owner has Pass/entitled inspection and private eligible Use. This is an inferred development privacy convention, not publisher timing. Harkonnen buyers, Richese cache/Black Market/special origins, anytime overlays and combined modules stay guarded pending their independent source/priority questions. Existing native Ixian ally replacement is distinct. Verification Planned; no retrofit, public start or full-family certification. | [Photographed face and bounded runtime contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) |
| Ixian Nexus Betrayal: Bidding or Technology | Fresh native-Ixian/classic-or-Tleilaxu Basic/Advanced local `ixian-betrayal` uses Ix47 Treachery and Nexus alone, with no initial Nexus deal or old-save retrofit. Resolve actual native Karama/BG counters first; a native denial opens no Nexus gate or cost. After all native passes, uniform publicly possible unallied non-Ixian held-Nexus seats acknowledge before the native effect; concealed face governs only private Use. These are bounded privacy/ordering inferences, not publisher universal priority. One physical Ixian Nexus denies one current Bidding attempt (normal count, no Ixian inspection/return) or one Advanced Technology declaration (both card locations preserved, attempt remains used); all-pass allows the original effect and suffix once. No whole-phase ban, completed undo, invented direct-Nexus Karama counter or blanket immunity. Richese special-lot exchange, wider priority/modules, complete games, strategy and public/live release remain open; verification is Planned pending frozen direct evidence. | [Photographed face and native source protocol](NEXUS_CARD_RULES.md#bounded-ixian-betrayal-source-protocol--1-october-2026), [fresh local runtime and acceptance](NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026), [five-facet internal topic](../game/reference.ts) |
+| Harkonnen Nexus Betrayal local preview | Fresh classic/base33/Nexus-only `harkonnen-betrayal` admits genuine personal/allied native matching calls. Native allied Karama/BG allowance comes first; a provisional uniform acknowledgement precedes the original outcome. Use returns the exact physical traitor immediately and preserves one automatic private actual Mentat replacement. The local ordering/privacy convention is not publisher/user priority and does not answer the previously asked universal reaction policy; public starts, old-game retrofit, arbitrary stacks and combined modules remain gated. Bounded native/private/recovery/CLI/phone verification is Partial development verified, not complete modes or deployment. | [Bounded source contract](NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026), [runtime limits](NEXUS_CARD_RUNTIME.md) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
| Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) |
diff --git a/game/bot-nexus-harkonnen-betrayal.ts b/game/bot-nexus-harkonnen-betrayal.ts
new file mode 100644
index 00000000..66de1f78
--- /dev/null
+++ b/game/bot-nexus-harkonnen-betrayal.ts
@@ -0,0 +1,17 @@
+import type { Action, GameView } from './engine';
+
+/** All four policies acknowledge only their own legal view. Neither the native
+ * source nor any rival card or private traitor estimate participates. */
+export function botNexusHarkonnenBetrayalActions(game: GameView): Action[] {
+ const reaction = game.nexusHarkonnenBetrayalReaction;
+ if (game.status !== 'playing' || game.phase !== 6 || !reaction?.event ||
+ !reaction.canPass || reaction.hasPassed || game.roomControl?.paused || game.roomControl?.closed ||
+ game.automaticContinuationPending || game.truthtrance || game.response || game.decision ||
+ game.phaseOpening || game.nexusCards?.waiting.length || game.nexusTraitors?.pending) return [];
+ const own = game.players.find((player) => player.id === game.me);
+ if (!(own?.bot ?? own?.autopilot)) return [];
+ return [{
+ type: reaction.canUse && !reaction.blocked ? 'nexusHarkonnenBetrayalUse' : 'nexusHarkonnenBetrayalPass',
+ event: reaction.event,
+ }];
+}
diff --git a/game/bots.ts b/game/bots.ts
index 07035830..0334fd8c 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -106,6 +106,7 @@ import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kul
import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal';
import { botNexusGuildBetrayalActions } from './bot-nexus-guild-betrayal';
import { botNexusIxianBetrayalActions } from './bot-nexus-ixian-betrayal';
+import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -3832,6 +3833,13 @@ 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.nexusHarkonnenBetrayalReaction) {
+ if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
+ g.automaticContinuationPending) return [];
+ // Native counter and existing private decisions still own their source.
+ if (g.response || g.truthtrance || g.decision) return policyActions(g);
+ return botNexusHarkonnenBetrayalActions(g);
+ }
if (g.nexusIxianBetrayalReaction) {
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
g.automaticContinuationPending) return [];
diff --git a/game/engine.ts b/game/engine.ts
index 007d6b19..bab76330 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -49,6 +49,7 @@ import { shipmentAvailable } from './shipment-opportunity';
import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal';
import { createIxianReplacementSource, validateIxianReplacementSource, closeIxianReplacementSource, validateIxianReplacementHistory, initialIxianReplacementCursor, ixianReplacementEvent, type IxianReplacementSource, type IxianReplacementReceipt, type IxianReplacementCursor } from './nexus-ixian-replacement';
import { createIxianBetrayalSource, validateIxianBetrayalFrame, initialIxianBetrayalCursor, closeIxianBetrayalSource, validateIxianBetrayalHistory, ixianBetrayalResponders, ixianBetrayalEligible, type IxianBetrayalSource, type IxianBetrayalCursor, type IxianBetrayalReceipt } from './nexus-ixian-betrayal';
+import { createHarkonnenBetrayalSource, validateHarkonnenBetrayalFrame, initialHarkonnenBetrayalCursor, closeHarkonnenBetrayalSource, validateHarkonnenBetrayalHistory, harkonnenBetrayalResponders, harkonnenBetrayalEligible, createHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements, drawHarkonnenBetrayalReplacement, type HarkonnenBetrayalSource, type HarkonnenBetrayalCursor, type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement, type HarkonnenBetrayalAuthority } from './nexus-harkonnen-betrayal';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
@@ -724,6 +725,13 @@ export type Battle = {
plans: Record;
revealed: boolean;
traitorCalls: Record;
+ /** Original native allowance, retained separately from the Nexus source. */
+ nexusHarkonnenAllowance?: {
+ provider: string;
+ nativeWindow: 'direct' | 'harkonnenTraitor';
+ required: string[];
+ passed: string[];
+ };
preparation?: {
kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
owner: string;
@@ -1139,6 +1147,17 @@ export type Game = {
} | null;
nexusIxianBetrayalHistory?: IxianBetrayalReceipt[];
nexusIxianBetrayalCursor?: IxianBetrayalCursor;
+ /** Fresh bounded native Harkonnen traitor-cancellation profile. */
+ nexusHarkonnenBetrayalPreview?: true;
+ pendingNexusHarkonnenBetrayal?: {
+ source: HarkonnenBetrayalSource;
+ required: string[];
+ passed: string[];
+ } | null;
+ nexusHarkonnenBetrayalHistory?: HarkonnenBetrayalReceipt[];
+ nexusHarkonnenBetrayalCursor?: HarkonnenBetrayalCursor;
+ pendingNexusHarkonnenReplacement?: HarkonnenBetrayalReplacement | null;
+ nexusHarkonnenReplacementHistory?: HarkonnenBetrayalReplacement[];
kullSequence?: number;
kullRestrictions?: KullPhaseRestriction[];
pendingKull?: {
@@ -8125,6 +8144,217 @@ export function initializeNexusGameForAudit(state: Game): Game {
'Enable Nexus cards in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
}
+/** Fresh audit admission only; ordinary games and public starts never opt in. */
+export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game {
+ requireRule(harkonnenNexusBetrayalModeSupported(state) &&
+ state.nexusCards?.cards === null && state.nexusCards.phase === null &&
+ state.nexusHarkonnenBetrayalPreview === undefined &&
+ state.pendingNexusHarkonnenBetrayal === undefined &&
+ state.nexusHarkonnenBetrayalHistory === undefined &&
+ state.nexusHarkonnenBetrayalCursor === undefined &&
+ state.pendingNexusHarkonnenReplacement === undefined &&
+ state.nexusHarkonnenReplacementHistory === undefined,
+ 'Harkonnen Nexus Betrayal requires a fresh classic Harkonnen lobby with only base Treachery and Nexus.');
+ requireFreshBaseRuntime(state);
+ const g = initializeNexusGameForAudit(state);
+ g.nexusHarkonnenBetrayalPreview = true;
+ g.pendingNexusHarkonnenBetrayal = null;
+ g.nexusHarkonnenBetrayalHistory = [];
+ g.nexusHarkonnenBetrayalCursor = initialHarkonnenBetrayalCursor();
+ g.pendingNexusHarkonnenReplacement = null;
+ g.nexusHarkonnenReplacementHistory = [];
+ return g;
+}
+function harkonnenNexusBetrayalModeSupported(g: Game): boolean {
+ return typeof g.advanced === 'boolean' && !!g.nexusCards && g.expansions.length === 0 &&
+ g.players.length >= 2 && g.players.length <= 6 && !!byFaction(g,'harkonnen') &&
+ new Set(g.players.map(p => p.faction)).size === g.players.length &&
+ g.players.every(p => CLASSIC_FACTIONS[p.faction]) &&
+ !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
+ !g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards &&
+ !g.ecazTreachery && !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview &&
+ !g.richeseBetrayalPreview && !g.guildBetrayalPreview &&
+ !g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.moritaniAssassinatePreview;
+}
+function harkonnenNexusBetrayalContext(g: Game) {
+ const b = g.battle!;
+ const plans = Object.fromEntries([b.attacker,b.defender].map(id => [id,{
+ leader:b.plans[id].leader,...(b.plans[id].kwisatz === undefined ? {} : {kwisatz:b.plans[id].kwisatz}),
+ }]));
+ const battle = {event:b.event!,attacker:b.attacker,defender:b.defender,plans,heroLeaderIds:[]};
+ return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
+ sequence:g.nexusHarkonnenBetrayalCursor!.sequence,players:g.players,
+ cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g),
+ traitors:nexusTraitorSnapshot(g),universe:nexusTraitorUniverse(g),battle};
+}
+function harkonnenNexusBetrayalAuthority(g: Game): HarkonnenBetrayalAuthority {
+ const b = g.battle, allowance = b?.nexusHarkonnenAllowance;
+ requireRule(b?.revealed && b.event && allowance && g.phase === 6 &&
+ b.traitorCalls[allowance.provider] === true &&
+ traitorVoters(g,b).includes(allowance.provider) &&
+ getPlayer(g,allowance.provider).faction === 'harkonnen',
+ 'The Harkonnen reaction lost its original legal native call.');
+ const declaration = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g),allowance.provider));
+ requireRule(JSON.stringify(declaration) === JSON.stringify(b.traitorDeclarations?.[allowance.provider]) &&
+ allowance.nativeWindow === ([b.attacker,b.defender].includes(allowance.provider) ? 'direct' : 'harkonnenTraitor'),
+ 'The Harkonnen call lost its original canonical declaration or native window.');
+ const required = allowance.nativeWindow === 'direct' ? [] :
+ g.players.filter(p => responseCancelCards(g,p,{kind:'harkonnenTraitor',owner:allowance.provider,passed:[]}).length > 0).map(p => p.id);
+ requireRule(JSON.stringify(required) === JSON.stringify(allowance.required) &&
+ new Set(allowance.passed).size === allowance.passed.length &&
+ allowance.passed.every(id => g.players.some(p => p.id === id)) &&
+ required.every(id => allowance.passed.includes(id)) &&
+ (allowance.nativeWindow !== 'direct' || allowance.passed.length === 0),
+ 'The original native Harkonnen counters were not legally completed.');
+ return {provider:allowance.provider,declaration,nativeWindow:allowance.nativeWindow,
+ nativeRequired:required,nativePassed:allowance.passed,
+ nativeContext:JSON.stringify({event:b.event,territory:b.territory,attacker:b.attacker,
+ defender:b.defender,plans:b.plans,allowance})};
+}
+function harkonnenNexusBetrayalParent(g: Game): string {
+ return JSON.stringify({status:g.status,turn:g.turn,phase:g.phase,advanced:g.advanced,
+ active:g.active,order:g.order,ready:g.ready,battle:g.battle,
+ players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,
+ hand:p.hand,traitors:p.traitors,revealedTraitors:p.revealedTraitors,leaders:p.leaders,
+ forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})),
+ deck:g.deck,discard:g.discard,traitorReserve:g.traitorReserve,nexus:g.nexusCards,
+ response:g.response,decision:g.decision,truthtrance:g.truthtrance,
+ phaseOpening:g.phaseOpening,pendingKarama:g.pendingKarama,
+ pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard,
+ replacement:g.pendingNexusHarkonnenReplacement});
+}
+function harkonnenNexusBetrayalIntegrity(g: Game, mentatOpening = false): void {
+ const frame = g.pendingNexusHarkonnenBetrayal;
+ if (!g.nexusHarkonnenBetrayalPreview) {
+ requireRule(g.nexusHarkonnenBetrayalPreview === undefined && !frame &&
+ g.nexusHarkonnenBetrayalHistory === undefined && g.nexusHarkonnenBetrayalCursor === undefined &&
+ !g.pendingNexusHarkonnenReplacement && g.nexusHarkonnenReplacementHistory === undefined,
+ 'Harkonnen Nexus Betrayal lost its explicit fresh profile.');
+ return;
+ }
+ requireRule(harkonnenNexusBetrayalModeSupported(g) && g.nexusHarkonnenBetrayalCursor &&
+ Array.isArray(g.nexusHarkonnenBetrayalHistory) && Array.isArray(g.nexusHarkonnenReplacementHistory),
+ 'The Harkonnen profile lost its bounded history.');
+ const universe = nexusTraitorUniverse(g);
+ nexusCardsIntegrity(g);
+ nexusRule(() => validateHarkonnenBetrayalHistory(g.nexusHarkonnenBetrayalHistory!,g.nexusHarkonnenBetrayalCursor!,universe));
+ nexusRule(() => validateHarkonnenBetrayalReplacements(g.nexusHarkonnenBetrayalHistory!,
+ g.nexusHarkonnenBetrayalCursor!,[...g.nexusHarkonnenReplacementHistory!,
+ ...(g.pendingNexusHarkonnenReplacement ? [g.pendingNexusHarkonnenReplacement] : [])],universe));
+ requireRule(g.nexusHarkonnenBetrayalHistory!.every(r => r.source.turn <= g.turn) &&
+ g.nexusHarkonnenReplacementHistory!.every(r => r.status === 'drawn' && r.turn <= g.turn) &&
+ (!g.pendingNexusHarkonnenReplacement || (g.status === 'playing' &&
+ g.pendingNexusHarkonnenReplacement.status === 'due' &&
+ g.pendingNexusHarkonnenReplacement.turn === g.turn &&
+ (g.phase === 6 || g.phase === 7 || (mentatOpening && g.phase === 8)))),
+ 'The private replacement lost its current native Mentat obligation.');
+ if (g.status !== 'setup' && g.status !== 'lobby')
+ nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g),universe));
+ if (!frame) {
+ const battle = g.battle, allowance = battle?.nexusHarkonnenAllowance;
+ if (allowance) {
+ const closed = g.nexusHarkonnenBetrayalHistory!.find(receipt =>
+ receipt.source.declaration.event === battle!.event && receipt.source.provider === allowance.provider);
+ requireRule(closed && closed.source.nativeWindow === allowance.nativeWindow &&
+ JSON.stringify(closed.source.nativeRequired) === JSON.stringify(allowance.required) &&
+ JSON.stringify(closed.source.nativePassed) === JSON.stringify(allowance.passed) &&
+ JSON.stringify(closed.source.declaration) === JSON.stringify(battle!.traitorDeclarations?.[allowance.provider]) &&
+ battle!.traitorCalls[allowance.provider] === (closed.outcome === 'pass'),
+ 'The original native Harkonnen allowance lost its pending or closed Nexus acknowledgement.');
+ }
+ return;
+ }
+ requireRule(Object.keys(frame).sort().join(',') === 'passed,required,source' &&
+ Array.isArray(frame.required) && Array.isArray(frame.passed) &&
+ frame.required.some(id => !frame.passed.includes(id)) &&
+ !g.response && !g.decision && !g.phaseOpening && !g.truthtrance &&
+ !g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard,
+ 'The Harkonnen acknowledgement lost original native priority.');
+ nexusRule(() => validateHarkonnenBetrayalFrame(harkonnenNexusBetrayalContext(g),frame.source,
+ harkonnenNexusBetrayalAuthority(g),harkonnenNexusBetrayalParent(g),frame.required,frame.passed));
+}
+function offerHarkonnenNexusBetrayal(g: Game, provider: string, response?: ResponseWindow): boolean {
+ if (!g.nexusHarkonnenBetrayalPreview || getPlayer(g,provider).faction !== 'harkonnen') return false;
+ const b = g.battle!;
+ const required = nexusRule(() => harkonnenBetrayalResponders(g.nexusCards!.cards!,g.players));
+ if (!required.length) return false;
+ requireRule(!b.nexusHarkonnenAllowance && (!response || response.kind === 'harkonnenTraitor'),
+ 'The original Harkonnen declaration was already acknowledged.');
+ b.nexusHarkonnenAllowance = {provider,nativeWindow:response ? 'harkonnenTraitor' : 'direct',
+ required:response ? g.players.filter(p => responseCancelCards(g,p,response).length > 0).map(p => p.id) : [],
+ passed:response ? [...response.passed] : []};
+ const declaration = harkonnenNexusBetrayalAuthority(g).declaration;
+ const owner = getPlayer(g,provider);
+ owner.revealedTraitors = [...new Set([...(owner.revealedTraitors ?? []),declaration.identity])];
+ const name = g.players.flatMap(p => p.leaders).find(l => l.id === declaration.leader)?.name ?? declaration.leader;
+ log(g,`${owner.name} revealed ${name} as a traitor.`);
+ const source = nexusRule(() => createHarkonnenBetrayalSource(harkonnenNexusBetrayalContext(g),
+ harkonnenNexusBetrayalAuthority(g),harkonnenNexusBetrayalParent(g)));
+ g.pendingNexusHarkonnenBetrayal = {source,required,passed:[]};
+ return true;
+}
+function harkonnenNexusBetrayalView(g: Game,id: string) {
+ const frame = g.pendingNexusHarkonnenBetrayal;
+ if (!frame) return null;
+ const hasPassed = frame.passed.includes(id), canPass = frame.required.includes(id) && !hasPassed;
+ const d = frame.source.declaration;
+ return {event:frame.source.event,provider:frame.source.provider,beneficiary:d.beneficiary,
+ target:d.target,leader:d.leader,identity:d.identity,canPass,hasPassed,
+ canUse:canPass && nexusRule(() => harkonnenBetrayalEligible(g.nexusCards!.cards!,g.players,id)),blocked:null as string | null};
+}
+function decideHarkonnenNexusBetrayal(g: Game,id: string,action: Action): void {
+ harkonnenNexusBetrayalIntegrity(g);
+ const frame = g.pendingNexusHarkonnenBetrayal!;
+ requireRule(['nexusHarkonnenBetrayalPass','nexusHarkonnenBetrayalUse'].includes(action?.type) &&
+ action.event === frame.source.event && Object.keys(action).length === 2 &&
+ Object.keys(action).every(key => key === 'type' || key === 'event') &&
+ frame.required.includes(id) && !frame.passed.includes(id),
+ 'A current required seat must acknowledge only the original Harkonnen event.');
+ const use = action.type === 'nexusHarkonnenBetrayalUse';
+ if (use) requireRule(!g.pendingNexusHarkonnenReplacement &&
+ nexusRule(() => harkonnenBetrayalEligible(g.nexusCards!.cards!,g.players,id)),
+ 'You do not own an eligible Harkonnen Nexus card or the prior replacement is still due.');
+ if (!use) {
+ frame.passed.push(id);
+ if (frame.required.some(seat => !frame.passed.includes(seat))) return;
+ }
+ const closed = nexusRule(() => closeHarkonnenBetrayalSource(frame.source,use ? 'use' : 'pass',
+ use ? id : null,g.nexusHarkonnenBetrayalCursor!));
+ if (use) {
+ const owner = getPlayer(g,frame.source.provider), identity = frame.source.declaration.identity;
+ requireRule(owner.traitors.filter(card => card === identity).length === 1 && g.traitorReserve,
+ 'The original called physical traitor is no longer held exactly once.');
+ const replacement = nexusRule(() => createHarkonnenBetrayalReplacement(closed.receipt));
+ g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players));
+ owner.traitors = owner.traitors.filter(card => card !== identity);
+ owner.revealedTraitors = (owner.revealedTraitors ?? []).filter(card => card !== identity);
+ g.traitorReserve = shuffle([...g.traitorReserve,identity]);
+ g.battle!.traitorCalls[owner.id] = false;
+ g.pendingNexusHarkonnenReplacement = replacement;
+ log(g,`${getPlayer(g,id).name} spent Harkonnen Nexus Betrayal. The declared Traitor Card returned to the shuffled Traitor Deck; Harkonnen will draw one private replacement during this turn’s Mentat Pause.`);
+ }
+ g.nexusHarkonnenBetrayalHistory!.push(closed.receipt);
+ g.nexusHarkonnenBetrayalCursor = closed.cursor;
+ g.pendingNexusHarkonnenBetrayal = null;
+ if (traitorVoters(g,g.battle!).every(voter => g.battle!.traitorCalls[voter] !== undefined))
+ advanceBattleOutcome(g);
+}
+function drawHarkonnenNexusBetrayalReplacement(g: Game): void {
+ const due = g.pendingNexusHarkonnenReplacement;
+ if (!due) return;
+ harkonnenNexusBetrayalIntegrity(g,true);
+ requireRule(g.status === 'playing' && g.phase === 8 && due.turn === g.turn &&
+ g.traitorReserve && g.traitorReserve.length > 0,
+ 'The private Harkonnen replacement requires its actual current Mentat opening and Traitor Deck.');
+ const identity = g.traitorReserve[0];
+ const drawn = nexusRule(() => drawHarkonnenBetrayalReplacement(due,g.nexusHarkonnenBetrayalHistory!,
+ g.nexusHarkonnenBetrayalCursor!,identity,nexusTraitorUniverse(g)));
+ g.pendingNexusHarkonnenReplacement = null;
+ g.nexusHarkonnenReplacementHistory!.push(drawn);
+ g.traitorReserve.shift();
+ getPlayer(g,due.provider).traitors.push(identity);
+ log(g,`${getPlayer(g,due.provider).name} drew the one private Traitor Card replacement owed by Harkonnen Nexus Betrayal.`);
+}
/** Offline-only, fresh native Ixian/Ix47 + Nexus profile. */
export function initializeIxianNexusBetrayalGameForAudit(state: Game): Game {
requireRule(ixianNexusBetrayalModeSupported(state) &&
@@ -16945,6 +17175,7 @@ function beginPhase(g: Game) {
collect(g);
}
if (g.phase === 8) {
+ drawHarkonnenNexusBetrayalReplacement(g);
replaceMoritaniAssassinationTraitors(g);
if (g.inflation && g.inflation.updatedTurn < g.turn) {
if (g.inflation.flipped) {
@@ -19869,6 +20100,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
for (const revelation of quote.revelations) {
const holder = getPlayer(g, revelation.player),
identity = revelation.identity;
+ if (b.nexusHarkonnenAllowance?.provider === holder.id &&
+ b.traitorDeclarations?.[holder.id]?.identity === identity) continue;
holder.revealedTraitors = [
...new Set([...(holder.revealedTraitors ?? []), identity]),
];
@@ -22732,6 +22965,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
} else if (response.kind === 'harkonnenTraitor') {
const b = g.battle!;
if (canceled) b.traitorCalls[response.owner] = false;
+ else if (offerHarkonnenNexusBetrayal(g,response.owner,response)) return;
if (
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
)
@@ -25464,6 +25698,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ harkonnenNexusBetrayalIntegrity(state);
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
@@ -25552,6 +25787,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
+ harkonnenNexusBetrayalIntegrity(g);
+ if (g.pendingNexusHarkonnenBetrayal) return g;
ixianNexusBetrayalIntegrity(g);
if (g.pendingNexusIxianBetrayal) return g;
ixianNexusReplacementIntegrity(g);
@@ -25686,9 +25923,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
guildRateIntegrity(g);
arrivalOverlapIntegrity(g);
guildBetrayalIntegrity(g);
+ harkonnenNexusBetrayalIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
+ if (g.pendingNexusHarkonnenBetrayal) return;
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
@@ -25754,6 +25993,7 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
+ if (g.pendingNexusHarkonnenBetrayal) return;
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingNexusIxianReplacement) return;
if (g.pendingGuildBetrayal) return;
@@ -25762,6 +26002,7 @@ function settleAutomaticContinuations(g: Game) {
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
+ if (g.pendingNexusHarkonnenBetrayal) return;
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingNexusIxianReplacement) return;
@@ -25787,6 +26028,7 @@ function settleAutomaticContinuations(g: Game) {
finishResponse(g, false);
} else if (!finishAutomaticDecision(g)) return;
finishActionContinuations(g);
+ if (g.pendingNexusHarkonnenBetrayal) return;
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
@@ -25805,6 +26047,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
+ harkonnenNexusBetrayalIntegrity(state);
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
@@ -25843,6 +26086,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
+ if (g.pendingNexusHarkonnenBetrayal) return g;
if (g.pendingNexusIxianBetrayal) return g;
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) return g;
@@ -25886,6 +26130,7 @@ export function normalizeAutomaticGame(state: Game): Game {
moritaniExtortionIntegrity(g);
guildRateIntegrity(g);
arrivalOverlapIntegrity(g);
+ harkonnenNexusBetrayalIntegrity(g);
return g;
}
@@ -25897,6 +26142,12 @@ function applyActionInner(
): Game {
ecazTreacheryIntegrity(state);
const g = structuredClone(state);
+ if (g.pendingNexusHarkonnenBetrayal) {
+ getPlayer(g,id);
+ if (action?.type === 'advanceBots') return g;
+ decideHarkonnenNexusBetrayal(g,id,action);
+ return g;
+ }
if (g.pendingNexusIxianBetrayal) {
getPlayer(g,id);
if (action?.type === 'advanceBots') return g;
@@ -29592,6 +29843,7 @@ function applyActionInner(
g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] };
return g;
}
+ if (action.call && offerHarkonnenNexusBetrayal(g,id)) return g;
if (
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
@@ -29758,6 +30010,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
+ harkonnenNexusBetrayalIntegrity(state);
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state);
@@ -30133,6 +30386,8 @@ export function viewGame(state: Game, id: string) {
? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null,
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
nexusCards: projectedNexusCards(g, id),
+ nexusHarkonnenBetrayalPreview: !!g.nexusHarkonnenBetrayalPreview,
+ nexusHarkonnenBetrayalReaction: harkonnenNexusBetrayalView(g,id),
nexusIxianBetrayalPreview: !!g.nexusIxianBetrayalPreview,
nexusIxianBetrayalReaction: ixianNexusBetrayalView(g,id),
nexusIxianReplacementPreview: !!g.nexusIxianReplacementPreview,
@@ -30204,7 +30459,9 @@ export function viewGame(state: Game, id: string) {
storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm,
playerPositions: normalizedPlayerPositions(g),
order: g.order,
- active: g.pendingNexusIxianBetrayal
+ active: g.pendingNexusHarkonnenBetrayal
+ ? (() => {const remaining = g.pendingNexusHarkonnenBetrayal.required.filter(seat => !g.pendingNexusHarkonnenBetrayal!.passed.includes(seat)); return remaining.length === 1 ? remaining[0] : null;})()
+ : g.pendingNexusIxianBetrayal
? (() => {const remaining = g.pendingNexusIxianBetrayal.required.filter(seat => !g.pendingNexusIxianBetrayal!.passed.includes(seat)); return remaining.length === 1 ? remaining[0] : null;})()
: g.active,
movementRemaining: g.movementRemaining ?? null,
diff --git a/game/nexus-harkonnen-betrayal.ts b/game/nexus-harkonnen-betrayal.ts
new file mode 100644
index 00000000..ec263365
--- /dev/null
+++ b/game/nexus-harkonnen-betrayal.ts
@@ -0,0 +1,475 @@
+import { baseDeck, leaders, type Card } from './cards';
+import {
+ nexusCardMode, validateNexusCards, validateNexusPlayers,
+ type NexusPlayer, type NexusState,
+} from './nexus-cards';
+import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
+import {
+ validateTraitorDeclaration, type TraitorDeclaration, type TraitorDeclarationContext,
+} from './traitor-declarations';
+import { traitorDeck } from './traitors';
+
+export type HarkonnenBetrayalAuthority = {
+ provider: string;
+ declaration: TraitorDeclaration;
+ nativeWindow: 'direct' | 'harkonnenTraitor';
+ nativeContext: string;
+ nativeRequired: readonly string[];
+ nativePassed: readonly string[];
+};
+type Battle = Omit;
+export type HarkonnenBetrayalContext = {
+ status: string;
+ phase: number;
+ turn: number;
+ advanced: boolean;
+ sequence: number;
+ players: readonly NexusPlayer[];
+ cards: NexusState;
+ physicalCards: readonly Card[];
+ traitors: NexusTraitorSnapshot;
+ universe: readonly string[];
+ battle: Battle;
+};
+export type HarkonnenBetrayalSource = HarkonnenBetrayalAuthority & {
+ version: 1;
+ event: string;
+ turn: number;
+ phase: 6;
+ advanced: boolean;
+ sequence: number;
+ required: readonly string[];
+ eligible: string | null;
+ players: readonly NexusPlayer[];
+ battle: Battle;
+ universe: readonly string[];
+ /** Current committed census only, never a requirement on later custody. */
+ inventory: string;
+ parent: string;
+ signature: string;
+};
+export type HarkonnenBetrayalCursor = { sequence: number; signature: string };
+export type HarkonnenBetrayalReceipt = {
+ source: HarkonnenBetrayalSource;
+ outcome: 'pass' | 'use';
+ holder: string | null;
+ previous: string;
+ signature: string;
+};
+export type HarkonnenBetrayalReplacement = {
+ version: 1;
+ event: string;
+ sequence: number;
+ provider: string;
+ turn: number;
+ identity: string;
+ /** Bounded head of the exact successful Use, not the latest cursor. */
+ receipt: string;
+ status: 'due' | 'drawn';
+ drawn: string | null;
+ signature: string;
+};
+export class HarkonnenBetrayalError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'HarkonnenBetrayalError';
+ }
+}
+const genesis = 'harkonnen-betrayal:0';
+const classic: Readonly> = {
+ atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true, beneGesserit: true,
+};
+const canonicalCards = new Map(baseDeck().map(card => [card.id, card]));
+const declarationKeys = ['event', 'voter', 'beneficiary', 'target', 'leader', 'identity', 'signature'] as const;
+const plain = (value: unknown): value is Record =>
+ value !== null && typeof value === 'object' && !Array.isArray(value) &&
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
+const text = (value: unknown): value is string => typeof value === 'string' &&
+ value.trim().length > 0 && !['__proto__', 'constructor', 'prototype'].includes(value);
+const whole = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+const array = (value: unknown): boolean => Array.isArray(value);
+function requireBetrayal(value: unknown, message: string): asserts value {
+ if (!value) throw new HarkonnenBetrayalError(message);
+}
+function exact(value: unknown, keys: readonly string[]): value is Record {
+ return plain(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
+}
+function members(value: unknown): value is readonly string[] {
+ return Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
+}
+const equal = (one: unknown, two: unknown): boolean => JSON.stringify(one) === JSON.stringify(two);
+function validateRoster(players: readonly NexusPlayer[]): void {
+ validateNexusPlayers(players);
+ requireBetrayal(players.every(player => Object.hasOwn(classic, player.faction)) &&
+ players.filter(player => player.faction === 'harkonnen').length === 1,
+ 'Harkonnen Betrayal requires native Harkonnen and two through six classic seats.');
+}
+function roster(players: readonly NexusPlayer[]): NexusPlayer[] {
+ return players.map(({ id, faction, ally }) => ({ id, faction, ally: ally ?? null }));
+}
+function canonicalUniverse(players: readonly NexusPlayer[]): string[] {
+ return traitorDeck(players.map(player => ({ leaders: leaders(player.faction) })));
+}
+function validateUniverse(universe: readonly string[], players: readonly NexusPlayer[]): void {
+ const expected = canonicalUniverse(players);
+ requireBetrayal(members(universe) && universe.length === expected.length &&
+ universe.every(identity => expected.includes(identity)),
+ 'Harkonnen Betrayal needs the original complete canonical classic Traitor universe.');
+}
+function validatePhysical(cards: readonly Card[]): void {
+ requireBetrayal(Array.isArray(cards) && cards.length === canonicalCards.size &&
+ new Set(cards.map(card => card?.id)).size === canonicalCards.size && cards.every(card => {
+ if (!plain(card) || !text(card.id)) return false;
+ const expected = canonicalCards.get(card.id);
+ return !!expected && exact(card, ['id', 'name', 'kind',
+ ...(expected.effect === undefined ? [] : ['effect'])]) && card.name === expected.name &&
+ card.kind === expected.kind && card.effect === expected.effect;
+ }), 'Harkonnen Betrayal requires complete unique canonical base33 Treachery custody.');
+}
+function validateBattle(battle: Battle): void {
+ requireBetrayal(exact(battle, ['event', 'attacker', 'defender', 'plans', 'heroLeaderIds']) &&
+ text(battle.event) && text(battle.attacker) && text(battle.defender) &&
+ battle.attacker !== battle.defender && exact(battle.plans, [battle.attacker, battle.defender]) &&
+ Array.isArray(battle.heroLeaderIds) && battle.heroLeaderIds.length === 0 &&
+ Object.values(battle.plans).every(plan => plain(plan) &&
+ exact(plan, ['leader', ...(Object.hasOwn(plan, 'kwisatz') ? ['kwisatz'] : [])]) &&
+ (plan.leader === null || text(plan.leader)) &&
+ (plan.kwisatz === undefined || typeof plan.kwisatz === 'boolean')),
+ 'Harkonnen Betrayal requires original sealed classic battle plans, without Ix heroes.');
+}
+function declarationContext(
+ battle: Battle, players: readonly NexusPlayer[], traitors?: NexusTraitorSnapshot,
+): TraitorDeclarationContext {
+ return { ...battle, players: players.map(player => ({ ...player,
+ traitors: traitors?.players.find(seat => seat.id === player.id)?.traitors ?? [],
+ })) };
+}
+function validateAuthority(
+ authority: HarkonnenBetrayalAuthority, players: readonly NexusPlayer[], battle: Battle,
+): void {
+ requireBetrayal(exact(authority, ['provider', 'declaration', 'nativeWindow', 'nativeContext',
+ 'nativeRequired', 'nativePassed']) && text(authority.provider) && text(authority.nativeContext) &&
+ members(authority.nativeRequired) && members(authority.nativePassed) &&
+ authority.nativeRequired.every(id => authority.nativePassed.includes(id)) &&
+ [...authority.nativeRequired, ...authority.nativePassed].every(id => players.some(p => p.id === id)) &&
+ players.find(player => player.id === authority.provider)?.faction === 'harkonnen' &&
+ authority.declaration?.voter === authority.provider,
+ 'Harkonnen Betrayal must follow its original allowed native Harkonnen declaration.');
+ try {
+ validateTraitorDeclaration(declarationContext(battle, players), authority.declaration);
+ } catch {
+ throw new HarkonnenBetrayalError('Harkonnen Betrayal lost its canonical native declaration or roles.');
+ }
+ const direct = authority.declaration.beneficiary === authority.provider;
+ requireBetrayal(direct ? authority.nativeWindow === 'direct' &&
+ authority.nativeRequired.length === 0 && authority.nativePassed.length === 0 :
+ authority.nativeWindow === 'harkonnenTraitor',
+ 'Harkonnen Betrayal cannot confuse personal calls with native allowed allied calls.');
+ requireBetrayal(players.some(player => leaders(player.faction).some(leader =>
+ leader.id === authority.declaration.leader)) && authority.declaration.identity === authority.declaration.leader,
+ 'Harkonnen Betrayal requires the opposing canonical classic leader identity.');
+}
+export function harkonnenBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] {
+ validateRoster(players);
+ validateNexusCards(cards, players);
+ return players.filter(player => !player.ally && player.faction !== 'harkonnen' &&
+ cards.hands[player.id] !== null).map(player => player.id);
+}
+export function harkonnenBetrayalEligible(
+ cards: NexusState, players: readonly NexusPlayer[], holder: string,
+): boolean {
+ validateRoster(players);
+ validateNexusCards(cards, players);
+ const player = players.find(seat => seat.id === holder);
+ return !!player && !player.ally && player.faction !== 'harkonnen' && cards.hands[holder] === 'harkonnen' &&
+ nexusCardMode('harkonnen', player.faction, players.map(seat => seat.faction)) === 'betrayal';
+}
+export function harkonnenBetrayalEvent(
+ turn: number, sequence: number, declaration: TraitorDeclaration, provider: string,
+): string {
+ requireBetrayal(whole(turn) && turn > 0 && whole(sequence) && text(provider) &&
+ plain(declaration) && declarationKeys.every(key => text(declaration[key])) && declaration.voter === provider,
+ 'Harkonnen Betrayal requires its original public declared-card event.');
+ return JSON.stringify(['nexusHarkonnenBetrayal', turn, sequence, declaration.event, provider,
+ declaration.beneficiary, declaration.target, declaration.leader, declaration.identity]);
+}
+function validateContext(ctx: HarkonnenBetrayalContext): void {
+ requireBetrayal(plain(ctx) && ctx.status === 'playing' && ctx.phase === 6 &&
+ whole(ctx.turn) && ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean',
+ 'Harkonnen Betrayal requires its current Battle phase and independent cursor.');
+ validateRoster(ctx.players);
+ validateNexusCards(ctx.cards, ctx.players);
+ validatePhysical(ctx.physicalCards);
+ validateUniverse(ctx.universe, ctx.players);
+ validateNexusTraitorSnapshot(ctx.traitors, ctx.universe);
+ requireBetrayal(ctx.traitors.players.length === ctx.players.length &&
+ ctx.players.every(player => ctx.traitors.players.some(seat =>
+ seat.id === player.id && seat.faction === player.faction)),
+ 'Harkonnen Betrayal physical Traitor custody must match its original roster.');
+ validateBattle(ctx.battle);
+}
+function sourceAuthority(source: HarkonnenBetrayalSource): HarkonnenBetrayalAuthority {
+ return { provider: source.provider, declaration: source.declaration, nativeWindow: source.nativeWindow,
+ nativeContext: source.nativeContext, nativeRequired: source.nativeRequired, nativePassed: source.nativePassed };
+}
+function inventory(ctx: HarkonnenBetrayalContext): string {
+ return JSON.stringify([ctx.cards, ctx.traitors, ctx.physicalCards]);
+}
+function sourceSignature(source: HarkonnenBetrayalSource): string {
+ return JSON.stringify(['harkonnenBetrayalSource', source.version, source.event, source.turn,
+ source.phase, source.advanced, source.sequence, sourceAuthority(source), source.required,
+ source.eligible, source.players, source.battle, source.universe, source.inventory, source.parent]);
+}
+function validateSavedSource(value: unknown): asserts value is HarkonnenBetrayalSource {
+ requireBetrayal(exact(value, ['provider', 'declaration', 'nativeWindow', 'nativeContext',
+ 'nativeRequired', 'nativePassed', 'version', 'event', 'turn', 'phase', 'advanced', 'sequence',
+ 'required', 'eligible', 'players', 'battle', 'universe', 'inventory', 'parent', 'signature']) &&
+ value.version === 1 && whole(value.turn) && value.turn > 0 && value.phase === 6 &&
+ typeof value.advanced === 'boolean' && whole(value.sequence) && members(value.required) &&
+ (value.eligible === null || text(value.eligible)) && text(value.event) && text(value.parent) &&
+ text(value.signature) && text(value.inventory) && Array.isArray(value.players) &&
+ plain(value.battle) && members(value.universe),
+ 'Harkonnen Betrayal lost its original canonical source.');
+ // Shape checked above; nested values are validated by their native protocols.
+ const source = value as HarkonnenBetrayalSource;
+ validateRoster(source.players);
+ requireBetrayal(source.players.every(player => exact(player, ['id', 'faction', 'ally'])),
+ 'Harkonnen Betrayal history must retain only the original public seat roles.');
+ validateUniverse(source.universe, source.players);
+ validateBattle(source.battle);
+ validateAuthority(sourceAuthority(source), source.players, source.battle);
+ // This is the original census, not today's holdings. Returning or redrawing
+ // the declared card later cannot invalidate this historical declaration.
+ let stored: unknown;
+ try {
+ stored = JSON.parse(source.inventory);
+ } catch {
+ throw new HarkonnenBetrayalError('Harkonnen Betrayal lost its original physical census.');
+ }
+ requireBetrayal(Array.isArray(stored) && stored.length === 3,
+ 'Harkonnen Betrayal lost its original physical census.');
+ type SavedInventory = [NexusState, NexusTraitorSnapshot, readonly Card[]];
+ const [cards, traitors, physicalCards] = stored as SavedInventory;
+ validateNexusCards(cards, source.players);
+ validateNexusTraitorSnapshot(traitors, source.universe);
+ validatePhysical(physicalCards);
+ requireBetrayal(traitors.players.length === source.players.length &&
+ source.players.every(player => traitors.players.some(seat =>
+ seat.id === player.id && seat.faction === player.faction)) &&
+ traitors.players.find(player => player.id === source.provider)?.traitors.includes(source.declaration.identity) &&
+ equal(source.required, harkonnenBetrayalResponders(cards, source.players)) &&
+ source.eligible === (source.players.find(player =>
+ harkonnenBetrayalEligible(cards, source.players, player.id))?.id ?? null),
+ 'Harkonnen Betrayal changed its original canonical custody or response eligibility.');
+ requireBetrayal(source.required.every(id => source.players.some(player => player.id === id &&
+ !player.ally && player.faction !== 'harkonnen')) &&
+ (source.eligible === null || source.required.includes(source.eligible)) &&
+ source.universe.includes(source.declaration.identity) &&
+ source.event === harkonnenBetrayalEvent(source.turn, source.sequence, source.declaration, source.provider) &&
+ source.signature === sourceSignature(source),
+ 'Harkonnen Betrayal changed its original event, roles or native source.');
+}
+export function validateHarkonnenBetrayalSource(
+ ctx: HarkonnenBetrayalContext, source: HarkonnenBetrayalSource,
+ authority: HarkonnenBetrayalAuthority, parent: string,
+): void {
+ validateSavedSource(source);
+ validateContext(ctx);
+ validateAuthority(authority, ctx.players, ctx.battle);
+ requireBetrayal(source.turn === ctx.turn && source.sequence === ctx.sequence &&
+ source.advanced === ctx.advanced && source.parent === parent && text(parent) &&
+ source.provider === authority.provider && source.nativeWindow === authority.nativeWindow &&
+ source.nativeContext === authority.nativeContext && equal(source.declaration, authority.declaration) &&
+ equal(source.nativeRequired, authority.nativeRequired) && equal(source.nativePassed, authority.nativePassed) &&
+ equal(source.players, roster(ctx.players)) &&
+ equal(source.battle, ctx.battle) && equal(source.universe, ctx.universe) &&
+ source.inventory === inventory(ctx) &&
+ ctx.traitors.players.find(player => player.id === source.provider)?.traitors.includes(source.declaration.identity) &&
+ equal(source.required, harkonnenBetrayalResponders(ctx.cards, ctx.players)) &&
+ source.eligible === (ctx.players.find(player => harkonnenBetrayalEligible(ctx.cards, ctx.players, player.id))?.id ?? null),
+ 'Harkonnen Betrayal no longer matches its current native attempt, custody or public responders.');
+}
+export function createHarkonnenBetrayalSource(
+ ctx: HarkonnenBetrayalContext, authority: HarkonnenBetrayalAuthority, parent: string,
+): HarkonnenBetrayalSource {
+ validateContext(ctx);
+ validateAuthority(authority, ctx.players, ctx.battle);
+ const source: HarkonnenBetrayalSource = {
+ ...structuredClone(authority), version: 1,
+ event: harkonnenBetrayalEvent(ctx.turn, ctx.sequence, authority.declaration, authority.provider),
+ turn: ctx.turn, phase: 6, advanced: ctx.advanced, sequence: ctx.sequence,
+ required: harkonnenBetrayalResponders(ctx.cards, ctx.players),
+ eligible: ctx.players.find(player => harkonnenBetrayalEligible(ctx.cards, ctx.players, player.id))?.id ?? null,
+ players: roster(ctx.players), battle: structuredClone(ctx.battle), universe: [...ctx.universe],
+ inventory: inventory(ctx), parent, signature: '',
+ };
+ source.signature = sourceSignature(source);
+ validateHarkonnenBetrayalSource(ctx, source, authority, parent);
+ return source;
+}
+export function validateHarkonnenBetrayalFrame(
+ ctx: HarkonnenBetrayalContext, source: HarkonnenBetrayalSource,
+ authority: HarkonnenBetrayalAuthority, parent: string,
+ required: readonly string[], passed: readonly string[],
+): void {
+ validateHarkonnenBetrayalSource(ctx, source, authority, parent);
+ requireBetrayal(members(required) && members(passed) && equal(required, source.required) &&
+ passed.every(id => required.includes(id)),
+ 'Harkonnen Betrayal lost its exact public required or passed membership.');
+}
+export function initialHarkonnenBetrayalCursor(): HarkonnenBetrayalCursor {
+ return { sequence: 0, signature: genesis };
+}
+function validateCursor(value: unknown): asserts value is HarkonnenBetrayalCursor {
+ requireBetrayal(exact(value, ['sequence', 'signature']) && whole(value.sequence) && text(value.signature) &&
+ (value.sequence !== 0 || value.signature === genesis),
+ 'Harkonnen Betrayal lost its independent closed-event cursor.');
+ if (value.sequence === 0) return;
+ let head: unknown, event: unknown;
+ try {
+ head = JSON.parse(value.signature);
+ if (Array.isArray(head) && typeof head[1] === 'string') event = JSON.parse(head[1]);
+ } catch {
+ throw new HarkonnenBetrayalError('Harkonnen Betrayal has a forged or recursive cursor head.');
+ }
+ requireBetrayal(Array.isArray(head) && head.length === 4 && head[0] === 'harkonnenBetrayalHead' &&
+ (head[2] === 'pass' ? head[3] === null : head[2] === 'use' && text(head[3])) &&
+ Array.isArray(event) && event.length === 9 && event[0] === 'nexusHarkonnenBetrayal' &&
+ whole(event[1]) && event[1] > 0 && event[2] === value.sequence - 1 && event.slice(3).every(text) &&
+ event[4] !== event[6] && event[5] !== event[6] && event[7] === event[8] &&
+ value.signature === JSON.stringify(head) && head[1] === JSON.stringify(event),
+ 'Harkonnen Betrayal has a forged or recursive cursor head.');
+}
+function receiptSignature(receipt: HarkonnenBetrayalReceipt): string {
+ return JSON.stringify(['harkonnenBetrayalReceipt', receipt.source.signature,
+ receipt.outcome, receipt.holder, receipt.previous]);
+}
+function receiptHead(receipt: HarkonnenBetrayalReceipt): string {
+ return JSON.stringify(['harkonnenBetrayalHead', receipt.source.event, receipt.outcome, receipt.holder]);
+}
+export function closeHarkonnenBetrayalSource(
+ source: HarkonnenBetrayalSource, outcome: 'pass' | 'use', holder: string | null,
+ cursor: HarkonnenBetrayalCursor,
+): { receipt: HarkonnenBetrayalReceipt; cursor: HarkonnenBetrayalCursor } {
+ validateSavedSource(source);
+ validateCursor(cursor);
+ requireBetrayal(source.sequence === cursor.sequence &&
+ cursor.sequence < Number.MAX_SAFE_INTEGER && (outcome === 'pass' ? holder === null :
+ outcome === 'use' && text(holder) && holder === source.eligible),
+ 'Harkonnen Betrayal cannot close twice or lose its actual response outcome.');
+ const receipt: HarkonnenBetrayalReceipt = {
+ source: structuredClone(source), outcome, holder, previous: cursor.signature, signature: '',
+ };
+ receipt.signature = receiptSignature(receipt);
+ return { receipt, cursor: { sequence: cursor.sequence + 1, signature: receiptHead(receipt) } };
+}
+/** Historical declarations do not claim continued possession of recycled cards.
+ * Signatures establish consistency, not authentication of rewritten saves. */
+export function validateHarkonnenBetrayalHistory(
+ history: readonly HarkonnenBetrayalReceipt[], cursor: HarkonnenBetrayalCursor,
+ universe?: readonly string[],
+): void {
+ validateCursor(cursor);
+ requireBetrayal(array(history) && history.length === cursor.sequence,
+ 'Harkonnen Betrayal history has missing or duplicated closed attempts.');
+ let previous = genesis, turn = 0;
+ let originalRoster: readonly NexusPlayer[] | undefined;
+ let originalUniverse: readonly string[] | undefined;
+ for (const [index, receipt] of history.entries()) {
+ requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']),
+ 'Harkonnen Betrayal history has a malformed receipt.');
+ validateSavedSource(receipt.source);
+ if (universe !== undefined) {
+ validateUniverse(universe, receipt.source.players);
+ requireBetrayal(equal(universe, receipt.source.universe), 'Harkonnen Betrayal changed its original universe.');
+ }
+ const identities = receipt.source.players.map(({ id, faction }) => ({ id, faction }));
+ requireBetrayal(receipt.source.sequence === index && receipt.source.turn >= turn &&
+ receipt.previous === previous &&
+ (originalRoster === undefined || equal(originalRoster, identities)) &&
+ (originalUniverse === undefined || equal(originalUniverse, receipt.source.universe)) &&
+ (receipt.outcome === 'pass' ? receipt.holder === null : receipt.outcome === 'use' &&
+ text(receipt.holder) && receipt.holder === receipt.source.eligible) &&
+ receipt.signature === receiptSignature(receipt),
+ 'Harkonnen Betrayal history changed its native attempt, outcome or continuation chain.');
+ originalRoster = identities;
+ originalUniverse = receipt.source.universe;
+ previous = receiptHead(receipt);
+ turn = receipt.source.turn;
+ }
+ requireBetrayal(cursor.signature === previous,
+ 'Harkonnen Betrayal history no longer matches its independent cursor.');
+}
+function replacementSignature(replacement: HarkonnenBetrayalReplacement): string {
+ return JSON.stringify(['harkonnenBetrayalReplacement', replacement.version, replacement.event,
+ replacement.sequence, replacement.provider, replacement.turn, replacement.identity,
+ replacement.receipt, replacement.status, replacement.drawn]);
+}
+export function createHarkonnenBetrayalReplacement(receipt: HarkonnenBetrayalReceipt): HarkonnenBetrayalReplacement {
+ requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']),
+ 'Harkonnen Betrayal replacement requires its exact closed native receipt.');
+ validateSavedSource(receipt.source);
+ validateCursor({ sequence: receipt.source.sequence, signature: receipt.previous });
+ requireBetrayal(receipt.outcome === 'use' && text(receipt.holder) && receipt.holder === receipt.source.eligible &&
+ text(receipt.previous) && receipt.signature === receiptSignature(receipt),
+ 'Only a successful Harkonnen Betrayal Use creates a Mentat replacement obligation.');
+ const replacement: HarkonnenBetrayalReplacement = {
+ version: 1, event: receipt.source.event, sequence: receipt.source.sequence,
+ provider: receipt.source.provider, turn: receipt.source.turn, identity: receipt.source.declaration.identity,
+ receipt: receiptHead(receipt), status: 'due', drawn: null, signature: '',
+ };
+ replacement.signature = replacementSignature(replacement);
+ return replacement;
+}
+function validateReplacement(
+ replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[],
+): void {
+ requireBetrayal(exact(replacement, ['version', 'event', 'sequence', 'provider', 'turn', 'identity',
+ 'receipt', 'status', 'drawn', 'signature']) && replacement.version === 1 &&
+ whole(replacement.sequence) && whole(replacement.turn) && replacement.turn > 0 &&
+ text(replacement.event) && text(replacement.provider) && text(replacement.identity) &&
+ text(replacement.receipt) && text(replacement.signature) &&
+ (replacement.status === 'due' ? replacement.drawn === null :
+ replacement.status === 'drawn' && text(replacement.drawn)),
+ 'Harkonnen Betrayal has a malformed or repeated Mentat replacement.');
+ const receipt = history[replacement.sequence];
+ requireBetrayal(receipt?.outcome === 'use' && replacement.receipt === receiptHead(receipt) &&
+ replacement.event === receipt.source.event && replacement.provider === receipt.source.provider &&
+ replacement.turn === receipt.source.turn && replacement.identity === receipt.source.declaration.identity &&
+ (replacement.drawn === null || receipt.source.universe.includes(replacement.drawn)) &&
+ replacement.signature === replacementSignature(replacement),
+ 'Harkonnen Betrayal replacement lost its exact successful Use obligation or actual drawn identity.');
+}
+export function validateHarkonnenBetrayalReplacement(
+ replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[],
+ cursor: HarkonnenBetrayalCursor, universe?: readonly string[],
+): void {
+ validateHarkonnenBetrayalHistory(history, cursor, universe);
+ validateReplacement(replacement, history);
+}
+export function drawHarkonnenBetrayalReplacement(
+ replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[],
+ cursor: HarkonnenBetrayalCursor, drawn: string, universe?: readonly string[],
+): HarkonnenBetrayalReplacement {
+ validateHarkonnenBetrayalReplacement(replacement, history, cursor, universe);
+ requireBetrayal(replacement.status === 'due' && text(drawn) &&
+ history[replacement.sequence].source.universe.includes(drawn),
+ 'Harkonnen Betrayal replacement draws exactly once from its actual canonical Traitor Deck.');
+ const result: HarkonnenBetrayalReplacement = { ...replacement, status: 'drawn', drawn, signature: '' };
+ result.signature = replacementSignature(result);
+ return result;
+}
+export function validateHarkonnenBetrayalReplacements(
+ history: readonly HarkonnenBetrayalReceipt[], cursor: HarkonnenBetrayalCursor,
+ replacements: readonly HarkonnenBetrayalReplacement[], universe?: readonly string[],
+): void {
+ validateHarkonnenBetrayalHistory(history, cursor, universe);
+ requireBetrayal(Array.isArray(replacements), 'Harkonnen Betrayal lost its replacement provenance.');
+ for (const replacement of replacements) validateReplacement(replacement, history);
+ const used = history.filter(receipt => receipt.outcome === 'use');
+ requireBetrayal(replacements.length === used.length &&
+ new Set(replacements.map(replacement => replacement.sequence)).size === replacements.length &&
+ replacements.filter(replacement => replacement.status === 'due').length <= 1,
+ 'Every successful Harkonnen Betrayal Use requires exactly one due or completed private draw.');
+}
diff --git a/game/reference.ts b/game/reference.ts
index eedad958..e7913017 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4252,6 +4252,37 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Genuine native Basic/Advanced Bidding and Advanced Technology allow/prevent, printed/BG priority, exact stock/count/attempt/suffix, public face parity, legal bots and authenticated restart/races/rejection pass. Actual backed-up CLI/phone Use/Pass/partial refresh and saved stock prove both endpoints. Native Harkonnen/BG and public admin ownership regressions retain prior continuation; wider modes and deployment remain open.', evidence: ['tests/nexus-ixian-betrayal.test.ts', 'tests/nexus-ixian-betrayal-runtime.test.ts', 'tests/fixture-nexus-ixian-betrayal.ts', 'tests/nexus-ixian-betrayal-controls.test.tsx', 'tests/bot-nexus-ixian-betrayal.test.ts', 'tests/nexus-ixian-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/ix-technology.test.ts', 'tests/admin-directory.test.ts'] },
],
},
+ {
+ id: 'nexus-harkonnen-betrayal',
+ title: 'Harkonnen Nexus Betrayal: cancel a declared traitor',
+ category: 'Advanced & expansions',
+ coverage: 'Partial',
+ developmentStage: 'Verified',
+ summary: 'The fresh local harkonnen-betrayal preview cancels one original native Harkonnen traitor call, returns that physical card immediately and gives Harkonnen a private replacement during Mentat Pause.',
+ searchText: 'Harkonnen Nexus Betrayal revealed declared traitor ally caller beneficiary shuffle replacement Mentat neutral acknowledgement',
+ steps: [
+ 'The bounded development profile requires native Harkonnen and two to six unique classic factions, Basic or Advanced, the base Treachery Deck and Nexus alone. No public start, existing-game retrofit, expansion deck, Homeworld, Leader Skill, Discovery or other overlay is enabled.',
+ 'Nexus has no initial deal. An actual alliance-qualified closing Nexus must supply the unallied holder. An effect scenario needs at least three seats; two seats can continue ordinary play without inventing a card. Two simultaneous receivers need four actual seats.',
+ 'The photographed publisher face cancels a revealed Harkonnen traitor, shuffles that exact card back immediately, and gives Harkonnen one replacement during Mentat Pause. It is not a draw-now exchange, whole-battle ban or refund of an already resolved outcome. Native Cunning and absent-Harkonnen Secret Ally remain separate effects.',
+ 'Only a genuine legally matching native personal or allied Harkonnen call is a source. Preserve the actual caller, combatant beneficiary, target, original revealed plans and physical identity. A false or invalid call, protected leader, another faction or finished battle cannot offer this effect.',
+ 'Resolve the original allied Harkonnen Karama counter first, including legal Advanced Bene Gesserit conversion. Native cancellation opens no Nexus gate or cost. A personal Harkonnen call gains no invented native counter. After native allowance, declare the actual matched traitor before its outcome and await the neutral acknowledgements.',
+ 'Every publicly possible unallied non-Harkonnen held-Nexus owner sees the same declared-traitor acknowledgement, regardless of secret face. Only an actual privately eligible Harkonnen-card holder sees Use and their own unavailable reason. Native-counter-first and this uniform local preview timing are bounded inferences, not an answer to the outstanding universal reaction-policy question.',
+ 'Pass retains both cards and cannot be withdrawn for that event; all required passes retain the original call and resume it once. Use spends the physical singleton Nexus, returns only that declared Harkonnen traitor to the actual shuffled reserve, prevents only that call and resumes the surviving original battle once.',
+ 'The replacement is automatic at the actual current Mentat opening, before optional actions and victory acceptance in this bounded profile. Draw the actual reserve top privately once; it may be the returned card after its shuffle. No extra confirmation, selected replacement, Treachery slot, activation spice, force or leader cost is added.',
+ 'The public declaration exposes only its actual matched traitor and roles, not other held Harkonnen cards, rival Nexus faces, reserve order or private source. Inspect that declared Traitor Card through its public inspector, and inspect only your own actually held Nexus. A replacement identity remains private to Harkonnen; prior public history must not claim continued ownership of the returned card.',
+ 'Both actions submit only the offered event. Refresh retains the same acknowledgement and partial passes; due and completed replacement provenance survives without reshuffling, redrawing or replaying native battle and phase effects. Stale, foreign, malformed or changed committed sources reject before any cost.',
+ 'Finish the source before ordinary votes or competing card mutation. Existing response, decision and Truthtrance priority remain intact; every seat retains independent own autopilot control. Four difficulties use minimal legal projected Use/Pass, not calibrated strategy.',
+ 'Frozen genuine personal/allied Basic/Advanced, exact retirement and actual Mentat continuation, private controls, legal bots, authenticated races/rejection, backed-up same-setup CLI and phone evidence verify this bounded path. Raw administrator progress follows the same sole public remaining acknowledgement. The separate universal response and competing-module questions remain open; this is not complete Harkonnen/Nexus or deployed acceptance.',
+ ],
+ related: ['nexus-cards', 'battle', 'card-karama', 'implementation-checklist'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Original personal/allied call and native counter, exact immediate retirement, canceled call, surviving battle once and automatic private actual Mentat replacement are connected through a strict fresh local profile with bounded custody/history checks.', evidence: ['game/engine.ts', 'game/nexus-harkonnen-betrayal.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Public declared-Traitor inspector, own eligible event-only Use/Pass/Nexus inspector, sole/multiple public ownership and every-seat independent autopilot are connected. Actual phone Use, wrong-face Pass, partial refresh and raw administrator owner agree.', evidence: ['components/nexus-harkonnen-betrayal.tsx', 'components/game-table.tsx', 'game/table-turn.ts', 'db/admin-directory.ts'] },
+ { area: 'AI', status: 'Partial', detail: 'All four minimal policies consume canonical own-view choices and native pending priority with legal paced continuation; full strategy and calibration remain separate.', evidence: ['game/bot-nexus-harkonnen-betrayal.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Printed immediate/delayed operations, personal/allied roles, inferred local counter/privacy convention, fresh natural card rights and unchanged outside-profile/module/public/deployment questions are explicit.', evidence: ['docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_CARD_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Genuine native personal/allied Use/all-pass and actual Mentat top, printed/BG preemption, canonical stock, own/public entitlement, four bots and authenticated restart/races/rejection pass. Four privately backed-up original-setup CLI/phone rooms prove Use, wrong-face Pass, partial refresh, administrator owner and saved stock through real HTTP Mentat once. Independent native/privacy reviews have no finding; wider and deployed acceptance remain open.', evidence: ['tests/fixture-nexus-harkonnen-betrayal.ts', 'tests/nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-runtime.test.ts', 'tests/nexus-harkonnen-betrayal-controls.test.tsx', 'tests/bot-nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/admin-directory.test.ts'] },
+ ],
+ },
{
id: 'nexus-cards',
title: 'Nexus Cards',
@@ -4274,6 +4305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Harkonnen Cunning draws one Traitor Card before you choose one held card to return. It can pause another action, including a response or decision. Finish the private return before that action continues; active Truthtrance takes priority. Returning an identity already declared as a traitor does not cancel that declaration.',
'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.',
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
+ 'Harkonnen Betrayal has a separate fresh local harkonnen-betrayal profile for an actual personal or allied native traitor call. After any original allied counter allows it, uniform public possible held-Nexus owners acknowledge the actually declared traitor before its outcome. Use returns that exact physical traitor immediately and schedules one private actual Mentat replacement; all-pass keeps the original call. This provisional bounded convention does not answer the outstanding universal reaction policy; see its dedicated guide for bounded verified native/private/recovery/CLI/phone evidence and unchanged module/public/deployment gates.',
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws their replacements secretly, and only then shuffles the set-aside cards into the Traitor Deck. Unrevealed dancers stay in your hand. This single operation does not use or renew your ordinary Mentat replacement; the native all-three-revealed reset keeps its separate shuffle-before-draw order.',
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
'Ixian Secret Ally has a fresh local ixian-replacement profile for normal paid or printed Karama-paid auction purchases in classic base-deck/Nexus Basic or Advanced games. A supported unallied buyer may spend its actual Nexus to discard exactly the just-purchased card and draw the real deck top privately; Pass keeps both cards. Full-hand replacement and empty-deck same-card redraw are allowed, and original payment/income/auction continuation occur once. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. The neutral buyer choice is an inferred privacy convention, not publisher timing. See the dedicated guide for bounded verified native, private, recovery and phone evidence; this is not full-module certification.',
diff --git a/game/table-turn.ts b/game/table-turn.ts
index 12329702..f92160fe 100644
--- a/game/table-turn.ts
+++ b/game/table-turn.ts
@@ -12,7 +12,7 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
- Partial>;
+ Partial>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
@@ -33,8 +33,8 @@ export function tableActionOwner(g: TurnView): string | null {
const seated = (id: string | null | undefined) =>
g.players.some((p) => p.id === id) ? id! : null;
// active is the runtime's public sole unfinished acknowledgement seat,
- // never the private card holder or native advantage provider.
- if (g.nexusIxianBetrayalReaction) return seated(g.active);
+ // never the private card holder or native declaration provider.
+ if (g.nexusIxianBetrayalReaction || (g.nexusHarkonnenBetrayalReaction && !g.decision)) return seated(g.active);
if (g.nexusIxianReplacement) return seated(g.nexusIxianReplacement.buyer);
if (g.decision) return seated(g.decision.player);
if (g.biddingEnd) {
diff --git a/tests/admin-directory.test.ts b/tests/admin-directory.test.ts
index 207040bc..c5e43aa2 100644
--- a/tests/admin-directory.test.ts
+++ b/tests/admin-directory.test.ts
@@ -7,6 +7,7 @@ import { projectAdminRoom, readAdminDirectory } from '../db/admin-directory';
import { applyAction, viewGame } from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-betrayal';
+import { createHarkonnenNexusBetrayalFixture } from './fixture-nexus-harkonnen-betrayal';
const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
status, advanced, host: 'p1', turn: 1, phase: 0, expansions: [],
@@ -54,6 +55,75 @@ void test('admin directory allowlist reveals no private state, role power or sea
const project = (value: unknown) => projectAdminRoom({ code: 'IXADMINA', version: 3, updated_at: 100, state: JSON.stringify(value) });
+void test('Harkonnen acknowledgement ownership supersedes combatants and exposes only the unfinished public responder', () => {
+ for (const advanced of [false, true]) for (const remote of [false, true]) {
+ const f = createHarkonnenNexusBetrayalFixture({ advanced, remote, receiverCount: 2 });
+ assert.equal(f.required.length, 2);
+ const shared = project(f.game);
+ assert.deepEqual(shared.pending.owners, []);
+ for (const player of f.game.players)
+ assert.equal(tableActionOwner(viewGame(f.game, player.id)), null);
+ const partial = applyAction(f.game, f.holder, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
+ const remaining = f.required.find(id => id !== f.holder)!;
+ const sole = project(partial);
+ assert.deepEqual(sole.pending.owners, [remaining]);
+ for (const player of partial.players)
+ assert.equal(tableActionOwner(viewGame(partial, player.id)), remaining);
+ const privateChanges = {
+ ...partial,
+ players: partial.players.map(player => ({ ...player, hand: ['SECRET_HAND'], traitors: ['SECRET_TRAITOR'], spice: 'SECRET_SPICE' })),
+ traitorReserve: ['SECRET_RESERVE'],
+ pendingNexusHarkonnenBetrayal: {
+ ...partial.pendingNexusHarkonnenBetrayal,
+ source: { eligible: 'SECRET_ELIGIBILITY', parent: 'SECRET_PARENT', declaration: 'SECRET_DECLARATION' },
+ },
+ };
+ assert.deepEqual(project(privateChanges), sole);
+ assert.equal(JSON.stringify(sole).includes('SECRET'), false);
+ }
+});
+
+void test('Harkonnen admin progress is independent of private eligible and irrelevant Nexus faces', () => {
+ for (const remote of [false, true]) {
+ const eligible = createHarkonnenNexusBetrayalFixture({ advanced: true, remote, receiverCount: 2, face: 'harkonnen' });
+ const irrelevant = createHarkonnenNexusBetrayalFixture({ advanced: true, remote, receiverCount: 2, face: 'richese' });
+ assert.equal(viewGame(eligible.game, eligible.holder).nexusHarkonnenBetrayalReaction!.canUse, true);
+ assert.equal(viewGame(irrelevant.game, irrelevant.holder).nexusHarkonnenBetrayalReaction!.canUse, false);
+ assert.deepEqual(project(eligible.game), project(irrelevant.game));
+ const partial = (f: typeof eligible) => applyAction(f.game, f.holder, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
+ assert.deepEqual(project(partial(eligible)), project(partial(irrelevant)));
+ }
+});
+
+void test('Harkonnen acknowledgement metadata preserves higher pending priorities and neutral malformed membership', () => {
+ const input = {
+ ...state('Harkonnen membership QA', 'playing'), phase: 6,
+ battle: { attacker: 'p1', defender: 'UNSEATED_SECRET' },
+ pendingNexusHarkonnenBetrayal: { required: ['p1'], passed: [], source: 'SECRET_SOURCE' },
+ };
+ for (const priority of [
+ { status: 'finished' }, { status: 'lobby' }, { status: 'setup' },
+ { pendingTreacheryDiscard: { card: 'SECRET_CARD' } },
+ { response: { kind: 'SECRET_RESPONSE' } }, { phaseOpening: { passed: [] } },
+ { truthtrance: { question: 'SECRET_QUESTION' } },
+ { decision: { player: 'p1', card: 'SECRET_CARD' } },
+ ]) {
+ const value = { ...input, ...priority };
+ assert.deepEqual(project(value), project({ ...value, pendingNexusHarkonnenBetrayal: null }));
+ }
+ for (const membership of [
+ { required: ['UNSEATED_SECRET'], passed: [] },
+ { required: [null, 17, { id: 'SECRET_OBJECT' }], passed: [] },
+ { required: [], passed: [] }, { required: ['p1'], passed: ['p1'] },
+ { required: 'SECRET_REQUIRED', passed: [] }, { required: ['p1'], passed: 'SECRET_PASSED' }, {},
+ ]) {
+ const output = project({ ...input, pendingNexusHarkonnenBetrayal: { ...membership, source: 'SECRET_SOURCE' } });
+ assert.deepEqual(output.pending.owners, []);
+ assert.equal(output.status, 'playing');
+ assert.equal(JSON.stringify(output).includes('SECRET'), false);
+ }
+});
+
void test('directory ownership follows genuine shared and sole Ixian acknowledgements instead of the native bidder', () => {
for (const kind of ['technology', 'bidding'] as const) {
const f = createIxianNexusBetrayalFixture({ kind, receiverCount: 2 });
diff --git a/tests/bot-nexus-harkonnen-betrayal.test.ts b/tests/bot-nexus-harkonnen-betrayal.test.ts
new file mode 100644
index 00000000..c099b2e7
--- /dev/null
+++ b/tests/bot-nexus-harkonnen-betrayal.test.ts
@@ -0,0 +1,128 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame } from '../game/engine';
+import type { GameView } from '../game/engine';
+import { botActions, runBots } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
+import { createHarkonnenNexusBetrayalFixture } from './fixture-nexus-harkonnen-betrayal';
+
+const scenarios = [
+ { advanced: false, remote: false },
+ { advanced: true, remote: false },
+ { advanced: false, remote: true },
+ { advanced: true, remote: true },
+] as const;
+
+void test('every minimal policy uses the actual legal cancellation without any rival private estimates', () => {
+ for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
+ const f = createHarkonnenNexusBetrayalFixture(scenario);
+ const own = viewGame(f.game, f.holder);
+ own.players.find(p => p.id === own.me)!.bot = difficulty;
+ for (const rival of own.players.filter(p => p.id !== own.me)) {
+ delete rival.hand;
+ delete rival.traitors;
+ delete rival.spice;
+ }
+ const actions = botActions(own);
+ assert.deepEqual(actions, [{ type: 'nexusHarkonnenBetrayalUse', event: f.event }]);
+ const result = applyAction(f.game, f.holder, actions[0]);
+ assert.equal(viewGame(result, f.holder).nexusHarkonnenBetrayalReaction, null);
+ assert.equal(result.nexusCards!.cards!.hands[f.holder], null);
+ assert.equal(result.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), false);
+ assert.equal(result.pendingNexusHarkonnenReplacement!.identity, f.identity);
+ assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1);
+ }
+});
+
+void test('every policy passes an irrelevant face and waits after its acknowledgement or outside public membership', () => {
+ for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
+ const f = createHarkonnenNexusBetrayalFixture({ ...scenario, face: 'richese', secondFace: 'guild', receiverCount: 2 });
+ let game = f.game;
+ for (const seat of game.players) {
+ const own = viewGame(game, seat.id);
+ own.players.find(p => p.id === own.me)!.bot = difficulty;
+ const reaction = own.nexusHarkonnenBetrayalReaction;
+ if (!reaction) break;
+ const actions = botActions(own);
+ if (!reaction.canPass || reaction.hasPassed) {
+ assert.deepEqual(actions, []);
+ continue;
+ }
+ assert.deepEqual(actions, [{ type: 'nexusHarkonnenBetrayalPass', event: f.event }]);
+ game = applyAction(game, seat.id, actions[0]);
+ const refreshed = viewGame(structuredClone(game), seat.id);
+ refreshed.players.find(p => p.id === refreshed.me)!.bot = difficulty;
+ if (refreshed.nexusHarkonnenBetrayalReaction) assert.deepEqual(botActions(refreshed), []);
+ }
+ assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
+ assert.equal(game.nexusCards!.cards!.hands[f.holder], 'richese');
+ assert.equal(game.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), true);
+ assert.equal(game.pendingNexusHarkonnenReplacement ?? null, null);
+ }
+});
+
+void test('canonical response permissions and room scheduling override hints under all four policies', () => {
+ for (const difficulty of DIFFICULTIES) {
+ const f = createHarkonnenNexusBetrayalFixture();
+ const own: GameView = viewGame(f.game, f.holder);
+ own.players.find(p => p.id === own.me)!.bot = difficulty;
+ for (const reason of ['blocked', 'canUse'] as const) {
+ const blocked = structuredClone(own);
+ if (reason === 'blocked') blocked.nexusHarkonnenBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
+ else blocked.nexusHarkonnenBetrayalReaction!.canUse = false;
+ assert.deepEqual(botActions(blocked), [{ type: 'nexusHarkonnenBetrayalPass', event: f.event }]);
+ }
+ for (const reason of ['canPass', 'hasPassed', 'event', 'paused', 'closed', 'automatic', 'finished', 'human', 'phaseOpening'] as const) {
+ const blocked = structuredClone(own);
+ if (reason === 'canPass') blocked.nexusHarkonnenBetrayalReaction!.canPass = false;
+ else if (reason === 'hasPassed') blocked.nexusHarkonnenBetrayalReaction!.hasPassed = true;
+ else if (reason === 'event') blocked.nexusHarkonnenBetrayalReaction!.event = '';
+ else if (reason === 'paused' || reason === 'closed') blocked.roomControl = { ...DEFAULT_ROOM_CONTROL, [reason]: true };
+ else if (reason === 'automatic') blocked.automaticContinuationPending = true;
+ else if (reason === 'human') {
+ const seat = blocked.players.find(p => p.id === blocked.me)!;
+ delete seat.bot;
+ delete seat.autopilot;
+ } else if (reason === 'phaseOpening') blocked.phaseOpening = { passed: [] };
+ else blocked.status = 'finished';
+ assert.deepEqual(botActions(blocked), []);
+ }
+ }
+});
+
+void test('the original native allied counter remains the bot decision before any Nexus cancellation', () => {
+ for (const difficulty of DIFFICULTIES) for (const advanced of [false, true]) {
+ const f = createHarkonnenNexusBetrayalFixture({ remote: true, advanced });
+ assert.ok(f.beforeNativeCounter.response);
+ const native = f.beforeNativeCounter;
+ for (const seat of native.players) {
+ const own = viewGame(native, seat.id);
+ own.players.find(p => p.id === own.me)!.bot = difficulty;
+ assert.equal(own.nexusHarkonnenBetrayalReaction, null);
+ for (const action of botActions(own)) {
+ assert.notEqual(action.type, 'nexusHarkonnenBetrayalUse');
+ assert.notEqual(action.type, 'nexusHarkonnenBetrayalPass');
+ // Exercise actual counter legality, rather than inventing a second gate.
+ const result = applyAction(native, seat.id, action);
+ assert.equal(result.nexusCards!.cards!.hands[f.holder], 'harkonnen');
+ assert.equal(result.pendingNexusHarkonnenReplacement ?? null, null);
+ }
+ }
+ }
+});
+
+void test('paced native bot execution uses or acknowledges every required seat without unrelated fallthrough', () => {
+ for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) for (const face of ['harkonnen', 'richese'] as const) {
+ const f = createHarkonnenNexusBetrayalFixture({ ...scenario, face, secondFace: 'guild', receiverCount: 2 });
+ let game = f.game;
+ for (const id of f.required) game = applyAction(game, id, { type: 'setAutopilot', difficulty });
+ for (let step = 0; step < f.required.length && viewGame(game, f.holder).nexusHarkonnenBetrayalReaction; step++) {
+ game = runBots(game, 1);
+ }
+ assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
+ assert.equal(game.nexusCards!.cards!.hands[f.holder], face === 'harkonnen' ? null : 'richese');
+ assert.equal(game.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), face !== 'harkonnen');
+ assert.equal(game.pendingNexusHarkonnenReplacement?.identity ?? null, face === 'harkonnen' ? f.identity : null);
+ }
+});
diff --git a/tests/fixture-nexus-harkonnen-betrayal.ts b/tests/fixture-nexus-harkonnen-betrayal.ts
new file mode 100644
index 00000000..a88e78ee
--- /dev/null
+++ b/tests/fixture-nexus-harkonnen-betrayal.ts
@@ -0,0 +1,174 @@
+import assert from 'node:assert/strict';
+import {applyAction,createGame,initializeHarkonnenNexusBetrayalGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine';
+import {botActions} from '../game/bots';
+import {TERRITORIES} from '../game/board';
+
+export type HarkonnenBetrayalFixtureOptions = {initial?:Game;advanced?:boolean;remote?:boolean;face?:'harkonnen'|'richese';secondFace?:'harkonnen'|'richese'|'guild';receiverCount?:1|2;seatIds?:string[]};
+export type HarkonnenBetrayalFixture = {beforeCall:Game;beforeNativeCounter:Game;game:Game;event:string;provider:string;holder:string;required:string[];beneficiary:string;target:string;identity:string;callAction:Action;advanced:boolean;remote:boolean};
+export type HarkonnenBetrayalNativeStep = {actor:string;action:Action};
+
+export function settleHarkonnenBetrayalNativeCounters(state:Game):Game {
+ let game=state;
+ for(let i=0;i<100;i++) {
+ if(game.pendingNexusHarkonnenBetrayal||!game.response) return game;
+ const seat=game.players.find(p=>!game.response!.passed.includes(p.id));
+ assert.ok(seat);
+ game=applyAction(game,seat.id,{type:'passResponse'});
+ }
+ throw new Error('Original Harkonnen native counters did not settle.');
+}
+export function nextHarkonnenBetrayalNativeStep(game:Game):HarkonnenBetrayalNativeStep {
+ const frame=game.pendingNexusHarkonnenBetrayal;
+ if(frame) return {actor:frame.required.find(id=>!frame.passed.includes(id))!,action:{type:'nexusHarkonnenBetrayalPass',event:frame.source.event}};
+ if(game.nexusCards?.phase?.stage==='drawing') {
+ const phase=game.nexusCards.phase,actor=phase.eligible.find(id=>!phase.done.includes(id))!;
+ return {actor,action:{type:'nexusCardChoice',turn:phase.turn,card:game.nexusCards.cards!.hands[actor],choice:'keep',ownRedraws:0}};
+ }
+ if(game.pendingTreacheryDiscard) return {actor:game.players[0].id,action:{type:'advanceBots'}};
+ if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}};
+ if(game.phaseOpening) return {actor:game.players.find(p=>!game.phaseOpening!.passed.includes(p.id))!.id,action:{type:'ready'}};
+ if(game.battle?.preparation && ['voice','prescience'].includes(game.battle.preparation.kind))
+ return {actor:game.battle.preparation.owner,action:{type:'declineBattlePower'}};
+ if(game.decision?.kind==='fullPlanOffer')
+ return {actor:game.decision.player,action:{type:'decision',decline:true}};
+ if(game.status==='playing'&&!game.decision) {
+ if(game.phase===0&&game.stormPending===null) return {actor:game.stormDialers.find(id=>game.stormDials[id]===undefined)!,action:{type:'stormDial',amount:game.turn===1?0:1}};
+ if(game.phase===3&&game.auction) return {actor:game.auction.active,action:{type:'passBid'}};
+ if(game.phase===5) return {actor:game.active!,action:{type:'endMovement'}};
+ if(game.battle?.revealed) {
+ const voter=viewGame(game,game.players[0].id).battle!.traitorVoters.find(id=>game.battle!.traitorCalls[id]===undefined);
+ if(voter) return {actor:voter,action:{type:'traitorCall',call:false}};
+ }
+ if(game.phase!==6||!game.active) {
+ const seat=game.players.find(p=>!game.ready.includes(p.id));
+ if(seat) return {actor:seat.id,action:{type:'ready'}};
+ }
+ }
+ for(const p of game.players) {
+ if(game.decision&&game.decision.player!==p.id) continue;
+ const view=viewGame(game,p.id);
+ view.players.find(s=>s.id===p.id)!.bot='Easy';
+ const actions=botActions(view),action=actions.find(a=>a.type==='ready')??actions[0];
+ if(action) return {actor:p.id,action};
+ }
+ throw new Error(`No native Harkonnen fixture step at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
+}
+function step(game:Game):Game {const next=nextHarkonnenBetrayalNativeStep(game);return applyAction(game,next.actor,next.action);}
+export function advanceHarkonnenBetrayalToMentat(state:Game):Game {
+ let game=state;
+ const turn=game.turn;
+ assert.ok(!game.pendingNexusHarkonnenBetrayal,'Settle the public acknowledgement before advancing.');
+ for(let i=0;game.phase!==8&&game.turn===turn&&game.status==='playing'&&i<500;i++) game=step(game);
+ assert.equal(game.turn,turn);assert.equal(game.phase,8);
+ assert.equal(game.pendingNexusHarkonnenReplacement,null);
+ return game;
+}
+/** Only original fresh setup randomness is deterministic. No replacement draw or
+ * shuffle is mocked, and an already-started setup is never reinitialized. */
+function setup(game:Game):Game {
+ const original=Object.getOwnPropertyDescriptor(crypto,'getRandomValues');
+ let seed=142;
+ crypto.getRandomValues=(array:T):T=>{
+ assert.ok(array);
+ const bytes=new Uint8Array(array.buffer,array.byteOffset,array.byteLength);
+ for(let i=0;i>>0;bytes[i]=seed>>>24;}
+ return array;
+ };
+ try {
+ if(game.status==='lobby') {
+ for(const p of game.players) if(!p.ready) game=applyAction(game,p.id,{type:'ready'});
+ game.nexusCards??={cards:null,phase:null};
+ game=initializeHarkonnenNexusBetrayalGameForAudit(game);
+ // Conserved starting-deal ordering ensures a real printed counter; BG
+ // instead receives an actual Worthless Card for native Advanced conversion.
+ const bg=game.advanced&&game.players.find(p=>p.faction==='beneGesserit');
+ const originals=[...game.deck],first=originals.find(c=>bg?c.kind==='worthless':c.effect==='karama')!;
+ assert.ok(first);
+ const counterSeat=bg?bg.id:game.players.find(p=>p.faction!=='harkonnen')!.id;
+ const seen=new Set();
+ const deal=game.players.flatMap(p=>Array.from({length:p.faction==='harkonnen'?2:1},(_,i)=>{
+ const card=p.id===counterSeat&&i===0?first:
+ originals.find(c=>c.id!==first.id&&!seen.has(c.id)&&c.kind==='worthless')??
+ originals.find(c=>c.id!==first.id&&!seen.has(c.id))!;
+ seen.add(card.id);return card;
+ }));
+ game.deck=[...deal,...originals.filter(c=>!seen.has(c.id))];
+ }
+ for(let i=0;game.status==='setup'&&i<150;i++) game=step(game);
+ assert.equal(game.status,'playing');
+ return game;
+ } finally {
+ if(original) Object.defineProperty(crypto,'getRandomValues',original);
+ else Reflect.deleteProperty(crypto,'getRandomValues');
+ }
+}
+/** Genuine setup, ordinary first turn, real second-turn Nexus/alliance and closing
+ * draw. Only a conserved two-force battle is staged during native Movement;
+ * its selection, sealed plans, call, counters and all continuation are native. */
+export function createHarkonnenNexusBetrayalFixture(options:HarkonnenBetrayalFixtureOptions={}):HarkonnenBetrayalFixture {
+ const remote=options.remote??false,face=options.face??'harkonnen';
+ let game:Game;
+ if(options.initial) {
+ game=structuredClone(options.initial);
+ assert.ok(game.status==='lobby'||(game.status==='setup'&&game.nexusHarkonnenBetrayalPreview&&game.turn===1&&game.phase===0&&game.players.every(p=>p.hand.length===0)),'Only a fresh lobby or admitted undealt setup can continue.');
+ if(options.advanced!==undefined) assert.equal(game.advanced,options.advanced);
+ } else {
+ const ids=options.seatIds??(options.receiverCount===2?['p0','p1','p2','p3']:['p0','p1','p2']);
+ game=createGame('HARKONNENBETRAYAL',newPlayer(ids[0],'Atreides','atreides'),options.advanced??false);
+ joinGame(game,newPlayer(ids[1],'Harkonnen','harkonnen'));
+ joinGame(game,newPlayer(ids[2],'Guild','guild'));
+ if(ids.length>3) joinGame(game,newPlayer(ids[3],'Emperor','emperor'));
+ }
+ game=setup(game);
+ const provider=game.players.find(p=>p.faction==='harkonnen')!.id,owner=game.players.find(p=>p.id===provider)!;
+ const targetPlayer=game.players.find(p=>p.id!==provider&&p.leaders.some(l=>owner.traitors.includes(l.id)));
+ assert.ok(targetPlayer,'The preserved real setup has no opposing native leader in the Harkonnen hand.');
+ const target=targetPlayer.id,holder=target;
+ const identity=targetPlayer.leaders.find(l=>owner.traitors.includes(l.id))!.id;
+ const second=options.receiverCount===2?game.players.find(p=>p.id!==provider&&p.id!==holder)!.id:null;
+ const partners=game.players.filter(p=>p.id!==holder&&p.id!==second);
+ assert.ok(partners.length>=2,'A real settled alliance needs two nonreceiver seats.');
+ const partner=partners.find(p=>p.id!==provider)!;
+ const beneficiary=remote?partner.id:provider;
+ const nx=game.nexusCards!.cards!,faces:typeof nx.deck=[face,...(second?[options.secondFace??(face==='harkonnen'?'richese':'harkonnen')]:[])];
+ assert.equal(new Set(faces).size,faces.length,'Each actual Nexus face remains a physical singleton.');
+ nx.deck=[...faces,...nx.deck.filter(c=>!faces.includes(c))];
+ for(let pos=0;pos<2;pos++) {const index=game.spiceDeck.findIndex((c,i)=>i>=pos&&'territory'in c);assert.ok(index>=pos);game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]);}
+ for(let i=0;!(game.turn===2&&game.phase===0&&!game.response&&!game.decision)&&i<1500;i++) game=step(game);
+ assert.equal(game.turn,2);
+ while(game.phase!==1) game=step(game);
+ const worm=game.spiceDeck.findIndex(c=>'worm'in c&&!c.greatMaker&&!c.suppressed);assert.ok(worm>=0);
+ game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
+ for(let i=0;!(game.nexus&&!game.spiceWindow&&!game.spiceResolution&&!game.response&&!game.decision)&&i<200;i++) game=step(game);
+ assert.equal(game.nexus,true);
+ game=applyAction(game,provider,{type:'alliance',target:partner.id});
+ game=applyAction(game,partner.id,{type:'alliance',target:provider});
+ for(let i=0;game.nexusCards?.phase?.stage!=='drawing'&&i<200;i++) game=step(game);
+ assert.equal(game.nexusCards!.phase!.stage,'drawing');
+ game=applyAction(game,holder,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
+ if(second) game=applyAction(game,second,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
+ while(game.nexusCards!.phase!.stage==='drawing') game=step(game);
+ assert.equal(game.nexusCards!.cards!.hands[holder],face);
+ for(let i=0;game.phase!==5&&i<300;i++) game=step(game);
+ assert.equal(game.phase,5);
+ const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))))!;
+ assert.ok(territory);
+ for(const id of [beneficiary,target]) {const p=game.players.find(s=>s.id===id)!;assert.ok(p.reserves>0);p.reserves--;p.forces[`${territory.id}:${territory.sectors[0]}`]=1;}
+ while(game.phase===5) game=step(game);
+ assert.equal(game.phase,6);
+ const chooser=game.active!;assert.ok([beneficiary,target].includes(chooser));
+ game=applyAction(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser===target?beneficiary:target});
+ for(let i=0;(game.response||game.decision||game.battle?.preparation||(game.battle?.preLeader&&!game.battle.preLeader.closed))&&i<100;i++) game=step(game);
+ for(const id of [game.battle!.attacker,game.battle!.defender]) {
+ const p=game.players.find(s=>s.id===id)!;
+ game=applyAction(game,id,{type:'battlePlan',dial:0,leader:id===target?identity:p.leaders.find(l=>!l.dead&&!l.usedAt)!.id});
+ }
+ assert.equal(game.battle!.revealed,true);
+ for(let i=0;(game.response||game.decision)&&i<50;i++) game=step(game);
+ const beforeCall=structuredClone(game),callAction:Action={type:'traitorCall',call:true};
+ game=applyAction(game,provider,callAction);
+ const beforeNativeCounter=structuredClone(game);
+ game=settleHarkonnenBetrayalNativeCounters(game);
+ const frame=game.pendingNexusHarkonnenBetrayal;assert.ok(frame);
+ return {beforeCall,beforeNativeCounter,game,event:frame.source.event,provider,holder,required:[...frame.required],beneficiary,target,identity,callAction,advanced:game.advanced,remote};
+}
diff --git a/tests/nexus-harkonnen-betrayal-controls.test.tsx b/tests/nexus-harkonnen-betrayal-controls.test.tsx
new file mode 100644
index 00000000..a0c4d9f6
--- /dev/null
+++ b/tests/nexus-harkonnen-betrayal-controls.test.tsx
@@ -0,0 +1,179 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { Children, createElement, isValidElement } from 'react';
+import type { ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { applyAction, viewGame, type Action, type GameView } from '../game/engine';
+import { tableActionOwner } from '../game/table-turn';
+import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
+import { createHarkonnenNexusBetrayalFixture } from './fixture-nexus-harkonnen-betrayal';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
+ return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
+ },
+});
+// Next/CSS loading hooks must precede the UI imports.
+const { NexusHarkonnenBetrayal } = await import('../components/nexus-harkonnen-betrayal');
+const { GameTable } = await import('../components/game-table');
+
+function click(node: ReactNode, selector: string): boolean {
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement<{ children?: ReactNode; onClick?: () => void; 'data-testid'?: string }>(child)) continue;
+ if (child.props['data-testid'] === selector && child.props.onClick) {
+ child.props.onClick();
+ return true;
+ }
+ if (click(child.props.children, selector)) return true;
+ }
+ return false;
+}
+function select(game: GameView, choice: 'use' | 'pass', busy = false): Action | undefined {
+ let action: Action | undefined;
+ click(NexusHarkonnenBetrayal({ game, busy, act(value) { action = value; } }), `nexus-harkonnen-betrayal-${choice}`);
+ return action;
+}
+function markup(game: GameView): string {
+ return renderToStaticMarkup(createElement(NexusHarkonnenBetrayal, { game, busy: false, act() {} }));
+}
+function table(game: GameView): string {
+ return renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
+}
+const scenarios = [
+ { advanced: false, remote: false },
+ { advanced: true, remote: false },
+ { advanced: false, remote: true },
+ { advanced: true, remote: true },
+] as const;
+
+for (const scenario of scenarios) {
+ void test(`${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.remote ? 'allied' : 'personal'} human controls cancel the actual declared card or preserve it with Pass`, () => {
+ const f = createHarkonnenNexusBetrayalFixture(scenario);
+ const own = viewGame(f.game, f.holder);
+ const use = select(own, 'use');
+ assert.ok(use);
+ assert.deepEqual(use, { type: 'nexusHarkonnenBetrayalUse', event: f.event });
+ const denied = applyAction(f.game, f.holder, use);
+ assert.equal(viewGame(denied, f.holder).nexusHarkonnenBetrayalReaction, null);
+ assert.equal(denied.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), false);
+ assert.equal(denied.nexusCards!.cards!.hands[f.holder], null);
+ assert.equal(denied.pendingNexusHarkonnenReplacement!.identity, f.identity);
+ assert.equal(select(viewGame(denied, f.holder), 'use'), undefined);
+ assert.throws(() => applyAction(denied, f.holder, use));
+
+ let allowed = f.game;
+ for (const id of f.required) {
+ const pass = select(viewGame(allowed, id), 'pass');
+ assert.ok(pass);
+ assert.deepEqual(pass, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
+ allowed = applyAction(allowed, id, pass);
+ }
+ assert.equal(viewGame(allowed, f.holder).nexusHarkonnenBetrayalReaction, null);
+ assert.equal(allowed.players.find(p => p.id === f.provider)!.traitors.includes(f.identity), true);
+ assert.equal(allowed.nexusCards!.cards!.hands[f.holder], 'harkonnen');
+ assert.equal(allowed.pendingNexusHarkonnenReplacement ?? null, null);
+ });
+}
+
+void test('the public declaration names only the actual card and roles; inspectors remain private to their own held face', () => {
+ for (const scenario of scenarios) {
+ const f = createHarkonnenNexusBetrayalFixture(scenario);
+ const traitorName = viewGame(f.game, f.holder).allLeaders.find(l => l.id === f.identity)!.name;
+ for (const seat of f.game.players) {
+ const own = viewGame(f.game, seat.id);
+ const html = markup(own);
+ for (const id of [f.provider, f.beneficiary, f.target]) assert.ok(html.includes(f.game.players.find(p => p.id === id)!.name));
+ assert.ok(html.includes(traitorName));
+ if (own.nexusCards?.card === 'harkonnen') assert.ok(html.includes('Inspect Harkonnen Nexus card'));
+ else assert.equal(html.includes('Inspect Harkonnen Nexus card'), false);
+ if (seat.id !== f.provider) {
+ for (const hidden of f.game.players.find(p => p.id === f.provider)!.traitors.filter(id => id !== f.identity)) {
+ const hiddenName = own.allLeaders.find(l => l.id === hidden)!.name;
+ assert.equal(html.includes(hiddenName), false);
+ }
+ }
+ if (!f.required.includes(seat.id)) {
+ assert.equal(select(own, 'use'), undefined);
+ assert.equal(select(own, 'pass'), undefined);
+ assert.equal(own.nexusHarkonnenBetrayalReaction!.blocked, null);
+ }
+ }
+ const wrong = createHarkonnenNexusBetrayalFixture({ ...scenario, face: 'richese' });
+ const ownWrong = viewGame(wrong.game, wrong.holder);
+ assert.equal(ownWrong.nexusHarkonnenBetrayalReaction!.canPass, true);
+ assert.equal(ownWrong.nexusHarkonnenBetrayalReaction!.blocked, null);
+ assert.equal(select(ownWrong, 'use'), undefined);
+ assert.ok(select(ownWrong, 'pass'));
+ assert.equal(markup(ownWrong).includes('Inspect Harkonnen Nexus card'), false);
+ assert.ok(markup(ownWrong).includes('Inspect Richese Nexus card'));
+ }
+});
+
+void test('partial acknowledgements use the sole public unfinished seat, not the provider or private face', () => {
+ const f = createHarkonnenNexusBetrayalFixture({ receiverCount: 2 });
+ for (const seat of f.game.players) assert.equal(tableActionOwner(viewGame(f.game, seat.id)), null);
+ const first = f.required[0];
+ const pass = select(viewGame(f.game, first), 'pass');
+ assert.ok(pass);
+ const next = applyAction(f.game, first, pass);
+ for (const seat of next.players) assert.equal(tableActionOwner(viewGame(next, seat.id)), f.required[1]);
+ const refreshed = viewGame(structuredClone(next), first);
+ assert.equal(select(refreshed, 'pass'), undefined);
+ assert.equal(select(refreshed, 'use'), undefined);
+ assert.equal(select(viewGame(next, f.provider), 'use'), undefined);
+});
+
+void test('table reserves the source and dispatch honors own response, room, transport and autopilot locks', () => {
+ for (const scenario of scenarios) {
+ const f = createHarkonnenNexusBetrayalFixture(scenario);
+ const own: GameView = viewGame(f.game, f.holder);
+ const html = table(own);
+ assert.ok(html.includes('Declared Harkonnen traitor acknowledgement'));
+ assert.equal(html.includes('Reveal traitor'), false);
+ assert.equal(html.includes('No traitor'), false);
+ assert.equal(html.includes('Make a bribe'), false);
+ for (const restriction of ['paused', 'closed', 'autopilot', 'busy', 'automatic'] as const) {
+ const blocked = structuredClone(own);
+ if (restriction === 'autopilot') blocked.players.find(p => p.id === own.me)!.autopilot = 'Easy';
+ else if (restriction === 'automatic') blocked.automaticContinuationPending = true;
+ else if (restriction !== 'busy') blocked.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
+ for (const choice of ['use', 'pass'] as const) assert.equal(select(blocked, choice, restriction === 'busy'), undefined);
+ }
+ const blocked = structuredClone(own);
+ blocked.nexusHarkonnenBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
+ assert.equal(select(blocked, 'use'), undefined);
+ assert.ok(select(blocked, 'pass'));
+ if (scenario.remote) {
+ const native = viewGame(f.beforeNativeCounter, f.holder);
+ assert.ok(native.response);
+ assert.equal(native.nexusHarkonnenBetrayalReaction, null);
+ assert.equal(select(native, 'use'), undefined);
+ }
+ }
+});
+
+void test('every seat independently delegates and takes back control without changing declaration or custody', () => {
+ const f = createHarkonnenNexusBetrayalFixture({ receiverCount: 2 });
+ for (const seat of f.game.players) {
+ const delegated = applyAction(f.game, seat.id, { type: 'setAutopilot', difficulty: 'Hard' });
+ const own = viewGame(delegated, seat.id);
+ assert.ok(table(own).includes('Take back control'));
+ assert.equal(select(own, 'use'), undefined);
+ assert.equal(select(own, 'pass'), undefined);
+ const resumed = applyAction(delegated, seat.id, { type: 'setAutopilot', difficulty: null });
+ assert.equal(resumed.players.find(p => p.id === seat.id)!.autopilot, undefined);
+ assert.deepEqual(resumed.pendingNexusHarkonnenBetrayal, f.game.pendingNexusHarkonnenBetrayal);
+ assert.deepEqual(resumed.traitorReserve, f.game.traitorReserve);
+ assert.deepEqual(resumed.players.map(p => p.traitors), f.game.players.map(p => p.traitors));
+ }
+});
+
+aliases.deregister();
diff --git a/tests/nexus-harkonnen-betrayal-recovery.test.ts b/tests/nexus-harkonnen-betrayal-recovery.test.ts
new file mode 100644
index 00000000..7ecd69c5
--- /dev/null
+++ b/tests/nexus-harkonnen-betrayal-recovery.test.ts
@@ -0,0 +1,652 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
+import { shuffle, viewGame, type Action, type Game } from '../game/engine';
+import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
+import type { RoomsClock, SeatAuth } from '../db/rooms';
+import type * as Rooms from '../db/rooms';
+import { startPrototypeRoom } from '../tools/prototype-room';
+import { unitStore } from './fixture-nexus-room-store';
+import {
+ createHarkonnenNexusBetrayalFixture,
+ nextHarkonnenBetrayalNativeStep,
+ type HarkonnenBetrayalFixture,
+ type HarkonnenBetrayalFixtureOptions as Options,
+} from './fixture-nexus-harkonnen-betrayal';
+
+type SqlRow = Record;
+interface Fixture extends HarkonnenBetrayalFixture {
+ sqlite: DatabaseSync;
+ restart(): typeof Rooms;
+ hooks: { beforeWrite?: () => Promise; afterStatement?: (sql: string) => Promise };
+ code: string;
+ tokens: string[];
+ auths: SeatAuth[];
+ save(game: Game): void;
+ protectedRows: Record;
+ unrelatedCode: string;
+ unrelatedToken: string;
+ stock: string[];
+ traitorStock: string[];
+ setup: Game;
+ initial: Game;
+}
+const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
+const plain = (value: T): T => JSON.parse(JSON.stringify(value));
+
+/** Compare durable records, including credentials, lifecycle and recovery resources,
+ * rather than treating an attempted SQL update as a committed game action. */
+function records(sqlite: DatabaseSync, excludedRoom?: string) {
+ const result: Record = {};
+ for (const row of sqlite.prepare("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()) {
+ assert.equal(typeof row.name, 'string');
+ const table = String(row.name).replaceAll('"', '""');
+ const rows = sqlite.prepare(`SELECT * FROM "${table}"`).all();
+ result[String(row.name)] = rows.filter(value => !(row.name === 'rooms' && value.code === excludedRoom));
+ }
+ return result;
+}
+function physical(game: Game) {
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
+ const ids = cards.map(card => card.id).sort();
+ assert.equal(new Set(ids).size, ids.length, 'native Treachery cards have one physical custodian');
+ return ids;
+}
+function traitors(game: Game) {
+ const ids = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => [...player.traitors, ...player.traitorChoices])].sort();
+ assert.equal(new Set(ids).size, ids.length, 'each original physical Traitor Card has exactly one custodian');
+ return ids;
+}
+function player(game: Game, id: string) {
+ const value = game.players.find(player => player.id === id);
+ assert.ok(value);
+ return value;
+}
+function inventory(f: Fixture, game: Game) {
+ assert.deepEqual(physical(game), f.stock);
+ assert.equal(f.stock.length, 33);
+ assert.deepEqual(traitors(game), f.traitorStock);
+ assert.ok(game.nexusCards?.cards);
+ validateNexusCards(game.nexusCards.cards, game.players);
+ assert.deepEqual([...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard,
+ ...Object.values(game.nexusCards.cards.hands).filter(face => face !== null)].sort(), [...NEXUS_FACTIONS].sort());
+ assert.deepEqual(records(f.sqlite, f.code), f.protectedRows);
+}
+async function authenticate(f: Fixture, id: string) {
+ const index = f.auths.findIndex(auth => auth.playerId === id);
+ assert.ok(index >= 0);
+ const auth = await f.restart().authenticate(f.code, f.tokens[index]);
+ assert.equal(auth.playerId, id);
+ assert.equal(auth.tokenHash, f.auths[index].tokenHash);
+ return auth;
+}
+async function act(f: Fixture, game: Game, id: string, action: Action) {
+ await f.restart().act(f.code, await authenticate(f, id), game.version, action, clock);
+ // The room API treats this native wake-up as read-only; its persisted worker
+ // performs any genuine pending discard continuation through the normal CAS.
+ if (action.type === 'advanceBots') await f.restart().continueRoomAutomatic(f.code, clock);
+ return f.restart().readRoom(f.code);
+}
+async function nativeCounters(f: Fixture, initial: Game) {
+ let game = initial;
+ for (let step = 0; game.response && step < 20; step++) {
+ const responder = game.players.find(player => !game.response!.passed.includes(player.id));
+ assert.ok(responder);
+ game = await act(f, game, responder.id, { type: 'passResponse' });
+ }
+ assert.equal(game.response, null);
+ return game;
+}
+async function openGate(f: Fixture, beforeCall: Game) {
+ let game = await act(f, beforeCall, f.provider, f.callAction);
+ if (f.remote) {
+ if (game.response) {
+ assert.equal(game.response.kind, 'harkonnenTraitor');
+ assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
+ } else {
+ assert.equal(game.pendingNexusHarkonnenBetrayal!.source.nativeWindow, 'harkonnenTraitor');
+ assert.equal(game.pendingNexusHarkonnenBetrayal!.source.nativeRequired.length, 0,
+ 'Only a real counter-free native call may reach Nexus automatically.');
+ }
+ }
+ game = await nativeCounters(f, game);
+ assert.ok(game.pendingNexusHarkonnenBetrayal);
+ assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction!.event, f.event);
+ return game;
+}
+async function fixture(t: test.TestContext, options: Options & { counterKind?: 'printed' | 'BG Worthless' } = {}): Promise {
+ const store = unitStore();
+ t.after(() => store.sqlite.close());
+ const made = await store.rooms.createRoom('Native Harkonnen recovery', 'harkonnen', options.advanced ?? true, []);
+ const code = made.view.code;
+ const tokens = [made.token];
+ // BG's original prediction keeps the CLI-produced setup genuinely undealt;
+ // the fixture then consumes that native setup with its scoped deterministic RNG.
+ const factions = options.counterKind === 'printed' ? ['atreides', 'emperor'] as const
+ : ['atreides', 'emperor', 'beneGesserit'] as const;
+ for (const faction of factions) {
+ const joined = await store.rooms.joinRoom(code, faction, faction);
+ assert.ok(joined.token);
+ tokens.push(joined.token);
+ }
+ const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
+ let admitted = await store.rooms.readRoom(code);
+ for (const auth of auths) {
+ await store.rooms.act(code, auth, admitted.version, { type: 'ready' }, clock);
+ admitted = await store.rooms.readRoom(code);
+ }
+ // Main recovery scenarios continue the exact CLI-produced undealt setup.
+ // Counter scenarios retain the authenticated fresh lobby so the native fixture
+ // can conserve and order its first actual deal; no started custody is redealt.
+ if (!options.counterKind) startPrototypeRoom(store.sqlite, code, admitted.version, 'harkonnen-betrayal');
+ const setup = await store.rooms.readRoom(code);
+ const native = createHarkonnenNexusBetrayalFixture({ advanced: options.advanced, remote: options.remote,
+ receiverCount: options.receiverCount, face: options.face, initial: setup });
+ assert.equal(native.beforeCall.code, code);
+ assert.equal(native.beforeCall.host, admitted.host);
+ assert.equal(native.beforeCall.advanced, admitted.advanced);
+ assert.deepEqual(native.beforeCall.expansions, []);
+ assert.deepEqual(native.beforeCall.playerPositions, admitted.playerPositions);
+ assert.deepEqual(native.beforeCall.players.map(player => [player.id, player.name, player.faction]),
+ admitted.players.map(player => [player.id, player.name, player.faction]));
+ const save = (game: Game) => {
+ assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(game), game.version, code).changes, 1);
+ };
+ save(native.beforeCall);
+ const unrelated = await store.rooms.createRoom('Unrelated preserved lobby', 'guild', false, []);
+ const f: Fixture = { ...store, ...native, code, tokens, auths, save,
+ protectedRows: records(store.sqlite, code), unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token,
+ stock: physical(native.beforeCall), traitorStock: traitors(native.beforeCall), initial: native.beforeCall, setup };
+ f.initial = await openGate(f, native.beforeCall);
+ return f;
+}
+async function rejected(f: Fixture, game: Game, auth: SeatAuth, action: Action, version = game.version) {
+ const before = records(f.sqlite);
+ await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
+ assert.deepEqual(records(f.sqlite), before, 'rejection changes no durable game, credential or resource record');
+}
+async function restored(f: Fixture, game: Game) {
+ const before = records(f.sqlite);
+ assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(game));
+ for (const auth of f.auths) {
+ const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
+ assert.deepEqual(plain(view), plain(viewGame(game, auth.playerId)));
+ for (const key of ['pendingNexusHarkonnenBetrayal', 'nexusHarkonnenBetrayalCursor', 'nexusHarkonnenBetrayalHistory',
+ 'pendingNexusHarkonnenReplacement', 'nexusHarkonnenReplacementHistory', 'traitorReserve'])
+ assert.equal(Object.hasOwn(view, key), false, `${key} stays private`);
+ for (const rival of view.players.filter(player => player.id !== auth.playerId))
+ for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
+ const reaction = view.nexusHarkonnenBetrayalReaction;
+ if (reaction) {
+ assert.deepEqual(Object.keys(reaction).sort(), ['event', 'provider', 'beneficiary', 'target', 'leader', 'identity', 'canPass', 'hasPassed', 'canUse', 'blocked'].sort());
+ assert.equal(reaction.provider, f.provider);
+ assert.equal(reaction.beneficiary, f.beneficiary);
+ assert.equal(reaction.target, f.target);
+ assert.equal(reaction.identity, f.identity, 'only the genuine public declaration is revealed');
+ if (!f.required.includes(auth.playerId)) {
+ assert.equal(reaction.canPass, false);
+ assert.equal(reaction.canUse, false);
+ assert.equal(reaction.blocked, null);
+ }
+ }
+ }
+ await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
+ assert.deepEqual(records(f.sqlite), before, 'refresh/recovery never consumes a pending response or redraws a finished replacement');
+ inventory(f, game);
+}
+async function closeGate(f: Fixture, initial: Game, used: boolean) {
+ if (used) return act(f, initial, f.holder, { type: 'nexusHarkonnenBetrayalUse', event: f.event });
+ let game = initial;
+ for (const id of f.required) game = await act(f, game, id, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
+ return game;
+}
+function settled(f: Fixture, game: Game, used: boolean) {
+ assert.equal(game.pendingNexusHarkonnenBetrayal ?? null, null);
+ assert.equal(viewGame(game, f.holder).nexusHarkonnenBetrayalReaction, null);
+ const history = game.nexusHarkonnenBetrayalHistory!;
+ assert.equal(history.length, (f.initial.nexusHarkonnenBetrayalHistory?.length ?? 0) + 1);
+ const receipt = history.at(-1)!;
+ assert.deepEqual(receipt.source, f.initial.pendingNexusHarkonnenBetrayal!.source);
+ assert.equal(receipt.outcome, used ? 'use' : 'pass');
+ assert.equal(receipt.holder, used ? f.holder : null);
+ assert.equal(game.nexusHarkonnenBetrayalCursor!.sequence, f.initial.nexusHarkonnenBetrayalCursor!.sequence + 1);
+ assert.equal(game.nexusCards!.cards!.hands[f.holder], used ? null : f.initial.nexusCards!.cards!.hands[f.holder]);
+ assert.deepEqual(game.nexusCards!.cards!.discard, [...f.initial.nexusCards!.cards!.discard, ...(used ? ['harkonnen'] : [])]);
+ assert.deepEqual(player(game, f.provider).traitors, used
+ ? player(f.initial, f.provider).traitors.filter(identity => identity !== f.identity)
+ : player(f.initial, f.provider).traitors);
+ if (used) {
+ assert.deepEqual([...(game.traitorReserve ?? [])].sort(), [...(f.initial.traitorReserve ?? []), f.identity].sort());
+ assert.equal(player(game, f.provider).revealedTraitors?.includes(f.identity) ?? false, false);
+ assert.equal(game.pendingNexusHarkonnenReplacement!.status, 'due');
+ assert.equal(game.pendingNexusHarkonnenReplacement!.identity, f.identity);
+ assert.equal(game.pendingNexusHarkonnenReplacement!.provider, f.provider);
+ assert.equal(game.pendingNexusHarkonnenReplacement!.turn, f.initial.turn);
+ assert.equal(game.pendingNexusHarkonnenReplacement!.drawn, null);
+ assert.equal(game.battle?.traitorCalls[f.provider], false, 'the original call is canceled, not replayed');
+ } else {
+ assert.equal(game.pendingNexusHarkonnenReplacement ?? null, null);
+ assert.deepEqual(game.traitorReserve, f.initial.traitorReserve);
+ }
+ inventory(f, game);
+}
+async function mentat(f: Fixture, initial: Game, raceRecovery = false) {
+ let game = initial;
+ for (let step = 0; game.phase !== 8 && step < 80; step++) {
+ const next = nextHarkonnenBetrayalNativeStep(game);
+ const before = game;
+ if (raceRecovery) {
+ await Promise.all([act(f, before, next.actor, next.action),
+ f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
+ game = await f.restart().readRoom(f.code);
+ } else game = await act(f, before, next.actor, next.action);
+ }
+ assert.equal(game.phase, 8, 'actual battle resolution, Collection and native readiness reach Mentat');
+ assert.equal(game.turn, initial.turn);
+ assert.equal(game.battle, null);
+ return game;
+}
+
+for (const advanced of [false, true]) for (const remote of [false, true]) for (const used of [false, true]) {
+ void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${remote ? 'allied' : 'personal'} Harkonnen ${used ? 'Use' : 'all-pass'} continues the original battle and actual Mentat once`, async t => {
+ const f = await fixture(t, { advanced, remote });
+ await restored(f, f.initial);
+ const done = await closeGate(f, f.initial, used);
+ settled(f, done, used);
+ await restored(f, done);
+ const expectedDraw = done.traitorReserve?.[0];
+ const previousHeld = [...player(done, f.provider).traitors];
+ const pause = await mentat(f, done, true);
+ assert.equal(pause.pendingNexusHarkonnenReplacement ?? null, null);
+ if (used) {
+ const drawn = pause.nexusHarkonnenReplacementHistory!.at(-1)!;
+ assert.equal(drawn.status, 'drawn');
+ assert.equal(drawn.event, f.event);
+ assert.equal(drawn.drawn, expectedDraw);
+ assert.deepEqual(player(pause, f.provider).traitors, [...previousHeld, expectedDraw]);
+ assert.deepEqual(pause.traitorReserve, done.traitorReserve!.slice(1));
+ assert.equal(pause.nexusHarkonnenReplacementHistory!.length, (done.nexusHarkonnenReplacementHistory?.length ?? 0) + 1);
+ } else {
+ assert.deepEqual(pause.nexusHarkonnenReplacementHistory, done.nexusHarkonnenReplacementHistory);
+ assert.deepEqual(player(pause, f.provider).traitors, previousHeld);
+ }
+ await restored(f, pause);
+ for (const type of ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const)
+ await rejected(f, pause, await authenticate(f, f.holder), { type, event: f.event });
+ });
+}
+
+void test('SQLite lost successful Use response restores the exact committed shuffle and cannot spend or schedule twice', async t => {
+ const f = await fixture(t, { remote: true });
+ let lost = false;
+ f.hooks.afterStatement = async sql => {
+ if (sql.startsWith('UPDATE rooms SET state') && !lost) { lost = true; throw new Error('lost response after committed CAS'); }
+ };
+ try {
+ await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), f.initial.version,
+ { type: 'nexusHarkonnenBetrayalUse', event: f.event }, clock), /lost response/);
+ } finally { delete f.hooks.afterStatement; }
+ const committed = await f.restart().readRoom(f.code);
+ assert.equal(committed.version, f.initial.version + 1);
+ settled(f, committed, true);
+ await restored(f, committed);
+ await rejected(f, committed, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event }, f.initial.version);
+ await rejected(f, committed, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event });
+});
+
+void test('SQLite actor credentials, revoked sessions and event-only selectors fence private state without durable changes', async t => {
+ const f = await fixture(t);
+ const holder = await authenticate(f, f.holder);
+ const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
+ const use: Action = { type: 'nexusHarkonnenBetrayalUse', event: f.event };
+ const before = records(f.sqlite);
+ await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
+ await assert.rejects(f.restart().readSeatView(f.code, foreign));
+ await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.holder, tokenHash: foreign.tokenHash }));
+ assert.deepEqual(records(f.sqlite), before);
+ for (const auth of [foreign, { playerId: f.holder, tokenHash: foreign.tokenHash }, await authenticate(f, f.provider)])
+ await rejected(f, f.initial, auth, use);
+ await rejected(f, f.initial, holder, use, f.initial.version - 1);
+ for (const extra of [{ actor: f.provider }, { player: f.provider }, { provider: f.provider }, { holder: f.holder },
+ { identity: f.identity }, { leader: f.identity }, { target: f.target }, { card: 'harkonnen' }, { event: `${f.event}:stale` }, { replacement: f.identity }])
+ for (const type of ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const)
+ await rejected(f, f.initial, holder, { ...use, type, ...extra } as Action);
+ for (const action of [f.callAction, { type: 'passResponse' }, { type: 'ready' }, { type: 'traitorCall', call: false }, { type: 'nexusCard', choice: 'replace' }])
+ await rejected(f, f.initial, await authenticate(f, f.provider), action as Action);
+ f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
+ f.protectedRows = records(f.sqlite, f.code);
+ await assert.rejects(f.restart().readSeatView(f.code, holder));
+ await rejected(f, f.initial, holder, use);
+});
+
+void test('SQLite neutral rival acknowledgments are identical for genuine relevant and irrelevant natural Nexus draws', async t => {
+ const f = await fixture(t);
+ const irrelevantFixture = createHarkonnenNexusBetrayalFixture({ initial: f.setup, face: 'richese' });
+ const other: Fixture = { ...f, ...irrelevantFixture };
+ f.save(irrelevantFixture.beforeCall);
+ const irrelevant = await openGate(other, irrelevantFixture.beforeCall);
+ other.initial = irrelevant;
+ for (const auth of f.auths) {
+ const original = viewGame(f.initial, auth.playerId);
+ const changed = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
+ if (auth.playerId !== f.holder) {
+ const originalReaction = original.nexusHarkonnenBetrayalReaction!;
+ const changedReaction = changed.nexusHarkonnenBetrayalReaction!;
+ assert.deepEqual({ ...changedReaction, event: null }, { ...originalReaction, event: null });
+ assert.deepEqual(changed.players.map(player => [player.id, player.revealedTraitors]),
+ original.players.map(player => [player.id, player.revealedTraitors]));
+ assert.deepEqual(changed.nexusCards, original.nexusCards);
+ }
+ else {
+ assert.equal(changed.nexusHarkonnenBetrayalReaction!.canPass, true);
+ assert.equal(changed.nexusHarkonnenBetrayalReaction!.canUse, false);
+ }
+ }
+ await restored(other, irrelevant);
+ await rejected(other, irrelevant, await authenticate(other, other.holder), { type: 'nexusHarkonnenBetrayalUse', event: other.event });
+ const done = await closeGate(other, irrelevant, false);
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
+ assert.deepEqual(done.nexusCards!.cards!.discard, irrelevant.nexusCards!.cards!.discard);
+ inventory(f, done);
+});
+
+void test('SQLite two public receivers restore partial Pass, retain private faces and reject duplicate/stale acknowledgement', async t => {
+ const f = await fixture(t, { receiverCount: 2 });
+ assert.equal(f.required.length, 2);
+ const firstId = f.required.find(id => id !== f.holder)!;
+ const first = await act(f, f.initial, firstId, { type: 'nexusHarkonnenBetrayalPass', event: f.event });
+ assert.deepEqual(first.pendingNexusHarkonnenBetrayal!.passed, [firstId]);
+ assert.equal(viewGame(first, firstId).nexusHarkonnenBetrayalReaction!.hasPassed, true);
+ assert.equal(viewGame(first, f.holder).active, f.holder);
+ assert.deepEqual(first.nexusCards!.cards, f.initial.nexusCards!.cards);
+ assert.deepEqual(first.nexusHarkonnenBetrayalHistory, f.initial.nexusHarkonnenBetrayalHistory);
+ await restored(f, first);
+ await rejected(f, first, await authenticate(f, firstId), { type: 'nexusHarkonnenBetrayalPass', event: f.event });
+ await rejected(f, first, await authenticate(f, f.holder), { type: 'nexusHarkonnenBetrayalUse', event: f.event }, f.initial.version);
+ const done = await closeGate(f, first, true);
+ settled(f, done, true);
+});
+
+for (const race of ['use/pass', 'use/use', 'two passes'] as const) {
+ void test(`SQLite concurrent ${race} commits one source transition and restores the losing actor's fresh continuation`, async t => {
+ const f = await fixture(t, { receiverCount: race === 'two passes' ? 2 : 1 });
+ let arrivals = 0;
+ let release!: () => void;
+ const gate = new Promise(resolve => { release = resolve; });
+ f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
+ const ids = race === 'two passes' ? f.required : [f.holder, f.holder];
+ const auths = await Promise.all(ids.map(id => authenticate(f, id)));
+ const types = race === 'two passes' ? ['nexusHarkonnenBetrayalPass', 'nexusHarkonnenBetrayalPass'] as const
+ : race === 'use/use' ? ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalUse'] as const
+ : ['nexusHarkonnenBetrayalUse', 'nexusHarkonnenBetrayalPass'] as const;
+ let results: PromiseSettledResult[];
+ try {
+ results = await Promise.allSettled(auths.map((auth, index) => f.restart().act(f.code, auth, f.initial.version,
+ { type: types[index], event: f.event }, clock).finally(release)));
+ } finally { delete f.hooks.beforeWrite; }
+ assert.equal(arrivals, 2);
+ assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
+ assert.equal(results.filter(result => result.status === 'rejected').length, 1);
+ let done = await f.restart().readRoom(f.code);
+ assert.equal(done.version, f.initial.version + 1);
+ if (race === 'two passes') {
+ const winner = results.findIndex(result => result.status === 'fulfilled');
+ assert.deepEqual(done.pendingNexusHarkonnenBetrayal!.passed, [ids[winner]]);
+ await restored(f, done);
+ await rejected(f, done, auths[winner], { type: 'nexusHarkonnenBetrayalPass', event: f.event });
+ done = await act(f, done, ids[1 - winner], { type: 'nexusHarkonnenBetrayalPass', event: f.event });
+ settled(f, done, false);
+ } else settled(f, done, race === 'use/use' || results[0].status === 'fulfilled');
+ await restored(f, done);
+ });
+}
+
+async function corruptRejected(f: Fixture, corrupt: Game, name: string) {
+ f.save(corrupt);
+ const before = records(f.sqlite);
+ const original = plain(corrupt);
+ for (const auth of f.auths)
+ await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
+ for (const type of ['nexusHarkonnenBetrayalPass', 'nexusHarkonnenBetrayalUse'] as const)
+ await rejected(f, corrupt, await authenticate(f, f.holder), { type, event: f.event });
+ await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
+ assert.deepEqual(records(f.sqlite), before, `${name}: read/action/normalize never repairs or consumes corrupt saves`);
+ assert.deepEqual(corrupt, original);
+}
+
+for (const remote of [false, true]) {
+ void test(`SQLite ${remote ? 'allied' : 'personal'} committed source and physical custody corruption is immutable across authenticated read/action/recovery`, async t => {
+ const f = await fixture(t, remote ? { remote: true, counterKind: 'printed' } : { remote: false });
+ if (remote) assert.ok(f.initial.pendingNexusHarkonnenBetrayal!.source.nativePassed.length,
+ 'The lost-pass mutation requires an actual completed native counter window.');
+ const alterations: { name: string; change: (game: Game) => void }[] = [
+ { name: 'missing gate', change: game => { delete game.pendingNexusHarkonnenBetrayal; } },
+ { name: 'missing cursor', change: game => { delete game.nexusHarkonnenBetrayalCursor; } },
+ { name: 'missing history', change: game => { delete game.nexusHarkonnenBetrayalHistory; } },
+ { name: 'cursor sequence', change: game => { game.nexusHarkonnenBetrayalCursor!.sequence++; } },
+ { name: 'source parent', change: game => { game.pendingNexusHarkonnenBetrayal!.source.parent += ':forged'; } },
+ { name: 'native counter context', change: game => { game.pendingNexusHarkonnenBetrayal!.source.nativeContext += ':forged'; } },
+ { name: 'native window', change: game => { game.pendingNexusHarkonnenBetrayal!.source.nativeWindow = remote ? 'direct' : 'harkonnenTraitor'; } },
+ { name: 'source provider', change: game => { game.pendingNexusHarkonnenBetrayal!.source.provider = f.holder; } },
+ { name: 'matched identity', change: game => { game.pendingNexusHarkonnenBetrayal!.source.declaration.identity = game.traitorReserve![0]; } },
+ { name: 'battle event', change: game => { game.battle!.event += ':forged'; } },
+ { name: 'beneficiary role', change: game => { game.pendingNexusHarkonnenBetrayal!.source.declaration.beneficiary = f.holder; } },
+ { name: 'required membership', change: game => { game.pendingNexusHarkonnenBetrayal!.required = [f.provider]; } },
+ { name: 'invented pass', change: game => { game.pendingNexusHarkonnenBetrayal!.passed = [f.provider]; } },
+ { name: 'public declaration', change: game => { game.battle!.traitorDeclarations![f.provider].signature += ':forged'; } },
+ { name: 'missing called physical traitor', change: game => {
+ player(game, f.provider).traitors = player(game, f.provider).traitors.filter(identity => identity !== f.identity);
+ game.traitorReserve!.push(f.identity);
+ } },
+ { name: 'duplicate Traitor identity', change: game => { game.traitorReserve!.push(f.identity); } },
+ { name: 'missing Traitor identity', change: game => { game.traitorReserve!.pop(); } },
+ { name: 'duplicate Nexus', change: game => { game.nexusCards!.cards!.deck.push('harkonnen'); } },
+ { name: 'mutated committed Nexus face', change: game => {
+ const cards = game.nexusCards!.cards!;
+ const index = cards.deck.indexOf('richese');
+ assert.ok(index >= 0);
+ cards.hands[f.holder] = 'richese';
+ cards.deck[index] = 'harkonnen';
+ } },
+ { name: 'missing base Treachery', change: game => { game.deck.pop(); } },
+ { name: 'canonical Treachery face', change: game => { game.deck[0].name += ' forged'; } },
+ { name: 'lost preview boundary', change: game => { delete game.nexusHarkonnenBetrayalPreview; } },
+ { name: 'native faction changed', change: game => { player(game, f.provider).faction = 'guild'; } },
+ { name: 'foreign expansion', change: game => { game.expansions.push('ix'); } },
+ { name: 'wrong phase', change: game => { game.phase++; } },
+ { name: 'wrong turn', change: game => { game.turn++; } },
+ ...(remote ? [{ name: 'lost original counter passes', change: (game: Game) => {
+ game.pendingNexusHarkonnenBetrayal!.source.nativePassed = [];
+ } }] : []),
+ ];
+ for (const { name, change } of alterations) {
+ const corrupt = structuredClone(f.initial);
+ change(corrupt);
+ await corruptRejected(f, corrupt, name);
+ }
+ });
+}
+
+void test('SQLite exact successful Use obligation restores before Mentat, privately draws once and rejects corrupted late provenance', async t => {
+ const f = await fixture(t, { remote: true });
+ const due = await closeGate(f, f.initial, true);
+ await restored(f, due);
+ const pending = due.pendingNexusHarkonnenReplacement!;
+ const dueAlterations: { name: string; change: (game: Game) => void }[] = [
+ { name: 'missing obligation', change: game => { game.pendingNexusHarkonnenReplacement = null; } },
+ { name: 'replacement wrong provider', change: game => { game.pendingNexusHarkonnenReplacement!.provider = f.holder; } },
+ { name: 'replacement wrong turn', change: game => { game.pendingNexusHarkonnenReplacement!.turn++; } },
+ { name: 'replacement wrong identity', change: game => { game.pendingNexusHarkonnenReplacement!.identity = game.traitorReserve!.find(identity => identity !== f.identity)!; } },
+ { name: 'replacement receipt link', change: game => { game.pendingNexusHarkonnenReplacement!.receipt += ':forged'; } },
+ { name: 'premature replacement', change: game => {
+ game.pendingNexusHarkonnenReplacement!.status = 'drawn';
+ game.pendingNexusHarkonnenReplacement!.drawn = game.traitorReserve![0];
+ } },
+ { name: 'closed history link', change: game => { game.nexusHarkonnenBetrayalHistory!.at(-1)!.previous += ':forged'; } },
+ { name: 'closed declaration source', change: game => { game.nexusHarkonnenBetrayalHistory!.at(-1)!.source.nativeContext += ':forged'; } },
+ ];
+ for (const { name, change } of dueAlterations) {
+ const corrupt = structuredClone(due);
+ change(corrupt);
+ await corruptRejected(f, corrupt, name);
+ }
+ f.save(due);
+ let game = due;
+ for (let count = 0; game.phase !== 7 && count < 80; count++) {
+ const step = nextHarkonnenBetrayalNativeStep(game);
+ game = await act(f, game, step.actor, step.action);
+ }
+ assert.equal(game.phase, 7, 'the original battle reaches actual Collection without changing the due obligation');
+ await restored(f, game);
+ assert.deepEqual(game.pendingNexusHarkonnenReplacement, pending);
+ const pause = await mentat(f, game, true);
+ assert.equal(pause.pendingNexusHarkonnenReplacement, null);
+ const drawn = pause.nexusHarkonnenReplacementHistory!.at(-1)!;
+ assert.equal(drawn.receipt, pending.receipt);
+ assert.equal(drawn.drawn, game.traitorReserve![0]);
+ const providerView = await f.restart().readSeatView(f.code, await authenticate(f, f.provider));
+ assert.deepEqual(providerView.players.find(player => player.id === f.provider)!.traitors, player(pause, f.provider).traitors);
+ for (const auth of f.auths.filter(auth => auth.playerId !== f.provider)) {
+ const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
+ assert.equal(Object.hasOwn(view.players.find(player => player.id === f.provider)!, 'traitors'), false);
+ assert.equal(Object.hasOwn(view, 'nexusHarkonnenReplacementHistory'), false);
+ }
+ await restored(f, pause);
+ for (const change of [
+ (game: Game) => { game.nexusHarkonnenReplacementHistory = []; },
+ (game: Game) => { game.nexusHarkonnenReplacementHistory!.push(structuredClone(drawn)); },
+ (game: Game) => { game.nexusHarkonnenReplacementHistory!.at(-1)!.drawn = 'unknown-physical-identity'; },
+ (game: Game) => { game.nexusHarkonnenReplacementHistory!.at(-1)!.receipt += ':forged'; },
+ (game: Game) => { game.pendingNexusHarkonnenReplacement = structuredClone(pending); },
+ (game: Game) => { game.nexusHarkonnenBetrayalHistory = []; },
+ (game: Game) => { game.nexusHarkonnenBetrayalCursor!.signature += ':forged'; },
+ ]) {
+ const corrupt = structuredClone(pause);
+ change(corrupt);
+ await corruptRejected(f, corrupt, 'completed replacement provenance');
+ }
+});
+
+void test('SQLite Use shuffles the exact retired physical identity and permits its genuine same-card Mentat redraw once', async t => {
+ const f = await fixture(t);
+ const sourceReserve = [...f.initial.traitorReserve!, f.identity];
+ let quoteIndex = 0;
+ const expected = shuffle(sourceReserve, () => quoteIndex++ === 0 ? 0 : 0xffffffff / 4294967296);
+ assert.equal(expected[0], f.identity, 'deterministic real shuffle places the retired identity at the actual deck top');
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'getRandomValues');
+ let randomIndex = 0;
+ Object.defineProperty(globalThis.crypto, 'getRandomValues', {
+ configurable: true,
+ value: (array: T): T => {
+ assert.ok(array instanceof Uint32Array);
+ for (let index = 0; index < array.length; index++) array[index] = randomIndex++ === 0 ? 0 : 0xffffffff;
+ return array;
+ },
+ });
+ let due: Game;
+ try {
+ due = await closeGate(f, f.initial, true);
+ } finally {
+ if (descriptor) Object.defineProperty(globalThis.crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(globalThis.crypto, 'getRandomValues');
+ }
+ assert.deepEqual(due.traitorReserve, expected, 'Use performs the native shuffle on reserve plus exactly the called physical card');
+ assert.deepEqual(player(due, f.provider).traitors, player(f.initial, f.provider).traitors.filter(identity => identity !== f.identity));
+ await restored(f, due);
+ const pause = await mentat(f, due, true);
+ assert.equal(pause.nexusHarkonnenReplacementHistory!.at(-1)!.drawn, f.identity);
+ assert.equal(player(pause, f.provider).traitors.filter(identity => identity === f.identity).length, 1);
+ assert.deepEqual(pause.traitorReserve, expected.slice(1));
+ assert.equal(player(pause, f.provider).revealedTraitors?.includes(f.identity) ?? false, false,
+ 'a private replacement does not turn the historically declared card into current public ownership');
+ await restored(f, pause);
+});
+
+void test('SQLite lost final Collection response restores the original actual Mentat draw without replaying battle or phase effects', async t => {
+ const f = await fixture(t, { remote: true });
+ const due = await closeGate(f, f.initial, true);
+ let lost = false;
+ f.hooks.afterStatement = async sql => {
+ if (!sql.startsWith('UPDATE rooms SET state')) return;
+ const saved = await f.restart().readRoom(f.code);
+ if (saved.phase === 8 && !lost) { lost = true; throw new Error('lost final Collection response'); }
+ };
+ try {
+ await assert.rejects(mentat(f, due), /lost final Collection response/);
+ } finally { delete f.hooks.afterStatement; }
+ assert.equal(lost, true);
+ const pause = await f.restart().readRoom(f.code);
+ assert.equal(pause.phase, 8);
+ assert.equal(pause.turn, due.turn);
+ assert.equal(pause.battle, null);
+ assert.equal(pause.pendingNexusHarkonnenReplacement, null);
+ assert.equal(pause.nexusHarkonnenReplacementHistory!.at(-1)!.drawn, due.traitorReserve![0]);
+ assert.deepEqual(player(pause, f.provider).traitors, [...player(due, f.provider).traitors, due.traitorReserve![0]]);
+ assert.deepEqual(pause.traitorReserve, due.traitorReserve!.slice(1));
+ await restored(f, pause);
+});
+
+for (const counterKind of ['printed', 'BG Worthless'] as const) {
+ void test(`SQLite actual allied Harkonnen ${counterKind} counter preempts the Nexus acknowledgement and preserves its face`, async t => {
+ const f = await fixture(t, { remote: true, advanced: true, counterKind });
+ f.save(f.beforeCall);
+ const declared = await act(f, f.beforeCall, f.provider, f.callAction);
+ assert.ok(declared.response);
+ assert.equal(viewGame(declared, f.holder).nexusHarkonnenBetrayalReaction, null);
+ const counter = counterKind === 'printed'
+ ? declared.players.find(player => player.id !== f.provider && player.hand.some(card => card.effect === 'karama'))
+ : declared.players.find(player => player.faction === 'beneGesserit');
+ assert.ok(counter);
+ const card = counter.hand.find(card => counterKind === 'printed' ? card.effect === 'karama' : card.kind === 'worthless');
+ assert.ok(card, 'the conserved actual native deal supplies the counter card');
+ const canceled = await act(f, declared, counter.id, { type: 'card', mode: 'cancel', card: card.id });
+ const done = await nativeCounters(f, canceled);
+ assert.equal(done.pendingNexusHarkonnenBetrayal ?? null, null);
+ assert.equal(done.pendingNexusHarkonnenReplacement ?? null, null);
+ assert.equal(viewGame(done, f.holder).nexusHarkonnenBetrayalReaction, null);
+ assert.deepEqual(done.nexusHarkonnenBetrayalHistory, f.beforeCall.nexusHarkonnenBetrayalHistory);
+ assert.deepEqual(done.nexusHarkonnenBetrayalCursor, f.beforeCall.nexusHarkonnenBetrayalCursor);
+ assert.deepEqual(done.nexusCards!.cards, f.beforeCall.nexusCards!.cards);
+ assert.deepEqual(player(done, f.provider).traitors, player(f.beforeCall, f.provider).traitors);
+ assert.equal(done.discard.filter(value => value.id === card.id).length, 1);
+ await restored(f, done);
+ });
+}
+
+void test('SQLite credential revocation at the final Use fence cannot commit a source, shuffle or replacement', async t => {
+ const f = await fixture(t);
+ const auth = await authenticate(f, f.holder);
+ const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
+ let revoked = false;
+ f.hooks.beforeWrite = async () => {
+ assert.equal(revoked, false);
+ revoked = true;
+ f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
+ f.protectedRows = records(f.sqlite, f.code);
+ };
+ try {
+ await assert.rejects(f.restart().act(f.code, auth, f.initial.version, { type: 'nexusHarkonnenBetrayalUse', event: f.event }, clock));
+ } finally { delete f.hooks.beforeWrite; }
+ assert.equal(revoked, true);
+ assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
+ inventory(f, await f.restart().readRoom(f.code));
+ await assert.rejects(f.restart().readSeatView(f.code, auth));
+});
+
+void test('SQLite every credential keeps independent own autopilot control while the original Harkonnen source remains pending', async t => {
+ const f = await fixture(t, { receiverCount: 2 });
+ let game = f.initial;
+ for (const auth of f.auths) {
+ const previous = game;
+ game = await act(f, game, auth.playerId, { type: 'setAutopilot', difficulty: 'Easy' });
+ assert.equal(player(game, auth.playerId).autopilot, 'Easy');
+ assert.deepEqual(game.pendingNexusHarkonnenBetrayal, previous.pendingNexusHarkonnenBetrayal);
+ assert.deepEqual(game.nexusCards!.cards, previous.nexusCards!.cards);
+ assert.deepEqual(game.traitorReserve, previous.traitorReserve);
+ game = await act(f, game, auth.playerId, { type: 'setAutopilot', difficulty: null });
+ assert.equal(player(game, auth.playerId).autopilot ?? null, null);
+ assert.deepEqual(game.pendingNexusHarkonnenBetrayal, f.initial.pendingNexusHarkonnenBetrayal);
+ }
+ await restored(f, game);
+});
diff --git a/tests/nexus-harkonnen-betrayal-runtime.test.ts b/tests/nexus-harkonnen-betrayal-runtime.test.ts
new file mode 100644
index 00000000..90cb27f8
--- /dev/null
+++ b/tests/nexus-harkonnen-betrayal-runtime.test.ts
@@ -0,0 +1,168 @@
+import assert from 'node:assert/strict';
+import {test} from 'node:test';
+import {applyAction,createGame,initializeHarkonnenNexusBetrayalGameForAudit,joinGame,newPlayer,normalizeAutomaticGame,viewGame,type Action,type Game} from '../game/engine';
+import {baseDeck} from '../game/cards';
+import {traitorDeck} from '../game/traitors';
+import {validateNexusTraitorSnapshot} from '../game/nexus-traitor-exchange';
+import {createHarkonnenNexusBetrayalFixture,advanceHarkonnenBetrayalToMentat,type HarkonnenBetrayalFixture} from './fixture-nexus-harkonnen-betrayal';
+
+function stock(game:Game):void {
+ validateNexusTraitorSnapshot({reserve:game.traitorReserve!,players:game.players.map(({id,faction,traitors})=>({id,faction,traitors}))},traitorDeck(game.players));
+ assert.deepEqual([...game.deck,...game.discard,...game.players.flatMap(p=>p.hand),...(game.auction?.cards??[])].map(c=>c.id).sort(),baseDeck().map(c=>c.id).sort());
+}
+function use(f:HarkonnenBetrayalFixture):Game {return applyAction(f.game,f.holder,{type:'nexusHarkonnenBetrayalUse',event:f.event});}
+function reject(game:Game,actor:string,action:Action):void {
+ const before=JSON.stringify(game);assert.throws(()=>applyAction(game,actor,action));assert.equal(JSON.stringify(game),before);
+}
+for(const advanced of [false,true]) for(const remote of [false,true]) {
+ void test(`${advanced?'Advanced':'Basic'} ${remote?'allied remote':'personal'} real call cancellation retires exact identity and native Mentat privately draws once`,()=>{
+ const f=createHarkonnenNexusBetrayalFixture({advanced,remote});
+ assert.equal(f.beforeCall.battle!.traitorCalls[f.provider],undefined);
+ assert.equal(f.beforeNativeCounter.response?.kind,remote?'harkonnenTraitor':undefined);
+ assert.equal(f.game.battle!.traitorCalls[f.provider],true);
+ const reaction=viewGame(f.game,f.holder).nexusHarkonnenBetrayalReaction!;
+ assert.equal(reaction.identity,f.identity);assert.equal(reaction.beneficiary,f.beneficiary);assert.equal(reaction.target,f.target);assert.equal(reaction.canUse,true);
+ assert.ok(f.game.players.find(p=>p.id===f.provider)!.revealedTraitors!.includes(f.identity));
+ const before=JSON.stringify(f.game),spent=use(f);assert.equal(JSON.stringify(f.game),before);
+ assert.equal(spent.nexusCards!.cards!.hands[f.holder],null);
+ assert.equal(spent.nexusCards!.cards!.discard.filter(c=>c==='harkonnen').length,1);
+ assert.equal(spent.players.find(p=>p.id===f.provider)!.traitors.includes(f.identity),false);
+ assert.equal(spent.players.find(p=>p.id===f.provider)!.revealedTraitors!.includes(f.identity),false);
+ assert.equal(spent.traitorReserve!.filter(c=>c===f.identity).length,1);
+ assert.equal(spent.battle!.traitorCalls[f.provider],false);
+ assert.equal(spent.battle!.traitorDeclarations![f.provider].identity,f.identity);
+ assert.equal(spent.pendingNexusHarkonnenReplacement!.identity,f.identity);
+ assert.equal(spent.pendingNexusHarkonnenReplacement!.status,'due');
+ stock(spent);
+ const top=spent.traitorReserve![0],mentat=advanceHarkonnenBetrayalToMentat(spent);
+ assert.equal(mentat.nexusHarkonnenReplacementHistory!.length,1);
+ assert.equal(mentat.nexusHarkonnenReplacementHistory![0].drawn,top);
+ assert.equal(mentat.players.find(p=>p.id===f.provider)!.traitors.length,4);
+ assert.equal(mentat.players.find(p=>p.id===f.provider)!.traitors.includes(top),true);
+ assert.equal(mentat.pendingNexusHarkonnenReplacement,null);stock(mentat);
+ const stable=JSON.stringify(mentat);
+ for(const p of mentat.players) viewGame(mentat,p.id);
+ assert.equal(JSON.stringify(mentat),stable);
+ assert.deepEqual(normalizeAutomaticGame(mentat),mentat);
+ assert.equal(mentat.lastBattleContext!.result,'normal');
+ reject(mentat,f.holder,{type:'nexusHarkonnenBetrayalUse',event:f.event});
+ });
+ void test(`${advanced?'Advanced':'Basic'} ${remote?'remote':'personal'} all-pass retains both physical cards and permits original traitor outcome once`,()=>{
+ const f=createHarkonnenNexusBetrayalFixture({advanced,remote});
+ let game=f.game;
+ for(const id of f.required) game=applyAction(game,id,{type:'nexusHarkonnenBetrayalPass',event:f.event});
+ assert.equal(game.pendingNexusHarkonnenBetrayal,null);
+ assert.equal(game.pendingNexusHarkonnenReplacement,null);
+ assert.ok(game.players.find(p=>p.id===f.provider)!.traitors.includes(f.identity));
+ assert.equal(game.nexusCards!.cards!.hands[f.holder],'harkonnen');
+ game=advanceHarkonnenBetrayalToMentat(game);
+ assert.equal(game.lastBattleContext!.result,'traitor');
+ assert.equal(game.nexusHarkonnenReplacementHistory!.length,0);
+ assert.equal(game.players.find(p=>p.id===f.provider)!.revealedTraitors!.filter(card=>card===f.identity).length,1);
+ reject(game,f.provider,f.callAction);stock(game);
+ });
+}
+void test('Wrong-face holder receives the same public acknowledgement but cannot spend another face',()=>{
+ const f=createHarkonnenNexusBetrayalFixture({face:'richese',remote:true}),view=viewGame(f.game,f.holder);
+ assert.equal(view.nexusHarkonnenBetrayalReaction!.canPass,true);
+ assert.equal(view.nexusHarkonnenBetrayalReaction!.canUse,false);
+ assert.equal(view.nexusHarkonnenBetrayalReaction!.blocked,null);
+ reject(f.game,f.holder,{type:'nexusHarkonnenBetrayalUse',event:f.event});
+ const passed=applyAction(f.game,f.holder,{type:'nexusHarkonnenBetrayalPass',event:f.event});
+ assert.equal(passed.nexusCards!.cards!.hands[f.holder],'richese');
+ assert.equal(passed.nexusHarkonnenBetrayalHistory![0].outcome,'pass');
+});
+void test('Partial acknowledgement survives refresh; ordinary votes lock while independent controls remain legal',()=>{
+ const f=createHarkonnenNexusBetrayalFixture({receiverCount:2,remote:true});
+ assert.equal(f.required.length,2);assert.equal(viewGame(f.game,f.holder).active,null);
+ const other=f.required.find(id=>id!==f.holder)!;
+ let game=applyAction(f.game,other,{type:'nexusHarkonnenBetrayalPass',event:f.event});
+ game=normalizeAutomaticGame(structuredClone(game));
+ assert.equal(viewGame(game,f.holder).active,f.holder);
+ assert.equal(viewGame(game,other).nexusHarkonnenBetrayalReaction!.hasPassed,true);
+ reject(game,other,{type:'nexusHarkonnenBetrayalPass',event:f.event});
+ reject(game,f.target,{type:'traitorCall',call:false});
+ const source=JSON.stringify(game.pendingNexusHarkonnenBetrayal);
+ for(const p of game.players) game=applyAction(game,p.id,{type:'setAutopilot',difficulty:'Easy'});
+ assert.equal(JSON.stringify(game.pendingNexusHarkonnenBetrayal),source);
+ game=applyAction(game,f.holder,{type:'nexusHarkonnenBetrayalUse',event:f.event});
+ assert.equal(game.nexusHarkonnenBetrayalHistory!.length,1);
+ assert.equal(game.nexusCards!.cards!.hands[other],'richese');
+});
+void test('Arbitrary credentials, payloads, stale event and committed native/source/stock corruption fail without cost or cleanup',()=>{
+ const f=createHarkonnenNexusBetrayalFixture({remote:true}),action={type:'nexusHarkonnenBetrayalUse',event:f.event};
+ reject(f.game,'absent',action);reject(f.game,f.provider,action);
+ reject(f.game,f.holder,{...action,event:`${f.event}-old`});
+ reject(f.game,f.holder,{...action,card:f.identity});
+ const corruptions:Array<(g:Game)=>void>=[
+ g=>{g.pendingNexusHarkonnenBetrayal!.source.declaration.identity='absent';},
+ g=>{g.battle!.plans[f.target].leader=g.players.find(p=>p.id===f.target)!.leaders.find(l=>l.id!==f.identity)!.id;},
+ g=>{g.battle!.nexusHarkonnenAllowance!.passed=[];},
+ g=>{g.players.find(p=>p.id===f.provider)!.ally=null;},
+ g=>{g.traitorReserve!.push(f.identity);},
+ g=>{g.deck.push(g.deck[0]);},
+ g=>{g.players.find(p=>p.id===f.provider)!.traitors=g.players.find(p=>p.id===f.provider)!.traitors.filter(c=>c!==f.identity);},
+ ];
+ for(const mutate of corruptions) {const game=structuredClone(f.game);mutate(game);reject(game,f.holder,action);}
+});
+void test('Original printed Karama remote counter preempts Nexus without either Nexus or traitor cost',()=>{
+ const f=createHarkonnenNexusBetrayalFixture({remote:true}),counter=f.beforeNativeCounter.players.find(p=>p.id!==f.provider&&p.hand.some(c=>c.effect==='karama'))!;
+ assert.ok(counter);const card=counter.hand.find(c=>c.effect==='karama')!;
+ let game=applyAction(f.beforeNativeCounter,counter.id,{type:'card',mode:'cancel',card:card.id});
+ for(let i=0;game.response&&i<100;i++) {const seat=game.players.find(p=>!game.response!.passed.includes(p.id))!;game=applyAction(game,seat.id,{type:'passResponse'});}
+ assert.equal(game.pendingNexusHarkonnenBetrayal,null);assert.equal(game.nexusHarkonnenBetrayalHistory!.length,0);
+ assert.equal(game.nexusCards!.cards!.hands[f.holder],'harkonnen');
+ assert.ok(game.players.find(p=>p.id===f.provider)!.traitors.includes(f.identity));
+ assert.ok(game.discard.some(c=>c.id===card.id));
+ assert.equal(game.battle!.traitorCalls[f.provider],false);stock(game);
+});
+void test('Actual Advanced BG Worthless conversion resolves before any Nexus declaration',()=>{
+ const initial=createGame('HARKBG',newPlayer('bg','BG','beneGesserit'),true);
+ joinGame(initial,newPlayer('h','Harkonnen','harkonnen'));joinGame(initial,newPlayer('a','Atreides','atreides'));joinGame(initial,newPlayer('g','Guild','guild'));
+ const f=createHarkonnenNexusBetrayalFixture({initial,remote:true,advanced:true});
+ const bg=f.beforeNativeCounter.players.find(p=>p.faction==='beneGesserit')!,card=bg.hand.find(c=>c.kind==='worthless')!;
+ assert.ok(card);
+ let game=applyAction(f.beforeNativeCounter,bg.id,{type:'card',mode:'cancel',card:card.id});
+ for(let i=0;game.response&&i<100;i++) {const seat=game.players.find(p=>!game.response!.passed.includes(p.id))!;game=applyAction(game,seat.id,{type:'passResponse'});}
+ assert.equal(game.pendingNexusHarkonnenBetrayal,null);assert.equal(game.nexusHarkonnenBetrayalHistory!.length,0);
+ assert.equal(game.battle!.traitorCalls[f.provider],false);assert.ok(game.discard.some(c=>c.id===card.id));
+ assert.equal(game.nexusCards!.cards!.hands[f.holder],'harkonnen');stock(game);
+});
+void test('A real retirement shuffle may return the same physical identity as the private native draw, never an earlier read',()=>{
+ const f=createHarkonnenNexusBetrayalFixture({remote:true}),original=Object.getOwnPropertyDescriptor(crypto,'getRandomValues');
+ let first=true,spent:Game;
+ crypto.getRandomValues=(array:T):T=>{
+ assert.ok(array);
+ const values=new Uint32Array(array.buffer,array.byteOffset,array.byteLength/4);
+ for(let i=0;ip.id===f.provider)!.traitors.length,3);
+ const mentat=advanceHarkonnenBetrayalToMentat(spent);
+ assert.equal(mentat.nexusHarkonnenReplacementHistory![0].drawn,f.identity);
+ assert.equal(mentat.players.find(p=>p.id===f.provider)!.traitors.filter(card=>card===f.identity).length,1);
+ assert.equal(mentat.players.find(p=>p.id===f.provider)!.revealedTraitors!.includes(f.identity),false);
+ const own=viewGame(mentat,f.provider),rival=viewGame(mentat,f.holder);
+ assert.ok(own.players.find(p=>p.id===f.provider)!.traitors!.includes(f.identity));
+ assert.equal(rival.players.find(p=>p.id===f.provider)!.traitors,undefined);
+ assert.equal('pendingNexusHarkonnenReplacement' in rival,false);
+ assert.equal('nexusHarkonnenReplacementHistory' in rival,false);
+ stock(mentat);
+});
+void test('Fresh admission rejects replay, modules and ordinary-game retrofit while preserving lobby immutably',()=>{
+ let lobby=createGame('HARKBOUND',newPlayer('h','H','harkonnen'),false);joinGame(lobby,newPlayer('g','G','guild'));
+ for(const p of lobby.players) lobby=applyAction(lobby,p.id,{type:'ready'});
+ lobby.nexusCards={cards:null,phase:null};
+ const before=JSON.stringify(lobby),started=initializeHarkonnenNexusBetrayalGameForAudit(lobby);
+ assert.equal(JSON.stringify(lobby),before);assert.equal(started.nexusHarkonnenBetrayalPreview,true);
+ assert.throws(()=>initializeHarkonnenNexusBetrayalGameForAudit(started));
+ const modular=structuredClone(lobby);modular.expansions=['ix'];assert.throws(()=>initializeHarkonnenNexusBetrayalGameForAudit(modular));
+});
diff --git a/tests/nexus-harkonnen-betrayal.test.ts b/tests/nexus-harkonnen-betrayal.test.ts
new file mode 100644
index 00000000..c5c8190e
--- /dev/null
+++ b/tests/nexus-harkonnen-betrayal.test.ts
@@ -0,0 +1,399 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { baseDeck, leaders, treacheryDeck } from '../game/cards';
+import { NEXUS_FACTIONS, type NexusState } from '../game/nexus-cards';
+import { createTraitorDeclaration } from '../game/traitor-declarations';
+import { traitorDeck } from '../game/traitors';
+import {
+ HarkonnenBetrayalError, closeHarkonnenBetrayalSource, createHarkonnenBetrayalReplacement,
+ createHarkonnenBetrayalSource, drawHarkonnenBetrayalReplacement,
+ harkonnenBetrayalEligible, harkonnenBetrayalResponders, initialHarkonnenBetrayalCursor,
+ validateHarkonnenBetrayalFrame, validateHarkonnenBetrayalHistory,
+ validateHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements,
+ validateHarkonnenBetrayalSource,
+ type HarkonnenBetrayalAuthority, type HarkonnenBetrayalContext,
+ type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement,
+} from '../game/nexus-harkonnen-betrayal';
+
+const parent = 'independently-produced-native-battle-parent';
+function context(remote = false, face: 'harkonnen' | 'richese' = 'harkonnen'): HarkonnenBetrayalContext {
+ const players: HarkonnenBetrayalContext['players'] = [
+ { id: 'h', faction: 'harkonnen', ally: remote ? 'f' : null },
+ { id: 'a', faction: 'atreides', ally: null },
+ { id: 'e', faction: 'emperor', ally: null },
+ { id: 'f', faction: 'fremen', ally: remote ? 'h' : null },
+ ];
+ const universe = traitorDeck(players.map(player => ({ leaders: leaders(player.faction) })));
+ const custody = [
+ { id: 'h', faction: 'harkonnen' as const, traitors: ['emperor-0', 'emperor-1', 'fremen-0', 'atreides-0'] },
+ { id: 'a', faction: 'atreides' as const, traitors: ['harkonnen-0'] },
+ { id: 'e', faction: 'emperor' as const, traitors: ['harkonnen-1'] },
+ { id: 'f', faction: 'fremen' as const, traitors: ['harkonnen-2'] },
+ ];
+ const held = custody.flatMap(player => player.traitors);
+ const otherFace = face === 'harkonnen' ? 'richese' : 'guild';
+ return {
+ status: 'playing', phase: 6, turn: 2, advanced: false, sequence: 0, players,
+ cards: { version: 1, deck: NEXUS_FACTIONS.filter(card => card !== face && card !== otherFace),
+ discard: [], hands: { h: null, a: face, e: otherFace, f: null } },
+ physicalCards: baseDeck(), universe,
+ traitors: { reserve: universe.filter(identity => !held.includes(identity)), players: custody },
+ battle: { event: 'native-battle-0', attacker: remote ? 'f' : 'h', defender: 'e', heroLeaderIds: [],
+ plans: { [remote ? 'f' : 'h']: { leader: remote ? 'fremen-1' : 'harkonnen-3' },
+ e: { leader: 'emperor-0' } } },
+ };
+}
+function authority(ctx: HarkonnenBetrayalContext): HarkonnenBetrayalAuthority {
+ const remote = ctx.battle.attacker !== 'h';
+ const declaration = createTraitorDeclaration({ ...ctx.battle,
+ players: ctx.players.map(player => ({ ...player,
+ traitors: ctx.traitors.players.find(seat => seat.id === player.id)!.traitors })),
+ }, 'h');
+ return { provider: 'h', declaration, nativeWindow: remote ? 'harkonnenTraitor' : 'direct',
+ nativeContext: `native-allowed:${ctx.battle.event}`,
+ nativeRequired: remote ? ['a', 'e'] : [], nativePassed: remote ? ['e', 'a'] : [] };
+}
+function closed(ctx = context(), outcome: 'pass' | 'use' = 'use') {
+ const native = authority(ctx);
+ const source = createHarkonnenBetrayalSource(ctx, native, parent);
+ return closeHarkonnenBetrayalSource(source, outcome, outcome === 'use' ? 'a' : null,
+ initialHarkonnenBetrayalCursor());
+}
+function later(ctx: HarkonnenBetrayalContext): HarkonnenBetrayalContext {
+ const next = structuredClone(ctx);
+ next.sequence++;
+ next.battle.event = `native-battle-${next.sequence}`;
+ next.battle.plans.e.leader = 'emperor-1';
+ const hand = next.traitors.players.find(player => player.id === 'h')!.traitors;
+ hand.splice(hand.indexOf('emperor-0'), 1);
+ next.traitors.reserve.unshift('emperor-0');
+ next.cards.hands.a = null;
+ next.cards.discard.push('harkonnen');
+ return next;
+}
+function freeze(value: unknown): void {
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return;
+ for (const child of Object.values(value)) freeze(child);
+ Object.freeze(value);
+}
+
+for (const remote of [false, true]) {
+ for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} ${remote ? 'allied remote' : 'personal'} call binds its canonical declaration and original allowed native source`, () => {
+ const ctx = { ...context(remote), advanced }, native = authority(ctx);
+ const source = createHarkonnenBetrayalSource(ctx, native, parent);
+ assert.equal(source.declaration.beneficiary, remote ? 'f' : 'h');
+ assert.equal(source.declaration.target, 'e');
+ assert.equal(source.declaration.identity, 'emperor-0');
+ validateHarkonnenBetrayalFrame(ctx, source, native, parent, ['a', 'e'], ['e']);
+ const changed: HarkonnenBetrayalAuthority[] = [
+ { ...native, provider: 'a' }, { ...native, nativeContext: 'different-native-attempt' },
+ { ...native, nativeWindow: remote ? 'direct' : 'harkonnenTraitor' },
+ { ...native, declaration: { ...native.declaration, beneficiary: 'e' } },
+ { ...native, declaration: { ...native.declaration, target: 'a' } },
+ { ...native, declaration: { ...native.declaration, leader: 'emperor-1' } },
+ { ...native, declaration: { ...native.declaration, identity: 'emperor-1' } },
+ { ...native, declaration: { ...native.declaration, voter: 'a' } },
+ { ...native, declaration: { ...native.declaration, event: 'another-battle' } },
+ ];
+ for (const invalid of changed) {
+ assert.throws(() => validateHarkonnenBetrayalSource(ctx, source, invalid, parent), HarkonnenBetrayalError);
+ }
+ for (const invalid of [
+ { ...ctx, status: 'setup' }, { ...ctx, phase: 7 }, { ...ctx, turn: 3 },
+ { ...ctx, sequence: 1 }, { ...ctx, advanced: !advanced },
+ ]) {
+ assert.throws(() => validateHarkonnenBetrayalSource(invalid, source, native, parent), HarkonnenBetrayalError);
+ }
+ assert.throws(() => validateHarkonnenBetrayalSource(ctx, source, native, 'other-parent'), HarkonnenBetrayalError);
+ const kh = structuredClone(ctx);
+ kh.battle.plans.e.kwisatz = true;
+ assert.throws(() => createHarkonnenBetrayalSource(kh, native, parent), HarkonnenBetrayalError);
+ });
+ }
+}
+
+void test('remote calls require all original native passes and correct live alliance roles', () => {
+ const ctx = context(true), native = authority(ctx);
+ for (const patch of [
+ { nativePassed: ['e'] }, { nativePassed: ['e', 'e', 'a'] },
+ { nativeRequired: ['a', 'e', 'absent'], nativePassed: ['e', 'a', 'absent'] },
+ { nativePassed: ['e', 'a', 'absent'] }, { nativeContext: '' },
+ ]) {
+ assert.throws(() => createHarkonnenBetrayalSource(ctx, { ...native, ...patch }, parent), HarkonnenBetrayalError);
+ }
+ const unallied = structuredClone(ctx);
+ unallied.players = unallied.players.map(player => ({ ...player, ally: null }));
+ assert.throws(() => createHarkonnenBetrayalSource(unallied, native, parent), HarkonnenBetrayalError);
+ const personal = context(), direct = authority(personal);
+ assert.throws(() => createHarkonnenBetrayalSource(personal,
+ { ...direct, nativeRequired: ['a'], nativePassed: ['a'] }, parent), HarkonnenBetrayalError);
+});
+
+void test('the matched opposing leader can be a captured classic leader, not only its faction original discs', () => {
+ const ctx = context();
+ ctx.battle.plans.e.leader = 'fremen-0';
+ const native = authority(ctx);
+ const source = createHarkonnenBetrayalSource(ctx, native, parent);
+ const result = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
+ assert.equal(source.declaration.target, 'e');
+ assert.equal(source.declaration.identity, 'fremen-0');
+ assert.equal(createHarkonnenBetrayalReplacement(result.receipt).identity, 'fremen-0');
+ validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
+});
+
+void test('a personal native Harkonnen defender declares the actual attacking leader', () => {
+ const ctx = context();
+ ctx.battle.attacker = 'e';
+ ctx.battle.defender = 'h';
+ const native = authority(ctx);
+ native.nativeWindow = 'direct';
+ native.nativeRequired = [];
+ native.nativePassed = [];
+ const source = createHarkonnenBetrayalSource(ctx, native, parent);
+ assert.equal(source.declaration.beneficiary, 'h');
+ assert.equal(source.declaration.target, 'e');
+ const result = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
+ validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
+});
+
+void test('public acknowledgment membership is face neutral while only the private singleton owner may Use', () => {
+ const real = context(), wrong = context(false, 'richese');
+ const realSource = createHarkonnenBetrayalSource(real, authority(real), parent);
+ const wrongSource = createHarkonnenBetrayalSource(wrong, authority(wrong), parent);
+ assert.deepEqual(harkonnenBetrayalResponders(real.cards, real.players), ['a', 'e']);
+ assert.deepEqual(harkonnenBetrayalResponders(wrong.cards, wrong.players), ['a', 'e']);
+ assert.deepEqual(realSource.required, wrongSource.required);
+ assert.equal(realSource.event, wrongSource.event);
+ assert.equal(harkonnenBetrayalEligible(real.cards, real.players, 'a'), true);
+ assert.equal(harkonnenBetrayalEligible(wrong.cards, wrong.players, 'a'), false);
+ for (const holder of ['h', 'e', 'f', 'absent', null]) {
+ assert.throws(() => closeHarkonnenBetrayalSource(realSource, 'use', holder,
+ initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
+ }
+ assert.throws(() => closeHarkonnenBetrayalSource(wrongSource, 'use', 'a',
+ initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
+ const pass = closeHarkonnenBetrayalSource(wrongSource, 'pass', null, initialHarkonnenBetrayalCursor());
+ validateHarkonnenBetrayalHistory([pass.receipt], pass.cursor, wrong.universe);
+ const allied = context();
+ allied.players = allied.players.map(player => ({ ...player,
+ ally: player.id === 'a' ? 'f' : player.id === 'f' ? 'a' : null }));
+ assert.deepEqual(harkonnenBetrayalResponders(allied.cards, allied.players), ['e']);
+ assert.equal(harkonnenBetrayalEligible(allied.cards, allied.players, 'a'), false);
+ const nativeHeld = context();
+ nativeHeld.cards.hands.a = null;
+ nativeHeld.cards.hands.h = 'harkonnen';
+ assert.equal(harkonnenBetrayalEligible(nativeHeld.cards, nativeHeld.players, 'h'), false);
+ assert.deepEqual(harkonnenBetrayalResponders(nativeHeld.cards, nativeHeld.players), ['e']);
+});
+
+void test('frame rejects missing, reordered, duplicated or foreign responders and passes', () => {
+ const ctx = context(), native = authority(ctx);
+ const source = createHarkonnenBetrayalSource(ctx, native, parent);
+ for (const [required, passed] of [
+ [['a'], []], [['e', 'a'], []], [['a', 'e', 'h'], []], [['a', 'e', 'e'], []],
+ [['a', 'e'], ['a', 'a']], [['a', 'e'], ['h']], [['a', 'e'], ['absent']],
+ ]) {
+ assert.throws(() => validateHarkonnenBetrayalFrame(ctx, source, native, parent, required, passed), HarkonnenBetrayalError);
+ }
+ const changed = structuredClone(ctx);
+ changed.cards.hands.e = null;
+ changed.cards.deck.push('richese');
+ assert.throws(() => validateHarkonnenBetrayalFrame(changed, source, native, parent, ['a', 'e'], []), HarkonnenBetrayalError);
+});
+
+void test('canonical stock rejects fabricated, duplicate, missing or foreign physical cards and universes', () => {
+ const ctx = context(), native = authority(ctx);
+ for (const cards of [baseDeck().slice(1), [...baseDeck(), baseDeck()[0]], treacheryDeck(['ix']),
+ baseDeck().map((card, index) => index === 0 ? baseDeck()[1] : card)]) {
+ assert.throws(() => createHarkonnenBetrayalSource({ ...ctx, physicalCards: cards }, native, parent));
+ }
+ for (const patch of [{ name: 'Forged' }, { kind: 'special' }, { effect: 'fake' },
+ { id: 'fake' }, { extra: 'private-copy' }]) {
+ assert.throws(() => createHarkonnenBetrayalSource({ ...ctx,
+ physicalCards: [Object.assign({}, ctx.physicalCards[0], patch), ...ctx.physicalCards.slice(1)] }, native, parent));
+ }
+ for (const universe of [ctx.universe.slice(1), [...ctx.universe, ctx.universe[0]],
+ ctx.universe.map((identity, index) => index === 0 ? 'cheap-hero-traitor' : identity)]) {
+ assert.throws(() => createHarkonnenBetrayalSource({ ...ctx, universe }, native, parent));
+ }
+ const fabricated = structuredClone(ctx);
+ fabricated.universe = fabricated.universe.map(identity => identity === 'emperor-0' ? 'forged-leader' : identity);
+ fabricated.traitors.players[0].traitors[0] = 'forged-leader';
+ assert.throws(() => createHarkonnenBetrayalSource(fabricated, native, parent));
+ for (const variant of ['missing', 'duplicate', 'foreign', 'wrongRoster'] as const) {
+ const changed = structuredClone(ctx);
+ if (variant === 'missing') changed.traitors.reserve.pop();
+ if (variant === 'duplicate') changed.traitors.reserve.push('emperor-0');
+ if (variant === 'foreign') changed.traitors.reserve[0] = 'foreign-identity';
+ if (variant === 'wrongRoster') changed.traitors.players[0].id = 'absent';
+ assert.throws(() => createHarkonnenBetrayalSource(changed, native, parent));
+ }
+});
+
+void test('Nexus census cannot hide missing, duplicate or foreign custody behind public parity', () => {
+ const variants: NexusState[] = [];
+ const duplicate = context().cards; duplicate.deck.push('harkonnen'); variants.push(duplicate);
+ const missing = context().cards; missing.hands.a = null; variants.push(missing);
+ const extra = context().cards; extra.hands.foreign = null; variants.push(extra);
+ const unknown = context().cards; Object.assign(unknown.deck, { 0: 'unprinted-face' }); variants.push(unknown);
+ for (const cards of variants) {
+ assert.throws(() => harkonnenBetrayalResponders(cards, context().players));
+ assert.throws(() => harkonnenBetrayalEligible(cards, context().players, 'a'));
+ }
+ const excluded = context();
+ excluded.players = excluded.players.map(player => player.id === 'f' ? { ...player, faction: 'ixians' } : player);
+ assert.throws(() => createHarkonnenBetrayalSource(excluded, authority(context()), parent));
+});
+
+void test('pending source freezes current physical custody and native identity before any cost', () => {
+ const ctx = context(), native = authority(ctx);
+ const source = createHarkonnenBetrayalSource(ctx, native, parent);
+ const transferred = structuredClone(ctx);
+ transferred.traitors.players[0].traitors.shift();
+ transferred.traitors.players[1].traitors.push('emperor-0');
+ assert.throws(() => validateHarkonnenBetrayalSource(transferred, source, native, parent), HarkonnenBetrayalError);
+ const reordered = structuredClone(ctx);
+ reordered.traitors.reserve.reverse();
+ assert.throws(() => validateHarkonnenBetrayalSource(reordered, source, native, parent), HarkonnenBetrayalError);
+ const undeclared = structuredClone(ctx);
+ undeclared.traitors.players[0].traitors.shift();
+ undeclared.traitors.reserve.push('emperor-0');
+ assert.throws(() => createHarkonnenBetrayalSource(undeclared, native, parent), HarkonnenBetrayalError);
+ for (const patch of [
+ { signature: 'forged' }, { event: 'expired' }, { identity: 'forged' },
+ { provider: 'a' }, { required: ['a'] }, { eligible: 'e' }, { inventory: 'forged' },
+ { parent: 'forged' }, { extra: 'client-selector' },
+ ]) {
+ const corrupt = Object.assign(structuredClone(source), patch);
+ assert.throws(() => validateHarkonnenBetrayalSource(ctx, corrupt, native, parent), HarkonnenBetrayalError);
+ }
+});
+
+void test('quotes and closure preserve frozen inputs and produce independent saved values', () => {
+ const ctx = context(true), native = authority(ctx), snapshot = structuredClone({ ctx, native });
+ freeze(ctx); freeze(native);
+ const source = createHarkonnenBetrayalSource(ctx, native, parent);
+ freeze(source);
+ const cursor = initialHarkonnenBetrayalCursor(); freeze(cursor);
+ const result = closeHarkonnenBetrayalSource(source, 'use', 'a', cursor);
+ validateHarkonnenBetrayalFrame(ctx, source, native, parent, source.required, ['e']);
+ validateHarkonnenBetrayalHistory([result.receipt], result.cursor, ctx.universe);
+ assert.deepEqual({ ctx, native }, snapshot);
+ assert.deepEqual(cursor, initialHarkonnenBetrayalCursor());
+ assert.notEqual(result.receipt.source, source);
+ assert.notEqual(result.receipt.source.declaration, source.declaration);
+ assert.notEqual(result.receipt.source.battle, source.battle);
+ assert.notEqual(result.receipt.source.required, source.required);
+});
+
+void test('closed cursor is independent, exactly once, and rejects forged or missing history', () => {
+ const ctx = context(), native = authority(ctx), source = createHarkonnenBetrayalSource(ctx, native, parent);
+ const use = closed(ctx), pass = closed(ctx, 'pass');
+ assert.throws(() => closeHarkonnenBetrayalSource(source, 'use', 'a', use.cursor), HarkonnenBetrayalError);
+ assert.throws(() => closeHarkonnenBetrayalSource(source, 'pass', 'a', initialHarkonnenBetrayalCursor()), HarkonnenBetrayalError);
+ const next = later(ctx);
+ const nextSource = createHarkonnenBetrayalSource(next, authority(next), 'next-native-parent');
+ for (const cursor of [
+ { sequence: 0, signature: 'forged-genesis' },
+ { sequence: 1, signature: 'forged-head' },
+ { sequence: 1, signature: JSON.stringify(['harkonnenBetrayalHead', use.receipt.signature, 'use', 'a']) },
+ { sequence: 2, signature: use.cursor.signature },
+ ]) {
+ assert.throws(() => closeHarkonnenBetrayalSource(nextSource, 'pass', null, cursor), HarkonnenBetrayalError);
+ }
+ for (const history of [[], [use.receipt, use.receipt], [pass.receipt]]) {
+ assert.throws(() => validateHarkonnenBetrayalHistory(history, use.cursor, ctx.universe), HarkonnenBetrayalError);
+ }
+ for (const patch of [{ previous: 'forged' }, { signature: 'forged' }, { outcome: 'pass' }, { holder: 'e' }]) {
+ assert.throws(() => validateHarkonnenBetrayalHistory([Object.assign(structuredClone(use.receipt), patch)], use.cursor), HarkonnenBetrayalError);
+ }
+ assert.throws(() => validateHarkonnenBetrayalHistory([use.receipt], { ...use.cursor, signature: 'forged' }), HarkonnenBetrayalError);
+ assert.throws(() => validateHarkonnenBetrayalHistory([use.receipt], use.cursor, ctx.universe.slice(1)), HarkonnenBetrayalError);
+});
+
+void test('historical Use and its pending replacement survive return/recycling and later all-pass native attempts', () => {
+ const ctx = context(), first = closed(ctx);
+ const replacement = createHarkonnenBetrayalReplacement(first.receipt);
+ const next = later(ctx), native = authority(next);
+ const source = createHarkonnenBetrayalSource(next, native, 'independent-later-native-parent');
+ const pass = closeHarkonnenBetrayalSource(source, 'pass', null, first.cursor);
+ const history = [first.receipt, pass.receipt];
+ validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
+ validateHarkonnenBetrayalReplacement(replacement, history, pass.cursor, next.universe);
+ validateHarkonnenBetrayalReplacements(history, pass.cursor, [replacement], next.universe);
+ assert.equal(replacement.event, first.receipt.source.event);
+ assert.notEqual(replacement.event, pass.receipt.source.event);
+ // A real shuffle can put the exact returned identity on top again.
+ const drawn = drawHarkonnenBetrayalReplacement(replacement, history, pass.cursor,
+ next.traitors.reserve[0], next.universe);
+ assert.equal(drawn.drawn, replacement.identity);
+ next.traitors.players[0].traitors.push(next.traitors.reserve.shift()!);
+ next.cards.discard = [];
+ next.cards.hands.f = 'harkonnen';
+ validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
+ validateHarkonnenBetrayalReplacements(history, pass.cursor, [drawn], next.universe);
+ assert.equal(drawn.status, 'drawn');
+ assert.throws(() => drawHarkonnenBetrayalReplacement(drawn, history, pass.cursor, 'emperor-0'), HarkonnenBetrayalError);
+ assert.equal(replacement.status, 'due');
+ assert.equal(replacement.drawn, null);
+});
+
+void test('each successful Use has exactly one due or actual completed canonical private draw', () => {
+ const ctx = context(), use = closed(ctx), pass = closed(ctx, 'pass');
+ const due = createHarkonnenBetrayalReplacement(use.receipt), history = [use.receipt];
+ assert.throws(() => createHarkonnenBetrayalReplacement(pass.receipt), HarkonnenBetrayalError);
+ assert.throws(() => validateHarkonnenBetrayalReplacements(history, use.cursor, []), HarkonnenBetrayalError);
+ assert.throws(() => validateHarkonnenBetrayalReplacements(history, use.cursor, [due, due]), HarkonnenBetrayalError);
+ assert.throws(() => validateHarkonnenBetrayalReplacements([pass.receipt], pass.cursor, [due]), HarkonnenBetrayalError);
+ for (const patch of [
+ { provider: 'a' }, { turn: 3 }, { event: 'another-use' }, { identity: 'emperor-1' },
+ { sequence: 1 }, { receipt: pass.cursor.signature }, { signature: 'forged' },
+ { status: 'drawn', drawn: null }, { status: 'due', drawn: 'emperor-0' },
+ { status: 'drawn', drawn: 'forged-identity' }, { extra: 'selector' },
+ ]) {
+ const corrupt = Object.assign(structuredClone(due), patch);
+ assert.throws(() => validateHarkonnenBetrayalReplacement(corrupt, history, use.cursor), HarkonnenBetrayalError);
+ }
+ assert.throws(() => drawHarkonnenBetrayalReplacement(due, history, use.cursor, 'forged-identity'), HarkonnenBetrayalError);
+ freeze(due);
+ const drawn = drawHarkonnenBetrayalReplacement(due, history, use.cursor, 'atreides-1');
+ validateHarkonnenBetrayalReplacements(history, use.cursor, [drawn]);
+ assert.equal(drawn.drawn, 'atreides-1');
+});
+
+void test('bounded prototype rejects simultaneous replacement obligations but retains multiple completed Uses', () => {
+ const ctx = context(), first = closed(ctx), firstDue = createHarkonnenBetrayalReplacement(first.receipt);
+ const next = later(ctx);
+ next.turn = 3;
+ next.cards.discard = [];
+ next.cards.hands.a = 'harkonnen';
+ const source = createHarkonnenBetrayalSource(next, authority(next), 'next-turn-native-parent');
+ const second = closeHarkonnenBetrayalSource(source, 'use', 'a', first.cursor);
+ const secondDue = createHarkonnenBetrayalReplacement(second.receipt), history = [first.receipt, second.receipt];
+ assert.throws(() => validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDue, secondDue]), HarkonnenBetrayalError);
+ const firstDrawn = drawHarkonnenBetrayalReplacement(firstDue, history, second.cursor, 'emperor-0');
+ validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDrawn, secondDue]);
+ const secondDrawn = drawHarkonnenBetrayalReplacement(secondDue, history, second.cursor, 'emperor-1');
+ validateHarkonnenBetrayalReplacements(history, second.cursor, [firstDrawn, secondDrawn]);
+ assert.throws(() => validateHarkonnenBetrayalReplacements(history, second.cursor, [secondDrawn]), HarkonnenBetrayalError);
+});
+
+void test('native attempt history serialization stays linear with nonrecursive current and previous heads', () => {
+ const ctx = context(), history: HarkonnenBetrayalReceipt[] = [];
+ let cursor = initialHarkonnenBetrayalCursor(), halfway = 0;
+ const replacements: HarkonnenBetrayalReplacement[] = [];
+ for (let sequence = 0; sequence < 80; sequence++) {
+ ctx.sequence = sequence;
+ ctx.battle.event = `native-battle-${sequence}`;
+ const source = createHarkonnenBetrayalSource(ctx, authority(ctx), `original-native-parent-${sequence}`);
+ const result = closeHarkonnenBetrayalSource(source, 'pass', null, cursor);
+ history.push(result.receipt);
+ cursor = result.cursor;
+ if (sequence === 39) halfway = JSON.stringify(history).length;
+ }
+ validateHarkonnenBetrayalHistory(history, cursor, ctx.universe);
+ validateHarkonnenBetrayalReplacements(history, cursor, replacements, ctx.universe);
+ assert.ok(JSON.stringify(history).length < halfway * 2.1);
+ assert.ok(cursor.signature.length < 400);
+});
diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts
index ce180341..aa9b5016 100644
--- a/tests/prototype-room.test.ts
+++ b/tests/prototype-room.test.ts
@@ -652,3 +652,156 @@ void test('Ixian Betrayal CLI preserves a private consistent exact-version backu
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
});
+
+function harkonnenLobby(advanced = true, seats = 3) {
+ const game = createGame('PROTOTYP', newPlayer('h', 'Native Harkonnen', 'harkonnen'), advanced);
+ const factions = ['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit'] as const;
+ for (let index = 0; index < seats - 1; index++)
+ joinGame(game, newPlayer(`p${index}`, `Original ${index}`, factions[index]));
+ for (const player of game.players) player.ready = true;
+ return game;
+}
+
+for (const advanced of [false, true]) {
+ for (const seats of [2, 3, 4, 5, 6]) {
+ void test(`Harkonnen Betrayal ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry preserves seats and leaves all Nexus physically undealt`, t => {
+ const { db } = fixture(t);
+ const lobby = harkonnenLobby(advanced, seats);
+ db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(lobby), lobby.code);
+ const credentials = db.prepare('SELECT * FROM seats').all();
+ const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
+ const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
+ assert.throws(() => startPrototypeRoom(db, lobby.code, 6, 'harkonnen-betrayal'));
+ assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
+ const result = startPrototypeRoom(db, lobby.code, 7, 'harkonnen-betrayal');
+ const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(lobby.code)!.state as string) as Game;
+ assert.equal(result.version, 8);
+ assert.equal(saved.version, 8);
+ assert.equal(saved.status, 'setup');
+ assert.equal(saved.nexusHarkonnenBetrayalPreview, true);
+ assert.equal(saved.advanced, advanced);
+ assert.equal(saved.host, lobby.host);
+ assert.deepEqual(saved.expansions, []);
+ assert.deepEqual(saved.playerPositions, lobby.playerPositions);
+ assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
+ const stock = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand)];
+ assert.equal(stock.length, 33);
+ assert.equal(new Set(stock.map(card => card.id)).size, 33);
+ assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
+ assert.deepEqual(saved.nexusCards!.cards!.discard, []);
+ for (const player of saved.players) {
+ assert.equal(saved.nexusCards!.cards!.hands[player.id], null);
+ const view = viewGame(saved, player.id);
+ assert.equal(view.nexusHarkonnenBetrayalReaction, null);
+ assert.equal(view.nexusCards!.card, null);
+ assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
+ }
+ const started = db.prepare('SELECT * FROM rooms ORDER BY code').all();
+ assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'harkonnen-betrayal'));
+ assert.throws(() => startPrototypeRoom(db, lobby.code, 8, 'nexus'));
+ assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), started);
+ assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
+ assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
+ });
+ }
+}
+
+void test('Harkonnen Betrayal rejects incompatible lobbies, overlays and ordinary public starts without changing durable records', t => {
+ const { db } = fixture(t);
+ const native = harkonnenLobby();
+ const changes: ((game: Game) => void)[] = [
+ game => { game.players[0].ready = false; },
+ game => { game.code = 'NOTMATCH'; },
+ game => { game.players[0].faction = 'fremen'; },
+ game => { game.players[1].faction = 'harkonnen'; },
+ ...(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const).map(faction =>
+ (game: Game) => { game.players[1].faction = faction; }),
+ ...(['ix', 'choam', 'ecaz'] as const).map(expansion =>
+ (game: Game) => { game.expansions = [expansion]; }),
+ game => { game.discoveryEnabled = true; },
+ game => { game.ecazTreachery = true; },
+ game => { game.semutaPreview = true; },
+ game => { game.nexusIxianReplacementPreview = true; },
+ game => { game.nexusIxianBetrayalPreview = true; },
+ game => { game.nexusHarkonnenBetrayalPreview = true; },
+ game => { game.kullPreview = true; },
+ game => { game.nexusKullPreview = true; },
+ game => { game.guildBetrayalPreview = true; },
+ game => { game.richeseBetrayalPreview = true; },
+ game => { game.moritaniAssassinatePreview = true; },
+ game => { game.leaderSkills = createLeaderSkills(() => 0.5); },
+ game => { game.players.splice(1); },
+ game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
+ ];
+ const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
+ const game = applyAction(native, native.host, { type, enabled: true });
+ for (const player of game.players) player.ready = true;
+ return game;
+ });
+ for (const game of [...changes.map(change => {
+ const game = structuredClone(native);
+ change(game);
+ return game;
+ }), ...modules]) {
+ db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
+ const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
+ const seats = db.prepare('SELECT * FROM seats').all();
+ assert.throws(() => startPrototypeRoom(db, native.code, 7, 'harkonnen-betrayal'));
+ assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
+ assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
+ }
+ const original = structuredClone(native);
+ for (const action of [{ type: 'start', profile: 'harkonnen-betrayal' }, { type: 'initializeHarkonnenNexusBetrayalGameForAudit' }])
+ assert.throws(() => applyAction(native, native.host, action));
+ assert.deepEqual(native, original);
+});
+
+void test('Harkonnen Betrayal CLI backs up every room and seat privately before the exact fresh-version start and cannot redeal or overwrite proof', t => {
+ const directory = mkdtempSync(join(tmpdir(), 'dune-harkonnen-betrayal-'));
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
+ const database = join(directory, 'rooms.sqlite');
+ const db = new DatabaseSync(database);
+ db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
+ const lobby = harkonnenLobby();
+ db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(lobby.code, JSON.stringify(lobby), 7, 100);
+ const other = harkonnenLobby(false, 2);
+ other.code = 'KEEPGAME';
+ db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
+ startPrototypeRoom(db, other.code, 9, 'harkonnen-betrayal');
+ for (const player of lobby.players)
+ db.prepare('INSERT INTO seats VALUES(?,?,?)').run(lobby.code, player.id, 'isolated-test-' + player.id);
+ const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
+ const seats = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
+ db.close();
+ const output = join(directory, 'private-proof');
+ const invoke = (version: number, out: string) => spawnSync(process.execPath, [
+ '--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
+ '--profile', 'harkonnen-betrayal', '--db', database, '--room', lobby.code,
+ '--version', String(version), '--out', out,
+ ], { encoding: 'utf8', timeout: 120000 });
+ const result = invoke(7, output);
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(statSync(output).mode & 0o777, 0o700);
+ for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
+ assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
+ const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
+ assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
+ assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
+ backup.close();
+ const current = new DatabaseSync(database, { readOnly: true });
+ t.after(() => current.close());
+ const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
+ assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
+ assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
+ const row = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(lobby.code)!;
+ const saved = JSON.parse(row.state as string) as Game;
+ assert.equal(row.version, 8);
+ assert.equal(saved.nexusHarkonnenBetrayalPreview, true);
+ assert.equal(saved.status, 'setup');
+ assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]), lobby.players.map(p => [p.id, p.name, p.faction]));
+ assert.notEqual(invoke(8, output).status, 0);
+ assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
+ assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
+ assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
+ assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
+});
diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts
index 36b9aee6..bfb91b52 100644
--- a/tools/prototype-room.ts
+++ b/tools/prototype-room.ts
@@ -14,6 +14,7 @@ import {
initializeGuildBetrayalGameForAudit,
initializeIxianNexusReplacementGameForAudit,
initializeIxianNexusBetrayalGameForAudit,
+ initializeHarkonnenNexusBetrayalGameForAudit,
viewGame,
type Game,
} from '../game/engine';
@@ -33,6 +34,7 @@ export const PROTOTYPE_PROFILES = [
'guild-betrayal',
'ixian-replacement',
'ixian-betrayal',
+ 'harkonnen-betrayal',
] as const;
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
export function isPrototypeProfile(value: string): value is PrototypeProfile {
@@ -108,6 +110,11 @@ export function startPrototypeRoom(
...initial,
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
})
+ : profile === 'harkonnen-betrayal'
+ ? initializeHarkonnenNexusBetrayalGameForAudit({
+ ...initial,
+ nexusCards: initial.nexusCards ?? { cards: null, phase: null },
+ })
: profile === 'factions'
? initializeFactionExpansionsGameForAudit(initial)
: profile === 'nexus'
diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts
index 76a86f27..0032c051 100644
--- a/tools/start-prototype.ts
+++ b/tools/start-prototype.ts
@@ -20,7 +20,7 @@ async function main() {
});
if (values.help) {
console.log(
- 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
+ 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
);
return;
}