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 { 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 */}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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