From d820c8a4143cc20ee0fc197520cb1901e761de53 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 10 Sep 2026 10:02:31 +1000
Subject: [PATCH] Implement Atreides Nexus inspections with durable battle
commitments
---
components/battle-preparation.tsx | 72 +++-
components/game-table.tsx | 46 +--
components/nexus-cards.tsx | 45 ++-
docs/COMPONENT_INVENTORY.md | 2 +-
docs/IMPLEMENTATION_STATUS.md | 2 +
docs/NEXUS_ATREIDES_RULES.md | 111 ++++++
docs/NEXUS_ATREIDES_RUNTIME.md | 37 ++
docs/NEXUS_CARD_RUNTIME.md | 2 +
game/battle-inspections.ts | 487 +++++++++++++++++++++++
game/bots.ts | 90 +++--
game/choam-karama.ts | 14 +-
game/engine.ts | 230 ++++++++++-
game/karama-context.ts | 5 +
game/reference.ts | 18 +-
game/richese-cancellation.ts | 1 +
tests/battle-inspections.test.ts | 460 +++++++++++++++++++++
tests/fixture-nexus-inspection.ts | 180 +++++++++
tests/fixture-nexus-room-store.ts | 87 ++++
tests/nexus-card-recovery.test.ts | 90 +----
tests/nexus-inspection-bots.test.ts | 327 +++++++++++++++
tests/nexus-inspection-controls.test.ts | 219 ++++++++++
tests/nexus-inspection-engine.test.ts | 448 +++++++++++++++++++++
tests/nexus-inspection-integrity.test.ts | 153 +++++++
tests/nexus-inspection-recovery.test.ts | 113 ++++++
tests/nexus-residual-ambiguity.test.ts | 114 ++++++
25 files changed, 3171 insertions(+), 182 deletions(-)
create mode 100644 docs/NEXUS_ATREIDES_RULES.md
create mode 100644 docs/NEXUS_ATREIDES_RUNTIME.md
create mode 100644 game/battle-inspections.ts
create mode 100644 tests/battle-inspections.test.ts
create mode 100644 tests/fixture-nexus-inspection.ts
create mode 100644 tests/fixture-nexus-room-store.ts
create mode 100644 tests/nexus-inspection-bots.test.ts
create mode 100644 tests/nexus-inspection-controls.test.ts
create mode 100644 tests/nexus-inspection-engine.test.ts
create mode 100644 tests/nexus-inspection-integrity.test.ts
create mode 100644 tests/nexus-inspection-recovery.test.ts
create mode 100644 tests/nexus-residual-ambiguity.test.ts
diff --git a/components/battle-preparation.tsx b/components/battle-preparation.tsx
index c4377cae..4db23057 100644
--- a/components/battle-preparation.tsx
+++ b/components/battle-preparation.tsx
@@ -11,7 +11,62 @@ import {
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import type { Action, GameView } from '@/game/engine';
+import type { Action, GameView, PlanField } from '@/game/engine';
+import type { CommittedPlanElement } from '@/game/battle-inspections';
+
+/** Read only the recipient's projected commitments, including an explicit null. */
+export function battlePlanCommitments(game: GameView): Partial> {
+ const battle = game.battle;
+ if (!battle) return {};
+ const commitments = battle.ownCommitments ?? (
+ battle.insight && battle.prescience && battle.prescience.player !== game.me &&
+ [battle.attacker, battle.defender].includes(game.me)
+ ? [{ source: 'native' as const, beneficiary: battle.prescience.player, target: game.me,
+ field: battle.insight.field, value: battle.insight.value }]
+ : []
+ );
+ return Object.fromEntries(commitments.filter((item) => item.target === game.me).map((item) => [item.field, item]));
+}
+
+export function bindBattlePlanCommitments(game: GameView, plan: Action): Action {
+ if (plan.type !== 'battlePlan') return plan;
+ return { ...plan, ...Object.fromEntries(Object.values(battlePlanCommitments(game)).map((item) => [item.field, item.value])) };
+}
+
+export function battlePreparationField(game: GameView): PlanField | null {
+ const battle = game.battle;
+ if (battle?.preparation?.kind === 'nexusPrescienceAnswer') {
+ const inspection = battle.nexusInspection;
+ return inspection?.stage === 'answer' && inspection.event === battle.event &&
+ inspection.target === battle.preparation.owner && inspection.owner === battle.preparation.beneficiary
+ ? inspection.field : null;
+ }
+ return battle?.preparation?.kind === 'prescienceAnswer' ? battle.prescience?.field ?? null : null;
+}
+
+export function battleInspectionAnswerAction(game: GameView, value: string): Action | null {
+ const battle = game.battle;
+ const field = battlePreparationField(game);
+ if (game.status !== 'playing' || game.phase !== 6 || !battle || battle.revealed ||
+ battle.preparation?.owner !== game.me || !field) return null;
+ const answer = field === 'dial' ? Number(value) : value || null;
+ if (field === 'dial' && (value.trim() === '' || !Number.isFinite(answer) || Number(answer) < 0 || !Number.isSafeInteger(Number(answer) * 2))) return null;
+ return battle.preparation.kind === 'nexusPrescienceAnswer'
+ ? { type: 'nexusPrescienceAnswer', event: battle.nexusInspection!.event, value: answer }
+ : { type: 'prescienceAnswer', value: answer };
+}
+
+export function NexusInspectionHistory({ game }: { game: GameView }) {
+ const battle = game.battle;
+ if (!battle?.nexusInspection || ![battle.nexusInspection.owner, battle.nexusInspection.target].includes(game.me) || !battle.nexusInsights?.length) return null;
+ return
+ Nexus inspection
+ {battle.nexusInsights.map((insight, index) =>
+ {insight.active ? (battle.nexusInspection!.target === game.me ? 'Your committed element' : 'Current answer') : 'Previous answer'}: {insight.field} = {insight.label}
+ {!insight.active && · No longer binding}
+
)}
+ ;
+}
export function BattlePreparation({
game,
@@ -134,7 +189,9 @@ export function BattlePreparation({
>
);
- const field = b.prescience!.field;
+ const field = battlePreparationField(game);
+ if (!field) return This inspection is no longer awaiting an answer.
;
+ const answerAction = battleInspectionAnswerAction(game, value);
const stoneReason =
field === 'weapon' && isStoneBurner(me.hand?.find((c) => c.id === value))
? b.stoneBurnerContext?.blocked
@@ -173,6 +230,7 @@ export function BattlePreparation({
)}
{field === 'dial' ? (
)}
-
{stoneReason && {stoneReason}
}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 4e97c969..e123f8e5 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -121,7 +121,7 @@ import {
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { BattlePreparation } from './battle-preparation';
+import { BattlePreparation, battlePlanCommitments, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation';
import { PrivateBattlePlan } from './private-battle-plan';
import { RevealedBattle } from './revealed-battle';
import { EliteCount } from './elite-count';
@@ -374,13 +374,10 @@ export function GameTable({
);
const [discardCards, setDiscardCards] = useState>({});
const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null;
- const committed =
- g.battle?.insight && g.battle.prescience?.player !== me.id
- ? g.battle.insight
- : null;
+ const committed = battlePlanCommitments(g);
const selectedStone = me.hand?.find(
(c) =>
- c.id === (committed?.field === 'weapon' ? committed.value : weapon) &&
+ c.id === (committed.weapon ? committed.weapon.value : weapon) &&
isStoneBurner(c),
);
const stoneContext = g.battle?.stoneBurnerContext;
@@ -393,8 +390,8 @@ export function GameTable({
.map((pool) =>
stoneBurnerPlanBlock(
ownBattleForces,
- committed?.field === 'dial'
- ? Number(committed.value)
+ committed.dial
+ ? Number(committed.dial?.value)
: battleDial,
battleSupport,
pool,
@@ -1780,6 +1777,7 @@ export function GameTable({
faceDancerReplacement: 'Replace an unrevealed Face Dancer',
voice: 'The Voice',
prescience: 'Prescience',
+ nexusPrescience: 'Atreides Nexus Cunning',
emperorIncome:
g.response.source === 'ambassador'
? 'Emperor card-purchase income'
@@ -4038,6 +4036,7 @@ export function GameTable({
)}
{g.battle.revealed &&
{g.battle.insight && (
- {committed
+ {committed[g.battle.insight.field]
? 'Your committed element'
: 'Prescience'}
: {g.battle.insight.field} ={' '}
@@ -4133,11 +4132,11 @@ export function GameTable({
max={battleDialMaximum}
step={battleDialStep}
value={
- committed?.field === 'dial'
- ? Number(committed.value)
+ committed.dial
+ ? Number(committed.dial?.value)
: battleDial
}
- disabled={busy || committed?.field === 'dial'}
+ disabled={busy || !!committed.dial}
onChange={setDial}
/>
@@ -4216,11 +4215,11 @@ export function GameTable({