Files
SpaceWanderer/components/ship-systems.tsx
T

204 lines
8.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
import type { CrewState } from '@/lib/game/save'
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from '@/lib/game/tech'
import type { TaskType } from '@/lib/game/tasks'
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left
// column mirrors actions immediately; this panel applies each action's response directly via
// onChanged (no polling). A card's UPGRADE / EXPAND / BUILD button ARMS the action (#44): the
// crew team builder mounts at the top of the column and its DISPATCH POSTs the ship route with
// the team. Buttons are disabled when unaffordable, capped or gated by an
// unresearched tech, using the same pure helpers the API enforces (lib/game/ship-costs.ts +
// lib/game/tech.ts).
interface ShipSystemsPanelProps {
ship: ShipState | null
narrow?: boolean
onChanged: (next: ShipState) => void
researchedSlugs: Set<string> // slugs of researched techs — raises caps, lifts build gates
}
export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: ShipSystemsPanelProps) {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
// #44 — the armed ship action (which card + which of UPGRADE/EXPAND/BUILD); the builder above
// the grid picks the team and its DISPATCH performs it.
const [armed, setArmed] = useState<{ type: TaskType; url: string; body: Record<string, unknown>; systemId: 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 action response updates the ship 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])
// #44 — arm an action (toggle: clicking the armed action's button again disarms it).
const arm = useCallback((type: TaskType, url: string, body: Record<string, unknown>, systemId: number) => {
setArmed((a) => (a && a.systemId === systemId && a.type === type ? null : { type, url, body, systemId }))
}, [])
if (!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 SHIP SYSTEMS…</span>
</div>
)
}
const resources = totalQty(ship.inventory, 'resource')
const parts = totalQty(ship.inventory, 'part')
async function act(url: string, body: unknown) {
setBusy(true)
setError(null)
setArmed(null)
try {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = (await res.json()) as ShipState & { error?: string }
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
onChanged(data) // response is the fresh ship 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 ship action's crew picker (JC-13: mounted only while an action is
armed); its DISPATCH performs it with the picked team. */}
{armed !== null && crew !== null && (
<TeamBuilder
type={armed.type}
roster={crew.crew}
tasks={crew.tasks}
onQueue={(leadCrewId, helperCrewIds) => void act(armed.url, { ...armed.body, 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' }}>
{ship.systems.map((system) => (
<SystemCard
key={system.id}
system={system}
resources={resources}
parts={parts}
busy={busy}
researchedSlugs={researchedSlugs}
armedType={armed !== null && armed.systemId === system.id ? armed.type : null}
onArm={arm}
/>
))}
</div>
</div>
)
}
function SystemCard({
system,
resources,
parts,
busy,
researchedSlugs,
armedType,
onArm,
}: {
system: ShipSystemDto
resources: number
parts: number
busy: boolean
researchedSlugs: Set<string>
armedType: TaskType | null // #44 — which of this card's actions is the armed target
onArm: (type: TaskType, url: string, body: Record<string, unknown>, systemId: number) => void
}) {
const maxLevel = effectiveLevelCap(system.type, researchedSlugs)
const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs)
const gate = buildGate(system.type, researchedSlugs) // name of the required tech, or null when ungated/researched
const upgradeDisabled = busy || !system.built || system.level >= maxLevel || resources < upgradeCost(system.level)
const expandDisabled = busy || !system.built || system.capacity >= maxCapacity || resources < expandCost(system.capacity)
const buildDisabled = busy || system.built || gate !== null || parts < BUILD_PART_COST
return (
<Card>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{system.type}</CardTitle>
<Badge variant={system.built ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
{system.built ? `LV ${system.level}` : 'OFFLINE'}
</Badge>
</div>
</CardHeader>
<CardContent>
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
{system.built ? `LEVEL ${system.level} · CAPACITY ${system.capacity}` : 'NOT INSTALLED'}
</p>
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
{system.built
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
: `BUILD ${BUILD_PART_COST}× PARTS`}
</p>
{!system.built && gate !== null && (
<p style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>REQUIRES // {gate}</p>
)}
</CardContent>
<CardFooter className="gap-2">
{system.built ? (
<>
<Button
variant="OUTLINE"
size="SM"
disabled={upgradeDisabled}
onClick={() => onArm('SHIP_UPGRADE', '/api/ship/upgrade', { systemId: system.id }, system.id)}
>
{armedType === 'SHIP_UPGRADE' ? 'PICK TEAM' : 'UPGRADE'}
</Button>
<Button
variant="OUTLINE"
size="SM"
disabled={expandDisabled}
onClick={() => onArm('SHIP_EXPAND', '/api/ship/expand', { systemId: system.id }, system.id)}
>
{armedType === 'SHIP_EXPAND' ? 'PICK TEAM' : 'EXPAND'}
</Button>
</>
) : (
<Button
variant="OUTLINE"
size="SM"
disabled={buildDisabled}
onClick={() => onArm('SHIP_BUILD', '/api/ship/build', { type: system.type }, system.id)}
>
{armedType === 'SHIP_BUILD' ? 'PICK TEAM' : 'BUILD NEW'}
</Button>
)}
</CardFooter>
</Card>
)
}