'use client' import { useCallback, useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { TeamBuilder } from '@/components/team-builder' import { totalQty } from '@/lib/game/ship-costs' import type { ShipState } from '@/lib/game/ship' import type { CrewState } from '@/lib/game/save' import type { TechDto } from '@/lib/game/tech' // SKILL TREE tab — live data. The parent (MissionControl) owns the tech state + ship state; this panel // applies each research response directly via onResearched (no polling). A card's RESEARCH button ARMS // the tech (#44): the crew team builder mounts at the top of the column and its DISPATCH POSTs // /api/research with the team. The button is enabled only when the tech is AVAILABLE and its cost is // payable from the cargo hold, mirroring what POST /api/research enforces. interface SkillTreePanelProps { techs: TechDto[] | null ship: ShipState | null narrow?: boolean onResearched: (next: TechDto[]) => void } export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreePanelProps) { const [error, setError] = useState(null) const [busy, setBusy] = useState(false) // one action at a time — turn-based game // #44 — the armed tech (last-clicked AVAILABLE card); the builder above the grid picks the // team and its DISPATCH performs the research. const [armedTechId, setArmedTechId] = useState(null) const [crew, setCrew] = useState(null) // JC-14 — the roster + queue the builder mirrors: fetched on mount and after every // successful dispatch (the research response updates the tech state, not the roster). // Keep-old-on-failure, the house .catch(() => {}) pattern. const fetchCrew = useCallback(() => { fetch('/api/crew') .then(async (res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as CrewState }) .then((c) => setCrew(c)) .catch(() => {}) }, []) useEffect(() => { fetchCrew() }, [fetchCrew]) if (!techs || !ship) { return (
LOADING SKILL TREE…
) } const resources = totalQty(ship.inventory, 'resource') const parts = totalQty(ship.inventory, 'part') async function research(tech: TechDto, leadCrewId: number, helperCrewIds: number[]) { setBusy(true) setError(null) setArmedTechId(null) try { const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id, leadCrewId, helperCrewIds }) }) const data = (await res.json()) as TechDto[] & { error?: string } if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`) onResearched(data) // response is the fresh tech tree state — apply directly fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)) } finally { setBusy(false) } } return (
{/* #44 — the armed tech's crew picker (JC-13: mounted only while a tech is armed); its DISPATCH performs the research with the picked team. */} {armedTechId !== null && crew !== null && ( { const tech = techs.find((t) => t.id === armedTechId) if (tech) void research(tech, leadCrewId, helperCrewIds) }} /> )} {error && (
{error.toUpperCase()}
)}
{techs.map((tech) => ( setArmedTechId(armedTechId === t.id ? null : t.id)} /> ))}
) } function TechCard({ tech, resources, parts, busy, armed, onArm, }: { tech: TechDto resources: number parts: number busy: boolean armed: boolean // #44 — this card's tech is the armed target (the builder is open for it) onArm: (tech: TechDto) => void }) { const affordable = tech.costKind === 'resource' ? resources >= tech.costQty : parts >= tech.costQty const researchDisabled = busy || tech.state !== 'AVAILABLE' || !affordable return (
{tech.name} {tech.state}

COST {tech.costQty}× {tech.costKind === 'resource' ? 'RESOURCES' : 'PARTS'}

UNLOCKS // {tech.effect}

{tech.state === 'LOCKED' && (

REQUIRES // {tech.missingPrereqs.join(' + ')}

)}
) }