ui: real SHIP SYSTEMS tab (upgrade/expand/build buttons) + left column mirrors live ship state
This commit is contained in:
@@ -13,12 +13,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||||
import { CrewPanel } from '@/components/crew-panel'
|
import { CrewPanel } from '@/components/crew-panel'
|
||||||
import { LocalSystemPanel } from '@/components/local-system'
|
import { LocalSystemPanel } from '@/components/local-system'
|
||||||
|
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||||
import { WorldMapPanel } from '@/components/world-map'
|
import { WorldMapPanel } from '@/components/world-map'
|
||||||
|
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
||||||
|
import type { CrewDto } from '@/lib/game/save'
|
||||||
|
import type { ShipState } from '@/lib/game/ship'
|
||||||
|
|
||||||
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
||||||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
||||||
// WORLD MAP, LOCAL SYSTEM and CREW tabs render live data (/api/world, /api/system, /api/crew);
|
// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system,
|
||||||
// the other tabs keep placeholder content until their own issues land. Single player, no auth, by design.
|
// /api/crew, /api/ship); the other tabs keep placeholder content until their own issues land.
|
||||||
|
// Single player, no auth, by design.
|
||||||
|
|
||||||
function useNarrow(threshold = 900): boolean {
|
function useNarrow(threshold = 900): boolean {
|
||||||
const [narrow, setNarrow] = useState(
|
const [narrow, setNarrow] = useState(
|
||||||
@@ -38,22 +43,6 @@ function now(): string {
|
|||||||
|
|
||||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
||||||
|
|
||||||
const shipSystems: SystemEntry[] = [
|
|
||||||
{ name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
|
|
||||||
{ name: 'LIFE SUPPORT', status: 'ACTIVE' },
|
|
||||||
{ name: 'NAV COMPUTER', status: 'SCANNING' },
|
|
||||||
{ name: 'WEAPONS', status: 'OFFLINE' },
|
|
||||||
{ name: 'COMMS', status: 'ACTIVE' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const shipSystemCards = [
|
|
||||||
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
|
||||||
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
|
||||||
{ name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] },
|
|
||||||
{ name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] },
|
|
||||||
{ name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
const skillCards = [
|
const skillCards = [
|
||||||
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
||||||
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
||||||
@@ -89,6 +78,36 @@ const logLines: TerminalLine[] = [
|
|||||||
export function MissionControl() {
|
export function MissionControl() {
|
||||||
const narrow = useNarrow()
|
const narrow = useNarrow()
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
|
// Ship state is owned here (not in the tab panel) so the left column mirrors ship actions
|
||||||
|
// immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count
|
||||||
|
// feeds the roster bar — refetched on page load, like the other panels.
|
||||||
|
const [ship, setShip] = useState<ShipState | null>(null)
|
||||||
|
const [crewCount, setCrewCount] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/ship')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as ShipState
|
||||||
|
})
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled) setShip(s)
|
||||||
|
})
|
||||||
|
.catch(() => {}) // left column + tab keep their loading state on failure
|
||||||
|
fetch('/api/crew')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as CrewDto[]
|
||||||
|
})
|
||||||
|
.then((c) => {
|
||||||
|
if (!cancelled) setCrewCount(c.length)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||||||
@@ -257,11 +276,25 @@ export function MissionControl() {
|
|||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
<Badge variant="ACTIVE">4 ONLINE</Badge>
|
<Badge variant={ship && ship.systems.some((s) => s.built) ? 'ACTIVE' : 'OFFLINE'}>
|
||||||
|
{ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<StatusGrid systems={shipSystems} />
|
{ship ? (
|
||||||
|
<StatusGrid
|
||||||
|
systems={ship.systems.map((s) => ({
|
||||||
|
name: s.type,
|
||||||
|
status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const),
|
||||||
|
detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel notch="sm">
|
<Panel notch="sm">
|
||||||
@@ -269,11 +302,17 @@ export function MissionControl() {
|
|||||||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<PanelContent>
|
<PanelContent>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
{ship ? (
|
||||||
<Progress label="HULL INTEGRITY" value={87} />
|
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
||||||
<Progress label="REACTOR OUTPUT" value={94} />
|
// crew roster vs the display cap.
|
||||||
<Progress label="CREW ROSTER / 6" value={50} />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||||
</div>
|
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
|
||||||
|
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
|
||||||
|
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
|
)}
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,40 +369,9 @@ export function MissionControl() {
|
|||||||
<CrewPanel narrow={narrow} />
|
<CrewPanel narrow={narrow} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* SHIP SYSTEMS TAB */}
|
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
||||||
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} />
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{shipSystemCards.map((system) => (
|
|
||||||
<Card key={system.name}>
|
|
||||||
<CardHeader>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<CardTitle>{system.name}</CardTitle>
|
|
||||||
<Badge variant={system.status} className="ml-auto">
|
|
||||||
{system.level}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
|
||||||
MODULE BAY // PLACEHOLDER READOUT
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="gap-2">
|
|
||||||
{system.actions.map((action) => (
|
|
||||||
<Button key={action} variant="OUTLINE" size="SM">
|
|
||||||
{action}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* SKILL TREE TAB */}
|
{/* SKILL TREE TAB */}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'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 { BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
|
||||||
|
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
|
||||||
|
|
||||||
|
// 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). Buttons are disabled when unaffordable or capped, using the same
|
||||||
|
// cost constants the API enforces (lib/game/ship-costs.ts).
|
||||||
|
|
||||||
|
interface ShipSystemsPanelProps {
|
||||||
|
ship: ShipState | null
|
||||||
|
narrow?: boolean
|
||||||
|
onChanged: (next: ShipState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
} 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' }}>
|
||||||
|
{ship.systems.map((system) => (
|
||||||
|
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} onAct={act} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemCard({
|
||||||
|
system,
|
||||||
|
resources,
|
||||||
|
parts,
|
||||||
|
busy,
|
||||||
|
onAct,
|
||||||
|
}: {
|
||||||
|
system: ShipSystemDto
|
||||||
|
resources: number
|
||||||
|
parts: number
|
||||||
|
busy: boolean
|
||||||
|
onAct: (url: string, body: unknown) => void
|
||||||
|
}) {
|
||||||
|
const upgradeDisabled = busy || !system.built || system.level >= MAX_LEVEL || resources < upgradeCost(system.level)
|
||||||
|
const expandDisabled = busy || !system.built || system.capacity >= MAX_CAPACITY || resources < expandCost(system.capacity)
|
||||||
|
const buildDisabled = busy || system.built || 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>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="gap-2">
|
||||||
|
{system.built ? (
|
||||||
|
<>
|
||||||
|
<Button variant="OUTLINE" size="SM" disabled={upgradeDisabled} onClick={() => onAct('/api/ship/upgrade', { systemId: system.id })}>
|
||||||
|
UPGRADE
|
||||||
|
</Button>
|
||||||
|
<Button variant="OUTLINE" size="SM" disabled={expandDisabled} onClick={() => onAct('/api/ship/expand', { systemId: system.id })}>
|
||||||
|
EXPAND
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="OUTLINE" size="SM" disabled={buildDisabled} onClick={() => onAct('/api/ship/build', { type: system.type })}>
|
||||||
|
BUILD NEW
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ export const BASE_CAPACITY = 2
|
|||||||
export const MAX_LEVEL = 5 // UPGRADE cap
|
export const MAX_LEVEL = 5 // UPGRADE cap
|
||||||
export const MAX_CAPACITY = 10 // EXPAND cap
|
export const MAX_CAPACITY = 10 // EXPAND cap
|
||||||
|
|
||||||
|
// Display-only cap for the VESSEL METRICS crew roster bar (no gameplay effect yet).
|
||||||
|
export const CREW_CAPACITY = 6
|
||||||
|
|
||||||
// Cost model — all costs are in inventory items, consumed from the oldest cargo row of the
|
// Cost model — all costs are in inventory items, consumed from the oldest cargo row of the
|
||||||
// required kind first (see spendItems in lib/game/ship.ts):
|
// required kind first (see spendItems in lib/game/ship.ts):
|
||||||
// UPGRADE {systemId} — `level` units of any resource (scales with current level)
|
// UPGRADE {systemId} — `level` units of any resource (scales with current level)
|
||||||
|
|||||||
Reference in New Issue
Block a user