175 lines
7.4 KiB
TypeScript
175 lines
7.4 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { TeamBuilder } from '@/components/team-builder'
|
||
import type { CrewState, ExploreResult, SystemState } from '@/lib/game/save'
|
||
import type { FoundItem } from '@/lib/game/world'
|
||
|
||
// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an
|
||
// EXPLORE button per unexplored row. A button click ARMS the planet (#44): the crew team
|
||
// builder mounts at the top of the column and its DISPATCH POSTs /api/explore with the team.
|
||
// The parent (MissionControl) owns the /api/system state so the right column's CARGO HOLD
|
||
// mirrors scans immediately; this panel reports each scan's response up via onChanged and
|
||
// keeps only its own UI bits locally (no polling). Turn-based: one scan costs a turn, each
|
||
// body once.
|
||
|
||
interface LocalSystemPanelProps {
|
||
system: SystemState | null
|
||
onChanged: (next: SystemState) => void
|
||
}
|
||
|
||
export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) {
|
||
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [exploringId, setExploringId] = useState<number | null>(null)
|
||
// #44 — the armed planet (the last-clicked unexplored row); the builder above the list picks
|
||
// the team and its DISPATCH performs the scan.
|
||
const [armedPlanetId, setArmedPlanetId] = 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 scan response updates the system 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])
|
||
|
||
const explore = useCallback(
|
||
async (planetId: number, leadCrewId: number, helperCrewIds: number[]) => {
|
||
setExploringId(planetId)
|
||
setArmedPlanetId(null)
|
||
try {
|
||
const res = await fetch('/api/explore', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ planetId, leadCrewId, helperCrewIds }),
|
||
})
|
||
const body = (await res.json()) as ExploreResult & { error?: string }
|
||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||
onChanged(body) // fresh state straight from the response — parent mirrors it to CARGO HOLD
|
||
setLastOutcome(body.outcome)
|
||
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 {
|
||
setExploringId(null)
|
||
}
|
||
},
|
||
[onChanged, fetchCrew]
|
||
)
|
||
|
||
if (!system) {
|
||
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' }}>SCANNING SYSTEM…</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const badgeFor = (explored: boolean, hazard: boolean) =>
|
||
explored ? { label: 'SCANNED', variant: 'ACTIVE' as const } : hazard ? { label: 'HAZARD', variant: 'WARNING' as const } : { label: 'UNEXPLORED', variant: 'SCANNING' as const }
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
{/* #44 — the armed planet's crew picker (JC-13: mounted only while a planet is armed);
|
||
its DISPATCH performs the scan with the picked team. */}
|
||
{armedPlanetId !== null && crew !== null && (
|
||
<TeamBuilder
|
||
type="EXPLORE"
|
||
roster={crew.crew}
|
||
tasks={crew.tasks}
|
||
onQueue={(leadCrewId, helperCrewIds) => void explore(armedPlanetId, leadCrewId, helperCrewIds)}
|
||
/>
|
||
)}
|
||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
|
||
<div
|
||
style={{
|
||
padding: '0.4rem 0.75rem',
|
||
borderBottom: '1px solid var(--border)',
|
||
background: 'var(--surface-raised)',
|
||
fontSize: '0.6rem',
|
||
color: 'var(--text-muted)',
|
||
letterSpacing: '0.12em',
|
||
textTransform: 'uppercase',
|
||
}}
|
||
>
|
||
EXPLORABLE WORLDS // {system.systemName.toUpperCase()} — TURN {system.turn}
|
||
</div>
|
||
{system.planets.map((row, i) => {
|
||
const depth = row.parent !== null ? 1 : 0
|
||
const badge = badgeFor(row.explored, row.hazard)
|
||
return (
|
||
<div
|
||
key={row.id}
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
gap: '0.5rem',
|
||
padding: depth ? '0.6rem 0.75rem 0.6rem 1.75rem' : '0.6rem 0.75rem',
|
||
borderBottom: i < system.planets.length - 1 ? '1px solid var(--border)' : 'none',
|
||
}}
|
||
>
|
||
<div style={{ overflow: 'hidden' }}>
|
||
<div
|
||
style={{
|
||
fontSize: '0.68rem',
|
||
color: depth ? 'var(--text-muted)' : 'var(--text-secondary)',
|
||
letterSpacing: '0.04em',
|
||
textTransform: 'uppercase',
|
||
}}
|
||
>
|
||
{row.name}
|
||
</div>
|
||
<div style={{ fontSize: '0.57rem', color: 'var(--text-muted)', letterSpacing: '0.06em', marginTop: '2px' }}>
|
||
{depth ? `MOON OF ${system.planets.find((p) => p.id === row.parent)?.name ?? '?'} // ${row.type}` : row.type}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||
{!row.explored && (
|
||
<Button
|
||
variant="OUTLINE"
|
||
size="SM"
|
||
disabled={exploringId !== null}
|
||
onClick={() => setArmedPlanetId(armedPlanetId === row.id ? null : row.id)} // #44 — arms/disarms; the builder dispatches
|
||
>
|
||
{exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // ${armedPlanetId === row.id ? 'PICK TEAM' : 'EXPLORE'}`}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{lastOutcome !== null && (
|
||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||
SCAN RESULT //{' '}
|
||
{lastOutcome.length > 0 ? lastOutcome.map((i) => `${i.qty}× ${i.name.toUpperCase()}`).join(', ') : 'NOTHING FOUND'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>SCAN FAILED // {error.toUpperCase()}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|