ui: real SKILL TREE tab (research) + SHIP SYSTEMS buttons gated by researched techs
This commit is contained in:
@@ -3,8 +3,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { Progress } from '@/components/ui/progress'
|
||||
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 { LocalSystemPanel } from '@/components/local-system'
|
||||
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||
import { SkillTreePanel } from '@/components/skill-tree'
|
||||
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'
|
||||
import type { TechDto } from '@/lib/game/tech'
|
||||
|
||||
// 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
|
||||
// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system,
|
||||
// /api/crew, /api/ship); the other tabs keep placeholder content until their own issues land.
|
||||
// Single player, no auth, by design.
|
||||
// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS and SKILL TREE tabs render live data (/api/world,
|
||||
// /api/system, /api/crew, /api/ship, /api/tech); the other tabs keep placeholder content until
|
||||
// their own issues land. Single player, no auth, by design.
|
||||
|
||||
function useNarrow(threshold = 900): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
@@ -43,13 +43,6 @@ function now(): string {
|
||||
|
||||
// ── 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 = [
|
||||
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
||||
{ 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
|
||||
// feeds the roster bar — refetched on page load, like the other panels.
|
||||
const [ship, setShip] = useState<ShipState | null>(null)
|
||||
const [techs, setTechs] = useState<TechDto[] | null>(null)
|
||||
const [crewCount, setCrewCount] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,6 +89,15 @@ export function MissionControl() {
|
||||
if (!cancelled) setShip(s)
|
||||
})
|
||||
.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')
|
||||
.then(async (res) => {
|
||||
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 })
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -371,41 +378,12 @@ export function MissionControl() {
|
||||
|
||||
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
||||
<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>
|
||||
|
||||
{/* SKILL TREE TAB */}
|
||||
{/* SKILL TREE TAB — live data from /api/tech (state owned by MissionControl) */}
|
||||
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
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>
|
||||
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
||||
</TabsContent>
|
||||
|
||||
{/* MISSIONS TAB */}
|
||||
|
||||
@@ -4,21 +4,24 @@ 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 { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
|
||||
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
|
||||
// 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).
|
||||
// onChanged (no polling). 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 }: ShipSystemsPanelProps) {
|
||||
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
|
||||
|
||||
@@ -57,7 +60,7 @@ export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelPr
|
||||
)}
|
||||
<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} />
|
||||
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} researchedSlugs={researchedSlugs} onAct={act} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,17 +72,23 @@ function SystemCard({
|
||||
resources,
|
||||
parts,
|
||||
busy,
|
||||
researchedSlugs,
|
||||
onAct,
|
||||
}: {
|
||||
system: ShipSystemDto
|
||||
resources: number
|
||||
parts: number
|
||||
busy: boolean
|
||||
researchedSlugs: Set<string>
|
||||
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
|
||||
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>
|
||||
@@ -100,6 +109,9 @@ function SystemCard({
|
||||
? `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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user