Implement Atreides Nexus inspections with durable battle commitments

This commit is contained in:
Quintine committed 2026-09-10 10:02:31 +10:00
1 parent d2a1d63517
commit d820c8a414
25 files changed
+3171 -182

No files matched your search

+62 -10
View File
@@ -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<Record<PlanField, CommittedPlanElement>> {
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 <section aria-label="Private Nexus inspection history" className="space-y-2">
<h4>Nexus inspection</h4>
{battle.nexusInsights.map((insight, index) => <p className="notice" key={`${index}-${insight.field}`}>
{insight.active ? (battle.nexusInspection!.target === game.me ? 'Your committed element' : 'Current answer') : 'Previous answer'}: {insight.field} = {insight.label}
{!insight.active && <span className="fine"> · No longer binding</span>}
</p>)}
</section>;
}
export function BattlePreparation({
game,
@@ -134,7 +189,9 @@ export function BattlePreparation({
</Button>
</>
);
const field = b.prescience!.field;
const field = battlePreparationField(game);
if (!field) return <p className="notice">This inspection is no longer awaiting an answer.</p>;
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({
)}
<label htmlFor="prescience-answer">
{field === 'dial' ? 'Forces dialed' : field}
</label>
{field === 'dial' ? (
<Input
id="prescience-answer"
@@ -197,17 +255,11 @@ export function BattlePreparation({
))}
</select>
)}
</label>
{stoneReason && <p className="notice">{stoneReason}</p>}
<Button
className="game-action"
disabled={busy || !!stoneReason || (field === 'dial' && value === '')}
onClick={() =>
act({
type: 'prescienceAnswer',
value: field === 'dial' ? Number(value) : value || null,
})
}
disabled={busy || !!stoneReason || !answerAction}
onClick={() => { if (!busy && !stoneReason && answerAction) act(answerAction); }}
>
Commit this element
</Button>
+20 -26
View File
@@ -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<Record<string, boolean>>({});
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({
</p>
)}
{g.battle.revealed && <RevealedBattle game={g} />}
<NexusInspectionHistory game={g} />
{(!g.battle.preLeader || g.battle.preLeader.closed) && (
<BattlePromises
act={act}
@@ -4062,7 +4061,7 @@ export function GameTable({
<BattleLeaderOpportunity game={g} act={act} busy={busy} />
) : g.battle.preparation ? (
<BattlePreparation
key={`${g.battle.territory}-${g.battle.preparation.kind}`}
key={`${g.battle.event}-${g.battle.preparation.kind}-${g.battle.preparation.kind === 'nexusPrescienceAnswer' ? g.battle.nexusInspection?.field : g.battle.prescience?.field ?? ''}`}
game={g}
send={send}
busy={busy}
@@ -4106,7 +4105,7 @@ export function GameTable({
<>
{g.battle.insight && (
<p className="notice">
{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}
/>
<p className="fine">
@@ -4216,11 +4215,11 @@ export function GameTable({
<select
id="battle-leader"
value={
committed?.field === 'leader'
? String(committed.value ?? '')
committed.leader
? String(committed.leader?.value ?? '')
: leader
}
disabled={committed?.field === 'leader'}
disabled={!!committed.leader}
onChange={(e) => setLeader(e.target.value)}
>
<option value="">No leader</option>
@@ -4263,14 +4262,12 @@ export function GameTable({
<select
id={`battle-${String(name).toLowerCase()}`}
value={
committed?.field ===
String(name).toLowerCase()
? String(committed.value ?? '')
committed[name === 'Weapon' ? 'weapon' : 'defense']
? String(committed[name === 'Weapon' ? 'weapon' : 'defense']!.value ?? '')
: (v as string)
}
disabled={
committed?.field ===
String(name).toLowerCase()
!!committed[name === 'Weapon' ? 'weapon' : 'defense']
}
onChange={(e) =>
(fn as (s: string) => void)(e.target.value)
@@ -4345,7 +4342,7 @@ export function GameTable({
g.battle.fullPlan?.target === me.id
? 'Commit plan for inspection'
: 'Seal battle plan',
{
bindBattlePlanCommitments(g, {
type: 'battlePlan',
...(g.aid.available > 0 && allyPayment !== ''
? { allyPayment: Number(allyPayment) }
@@ -4366,10 +4363,7 @@ export function GameTable({
leader,
weapon,
defense,
...(committed
? { [committed.field]: committed.value }
: {}),
},
}),
!!stonePlanReason,
)}
</>
+42 -3
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import Image from 'next/image';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { faction, type FactionId } from '@/game/catalog';
import type { Action, GameView } from '@/game/engine';
import type { Action, GameView, PlanField } from '@/game/engine';
import { HOMEWORLD_CARDS } from '@/game/homeworld-cards';
import { nexusCardMode, type NexusCardMode } from '@/game/nexus-cards';
import type { NexusCardChoice } from '@/game/nexus-card-phase';
@@ -56,7 +56,7 @@ export function NexusCardFace({ card, mode, enlarged = false }: {
<DialogTitle>{identity.name} Nexus card</DialogTitle>
<DialogClose render={<Button variant="outline" className="min-h-11 min-w-16" />}>Close</DialogClose>
</div>
<DialogDescription className="sr-only">All three Nexus effects and their faction requirements. Card effects are not playable yet.</DialogDescription>
<DialogDescription className="sr-only">All three Nexus effects and their faction requirements. Atreides actions appear when available; other card effects are not playable yet.</DialogDescription>
<div className="min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-5"><NexusCardFace card={card} mode={mode} enlarged /></div>
</DialogPrimitive.Popup>
</DialogPortal>
@@ -84,6 +84,22 @@ export function nexusCardChoiceAction(game: GameView, choice: NexusCardChoice, o
return nexusCardAction(game, choice, ownRedraws);
}
/** The server's private offer defines current fields and timing; no other
* player's hand or sealed plan participates in client eligibility. */
export function nexusAtreidesAction(game: GameView, field?: PlanField): Action | null {
const offer = game.nexusAtreides;
if (!offer || offer.blocked || game.status !== 'playing' || game.phase !== 6 ||
game.nexusCards?.card !== 'atreides') return null;
const owner = game.players.find((player) => player.id === game.me);
if (!owner || nexusCardMode('atreides', owner.faction, game.players.map((player) => player.faction)) !== offer.mode) return null;
const battle = game.battle;
if (!battle || battle.event !== offer.event || battle.revealed) return null;
if (offer.mode === 'betrayal')
return field === undefined ? { type: 'nexusAtreides', event: offer.event, mode: offer.mode } : null;
if (!field || !offer.fields.includes(field) || ![battle.attacker, battle.defender].includes(game.me) || battle.submitted.includes(game.me)) return null;
return { type: 'nexusAtreides', event: offer.event, mode: offer.mode, field };
}
export function NexusCards({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) {
const [ownRedraws, setOwnRedraws] = useState<0 | 1 | 2>(0);
const offer = game.nexusCards;
@@ -102,8 +118,31 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
<div>
<h3>Nexus cards</h3>
<p className="fine">{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held</p>
<p>Drawing and keeping cards is supported; their effects are not playable yet.</p>
<p>Atreides Nexus actions are available at their battle timing; other card effects are not playable yet.</p>
</div>
{game.nexusAtreides && offer.card === 'atreides' && (
<div aria-label="Atreides Nexus effect" className="space-y-2">
<h4>{NEXUS_PANEL_NAMES[game.nexusAtreides.mode]}</h4>
{game.nexusAtreides.blocked ? <p className="fine">{game.nexusAtreides.blocked}</p> : (
<>
<p className="fine">{game.nexusAtreides.mode === 'betrayal'
? 'Discard this Nexus card to cancel the pending Atreides inspection.'
: game.nexusAtreides.mode === 'cunning'
? 'Discard this Nexus card to inspect a second, different element of your opponent’s plan.'
: 'Discard this Nexus card to inspect one element of your opponent’s plan.'}</p>
<div className="flex flex-wrap gap-2">
{(game.nexusAtreides.mode === 'betrayal' ? [undefined] : game.nexusAtreides.fields).map((field) => {
const action = nexusAtreidesAction(game, field);
return <Button key={field ?? 'betrayal'} className="game-action min-h-11 whitespace-normal" disabled={busy || !action}
onClick={() => { if (!busy && action) act(action); }}>
{field ? `Use ${NEXUS_PANEL_NAMES[game.nexusAtreides!.mode]}: inspect ${field}` : 'Use Betrayal'}
</Button>;
})}
</div>
</>
)}
</div>
)}
<div aria-label="Your private Nexus card" className="max-w-xl">
{offer.card ? <NexusCardFace card={offer.card} mode={mode} /> : <p>You do not hold a Nexus card.</p>}
</div>