Implement Atreides Nexus inspections with durable battle commitments
This commit is contained in:
1 parent
d2a1d63517
commit
d820c8a414
25 files changed
+3171
-182
No files matched your search
@@ -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
@@ -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,
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user