'use client'; import { controlsLeader } from '@/game/leader-control'; import { leaderStrengthLabel } from '@/game/cards'; import { battleCardLabel, isWeaponCard, isStoneBurner, isMirrorWeapon, isDefenseCard, VOICE_KINDS, } from '@/game/battle-cards'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import type { Action, GameView, PlanField } from '@/game/engine'; import type { CommittedPlanElement } from '@/game/battle-inspections'; import { battleCardSlotEligible, fixedBattleInspectionMatches, validBattleSlotPair, type BattleCardSlot, } from '@/game/battle-card-slots'; import { canUsePlanetologistBattleSpecial } from '@/game/leader-skill-combat'; import { mirrorWeaponModeBlock } from '@/game/mirror-weapon-mode'; /** Both selectors evaluate the complete physical pair, in either selection order. */ export function battlePlanCardOptions(game: GameView, slot: BattleCardSlot, leader: string, weapon: string, defense: string) { const me = game.players.find(player => player.id === game.me)!; const fixed = battlePlanCommitments(game)[slot]; const assignments = game.leaderSkills?.assignments.filter(a => a.controller === game.me) ?? []; return (me.hand ?? []).filter(card => { const w = slot === 'weapon' ? card : me.hand?.find(c => c.id === weapon); const d = slot === 'defense' ? card : me.hand?.find(c => c.id === defense); const planetologistWeapon = canUsePlanetologistBattleSpecial({ assignments, selectedLeader: leader, card: w, }); return (card.id !== 'ecaz-reinforcements' || (!!game.battle?.reinforcements && !game.battle.reinforcements.blocked) || (slot === 'weapon' && planetologistWeapon)) && (card.id !== 'ecaz-harass-withdraw' || (!!game.battle?.harassWithdraw && !game.battle.harassWithdraw.blocked) || (slot === 'weapon' && planetologistWeapon)) && (!isMirrorWeapon(card) || !mirrorWeaponModeBlock(game)) && battleCardSlotEligible(slot, card, { planetologistWeapon }) && (!fixed || fixedBattleInspectionMatches(slot, fixed.value, card.id, card)) && validBattleSlotPair(w, d, planetologistWeapon); }); } /** 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.field === 'weapon' || item.field === 'defense' ? battlePlanCardValue(game, item.field, typeof plan[item.field] === 'string' ? plan[item.field] as string : '') || null : item.value])) }; } /** An inspected absence of a category still permits a legal slot-only Special. */ export function battlePlanCardValue(game: GameView, slot: BattleCardSlot, selected: string): string { const commitment = battlePlanCommitments(game)[slot]; if (!commitment) return selected; if (commitment.value !== null) return String(commitment.value); const card = game.players.find((player) => player.id === game.me)?.hand?.find((card) => card.id === selected); return card && fixedBattleInspectionMatches(slot, null, card.id, card) ? card.id : ''; } 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, send, busy, }: { game: GameView; send: (action: Action) => Promise; busy: boolean; }) { const b = game.battle!; const preparation = b.preparation!; const me = game.players.find((p) => p.id === game.me)!; const [kind, setKind] = useState('poison'); const [must, setMust] = useState(false); const [value, setValue] = useState(''); const act = (action: Action) => void send(action); const beneficiary = game.players.find( (p) => p.id === preparation.beneficiary, )!; if (preparation.owner !== game.me) return (

Waiting for {game.players.find((p) => p.id === preparation.owner)?.name} {preparation.kind === 'voice' || preparation.kind === 'nexusVoice' ? ' to choose or decline the Voice.' : preparation.kind === 'prescience' ? ' to choose or decline prescience.' : ' to reveal the requested battle-plan element.'}

); if (preparation.kind === 'nexusVoice') { const offer = game.nexusBorrowedVoice; if (!offer) return

Waiting for the borrowed Voice opportunity.

; const selected = offer.choices.find(choice => choice.kind === kind && choice.must === must); return

Bene Gesserit Nexus · Secret Ally

Both combatants continue in attacker order before Prescience or sealed plans. Only an unallied holder may spend the card.

{offer.blocked &&

{offer.blocked}

} {offer.canUse && <>

Provisional policy: Karama may cancel the Voice. The Nexus card stays spent.

}
; } if (preparation.kind === 'voice') return ( <>

The Voice

{beneficiary.id === me.id ? 'Command your opponent' : `Assist ${beneficiary.name}`}{' '} before Atreides chooses prescience and the combatants seal their plans.

{preparation.blocked &&

{preparation.blocked}

} ); const prescienceTarget = beneficiary.id === b.attacker ? b.defender : b.attacker; const noFieldTarget = b.noFieldPlayers.includes(prescienceTarget); if (preparation.kind === 'prescience') return ( <>

Battle prescience

Choose one element of{' '} {beneficiary.id === me.id ? 'your' : `${beneficiary.name}’s`}{' '} opponent’s plan. Only that element becomes fixed.

{noFieldTarget && (

A No-Field battle keeps the number dialed hidden from Atreides. You can inspect the leader, weapon, or defense.

)} {[ 'leader', 'weapon', 'defense', ...(noFieldTarget ? [] : ['dial']), ].map((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 : null; const choices = field === 'leader' ? [ ...me.leaders .filter( (l) => !l.dead && controlsLeader(me, l) && (!l.usedAt || l.usedAt === b.territory), ) .map((l) => ({ id: l.id, name: `${l.name} · ${leaderStrengthLabel(l)}`, })), ...(me.hand?.filter((c) => c.kind === 'hero') ?? []), ] : (me.hand?.filter((c) => field === 'weapon' ? isWeaponCard(c) : isDefenseCard(c), ) ?? []); return ( <>

Reveal your {field}

{beneficiary.name} will see this element. You may still change the rest of your plan before sealing it.

{(field === 'weapon' || field === 'defense') && b.harassWithdraw && !b.harassWithdraw.blocked && (

Harass & Withdraw is neither a weapon nor a defense. Choose None for the category it will occupy; its identity stays private until plans are revealed.

)} {b.voice && (

Voice: {b.voice.must ? 'must play' : 'cannot play'}{' '} {battleCardLabel(b.voice.kind)}.

)} {field === 'dial' ? ( setValue(e.target.value)} /> ) : ( )} {stoneReason &&

{stoneReason}

} ); }