From 94e9fc728c4d6929c863f542f7268b3af224d374 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 00:35:39 +0000 Subject: [PATCH] ui: real SKILL TREE tab (research) + SHIP SYSTEMS buttons gated by researched techs --- components/mission-control.tsx | 66 +++++++------------- components/ship-systems.tsx | 28 ++++++--- components/skill-tree.tsx | 110 +++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 52 deletions(-) create mode 100644 components/skill-tree.tsx 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 )}
{ship.systems.map((system) => ( - + ))}
@@ -69,17 +72,23 @@ function SystemCard({ resources, parts, busy, + researchedSlugs, onAct, }: { system: ShipSystemDto resources: number parts: number busy: boolean + researchedSlugs: Set 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 ( @@ -100,6 +109,9 @@ function SystemCard({ ? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES` : `BUILD ${BUILD_PART_COST}× PARTS`}

+ {!system.built && gate !== null && ( +

REQUIRES // {gate}

+ )} {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 ( +
+ LOADING SKILL TREE… +
+ ) + } + + 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 ( +
+ {error && ( +
+ {error.toUpperCase()} +
+ )} +
+ {techs.map((tech) => ( + + ))} +
+
+ ) +} + +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 ( + + +
+ {tech.name} + + {tech.state} + +
+
+ +

+ COST {tech.costQty}× {tech.costKind === 'resource' ? 'RESOURCES' : 'PARTS'} +

+

UNLOCKS // {tech.effect}

+ {tech.state === 'LOCKED' && ( +

REQUIRES // {tech.missingPrereqs.join(' + ')}

+ )} +
+ + + +
+ ) +}