ui: real SKILL TREE tab (research) + SHIP SYSTEMS buttons gated by researched techs

This commit is contained in:
2026-09-29 00:35:39 +00:00
parent 7b0227cde9
commit 94e9fc728c
3 changed files with 152 additions and 52 deletions
+22 -44
View File
@@ -3,8 +3,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel' import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { StatCard } from '@/components/ui/stat-card' import { StatCard } from '@/components/ui/stat-card'
@@ -14,16 +12,18 @@ 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 { ShipSystemsPanel } from '@/components/ship-systems'
import { SkillTreePanel } from '@/components/skill-tree'
import { WorldMapPanel } from '@/components/world-map' import { WorldMapPanel } from '@/components/world-map'
import { CREW_CAPACITY } from '@/lib/game/ship-costs' import { CREW_CAPACITY } from '@/lib/game/ship-costs'
import type { CrewDto } from '@/lib/game/save' import type { CrewDto } from '@/lib/game/save'
import type { ShipState } from '@/lib/game/ship' import type { ShipState } from '@/lib/game/ship'
import type { TechDto } from '@/lib/game/tech'
// 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, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system, // WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS and SKILL TREE tabs render live data (/api/world,
// /api/crew, /api/ship); the other tabs keep placeholder content until their own issues land. // /api/system, /api/crew, /api/ship, /api/tech); the other tabs keep placeholder content until
// Single player, no auth, by design. // 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(
@@ -43,13 +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 skillCards = [
{ 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: 'CREW TRAINING PROTOCOLS', unlocks: 'RETRAIN CREW TRAITS', state: 'LOCKED' as const },
{ name: 'JUMP DRIVE TUNING', unlocks: 'SHORTER JUMP COOLDOWN', state: 'LOCKED' as const },
]
const encounters = [ const encounters = [
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' }, { name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
{ name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' }, { name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
@@ -82,6 +75,7 @@ export function MissionControl() {
// immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count // 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. // feeds the roster bar — refetched on page load, like the other panels.
const [ship, setShip] = useState<ShipState | null>(null) const [ship, setShip] = useState<ShipState | null>(null)
const [techs, setTechs] = useState<TechDto[] | null>(null)
const [crewCount, setCrewCount] = useState<number | null>(null) const [crewCount, setCrewCount] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
@@ -95,6 +89,15 @@ export function MissionControl() {
if (!cancelled) setShip(s) if (!cancelled) setShip(s)
}) })
.catch(() => {}) // left column + tab keep their loading state on failure .catch(() => {}) // left column + tab keep their loading state on failure
fetch('/api/tech')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as TechDto[]
})
.then((t) => {
if (!cancelled) setTechs(t)
})
.catch(() => {})
fetch('/api/crew') fetch('/api/crew')
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -116,6 +119,10 @@ export function MissionControl() {
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false }) const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
// tab via the same pure helpers the API enforces (lib/game/tech.ts).
const researchedSlugs = new Set((techs ?? []).filter((t) => t.state === 'RESEARCHED').map((t) => t.slug))
return ( return (
<div <div
style={{ style={{
@@ -371,41 +378,12 @@ export function MissionControl() {
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */} {/* 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' }}>
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} /> <ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} researchedSlugs={researchedSlugs} />
</TabsContent> </TabsContent>
{/* SKILL TREE TAB */} {/* SKILL TREE TAB — live data from /api/tech (state owned by MissionControl) */}
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}> <TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
<div <SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
style={{
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
gap: '0.75rem',
}}
>
{skillCards.map((skill) => (
<Card key={skill.name}>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{skill.name}</CardTitle>
<Badge variant={skill.state === 'AVAILABLE' ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
{skill.state}
</Badge>
</div>
</CardHeader>
<CardContent>
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
UNLOCKS: {skill.unlocks}
</p>
</CardContent>
<CardFooter>
<Button variant="OUTLINE" size="SM">
RESEARCH
</Button>
</CardFooter>
</Card>
))}
</div>
</TabsContent> </TabsContent>
{/* MISSIONS TAB */} {/* MISSIONS TAB */}
+20 -8
View File
@@ -4,21 +4,24 @@ import { useState } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' 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 { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
import type { ShipState, ShipSystemDto } from '@/lib/game/ship' import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from '@/lib/game/tech'
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left // 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 // 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 // onChanged (no polling). Buttons are disabled when unaffordable, capped or gated by an
// cost constants the API enforces (lib/game/ship-costs.ts). // unresearched tech, using the same pure helpers the API enforces (lib/game/ship-costs.ts +
// lib/game/tech.ts).
interface ShipSystemsPanelProps { interface ShipSystemsPanelProps {
ship: ShipState | null ship: ShipState | null
narrow?: boolean narrow?: boolean
onChanged: (next: ShipState) => void onChanged: (next: ShipState) => void
researchedSlugs: Set<string> // slugs of researched techs — raises caps, lifts build gates
} }
export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelProps) { export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: ShipSystemsPanelProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) // one action at a time — turn-based game const [busy, setBusy] = useState(false) // one action at a time — turn-based game
@@ -57,7 +60,7 @@ export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelPr
)} )}
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}> <div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
{ship.systems.map((system) => ( {ship.systems.map((system) => (
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} onAct={act} /> <SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} researchedSlugs={researchedSlugs} onAct={act} />
))} ))}
</div> </div>
</div> </div>
@@ -69,17 +72,23 @@ function SystemCard({
resources, resources,
parts, parts,
busy, busy,
researchedSlugs,
onAct, onAct,
}: { }: {
system: ShipSystemDto system: ShipSystemDto
resources: number resources: number
parts: number parts: number
busy: boolean busy: boolean
researchedSlugs: Set<string>
onAct: (url: string, body: unknown) => void onAct: (url: string, body: unknown) => void
}) { }) {
const upgradeDisabled = busy || !system.built || system.level >= MAX_LEVEL || resources < upgradeCost(system.level) const maxLevel = effectiveLevelCap(system.type, researchedSlugs)
const expandDisabled = busy || !system.built || system.capacity >= MAX_CAPACITY || resources < expandCost(system.capacity) const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs)
const buildDisabled = busy || system.built || parts < BUILD_PART_COST 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 ( return (
<Card> <Card>
@@ -100,6 +109,9 @@ function SystemCard({
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES` ? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
: `BUILD ${BUILD_PART_COST}× PARTS`} : `BUILD ${BUILD_PART_COST}× PARTS`}
</p> </p>
{!system.built && gate !== null && (
<p style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>REQUIRES // {gate}</p>
)}
</CardContent> </CardContent>
<CardFooter className="gap-2"> <CardFooter className="gap-2">
{system.built ? ( {system.built ? (
+110
View File
@@ -0,0 +1,110 @@
'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>
)
}