import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; import { classById, raceById } from "../app/characterCatalog"; import { learnedPartyTalentSelections, upcomingPartyTalentSelections, type PartyTalentSelection, } from "../game/partyTalentUi"; import { PARTY_ROLE_LABELS, usePartyStore, type PartyCommand, type PartyMember, } from "../game/partyStore"; import { loadTalentUiCatalog, talentUiTreeById, type TalentUiCatalog, } from "../game/talentUiCatalog"; import { useGameStore } from "../game/store"; import { useMenuController, type MenuAction } from "../input/useMenuController"; import { ControllerButton } from "./ControllerButton"; import { PartyCommandBar } from "./PartyFrames"; const COMMAND_SUMMARY: Readonly> = { attack: "The party will advance toward the next boss and clear every enemy along the route.", defend: "The party will stay with you and fight only enemies that engage the group.", stop: "The party will hold its current position until you issue another order.", }; const STATUS_LABEL: Readonly> = { ready: "Ready", advancing: "Advancing", guarding: "Guarding", healing: "Healing", attacking: "Attacking", holding: "Holding", down: "Down", }; function percent(current: number, maximum: number): number { if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0; return Math.max(0, Math.min(100, current / maximum * 100)); } function currentTalentRanks(selections: readonly PartyTalentSelection[]): readonly PartyTalentSelection[] { const latestByNode = new Map(); for (const selection of selections) latestByNode.set(selection.nodeId, selection); return [...latestByNode.values()].reverse(); } function TalentSelectionCard({ selection, upcoming = false }: { selection: PartyTalentSelection; upcoming?: boolean; }) { return (
{selection.name} {upcoming ? `Level ${selection.level}` : selection.treeName} Rank {selection.rank} / {selection.maxRank}

{selection.description}

); } function PartyManagementDialog() { const members = usePartyStore((state) => state.members); const command = usePartyStore((state) => state.command); const openOverlay = useGameStore((state) => state.openOverlay); const [inspectedMemberId, setInspectedMemberId] = useState(() => members[0]?.id ?? ""); const [talentCatalog, setTalentCatalog] = useState(null); const [talentCatalogErrorClassId, setTalentCatalogErrorClassId] = useState(null); const close = useCallback(() => openOverlay("pause"), [openOverlay]); useEffect(() => { if (!members.some((member) => member.id === inspectedMemberId)) { setInspectedMemberId(members[0]?.id ?? ""); } }, [inspectedMemberId, members]); const inspectedMember = members.find((member) => member.id === inspectedMemberId) ?? members[0] ?? null; useEffect(() => { const classId = inspectedMember?.classId; if (!classId || classId.startsWith("rom-")) { setTalentCatalog(null); setTalentCatalogErrorClassId(null); return; } let active = true; setTalentCatalogErrorClassId(null); void loadTalentUiCatalog(classId).then((catalog) => { if (active) setTalentCatalog(catalog); }).catch(() => { if (!active) return; setTalentCatalog(null); setTalentCatalogErrorClassId(classId); }); return () => { active = false; }; }, [inspectedMember?.classId]); const activeTalentCatalog = inspectedMember && talentCatalog?.classId === inspectedMember.classId ? talentCatalog : null; const learned = useMemo(() => inspectedMember && activeTalentCatalog ? learnedPartyTalentSelections( activeTalentCatalog, inspectedMember.classId, inspectedMember.specialization, inspectedMember.level, ) : [], [activeTalentCatalog, inspectedMember]); const currentTalents = useMemo(() => currentTalentRanks(learned), [learned]); const upcomingTalents = useMemo(() => inspectedMember && activeTalentCatalog ? upcomingPartyTalentSelections( activeTalentCatalog, inspectedMember.classId, inspectedMember.specialization, inspectedMember.level, 3, ) : [], [activeTalentCatalog, inspectedMember]); const actions = useMemo(() => [ ...members.map((member) => ({ id: `party-member-${member.id}`, run: () => setInspectedMemberId(member.id), })), ...(["attack", "defend", "stop"] as const).map((nextCommand) => ({ id: `party-command-${nextCommand}`, run: () => usePartyStore.getState().setCommand(nextCommand), })), { id: "party-command-recall", run: () => usePartyStore.getState().recallParty() }, { id: "party-management-back", run: close }, ], [close, members]); const controller = useMenuController(actions, { initialId: members[0] ? `party-member-${members[0].id}` : "party-command-defend", onBack: close, }); const memberClass = inspectedMember ? classById(inspectedMember.classId) : null; const memberRace = inspectedMember ? raceById(inspectedMember.raceId) : null; const primaryTree = inspectedMember && activeTalentCatalog ? talentUiTreeById(activeTalentCatalog, inspectedMember.talentTreeId) : null; const primaryPoints = inspectedMember ? learned.filter((selection) => selection.treeId === inspectedMember.talentTreeId).length : 0; const healthPercent = inspectedMember ? percent(inspectedMember.health, inspectedMember.maxHealth) : 0; const labelledBy = inspectedMember ? `party-member-tab-${inspectedMember.id}` : "party-management-title"; return (

Expedition roster and tactics

Party Management

×
{inspectedMember && memberClass && memberRace ? ( <>
Level {inspectedMember.level} · {inspectedMember.gender === "male" ? "Male" : "Female"} {memberRace.name} · Matches your level

{inspectedMember.name}

{inspectedMember.specialization} {memberClass.name} · {PARTY_ROLE_LABELS[inspectedMember.role]}

{STATUS_LABEL[inspectedMember.status]}{Math.ceil(inspectedMember.health)} / {inspectedMember.maxHealth}
Current group order {command[0].toUpperCase() + command.slice(1)}

{COMMAND_SUMMARY[command]}

Current build{primaryTree?.name ?? inspectedMember.specialization} {primaryPoints} primary · {learned.length} total
{currentTalents.map((selection) => )} {!activeTalentCatalog && (

{talentCatalogErrorClassId === inspectedMember.classId ? "Talent details are temporarily unavailable." : "Preparing this class's talent catalog…"}

)} {activeTalentCatalog && !currentTalents.length && (

Talent training begins at level 10. This build will advance automatically with you.

)}
Automatic progressionNext talent choices {upcomingTalents.length ? `Through level ${upcomingTalents.at(-1)?.level}` : "Complete"}
{upcomingTalents.map((selection) => ( ))} {activeTalentCatalog && !upcomingTalents.length && (

This party member has completed the level 80 talent plan.

)}
) :

Choose a party member to inspect their build.

}
D-pad NavigateA Select / orderB Pause menu
); } export function PartyManagementPanel() { const open = useGameStore((state) => state.overlay === "party"); return open ? : null; }