diff --git a/components/mission-control.tsx b/components/mission-control.tsx
index d3e67ef..4e72910 100644
--- a/components/mission-control.tsx
+++ b/components/mission-control.tsx
@@ -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(null)
+ const [techs, setTechs] = useState(null)
const [crewCount, setCrewCount] = useState(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 (
-
+
- {/* SKILL TREE TAB */}
+ {/* SKILL TREE TAB — live data from /api/tech (state owned by MissionControl) */}
-
- {skillCards.map((skill) => (
-
-
-
- {skill.name}
-
- {skill.state}
-
-
-
-
-
- UNLOCKS: {skill.unlocks}
-
-
-
-
-
-
- ))}
-
+
{/* MISSIONS TAB */}
diff --git a/components/ship-systems.tsx b/components/ship-systems.tsx
index 37177bc..8588381 100644
--- a/components/ship-systems.tsx
+++ b/components/ship-systems.tsx
@@ -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 // 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(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
)}
+ )}
{system.built ? (
diff --git a/components/skill-tree.tsx b/components/skill-tree.tsx
new file mode 100644
index 0000000..8a3827a
--- /dev/null
+++ b/components/skill-tree.tsx
@@ -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(null)
+ const [busy, setBusy] = useState(false) // one action at a time — turn-based game
+
+ if (!techs || !ship) {
+ return (
+