111 lines
4.2 KiB
TypeScript
111 lines
4.2 KiB
TypeScript
'use client'
|
||
|
||
import { 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 { totalQty } from '@/lib/game/ship-costs'
|
||
import type { ShipState } from '@/lib/game/ship'
|
||
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). RESEARCH 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
|
||
|
||
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) {
|
||
setBusy(true)
|
||
setError(null)
|
||
try {
|
||
const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id }) })
|
||
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
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : String(err))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
{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} onResearch={research} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TechCard({
|
||
tech,
|
||
resources,
|
||
parts,
|
||
busy,
|
||
onResearch,
|
||
}: {
|
||
tech: TechDto
|
||
resources: number
|
||
parts: number
|
||
busy: boolean
|
||
onResearch: (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={() => onResearch(tech)}>
|
||
RESEARCH
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
)
|
||
}
|