import { useEffect, useRef, type CSSProperties, type ReactNode } from "react"; import { BOSS_DEFINITIONS } from "../../game/bossCatalog"; import { HEALER_ABILITIES } from "../../game/healers"; import type { AbilitySlotId, ActiveCast, HealerAbilityId, MemberId, PartyMember } from "../../game/types"; import type { PartyDraftCandidate, PartyRosterMember, RewardChoice, RpgRoguelikeAction, RpgRoguelikeRunState, RunGearItem, } from "../../game/rpgRoguelike"; import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike"; import type { RpgUiCommand } from "../../game/rpgRoguelike"; import { normalizeRpgFocusId } from "../../game/rpgRoguelike"; import { PartyRoleBadge } from "./PartyRoleBadge"; export interface RpgRunUiProps { readonly run: RpgRoguelikeRunState; readonly focusedId?: string | null; readonly onFocusChange?: (focusId: string) => void; readonly onAction: (action: RpgRoguelikeAction) => void; readonly onRestartRun?: () => void; readonly onExitRun?: () => void; readonly className?: string; readonly liveCombat?: RpgLiveCombatUi; } export interface RpgLiveCombatUi { readonly party: readonly PartyMember[]; readonly selectedMemberId: MemberId; readonly mana: number; readonly maxMana: number; readonly cooldowns: Readonly>; readonly globalCooldownUntil: number; readonly time: number; readonly activeCast: ActiveCast | null; readonly spellResources: { readonly verdancy: number; readonly tidalSurge: number; readonly conviction: number; readonly chronoshards: number; }; readonly onSelectMember?: (memberId: MemberId) => void; readonly onCastAbility?: (abilitySlotId: AbilitySlotId) => void; } export interface RpgRunUiContext extends RpgRunUiProps { readonly activeFocusId: string | null; } export function createUiContext(props: RpgRunUiProps): RpgRunUiContext { return { ...props, activeFocusId: normalizeRpgFocusId(props.run, props.focusedId) }; } export function runAccentStyle(accent: string): CSSProperties { return { "--rpg-accent": accent } as CSSProperties; } export function executeUiCommand(context: RpgRunUiContext, command: RpgUiCommand): void { if (command.type === "run-action") context.onAction(command.action); else if (command.type === "restart-run") context.onRestartRun?.(); else context.onExitRun?.(); } interface FocusButtonProps { readonly context: RpgRunUiContext; readonly focusId: string; readonly command: RpgUiCommand; readonly className?: string; readonly disabled?: boolean; readonly pressed?: boolean; readonly style?: CSSProperties; readonly children: ReactNode; readonly label?: string; } export function FocusButton({ context, focusId, command, className = "", disabled = false, pressed, style, children, label, }: FocusButtonProps) { const selected = context.activeFocusId === focusId; const buttonRef = useRef(null); useEffect(() => { const button = buttonRef.current; if (!selected || !button) return; let parent = button.parentElement; while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) { const childRect = button.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect(); const overflow = getComputedStyle(parent); const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY); const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX); if (canScrollY && parent.scrollHeight > parent.clientHeight) { if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top; else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom; } if (canScrollX && parent.scrollWidth > parent.clientWidth) { if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left; else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right; } parent = parent.parentElement; } }, [selected]); return ( ); } export function phaseLabel(run: RpgRoguelikeRunState): string { switch (run.phase) { case "party-draft": return "Party Draft"; case "spell-draft": return "Spell Draft"; case "challenge-briefing": return "Hallway Challenge"; case "challenge-active": return "Challenge Active"; case "boss-briefing": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Boss" : "Boss Door"; case "boss-combat": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Battle" : "Boss Battle"; case "boss-cleared": return "Room Cleared"; case "reward": return "Reward Chest"; case "shop": return `Act ${run.shop?.act ?? 1} Intermission`; case "victory": return "Run Complete"; case "defeat": return "Party Fallen"; } } export function routeLabel(run: RpgRoguelikeRunState): string { if (run.bossIndex >= TOTAL_BOSS_COUNT - 1) return "Finale"; return `Act ${Math.floor(run.bossIndex / BOSSES_PER_ACT) + 1} · Room ${(run.bossIndex % BOSSES_PER_ACT) + 1}`; } export function currentBoss(run: RpgRoguelikeRunState) { const bossId = run.bossRoute[run.bossIndex]; return bossId ? BOSS_DEFINITIONS[bossId] : null; } export function RunHeader({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) { return (
{routeLabel(run)} {phaseLabel(run)}
{run.currency} {Math.ceil(run.playerHp)} {run.bossesDefeated}/{TOTAL_BOSS_COUNT}
); } export function RoutePips({ run }: { run: RpgRoguelikeRunState }) { return (
{run.bossRoute.map((bossId, index) => ( ))}
); } function titleCase(value: string): string { return value.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); } export function PartyCard({ member, candidate, selected = false, compact = false, action, }: { readonly member?: PartyRosterMember; readonly candidate?: PartyDraftCandidate; readonly selected?: boolean; readonly compact?: boolean; readonly action?: ReactNode; }) { const entry = member ?? candidate; if (!entry) return null; const hp = member?.hp ?? entry.stats.maxHp; const hpPercent = Math.max(0, Math.min(100, (hp / entry.stats.maxHp) * 100)); return (
{entry.rarity}

{entry.name}

{entry.className}

{!compact && (
HP{entry.stats.maxHp} ST{entry.stats.singleTarget.toFixed(2)} AOE{entry.stats.areaDamage.toFixed(2)} DEF{entry.stats.defense.toFixed(2)}
)} {member && (
{hp <= 0 ? "Fallen" : `${Math.ceil(hp)} / ${entry.stats.maxHp}`}
)} {!compact && entry.traitIds.length > 0 &&
{entry.traitIds.map(titleCase).join(" · ")}
} {action}
); } export function SpellCard({ spellId, rank = 0, selected = false, compact = false, action, }: { readonly spellId: HealerAbilityId; readonly rank?: number; readonly selected?: boolean; readonly compact?: boolean; readonly action?: ReactNode; }) { const spell = HEALER_ABILITIES[spellId]; return (
{spell.icon}
{spell.targeting} · {spell.mana} mana{rank > 0 ? ` · Rank ${rank}` : ""}

{spell.name}

{!compact &&

{spell.description}

}
{action}
); } export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): string { if (item.ownerId === "player") return "Healer"; return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion"; } export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string { const comparison = compareRunGear(run.equipment, item); const ownerName = gearOwnerName(run, item); const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none"; const change = comparison.delta >= 0 ? `Gain ${comparison.delta} percentage points` : `Lose ${Math.abs(comparison.delta)} percentage points`; return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`; } export function GearStatComparison({ run, item }: { readonly run: RpgRoguelikeRunState; readonly item: RunGearItem; }) { const ownerName = gearOwnerName(run, item); const comparison = compareRunGear(run.equipment, item); const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none"; const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`; return ( {ownerName} · {comparison.effectLabel}{delta} Current · {currentRank}+{comparison.currentValue}% Replacement · +{item.enhancement}+{comparison.replacementValue}% ); } export function GearCard({ run, item, price, action }: { readonly run: RpgRoguelikeRunState; readonly item: RunGearItem; readonly price?: number; readonly action?: ReactNode; }) { return (
{item.slotId === "weapon" ? "⚔" : item.slotId === "armor" ? "⬡" : "◇"}
{gearOwnerName(run, item)} · {item.slotId}

{item.name}

{price !== undefined &&

◆ {price}

}
{action}
); } export function rewardSummary(choice: RewardChoice): { icon: string; eyebrow: string; detail: string; accent: string } { if (choice.kind === "spell-rank") { const spell = HEALER_ABILITIES[choice.spellId]; return { icon: spell.icon, eyebrow: "Spell upgrade", detail: `${spell.name} reaches rank ${choice.nextRank}.`, accent: spell.color }; } if (choice.kind === "member-rarity") { return { icon: "♟", eyebrow: "Party upgrade", detail: `Promote companion to ${choice.nextRarity}.`, accent: "#b47aec" }; } if (choice.kind === "run-gear") { return { icon: "◇", eyebrow: `Gear +${choice.item.enhancement}`, detail: `Equip ${choice.item.name}; displaced gear moves to bag.`, accent: "#65aef2" }; } return { icon: "◆", eyebrow: "Run currency", detail: `Gain ${choice.amount} gold for this run.`, accent: "#efc858" }; } export function ChallengeObjective({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) { const challenge = run.currentChallenge; const previous = run.lastChallengeResult; if (!challenge && !previous) return null; const objective = challenge?.objective ?? previous!.objective; const metrics = challenge?.metrics ?? previous!.metrics; const current = metrics[objective.metric]; const progress = Math.max(0, Math.min(100, (current / objective.target) * 100)); const copy = objective.challengeId === "blockbreaker" ? `Break ${objective.target} bricks before the board falls.` : objective.challengeId === "hockey" ? `Defeat ${objective.target} ${objective.target === 1 ? "boss" : "bosses"} without conceding.` : `Defeat ${objective.target} enemies before the assault ends.`; return (
Repeat tier {objective.repeatIndex + 1}{objective.name}
{!compact &&

{copy}

}
{current} / {objective.target}
{!compact &&
Success: ◆ {objective.rewardCurrency} · chest quality +{objective.chestQualityBonus}
}
); }