153 lines
6.2 KiB
TypeScript
153 lines
6.2 KiB
TypeScript
'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<string | null>(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<number | null>(null)
|
||
const [crew, setCrew] = useState<CrewState | null>(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 (
|
||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING SKILL TREE…</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
{/* #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 && (
|
||
<TeamBuilder
|
||
type="RESEARCH"
|
||
roster={crew.crew}
|
||
tasks={crew.tasks}
|
||
onQueue={(leadCrewId, helperCrewIds) => {
|
||
const tech = techs.find((t) => t.id === armedTechId)
|
||
if (tech) void research(tech, leadCrewId, helperCrewIds)
|
||
}}
|
||
/>
|
||
)}
|
||
{error && (
|
||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
|
||
{techs.map((tech) => (
|
||
<TechCard key={tech.slug} tech={tech} resources={resources} parts={parts} busy={busy} armed={armedTechId === tech.id} onArm={(t) => setArmedTechId(armedTechId === t.id ? null : t.id)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<Card>
|
||
<CardHeader>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
<CardTitle>{tech.name}</CardTitle>
|
||
<Badge variant={tech.state === 'RESEARCHED' ? 'ACTIVE' : tech.state === 'AVAILABLE' ? 'SCANNING' : 'OFFLINE'} className="ml-auto">
|
||
{tech.state}
|
||
</Badge>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
|
||
COST {tech.costQty}× {tech.costKind === 'resource' ? 'RESOURCES' : 'PARTS'}
|
||
</p>
|
||
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>UNLOCKS // {tech.effect}</p>
|
||
{tech.state === 'LOCKED' && (
|
||
<p style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>REQUIRES // {tech.missingPrereqs.join(' + ')}</p>
|
||
)}
|
||
</CardContent>
|
||
<CardFooter>
|
||
<Button variant="OUTLINE" size="SM" disabled={researchDisabled} onClick={() => onArm(tech)}>
|
||
{armed ? 'PICK TEAM' : 'RESEARCH'}
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
)
|
||
}
|