350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
'use client';
|
||
import { controlsLeader } from '@/game/leader-control';
|
||
import { leaderStrengthLabel } from '@/game/cards';
|
||
import {
|
||
battleCardLabel,
|
||
isWeaponCard,
|
||
isStoneBurner,
|
||
isMirrorWeapon,
|
||
isDefenseCard,
|
||
voiceChoices,
|
||
} 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<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.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 <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,
|
||
send,
|
||
busy,
|
||
}: {
|
||
game: GameView;
|
||
send: (action: Action) => Promise<void>;
|
||
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 (
|
||
<p className="notice">
|
||
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.'}
|
||
</p>
|
||
);
|
||
if (preparation.kind === 'nexusVoice') {
|
||
const offer = game.nexusBorrowedVoice;
|
||
if (!offer) return <p className="notice">Waiting for the borrowed Voice opportunity.</p>;
|
||
const selected = offer.choices.find(choice => choice.kind === kind && choice.must === must);
|
||
return <section aria-label="Borrowed Voice" className="space-y-3">
|
||
<h3>Bene Gesserit Nexus · Secret Ally</h3>
|
||
<p className="muted">Both combatants continue in attacker order before Prescience or sealed plans. Only an unallied holder may spend the card.</p>
|
||
{offer.blocked && <p className="notice">{offer.blocked}</p>}
|
||
{offer.canUse && <>
|
||
<label htmlFor="borrowed-voice-kind">Card or type
|
||
<select id="borrowed-voice-kind" value={kind} onChange={e => setKind(e.target.value)}>
|
||
{voiceChoices(game.advanced).filter(k => offer.choices.some(choice => choice.kind === k))
|
||
.map(k => <option key={k} value={k}>{battleCardLabel(k)}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="decision-checkbox">
|
||
<input type="checkbox" checked={must} onChange={e => setMust(e.target.checked)} />
|
||
Require this card or type (unchecked: forbid)
|
||
</label>
|
||
<p className="notice">Provisional policy: Karama may cancel the Voice. The Nexus card stays spent.</p>
|
||
<Button className="game-action" disabled={busy || !!offer.blocked || !selected}
|
||
onClick={() => act({ type: 'nexusBorrowedVoice', event: offer.event, use: true, kind, must })}>
|
||
Spend Nexus · Use the Voice
|
||
</Button>
|
||
</>}
|
||
<Button className="game-action" disabled={busy || !!offer.blocked}
|
||
onClick={() => act({ type: 'nexusBorrowedVoice', event: offer.event, use: false })}>
|
||
Continue without borrowed Voice
|
||
</Button>
|
||
</section>;
|
||
}
|
||
if (preparation.kind === 'voice')
|
||
return (
|
||
<>
|
||
<h3>The Voice</h3>
|
||
<p className="muted">
|
||
{beneficiary.id === me.id
|
||
? 'Command your opponent'
|
||
: `Assist ${beneficiary.name}`}{' '}
|
||
before Atreides chooses prescience and the combatants seal their
|
||
plans.
|
||
</p>
|
||
{preparation.blocked && <p className="notice">{preparation.blocked}</p>}
|
||
<label htmlFor="voice-kind">
|
||
Card or type
|
||
<select
|
||
id="voice-kind"
|
||
value={kind}
|
||
onChange={(e) => setKind(e.target.value)}
|
||
>
|
||
{voiceChoices(game.advanced).filter((name) => name !== 'mirrorWeapon' || !mirrorWeaponModeBlock(game)).map((k) => (
|
||
<option key={k} value={k}>
|
||
{battleCardLabel(k)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="decision-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={must}
|
||
onChange={(e) => setMust(e.target.checked)}
|
||
/>
|
||
Require this card or type (unchecked: forbid)
|
||
</label>
|
||
<Button
|
||
className="game-action"
|
||
disabled={busy || !!preparation.blocked}
|
||
onClick={() => act({ type: 'voice', kind, must })}
|
||
>
|
||
Use the Voice
|
||
</Button>
|
||
<Button
|
||
className="game-action"
|
||
disabled={busy}
|
||
onClick={() => act({ type: 'declineBattlePower' })}
|
||
>
|
||
Decline the Voice
|
||
</Button>
|
||
</>
|
||
);
|
||
const prescienceTarget =
|
||
beneficiary.id === b.attacker ? b.defender : b.attacker;
|
||
const noFieldTarget = b.noFieldPlayers.includes(prescienceTarget);
|
||
if (preparation.kind === 'prescience')
|
||
return (
|
||
<>
|
||
<h3>Battle prescience</h3>
|
||
<p className="muted">
|
||
Choose one element of{' '}
|
||
{beneficiary.id === me.id ? 'your' : `${beneficiary.name}’s`}{' '}
|
||
opponent’s plan. Only that element becomes fixed.
|
||
</p>
|
||
{noFieldTarget && (
|
||
<p className="fine">
|
||
A No-Field battle keeps the number dialed hidden from Atreides. You
|
||
can inspect the leader, weapon, or defense.
|
||
</p>
|
||
)}
|
||
{[
|
||
'leader',
|
||
'weapon',
|
||
'defense',
|
||
...(noFieldTarget ? [] : ['dial']),
|
||
].map((field) => (
|
||
<Button
|
||
className="game-action"
|
||
key={field}
|
||
disabled={busy}
|
||
onClick={() => act({ type: 'prescience', field })}
|
||
>
|
||
Reveal {field}
|
||
</Button>
|
||
))}
|
||
<Button
|
||
className="game-action"
|
||
disabled={busy}
|
||
onClick={() => act({ type: 'declineBattlePower' })}
|
||
>
|
||
Decline prescience
|
||
</Button>
|
||
</>
|
||
);
|
||
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
|
||
: 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 (
|
||
<>
|
||
<h3>Reveal your {field}</h3>
|
||
<p className="muted">
|
||
{beneficiary.name} will see this element. You may still change the rest
|
||
of your plan before sealing it.
|
||
</p>
|
||
{(field === 'weapon' || field === 'defense') && b.harassWithdraw && !b.harassWithdraw.blocked && (
|
||
<p className="fine">
|
||
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.
|
||
</p>
|
||
)}
|
||
{b.voice && (
|
||
<p className="notice">
|
||
Voice: {b.voice.must ? 'must play' : 'cannot play'}{' '}
|
||
{battleCardLabel(b.voice.kind)}.
|
||
</p>
|
||
)}
|
||
<label htmlFor="prescience-answer">
|
||
{field === 'dial' ? 'Forces dialed' : field}
|
||
</label>
|
||
{field === 'dial' ? (
|
||
<Input
|
||
id="prescience-answer"
|
||
type="number"
|
||
min={0}
|
||
max={game.advanced || me.faction === 'ixians' ? 40 : 20}
|
||
step={game.advanced || me.faction === 'ixians' ? 0.5 : 1}
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
/>
|
||
) : (
|
||
<select
|
||
id="prescience-answer"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
>
|
||
<option value="">None</option>
|
||
{choices.map((choice) => (
|
||
<option key={choice.id} value={choice.id}>
|
||
{choice.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{stoneReason && <p className="notice">{stoneReason}</p>}
|
||
<Button
|
||
className="game-action"
|
||
disabled={busy || !!stoneReason || !answerAction}
|
||
onClick={() => { if (!busy && !stoneReason && answerAction) act(answerAction); }}
|
||
>
|
||
Commit this element
|
||
</Button>
|
||
</>
|
||
);
|
||
}
|