Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b79b77131f | ||
|
|
f36820ddff | ||
|
|
6a623c738a | ||
|
|
80b493746f | ||
|
|
37c3800479 | ||
|
|
cc6abe8f36 | ||
|
|
58ac64af4f | ||
|
|
1634c0602e | ||
|
|
922a20707b | ||
|
|
aa3c5f5501 | ||
|
|
7f48fd0e5e | ||
|
|
0573e6d719 | ||
|
|
2651981044 | ||
|
|
16a7b468e3 | ||
|
|
9faf0f3a33 | ||
|
|
4be04a779a | ||
|
|
5291e0fa6e | ||
|
|
e79015b15c |
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.3.0 — 2026-09-28 21:35
|
||||||
|
- World generation + jump between solar systems (issue #5): the game save, DB schema, turn counter and event writing that all later tabs build on. Single-player by design — one save row, no auth/accounts.
|
||||||
|
- New tables (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `saves` (id/seed/turn/created_at), `solar_systems` (name, x/y chart position, discovered, is_current), `jump_routes` (directed pairs, both directions stored for single-row adjacency lookups), `events` (append-only log: new-game + jump events so far).
|
||||||
|
- Seeded world generation (`lib/game/world.ts`, pure functions): a save's seed deterministically generates the starting system plus a ring of six undiscovered neighbours with jump routes; jumping into an undiscovered system grows the chart with four more neighbours around it, so there is always somewhere to jump and a route back.
|
||||||
|
- Save lifecycle (`lib/game/save.ts`): auto-initializes on first API hit when no save exists, exposes world state, validates jumps (adjacency required — non-adjacent/unknown rejected), sets current, increments the turn counter, writes events, persists durably.
|
||||||
|
- New endpoints: `GET /api/world` → `{ systems, edges, turn }`; `POST /api/jump { systemId }` → fresh world state (400 malformed body, 404 unknown system, 409 not adjacent). OpenAPI spec in code (`lib/openapi.ts`) updated for both.
|
||||||
|
- WORLD MAP tab now renders REAL data: fetches `/api/world` on mount, draws the live NodeGraph (current = ACTIVE/CURRENT, discovered = NEUTRAL/DISCOVERED, unknown = OFFLINE/UNKNOWN) with a jump button per adjacent system; placeholder map constants removed for this tab only — other tabs keep their placeholders until their own issues land.
|
||||||
|
|
||||||
|
## v0.2.2 — 2026-09-28 21:02
|
||||||
|
- LOCAL SYSTEM tab: explorable-worlds list now staggers moons under their parent planet (indented rows) — ASHFALL attached under BRIMSTONE, new PALEMOON moon under AURELLE; badges unchanged.
|
||||||
|
|
||||||
|
## v0.2.1 — 2026-09-28 20:22
|
||||||
|
- LOCAL SYSTEM tab: removed the system node graph (star/planets/moons) — the tab now shows only the explorable-worlds list at full panel width. WORLD MAP keeps its node graph; `components/ui/node-graph.tsx` unchanged and still in use there.
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-09-28 19:19
|
||||||
|
- Game interface template (visual only, zero game logic): the operations panel in `components/mission-control.tsx` is now seven tabs — WORLD MAP (NodeGraph of 10 placeholder solar systems linked by jump routes), LOCAL SYSTEM (NodeGraph of star + planets/moons plus an explorable-worlds list), CREW (crew cards with name/role and good/bad trait placeholders), SHIP SYSTEMS (system cards with inert EXPAND / UPGRADE / BUILD NEW affordances), SKILL TREE (tech cards unlocking new system builds/upgrades), MISSIONS (previous NEXT MISSION content), EVENT LOG (ship log terminal). All data is local placeholder constants.
|
||||||
|
- Left column: VESSEL METRICS progress rings replaced with scificn Progress bracket bars; SHIP SYSTEMS status panel, header stat cards and right column unchanged.
|
||||||
|
- New scificn-ui components copied into `components/ui/`: NodeGraph, Progress (+ `@radix-ui/react-progress` dependency).
|
||||||
|
|
||||||
## v0.1.0 — 2026-09-28 15:47
|
## v0.1.0 — 2026-09-28 15:47
|
||||||
- Initial scaffold (stack proof): base stack copied/adapted from Flight-Ops (`../flight`) — Next.js 16 App Router + React 19 + TypeScript strict, bun package manager, Node 22 in CI/Docker.
|
- Initial scaffold (stack proof): base stack copied/adapted from Flight-Ops (`../flight`) — Next.js 16 App Router + React 19 + TypeScript strict, bun package manager, Node 22 in CI/Docker.
|
||||||
- Single-player only by design: NO auth/accounts/multiplayer anywhere (Flight-Ops's users/sessions/admin/leaderboard patterns deliberately not copied).
|
- Single-player only by design: NO auth/accounts/multiplayer anywhere (Flight-Ops's users/sessions/admin/leaderboard patterns deliberately not copied).
|
||||||
|
|||||||
+2
-2
@@ -46,8 +46,8 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
|
|||||||
## 7. App architecture quick map
|
## 7. App architecture quick map
|
||||||
- **Scaffolded (v0.1.0)** — stack proof only, no game systems yet; the rest of this section is what's built + what lands next.
|
- **Scaffolded (v0.1.0)** — stack proof only, no game systems yet; the rest of this section is what's built + what lands next.
|
||||||
- Next.js 16 App Router + React 19 + TypeScript strict, bun package manager (bun.lock), Node 22 in CI/Docker.
|
- Next.js 16 App Router + React 19 + TypeScript strict, bun package manager (bun.lock), Node 22 in CI/Docker.
|
||||||
- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
|
- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart, NodeGraph, Progress), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. As of v0.2.0 the operations panel is a 7-tab visual template (WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE, MISSIONS, EVENT LOG) built on NodeGraph + Card widgets. As of v0.3.0 the WORLD MAP tab renders live data from `GET /api/world` (seeded world generation in `lib/game/`, save/jump/events via Drizzle/sql.js — single player, one save, no auth); the other tabs keep local placeholder constants until their own issues land. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
|
||||||
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Schema is EMPTY at scaffold stage — first game tables land with the turn engine.
|
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Game tables (`saves`, `solar_systems`, `jump_routes`, `events`) live since v0.3.0 — single-player save with seeded world generation + turn counter; DDL in `lib/db/client.ts` must stay in sync with `lib/db/schema.ts`.
|
||||||
- Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only.
|
- Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only.
|
||||||
- Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer).
|
- Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer).
|
||||||
- Plain React hooks for client state, no external store. **NO auth/accounts/multiplayer by design** (single player only) — there is no users/sessions table and no session headers; a "session" means the local single-player save slot in the DB, not an identity system. Do not copy Flight-Ops's lib/auth.ts / SignInScreen / admin patterns.
|
- Plain React hooks for client state, no external store. **NO auth/accounts/multiplayer by design** (single player only) — there is no users/sessions table and no session headers; a "session" means the local single-player save slot in the DB, not an identity system. Do not copy Flight-Ops's lib/auth.ts / SignInScreen / admin patterns.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { JumpError, jumpToSystem } from '@/lib/game/save'
|
||||||
|
|
||||||
|
// Jump to an adjacent solar system. Body: { "systemId": number }. Returns the fresh world state.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemId = (body as { systemId?: unknown }).systemId
|
||||||
|
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||||
|
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
return Response.json(await jumpToSystem(db, systemId))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof JumpError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { getWorldState } from '@/lib/game/save'
|
||||||
|
|
||||||
|
// The full world state for the single active save; auto-initializes on first hit.
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
return Response.json(await getWorldState(db))
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -136,6 +137,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||||
|
|||||||
+243
-127
@@ -2,19 +2,21 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { BarChart } from '@/components/ui/bar-chart'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { LineChart } from '@/components/ui/line-chart'
|
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 { ProgressRing } from '@/components/ui/progress-ring'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { StatCard } from '@/components/ui/stat-card'
|
import { StatCard } from '@/components/ui/stat-card'
|
||||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||||
|
import { WorldMapPanel } from '@/components/world-map'
|
||||||
|
|
||||||
// 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), with
|
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
||||||
// placeholder SpaceWanderer content — no game logic yet. Single player, no auth, by design.
|
// WORLD MAP tab renders live data from /api/world; the other tabs keep placeholder content
|
||||||
|
// until 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(
|
||||||
@@ -42,19 +44,41 @@ const shipSystems: SystemEntry[] = [
|
|||||||
{ name: 'COMMS', status: 'ACTIVE' },
|
{ name: 'COMMS', status: 'ACTIVE' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const bayOutput = [
|
// LOCAL SYSTEM — explorable worlds of the current system (regenerated per save after each jump, later).
|
||||||
{ label: 'PWR', value: 14 },
|
const explorablePlanets = [
|
||||||
{ label: 'NAV', value: 9 },
|
{
|
||||||
{ label: 'COM', value: 6 },
|
name: 'AURELLE', type: 'TERRESTRIAL // OCEAN CONTINENTS', state: 'UNEXPLORED' as const,
|
||||||
{ label: 'WPN', value: 3 },
|
moons: [{ name: 'PALEMOON', type: 'ICE MOON // FROST PLAINS', state: 'UNEXPLORED' as const }],
|
||||||
{ label: 'LSS', value: 12 },
|
},
|
||||||
{ label: 'ENG', value: 11 },
|
{
|
||||||
{ label: 'BAY', value: 7 },
|
name: 'BRIMSTONE', type: 'GAS GIANT // CLOUD DECKS', state: 'SCANNED' as const,
|
||||||
{ label: 'MED', value: 5 },
|
moons: [{ name: 'ASHFALL', type: 'VOLCANIC MOON // SULFUR VEINS', state: 'UNEXPLORED' as const }],
|
||||||
|
},
|
||||||
|
{ name: 'CINDERROCK', type: 'ROCKY // IRON RICH', state: 'UNEXPLORED' as const, moons: [] },
|
||||||
|
{ name: 'DRIFTBAND', type: 'ASTEROID FIELD // HAZARD', state: 'HAZARD' as const, moons: [] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const hullTrack = [100, 98, 97, 96, 94, 95, 92, 90, 89, 87]
|
const crew = [
|
||||||
const hullLabels = hullTrack.map((_, i) => `T-${hullTrack.length - 1 - i}`)
|
{ name: 'CMDR. E. VANCE', role: 'COMMANDER', good: 'STEADY UNDER PRESSURE', bad: 'RIGID — SLOW TO ADAPT' },
|
||||||
|
{ name: 'LT. M. OKONKWO', role: 'PILOT', good: 'RECKLESS COURAGE', bad: 'SHORT FUSE' },
|
||||||
|
{ name: 'DR. I. SORENSEN', role: 'MEDIC', good: 'CALM IN A CRISIS', bad: 'OVERCAUTIOUS' },
|
||||||
|
{ name: 'R. "SPARK" DELACRUZ', role: 'ENGINEER', good: 'FIXES ANYTHING', bad: 'CARELESS WITH FUSES' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const shipSystemCards = [
|
||||||
|
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||||
|
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||||
|
{ name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||||
|
{ name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] },
|
||||||
|
{ name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
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' },
|
||||||
@@ -237,7 +261,7 @@ export function MissionControl() {
|
|||||||
background: 'var(--border)',
|
background: 'var(--border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS ── */}
|
{/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS (mirrors the SHIP SYSTEMS tab) ── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--background)',
|
background: 'var(--background)',
|
||||||
@@ -264,64 +288,16 @@ export function MissionControl() {
|
|||||||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<PanelContent>
|
<PanelContent>
|
||||||
<div
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||||
style={{
|
<Progress label="HULL INTEGRITY" value={87} />
|
||||||
display: 'flex',
|
<Progress label="REACTOR OUTPUT" value={94} />
|
||||||
justifyContent: 'space-around',
|
<Progress label="CREW ROSTER / 6" value={50} />
|
||||||
paddingTop: '0.5rem',
|
|
||||||
paddingBottom: '0.25rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProgressRing value={87} label="HULL" variant="ACTIVE" size={74} />
|
|
||||||
<ProgressRing value={94} label="REACTOR" variant="ACTIVE" size={74} />
|
|
||||||
<ProgressRing value={50} label="CREW / 6" variant="WARNING" size={74} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '0.75rem',
|
|
||||||
paddingTop: '0.75rem',
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr 1fr',
|
|
||||||
gap: '0.25rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: 'HULL INTEGRITY', note: 'last scan T-02' },
|
|
||||||
{ label: 'REACTOR OUTPUT', note: 'idle cycle' },
|
|
||||||
{ label: 'CREW ROSTER', note: 'of 6 berths' },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.label}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.52rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.08em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.48rem',
|
|
||||||
color: 'var(--border)',
|
|
||||||
letterSpacing: '0.06em',
|
|
||||||
marginTop: '1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.note}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── CENTER: OPERATIONS TABS ── */}
|
{/* ── CENTER: GAME TEMPLATE TABS (visual only — no handlers, placeholder data) ── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--background)',
|
background: 'var(--background)',
|
||||||
@@ -341,75 +317,227 @@ export function MissionControl() {
|
|||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<PanelContent style={{ padding: 0 }}>
|
<PanelContent style={{ padding: 0 }}>
|
||||||
<Tabs defaultValue="systems">
|
<Tabs defaultValue="world-map">
|
||||||
<TabsList
|
<TabsList
|
||||||
style={{ padding: '0 1rem', background: 'var(--surface-raised)' }}
|
style={{
|
||||||
|
padding: '0 1rem',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
overflowX: 'auto',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="systems">SYSTEMS</TabsTrigger>
|
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
|
||||||
<TabsTrigger value="next">NEXT MISSION</TabsTrigger>
|
<TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger>
|
||||||
<TabsTrigger value="log">LOG</TabsTrigger>
|
<TabsTrigger value="crew">CREW</TabsTrigger>
|
||||||
<TabsTrigger value="hull">HULL TRACK</TabsTrigger>
|
<TabsTrigger value="ship-systems">SHIP SYSTEMS</TabsTrigger>
|
||||||
|
<TabsTrigger value="skill-tree">SKILL TREE</TabsTrigger>
|
||||||
|
<TabsTrigger value="missions">MISSIONS</TabsTrigger>
|
||||||
|
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* SYSTEMS TAB */}
|
{/* WORLD MAP TAB — live data from /api/world */}
|
||||||
<TabsContent value="systems" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<BarChart
|
<WorldMapPanel height={380} />
|
||||||
data={bayOutput}
|
</TabsContent>
|
||||||
orientation="vertical"
|
|
||||||
variant="ACTIVE"
|
{/* LOCAL SYSTEM TAB */}
|
||||||
title="MODULE OUTPUT BY BAY (PLACEHOLDER)"
|
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: '1rem',
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
|
||||||
gap: '0.625rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: 'TOTAL MODULES', value: 34 },
|
|
||||||
{ label: 'ONLINE', value: 29 },
|
|
||||||
{ label: 'EFFICIENCY', value: '85%' },
|
|
||||||
].map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.label}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
textAlign: 'center',
|
background: 'var(--surface)',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '0.55rem',
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
color: 'var(--text-muted)',
|
color: 'var(--text-muted)',
|
||||||
letterSpacing: '0.12em',
|
letterSpacing: '0.12em',
|
||||||
marginBottom: '0.4rem',
|
textTransform: 'uppercase',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{s.label}
|
EXPLORABLE WORLDS // FIND THINGS HERE (PLACEHOLDER)
|
||||||
|
</div>
|
||||||
|
{explorablePlanets.flatMap((planet) => [
|
||||||
|
{ name: planet.name, type: planet.type, state: planet.state, depth: 0 },
|
||||||
|
...planet.moons.map((moon) => ({
|
||||||
|
name: moon.name,
|
||||||
|
type: moon.type,
|
||||||
|
state: moon.state,
|
||||||
|
depth: 1,
|
||||||
|
})),
|
||||||
|
]).map((row, i, rows) => (
|
||||||
|
<div
|
||||||
|
key={row.name}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: row.depth
|
||||||
|
? '0.6rem 0.75rem 0.6rem 1.75rem'
|
||||||
|
: '0.6rem 0.75rem',
|
||||||
|
borderBottom:
|
||||||
|
i < rows.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
color: row.depth ? 'var(--text-muted)' : 'var(--text-secondary)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '1.1rem',
|
fontSize: '0.57rem',
|
||||||
fontWeight: 700,
|
color: 'var(--text-muted)',
|
||||||
color: 'var(--color-green)',
|
letterSpacing: '0.06em',
|
||||||
textShadow: 'var(--text-glow-green)',
|
marginTop: '2px',
|
||||||
letterSpacing: '0.02em',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{s.value}
|
{row.type}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.state === 'UNEXPLORED'
|
||||||
|
? 'SCANNING'
|
||||||
|
: row.state === 'HAZARD'
|
||||||
|
? 'WARNING'
|
||||||
|
: 'ACTIVE'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* NEXT MISSION TAB */}
|
{/* CREW TAB */}
|
||||||
<TabsContent value="next" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{crew.map((member) => (
|
||||||
|
<Card key={member.name}>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{member.name}</CardTitle>
|
||||||
|
<Badge variant="ACTIVE" className="ml-auto">
|
||||||
|
ACTIVE
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.role}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||||
|
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{member.good}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||||
|
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{member.bad}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* SHIP SYSTEMS TAB */}
|
||||||
|
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shipSystemCards.map((system) => (
|
||||||
|
<Card key={system.name}>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{system.name}</CardTitle>
|
||||||
|
<Badge variant={system.status} className="ml-auto">
|
||||||
|
{system.level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
MODULE BAY // PLACEHOLDER READOUT
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="gap-2">
|
||||||
|
{system.actions.map((action) => (
|
||||||
|
<Button key={action} variant="OUTLINE" size="SM">
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* SKILL TREE TAB */}
|
||||||
|
<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>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* MISSIONS TAB */}
|
||||||
|
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||||
>
|
>
|
||||||
@@ -466,8 +594,8 @@ export function MissionControl() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* LOG TAB */}
|
{/* EVENT LOG TAB */}
|
||||||
<TabsContent value="log" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<Terminal
|
<Terminal
|
||||||
lines={logLines}
|
lines={logLines}
|
||||||
title="SHIP LOG // PLACEHOLDER FEED"
|
title="SHIP LOG // PLACEHOLDER FEED"
|
||||||
@@ -475,18 +603,6 @@ export function MissionControl() {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* HULL TRACK TAB */}
|
|
||||||
<TabsContent value="hull" style={{ margin: 0, padding: '1rem' }}>
|
|
||||||
<LineChart
|
|
||||||
series={[{ id: 'hull', label: 'HULL INTEGRITY %', data: hullTrack }]}
|
|
||||||
labels={hullLabels}
|
|
||||||
title="HULL INTEGRITY TRACK // PLACEHOLDER"
|
|
||||||
showArea
|
|
||||||
animated={false}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type NodeGraphVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||||
|
export type NodeStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'NEUTRAL'
|
||||||
|
|
||||||
|
export interface NodeGraphNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
x: number // 0–100 coordinate space
|
||||||
|
y: number
|
||||||
|
status?: NodeStatus
|
||||||
|
sublabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeGraphEdge {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
label?: string
|
||||||
|
animated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
nodes: NodeGraphNode[]
|
||||||
|
edges: NodeGraphEdge[]
|
||||||
|
variant?: NodeGraphVariant
|
||||||
|
title?: string
|
||||||
|
height?: number
|
||||||
|
directed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
||||||
|
DEFAULT: 'var(--text-secondary)',
|
||||||
|
ACTIVE: 'var(--color-green)',
|
||||||
|
WARNING: 'var(--color-amber)',
|
||||||
|
CRITICAL: 'var(--color-red)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<NodeStatus, string> = {
|
||||||
|
ACTIVE: 'var(--color-green)',
|
||||||
|
OFFLINE: 'var(--border)',
|
||||||
|
WARNING: 'var(--color-amber)',
|
||||||
|
CRITICAL: 'var(--color-red)',
|
||||||
|
NEUTRAL: 'var(--text-muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_W = 100
|
||||||
|
const NODE_H = 36
|
||||||
|
|
||||||
|
const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
variant = 'ACTIVE',
|
||||||
|
title,
|
||||||
|
height = 320,
|
||||||
|
directed = false,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [svgW, setSvgW] = React.useState(600)
|
||||||
|
const [hovered, setHovered] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const ro = new ResizeObserver(entries => {
|
||||||
|
setSvgW(entries[0].contentRect.width)
|
||||||
|
})
|
||||||
|
ro.observe(containerRef.current)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const color = VARIANT_COLORS[variant]
|
||||||
|
const markerId = `arrow-${variant}`
|
||||||
|
|
||||||
|
function nodePx(node: NodeGraphNode) {
|
||||||
|
return (node.x / 100) * svgW
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodePy(node: NodeGraphNode) {
|
||||||
|
return (node.y / 100) * height
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeEndpoints(fromNode: NodeGraphNode, toNode: NodeGraphNode) {
|
||||||
|
const fx = nodePx(fromNode)
|
||||||
|
const fy = nodePy(fromNode)
|
||||||
|
const tx = nodePx(toNode)
|
||||||
|
const ty = nodePy(toNode)
|
||||||
|
const dx = tx - fx
|
||||||
|
const dy = ty - fy
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (dist < 1) return { x1: fx, y1: fy, x2: tx, y2: ty }
|
||||||
|
|
||||||
|
const ux = dx / dist
|
||||||
|
const uy = dy / dist
|
||||||
|
const hw = NODE_W / 2
|
||||||
|
const hh = NODE_H / 2
|
||||||
|
|
||||||
|
const tW = Math.abs(ux) > 0.001 ? hw / Math.abs(ux) : Infinity
|
||||||
|
const tH = Math.abs(uy) > 0.001 ? hh / Math.abs(uy) : Infinity
|
||||||
|
const t = Math.min(tW, tH)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x1: fx + ux * t,
|
||||||
|
y1: fy + uy * t,
|
||||||
|
x2: tx - ux * t,
|
||||||
|
y2: ty - uy * t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
||||||
|
const hasAnimatedEdge = edges.some(e => e.animated)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(className)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
|
||||||
|
<svg width={svgW} height={height} style={{ display: 'block' }}>
|
||||||
|
<defs>
|
||||||
|
{directed && (
|
||||||
|
<marker
|
||||||
|
id={markerId}
|
||||||
|
markerWidth="6"
|
||||||
|
markerHeight="6"
|
||||||
|
refX="6"
|
||||||
|
refY="3"
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 6 3 L 0 6 Z" fill={color} />
|
||||||
|
</marker>
|
||||||
|
)}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{hasAnimatedEdge && (
|
||||||
|
<style>{`
|
||||||
|
@keyframes nodeGraphDash { to { stroke-dashoffset: -8; } }
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edges */}
|
||||||
|
{edges.map((edge, ei) => {
|
||||||
|
const fromNode = nodeMap.get(edge.from)
|
||||||
|
const toNode = nodeMap.get(edge.to)
|
||||||
|
if (!fromNode || !toNode) return null
|
||||||
|
|
||||||
|
const { x1, y1, x2, y2 } = edgeEndpoints(fromNode, toNode)
|
||||||
|
const mx = (x1 + x2) / 2
|
||||||
|
const my = (y1 + y2) / 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={ei}>
|
||||||
|
<line
|
||||||
|
x1={x1.toFixed(1)}
|
||||||
|
y1={y1.toFixed(1)}
|
||||||
|
x2={x2.toFixed(1)}
|
||||||
|
y2={y2.toFixed(1)}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1}
|
||||||
|
opacity={0.5}
|
||||||
|
strokeDasharray={edge.animated ? '4 4' : undefined}
|
||||||
|
markerEnd={directed ? `url(#${markerId})` : undefined}
|
||||||
|
style={edge.animated
|
||||||
|
? { animation: 'nodeGraphDash 0.5s linear infinite' }
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
{edge.label && (
|
||||||
|
<text
|
||||||
|
x={mx.toFixed(1)}
|
||||||
|
y={my.toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{edge.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{nodes.map(node => {
|
||||||
|
const cx = nodePx(node)
|
||||||
|
const cy = nodePy(node)
|
||||||
|
const isHovered = hovered === node.id
|
||||||
|
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
onMouseEnter={() => setHovered(node.id)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
style={{ cursor: 'default' }}
|
||||||
|
>
|
||||||
|
{/* Background */}
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill="var(--surface-raised)"
|
||||||
|
/>
|
||||||
|
{/* Hover tint */}
|
||||||
|
{isHovered && (
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill={statusColor}
|
||||||
|
fillOpacity={0.12}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Border */}
|
||||||
|
<rect
|
||||||
|
x={(cx - NODE_W / 2).toFixed(1)}
|
||||||
|
y={(cy - NODE_H / 2).toFixed(1)}
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
fill="none"
|
||||||
|
stroke={statusColor}
|
||||||
|
strokeWidth={isHovered ? 1.5 : 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
x={cx.toFixed(1)}
|
||||||
|
y={(node.sublabel ? cy - 5 : cy).toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={10}
|
||||||
|
fill={statusColor}
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
fontWeight="600"
|
||||||
|
>
|
||||||
|
{node.label.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Sublabel */}
|
||||||
|
{node.sublabel && (
|
||||||
|
<text
|
||||||
|
x={cx.toFixed(1)}
|
||||||
|
y={(cy + 8).toFixed(1)}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{node.sublabel}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NodeGraph.displayName = 'NodeGraph'
|
||||||
|
|
||||||
|
export { NodeGraph }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ProgressProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||||
|
showValue?: boolean
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
ProgressProps
|
||||||
|
>(({ className, value = 0, showValue = true, label, ...props }, ref) => {
|
||||||
|
const pct = Math.min(Math.max(value ?? 0, 0), 100)
|
||||||
|
const filled = Math.round((pct / 100) * 20)
|
||||||
|
const empty = 20 - filled
|
||||||
|
const barText = '='.repeat(filled) + ' '.repeat(empty)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', width: '100%' }}>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative w-full overflow-hidden rounded-none', className)}
|
||||||
|
value={value}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Bracket-style visual */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-green)',
|
||||||
|
textShadow: 'var(--text-glow-green)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>[</span>
|
||||||
|
<span style={{ letterSpacing: '-0.05em', whiteSpace: 'pre' }}>{barText}</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>]</span>
|
||||||
|
{showValue && (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '0.65rem', marginLeft: '0.25rem' }}>
|
||||||
|
{Math.round(pct)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accessible indicator (visually hidden, only for Radix a11y) */}
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0,0,0,0)',
|
||||||
|
transform: `translateX(-${100 - pct}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { NodeGraph } from '@/components/ui/node-graph'
|
||||||
|
import type { WorldState } from '@/lib/game/save'
|
||||||
|
|
||||||
|
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and
|
||||||
|
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not
|
||||||
|
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount,
|
||||||
|
// refetch via the jump response — no polling.
|
||||||
|
|
||||||
|
interface WorldMapPanelProps {
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
||||||
|
const [world, setWorld] = useState<WorldState | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/world')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as WorldState
|
||||||
|
})
|
||||||
|
.then((state) => {
|
||||||
|
if (!cancelled) setWorld(state)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const jump = useCallback(async (systemId: number) => {
|
||||||
|
setJumpingId(systemId)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/jump', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ systemId }),
|
||||||
|
})
|
||||||
|
const body = (await res.json()) as WorldState & { error?: string }
|
||||||
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
|
setWorld(body) // fresh state straight from the response — no refetch needed
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setJumpingId(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error && !world) {
|
||||||
|
return (
|
||||||
|
<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' }}>
|
||||||
|
CHART OFFLINE // {error.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!world) {
|
||||||
|
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' }}>SCANNING CHART…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = world.systems.find((s) => s.current)!
|
||||||
|
const reachable = world.edges
|
||||||
|
.filter(([a, b]) => a === current.id || b === current.id)
|
||||||
|
.map(([a, b]) => (a === current.id ? b : a))
|
||||||
|
.map((id) => world.systems.find((s) => s.id === id)!)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
<NodeGraph
|
||||||
|
nodes={world.systems.map((s) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
label: s.name,
|
||||||
|
x: s.x,
|
||||||
|
y: s.y,
|
||||||
|
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE',
|
||||||
|
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
||||||
|
}))}
|
||||||
|
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
|
||||||
|
variant="ACTIVE"
|
||||||
|
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
|
||||||
|
height={height}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
JUMP ROUTES // FROM {current.name.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', padding: '0.6rem 0.75rem' }}>
|
||||||
|
{reachable.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s.id}
|
||||||
|
variant="OUTLINE"
|
||||||
|
size="SM"
|
||||||
|
disabled={jumpingId !== null}
|
||||||
|
onClick={() => void jump(s.id)}
|
||||||
|
>
|
||||||
|
{jumpingId === s.id ? 'JUMPING…' : `${s.name.toUpperCase()} // JUMP`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||||
|
JUMP FAILED // {error.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+35
-6
@@ -1,25 +1,54 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||||
|
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||||
|
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||||||
|
|
||||||
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mission control layout renders scificn-ui components', async ({ page }) => {
|
test('game template tabs render (scificn-ui component layer wired in)', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// Stat card, tab trigger and footer badge prove the copied component layer is wired in.
|
// Stat card + footer badge prove the layout shell; Radix Tabs.Trigger renders role="tab".
|
||||||
await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible();
|
await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible();
|
||||||
// Radix Tabs.Trigger renders role="tab", not a plain button.
|
await expect(page.getByRole('tab', { name: 'WORLD MAP' })).toBeVisible();
|
||||||
await expect(page.getByRole('tab', { name: 'SYSTEMS' })).toBeVisible();
|
await expect(page.getByRole('tab', { name: 'CREW' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'SKILL TREE' })).toBeVisible();
|
||||||
await expect(page.getByText('SYS ONLINE')).toBeVisible();
|
await expect(page.getByText('SYS ONLINE')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operations tabs switch panels client-side', async ({ page }) => {
|
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('tab', { name: 'LOG' }).click();
|
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||||||
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
|
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('world map shows the generated starting system and its neighbours', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Live chart title carries the turn counter; a fresh save sits at the start with six jump routes.
|
||||||
|
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
|
||||||
|
const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ });
|
||||||
|
await expect(jumpButtons.first()).toBeVisible();
|
||||||
|
await expect(jumpButtons).toHaveCount(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
|
||||||
|
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
const currentId = before.systems.find((s) => s.current)!.id;
|
||||||
|
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||||
|
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||||
|
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: `${targetName.toUpperCase()} // JUMP` }).click();
|
||||||
|
|
||||||
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
expect(after.turn).toBe(before.turn + 1);
|
||||||
|
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||||
|
});
|
||||||
|
|
||||||
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||||
const res = await page.request.get('/api/version');
|
const res = await page.request.get('/api/version');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { expect, test } from '@playwright/test';
|
|||||||
const repoRoot = path.join(__dirname, '..');
|
const repoRoot = path.join(__dirname, '..');
|
||||||
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
||||||
|
|
||||||
|
// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||||
|
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||||
|
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||||||
|
|
||||||
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
@@ -17,6 +21,15 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const before = (await resBefore.json()) as { version: string };
|
const before = (await resBefore.json()) as { version: string };
|
||||||
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
|
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
|
|
||||||
|
// Game-state baseline: jump once so there is real progress that must survive the restart.
|
||||||
|
const worldBefore = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
const currentId = worldBefore.systems.find((s) => s.current)!.id;
|
||||||
|
const edge = worldBefore.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||||
|
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||||
|
const jumpRes = await page.request.post('/api/jump', { data: { systemId: targetId } });
|
||||||
|
expect(jumpRes.status()).toBe(200);
|
||||||
|
const worldJumped = (await jumpRes.json()) as WorldState;
|
||||||
|
|
||||||
// Restart the app container mid-run — the named DB volume must survive it.
|
// Restart the app container mid-run — the named DB volume must survive it.
|
||||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
|
||||||
@@ -37,6 +50,11 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
expect(after).toBeDefined();
|
expect(after).toBeDefined();
|
||||||
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
||||||
|
|
||||||
|
// Game state survived: same turn counter and jumped-to current system as before the restart.
|
||||||
|
const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
expect(worldAfter.turn).toBe(worldJumped.turn);
|
||||||
|
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||||
|
|
||||||
// The page itself serves again with the landing title.
|
// The page itself serves again with the landing title.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
|
|||||||
+35
-4
@@ -14,12 +14,43 @@ export interface GameDb {
|
|||||||
file: string;
|
file: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. Game tables land with
|
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. _meta is DB plumbing
|
||||||
// the turn engine; _meta is DB plumbing (not game state): a brand-new sql.js database exports
|
// (not game state): a brand-new sql.js database exports as ZERO bytes, so without at least
|
||||||
// as ZERO bytes, so without at least one real table there would be nothing valid to persist
|
// one real table there would be nothing valid to persist on the ./db volume from first boot.
|
||||||
// on the ./db volume from first boot.
|
|
||||||
const DDL = `
|
const DDL = `
|
||||||
CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT);
|
CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saves (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
seed TEXT NOT NULL,
|
||||||
|
turn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS solar_systems (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
x REAL NOT NULL,
|
||||||
|
y REAL NOT NULL,
|
||||||
|
discovered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_current INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jump_routes (
|
||||||
|
from_system INTEGER NOT NULL REFERENCES solar_systems(id),
|
||||||
|
to_system INTEGER NOT NULL REFERENCES solar_systems(id),
|
||||||
|
PRIMARY KEY (from_system, to_system)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
turn INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
|
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
|
||||||
|
|||||||
+44
-3
@@ -1,6 +1,47 @@
|
|||||||
// Drizzle schema for game state. EMPTY on purpose at scaffold stage (v0.1.0) — no game
|
import { integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
// tables yet; the first turn-engine/ship/crew tables land with the first game systems.
|
|
||||||
|
// Drizzle schema for game state. Single-player by design: exactly ONE save row ever exists
|
||||||
|
// (no auth/accounts), so every table hangs off saves.id but only one save is in play.
|
||||||
// Column names/types here are the source of truth; matching CREATE TABLE statements live
|
// Column names/types here are the source of truth; matching CREATE TABLE statements live
|
||||||
// in lib/db/client.ts (DDL) and must stay in sync with this file.
|
// in lib/db/client.ts (DDL) and must stay in sync with this file.
|
||||||
|
|
||||||
export {};
|
export const saves = sqliteTable('saves', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
// Seed for the deterministic world generator — same seed always regenerates the same world.
|
||||||
|
seed: text('seed').notNull(),
|
||||||
|
turn: integer('turn').notNull().default(0),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const solarSystems = sqliteTable('solar_systems', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
// 0–100 coordinate space, rendered directly by the NodeGraph component.
|
||||||
|
x: real('x').notNull(),
|
||||||
|
y: real('y').notNull(),
|
||||||
|
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
isCurrent: integer('is_current', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Jump routes, stored as directed pairs (both directions of each route) so adjacency and
|
||||||
|
// "reachable from current" are single-row lookups. Read side dedupes for display.
|
||||||
|
export const jumpRoutes = sqliteTable(
|
||||||
|
'jump_routes',
|
||||||
|
{
|
||||||
|
fromSystem: integer('from_system').notNull().references(() => solarSystems.id),
|
||||||
|
toSystem: integer('to_system').notNull().references(() => solarSystems.id),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.fromSystem, t.toSystem] })],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
||||||
|
// writers and a read endpoint.
|
||||||
|
export const events = sqliteTable('events', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
turn: integer('turn').notNull(),
|
||||||
|
type: text('type').notNull(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import { getWorldState, jumpToSystem, type WorldState } from './save'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-save-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
return { db: await openGameDb(file), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function currentOf(world: WorldState): WorldState['systems'][number] {
|
||||||
|
const current = world.systems.filter((s) => s.current)
|
||||||
|
expect(current).toHaveLength(1)
|
||||||
|
return current[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ensureSave / getWorldState', () => {
|
||||||
|
it('auto-initializes a save on first read: start + 6 neighbours, turn 0, new_game event', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const world = await getWorldState(db)
|
||||||
|
|
||||||
|
expect(world.turn).toBe(0)
|
||||||
|
expect(world.systems).toHaveLength(7)
|
||||||
|
expect(currentOf(world).discovered).toBe(true)
|
||||||
|
expect(world.systems.filter((s) => !s.discovered)).toHaveLength(6)
|
||||||
|
expect(world.edges).toHaveLength(12) // start↔each neighbour + ring cycle
|
||||||
|
|
||||||
|
const ids = new Set(world.systems.map((s) => s.id))
|
||||||
|
for (const [a, b] of world.edges) {
|
||||||
|
expect(ids.has(a)).toBe(true)
|
||||||
|
expect(ids.has(b)).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
|
||||||
|
expect(rows[0].values).toEqual([['new_game', 0]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent — a second read does not regenerate the world', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const first = await getWorldState(db)
|
||||||
|
const second = await getWorldState(db)
|
||||||
|
expect(second).toEqual(first)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('jumpToSystem validation', () => {
|
||||||
|
it('rejects unknown systems with 404 and jumping to the current system with 409', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const world = await getWorldState(db)
|
||||||
|
const start = currentOf(world)
|
||||||
|
|
||||||
|
await expect(jumpToSystem(db, 999_999)).rejects.toMatchObject({ status: 404 })
|
||||||
|
await expect(jumpToSystem(db, start.id)).rejects.toMatchObject({ status: 409 })
|
||||||
|
|
||||||
|
const after = await getWorldState(db)
|
||||||
|
expect(after.turn).toBe(0) // rejected jumps mutate nothing
|
||||||
|
expect(currentOf(after).id).toBe(start.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-adjacent systems with 409 and leaves state untouched', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const w0 = await getWorldState(db)
|
||||||
|
const start = currentOf(w0)
|
||||||
|
|
||||||
|
// Every initial neighbour is adjacent to the start, so jump once first.
|
||||||
|
const n0 = w0.systems.find((s) => !s.current)!
|
||||||
|
await jumpToSystem(db, n0.id)
|
||||||
|
const w1 = await getWorldState(db)
|
||||||
|
expect(currentOf(w1).id).toBe(n0.id)
|
||||||
|
|
||||||
|
// A system with no route to the current one (ring members two steps away qualify).
|
||||||
|
const adjacent = new Set(
|
||||||
|
w1.edges.filter(([a, b]) => a === n0.id || b === n0.id).flatMap(([a, b]) => [a, b]),
|
||||||
|
)
|
||||||
|
const far = w1.systems.find((s) => !adjacent.has(s.id))!
|
||||||
|
|
||||||
|
await expect(jumpToSystem(db, far.id)).rejects.toMatchObject({ status: 409 })
|
||||||
|
const after = await getWorldState(db)
|
||||||
|
expect(after.turn).toBe(1)
|
||||||
|
expect(currentOf(after).id).toBe(n0.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('jumpToSystem success path', () => {
|
||||||
|
it('sets current, increments turn, discovers the target and grows the chart', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const w0 = await getWorldState(db)
|
||||||
|
const start = currentOf(w0)
|
||||||
|
const target = w0.systems.find((s) => !s.current)! // all initial neighbours are adjacent to the start
|
||||||
|
|
||||||
|
const after = await jumpToSystem(db, target.id)
|
||||||
|
|
||||||
|
expect(after.turn).toBe(1)
|
||||||
|
expect(currentOf(after).id).toBe(target.id)
|
||||||
|
expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true)
|
||||||
|
|
||||||
|
// Expansion: 4 new undiscovered neighbours around the target, each routed to it.
|
||||||
|
const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id))
|
||||||
|
expect(fresh).toHaveLength(4)
|
||||||
|
expect(fresh.every((s) => !s.discovered)).toBe(true)
|
||||||
|
for (const n of fresh) {
|
||||||
|
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
|
||||||
|
expect(rows[0].values).toEqual([['new_game', 0], ['jump', 1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists the current system and turn across a DB reopen (container restart)', async () => {
|
||||||
|
const { db, file } = await freshFile()
|
||||||
|
const w0 = await getWorldState(db)
|
||||||
|
const target = w0.systems.find((s) => !s.current)!
|
||||||
|
const after = await jumpToSystem(db, target.id)
|
||||||
|
|
||||||
|
const reopened = await openGameDb(file) // a new process would see the same file
|
||||||
|
const again = await getWorldState(reopened)
|
||||||
|
|
||||||
|
expect(again.turn).toBe(after.turn)
|
||||||
|
expect(currentOf(again).id).toBe(target.id)
|
||||||
|
expect(again.systems).toHaveLength(after.systems.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import type { GameDb } from '@/lib/db/client'
|
||||||
|
import { persist } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { generateRing, generateStartRegion, mulberry32 } from './world'
|
||||||
|
|
||||||
|
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
||||||
|
// Single-player by design — exactly one save row ever exists, so every query below is
|
||||||
|
// unscoped (there is nothing to scope against).
|
||||||
|
|
||||||
|
export interface WorldSystemDto {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
discovered: boolean
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldState {
|
||||||
|
systems: WorldSystemDto[]
|
||||||
|
edges: [number, number][] // undirected system-id pairs, deduped + sorted for stable output
|
||||||
|
turn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||||
|
export class JumpError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newSeed(): string {
|
||||||
|
const buf = new Uint32Array(1)
|
||||||
|
crypto.getRandomValues(buf)
|
||||||
|
return String(buf[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic per save+turn: the whole world of a seed is reproducible from (seed, turn).
|
||||||
|
function expansionRng(seed: string, turn: number): () => number {
|
||||||
|
return mulberry32((Number(seed) + turn * 1_000_003) >>> 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertSystems(db: GameDb, saveId: number, systems: { name: string; x: number; y: number }[], discovered: boolean): Promise<Map<string, number>> {
|
||||||
|
const rows = await db.db
|
||||||
|
.insert(schema.solarSystems)
|
||||||
|
.values(systems.map((s) => ({ saveId, name: s.name, x: s.x, y: s.y, discovered })))
|
||||||
|
.returning({ id: schema.solarSystems.id, name: schema.solarSystems.name })
|
||||||
|
return new Map(rows.map((r) => [r.name, r.id]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
|
||||||
|
for (const [a, b] of edges) {
|
||||||
|
const from = resolve(a)
|
||||||
|
const to = resolve(b)
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: from, toSystem: to })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: to, toSystem: from }) // both directions → adjacency is a single-row lookup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the save + starting region when the DB has none. No-op otherwise — safe to call
|
||||||
|
// on every request (single player, one process: check-then-insert cannot race in practice).
|
||||||
|
export async function ensureSave(db: GameDb): Promise<void> {
|
||||||
|
const existing = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
if (existing.length > 0) return
|
||||||
|
|
||||||
|
const seed = newSeed()
|
||||||
|
const region = generateStartRegion(mulberry32(Number(seed)))
|
||||||
|
const nowIso = new Date().toISOString()
|
||||||
|
|
||||||
|
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso }).returning()
|
||||||
|
// All inserted as undiscovered first; the start is then marked discovered + current.
|
||||||
|
const ids = await insertSystems(db, save.id, [region.start, ...region.neighbors], false)
|
||||||
|
const startId = ids.get(region.start.name)!
|
||||||
|
await db.db.update(schema.solarSystems).set({ discovered: true, isCurrent: true }).where(eq(schema.solarSystems.id, startId))
|
||||||
|
|
||||||
|
const resolve = (index: number) => (index === 0 ? startId : ids.get(region.neighbors[index - 1].name)!)
|
||||||
|
await insertRoutes(db, region.edges, resolve)
|
||||||
|
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorldState(db: GameDb): Promise<WorldState> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const systems = await db.db.select().from(schema.solarSystems)
|
||||||
|
const routes = await db.db.select().from(schema.jumpRoutes)
|
||||||
|
|
||||||
|
// Dedupe the stored directed pairs into sorted undirected edges.
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const edges: [number, number][] = []
|
||||||
|
for (const r of routes) {
|
||||||
|
const a = Math.min(r.fromSystem, r.toSystem)
|
||||||
|
const b = Math.max(r.fromSystem, r.toSystem)
|
||||||
|
const key = `${a}-${b}`
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
edges.push([a, b])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
systems: systems.map((s) => ({ id: s.id, name: s.name, x: s.x, y: s.y, discovered: s.discovered, current: s.isCurrent })),
|
||||||
|
edges,
|
||||||
|
turn: save.turn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldState> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const systems = await db.db.select().from(schema.solarSystems)
|
||||||
|
|
||||||
|
const target = systems.find((s) => s.id === systemId)
|
||||||
|
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
|
||||||
|
const current = systems.find((s) => s.isCurrent)!
|
||||||
|
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
|
||||||
|
|
||||||
|
const routes = await db.db.select().from(schema.jumpRoutes)
|
||||||
|
const adjacent = routes.some((r) => r.fromSystem === current.id && r.toSystem === target.id)
|
||||||
|
if (!adjacent) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
|
||||||
|
|
||||||
|
const turn = save.turn + 1
|
||||||
|
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
|
||||||
|
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
|
||||||
|
|
||||||
|
if (!target.discovered) {
|
||||||
|
// Growing the chart: a ring of new (undiscovered) neighbours around the newly reached
|
||||||
|
// system, so there is always somewhere to jump next and a route back.
|
||||||
|
const rng = expansionRng(save.seed, turn)
|
||||||
|
const takenNames = new Set(systems.map((s) => s.name))
|
||||||
|
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
|
||||||
|
const ids = await insertSystems(db, save.id, ring.systems, false)
|
||||||
|
const resolve = (index: number) => (index === 0 ? target.id : ids.get(ring.systems[index - 1].name)!)
|
||||||
|
await insertRoutes(db, ring.edges, resolve)
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
return getWorldState(db)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { generateRing, generateStartRegion, makeName, mulberry32 } from './world'
|
||||||
|
|
||||||
|
describe('generateStartRegion', () => {
|
||||||
|
const region = generateStartRegion(mulberry32(42))
|
||||||
|
|
||||||
|
it('produces a starting system plus six neighbours', () => {
|
||||||
|
expect(region.neighbors).toHaveLength(6)
|
||||||
|
expect(region.start.x).toBe(50)
|
||||||
|
expect(region.start.y).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives every system a unique name and in-bounds coordinates', () => {
|
||||||
|
const all = [region.start, ...region.neighbors]
|
||||||
|
const names = all.map((s) => s.name)
|
||||||
|
expect(new Set(names).size).toBe(names.length)
|
||||||
|
for (const s of all) {
|
||||||
|
expect(s.x).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(s.x).toBeLessThanOrEqual(100)
|
||||||
|
expect(s.y).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(s.y).toBeLessThanOrEqual(100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connects the start to every neighbour and closes the ring cycle', () => {
|
||||||
|
const n = region.neighbors.length
|
||||||
|
// anchor↔each member (n edges) + ring cycle (n edges)
|
||||||
|
expect(region.edges).toHaveLength(n * 2)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
expect(region.edges).toContainEqual([0, i + 1])
|
||||||
|
expect(region.edges).toContainEqual([i + 1, ((i + 1) % n) + 1])
|
||||||
|
}
|
||||||
|
// every edge endpoint is a valid index into [start, ...neighbors]
|
||||||
|
for (const [a, b] of region.edges) {
|
||||||
|
expect(a).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(a).toBeLessThan(n + 1)
|
||||||
|
expect(b).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(b).toBeLessThan(n + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic under a fixed seed and differs across seeds', () => {
|
||||||
|
const again = generateStartRegion(mulberry32(42))
|
||||||
|
expect(again).toEqual(region)
|
||||||
|
const other = generateStartRegion(mulberry32(43))
|
||||||
|
expect(other.start.name !== region.start.name || other.neighbors[0].name !== region.neighbors[0].name).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateRing (expansion)', () => {
|
||||||
|
it('adds four systems spaced away from the anchor and existing positions', () => {
|
||||||
|
const takenNames = new Set(['Kepler Drift'])
|
||||||
|
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
|
||||||
|
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
|
||||||
|
|
||||||
|
expect(ring.systems).toHaveLength(4)
|
||||||
|
const ringNames = ring.systems.map((s) => s.name)
|
||||||
|
for (const s of ring.systems) {
|
||||||
|
expect(Math.hypot(s.x - 50, s.y - 50)).toBeGreaterThanOrEqual(16)
|
||||||
|
expect(s.name).not.toBe('Kepler Drift') // never reuses a pre-taken name
|
||||||
|
}
|
||||||
|
expect(new Set(ringNames).size).toBe(4) // and makeName claimed them into the set
|
||||||
|
for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
|
||||||
|
expect(ring.edges).toHaveLength(8) // anchor↔each + cycle
|
||||||
|
for (const [a, b] of ring.edges) {
|
||||||
|
expect(a).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(a).toBeLessThan(5)
|
||||||
|
expect(b).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(b).toBeLessThan(5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never reuses a taken name', () => {
|
||||||
|
const rng = mulberry32(1)
|
||||||
|
const taken = new Set<string>()
|
||||||
|
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
||||||
|
expect(taken.size).toBe(40)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Pure, seeded world generation — no DB, no Date, no Math.random. The same seed always
|
||||||
|
// yields the same world, which is what makes unit tests (and save regeneration) deterministic.
|
||||||
|
|
||||||
|
export interface WorldSystem {
|
||||||
|
name: string
|
||||||
|
x: number // 0–100 coordinate space, rendered directly by NodeGraph
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const START_X = 50
|
||||||
|
export const START_Y = 50
|
||||||
|
|
||||||
|
const START_RING = 6 // neighbours around the starting system on a new game
|
||||||
|
const EXPAND_RING = 4 // new neighbours generated when jumping into an undiscovered system
|
||||||
|
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
|
||||||
|
|
||||||
|
// mulberry32 — small fast seeded PRNG; plenty for name/position picking.
|
||||||
|
export function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0
|
||||||
|
return () => {
|
||||||
|
a |= 0
|
||||||
|
a = (a + 0x6d2b79f5) | 0
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST = [
|
||||||
|
'KEPLER', 'VESPER', 'HOLLOW', 'CINDER', 'LANTERN', 'SABLE', 'ORPHEUS', 'MIRE',
|
||||||
|
'THALASSA', 'KUIPER', 'AURELLE', 'BRIMSTONE', 'EMBER', 'FROST', 'GALE', 'IRON',
|
||||||
|
]
|
||||||
|
const SECOND = ['DRIFT', 'REACH', 'GATE', 'FIELD', 'VOID', 'DEEP', 'BEND', 'SHARD']
|
||||||
|
|
||||||
|
function title(word: string): string {
|
||||||
|
return word.charAt(0) + word.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one.
|
||||||
|
export function makeName(rng: () => number, taken: Set<string>): string {
|
||||||
|
for (;;) {
|
||||||
|
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
||||||
|
if (!taken.has(name)) {
|
||||||
|
taken.add(name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number): number {
|
||||||
|
return Math.min(96, Math.max(4, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Places `count` systems on a ring around (cx,cy), nudging each candidate until it is at
|
||||||
|
// least MIN_SPACING away from every known position. After 12 tries it accepts the last
|
||||||
|
// candidate anyway — ponytail: a slightly crowded node beats an infinite loop; with ≤6
|
||||||
|
// rings per region and spacing 16 in a 0–100 box this never triggers in practice.
|
||||||
|
function placeRing(
|
||||||
|
rng: () => number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
count: number,
|
||||||
|
takenNames: Set<string>,
|
||||||
|
takenPositions: WorldSystem[],
|
||||||
|
): WorldSystem[] {
|
||||||
|
const placed: WorldSystem[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const baseAngle = (i / count) * Math.PI * 2 + (rng() - 0.5) * 0.6
|
||||||
|
let x = cx
|
||||||
|
let y = cy
|
||||||
|
for (let attempt = 0; attempt < 12; attempt++) {
|
||||||
|
const angle = baseAngle + attempt * 0.45
|
||||||
|
const radius = 26 + rng() * 8
|
||||||
|
x = clamp(cx + Math.cos(angle) * radius)
|
||||||
|
y = clamp(cy + Math.sin(angle) * radius)
|
||||||
|
if ([...takenPositions, ...placed].every((p) => Math.hypot(p.x - x, p.y - y) >= MIN_SPACING)) break
|
||||||
|
}
|
||||||
|
placed.push({ name: makeName(rng, takenNames), x, y })
|
||||||
|
}
|
||||||
|
return placed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
|
||||||
|
// cycle connecting consecutive members (so every system has ≥2 routes and you can always
|
||||||
|
// get back). Pairs are indices into [anchor, ...members].
|
||||||
|
function ringEdges(n: number): [number, number][] {
|
||||||
|
const edges: [number, number][] = []
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
edges.push([0, i + 1])
|
||||||
|
edges.push([i + 1, ((i + 1) % n) + 1])
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRegion {
|
||||||
|
start: WorldSystem
|
||||||
|
neighbors: WorldSystem[]
|
||||||
|
edges: [number, number][] // indices into [start, ...neighbors]
|
||||||
|
}
|
||||||
|
|
||||||
|
// New game: the starting system at chart centre plus a ring of undiscovered neighbours.
|
||||||
|
export function generateStartRegion(rng: () => number): StartRegion {
|
||||||
|
const taken = new Set<string>()
|
||||||
|
const start: WorldSystem = { name: makeName(rng, taken), x: START_X, y: START_Y }
|
||||||
|
const neighbors = placeRing(rng, START_X, START_Y, START_RING, taken, [start])
|
||||||
|
return { start, neighbors, edges: ringEdges(neighbors.length) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RingRegion {
|
||||||
|
systems: WorldSystem[]
|
||||||
|
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
|
||||||
|
// neighbours around it. `takenNames`/`takenPositions` are every system already in the save.
|
||||||
|
export function generateRing(
|
||||||
|
rng: () => number,
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
takenNames: Set<string>,
|
||||||
|
takenPositions: WorldSystem[],
|
||||||
|
): RingRegion {
|
||||||
|
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
|
||||||
|
return { systems, edges: ringEdges(systems.length) }
|
||||||
|
}
|
||||||
+14
-2
@@ -3,8 +3,20 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { openApiSpec } from './openapi';
|
import { openApiSpec } from './openapi';
|
||||||
|
|
||||||
describe('OpenAPI spec', () => {
|
describe('OpenAPI spec', () => {
|
||||||
it('documents exactly the one public endpoint (scaffold stage)', () => {
|
it('documents every public endpoint', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/version']);
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/jump', '/api/version', '/api/world']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the world endpoints as WorldState in and out', () => {
|
||||||
|
const world = openApiSpec.paths['/api/world'].get.responses[200].content['application/json'].schema;
|
||||||
|
expect(world.$ref).toBe('#/components/schemas/WorldState');
|
||||||
|
|
||||||
|
const jump = openApiSpec.paths['/api/jump'].post;
|
||||||
|
expect(jump.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/JumpRequest');
|
||||||
|
expect(jump.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/WorldState');
|
||||||
|
for (const status of ['400', '404', '409'] as const) {
|
||||||
|
expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps info.version in sync with package.json', () => {
|
it('keeps info.version in sync with package.json', () => {
|
||||||
|
|||||||
+113
-1
@@ -8,7 +8,7 @@ export const openApiSpec = {
|
|||||||
info: {
|
info: {
|
||||||
title: 'SpaceWanderer API',
|
title: 'SpaceWanderer API',
|
||||||
description:
|
description:
|
||||||
'JSON API for SpaceWanderer — a single-player turn-based space adventure. Scaffold stage: app metadata only; game endpoints (turn actions, state snapshots) land with the first game systems.',
|
'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between adjacent solar systems.',
|
||||||
// Sourced from package.json so it stays in sync with every version bump.
|
// Sourced from package.json so it stays in sync with every version bump.
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
},
|
},
|
||||||
@@ -32,6 +32,74 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/api/world': {
|
||||||
|
get: {
|
||||||
|
summary: 'World state',
|
||||||
|
description:
|
||||||
|
'The full world of the single active save — every known solar system, jump routes and the turn counter. Auto-initializes a new game (seeded world generation) when no save exists yet.',
|
||||||
|
operationId: 'getWorld',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Current world state',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/WorldState' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/jump': {
|
||||||
|
post: {
|
||||||
|
summary: 'Jump to an adjacent solar system',
|
||||||
|
description:
|
||||||
|
'Validates that the target is a jump route away from the current system, sets it as current (discovering it if new and generating its neighbours), increments the turn counter and writes a jump event. Returns the fresh world state.',
|
||||||
|
operationId: 'jumpToSystem',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/JumpRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Fresh world state after the jump',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/WorldState' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Malformed body or non-integer systemId',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown system id',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: 'Target is not adjacent to the current system (or is the current system itself)',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -42,6 +110,50 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['version'],
|
required: ['version'],
|
||||||
},
|
},
|
||||||
|
WorldSystem: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
x: { type: 'number', description: '0–100 chart coordinate' },
|
||||||
|
y: { type: 'number', description: '0–100 chart coordinate' },
|
||||||
|
discovered: { type: 'boolean' },
|
||||||
|
current: { type: 'boolean', description: 'True for the system the ship is in now' },
|
||||||
|
},
|
||||||
|
required: ['id', 'name', 'x', 'y', 'discovered', 'current'],
|
||||||
|
},
|
||||||
|
WorldState: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
systems: { type: 'array', items: { $ref: '#/components/schemas/WorldSystem' } },
|
||||||
|
edges: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Undirected jump routes as [fromId, toId] pairs (sorted, deduped)',
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'integer' },
|
||||||
|
minItems: 2,
|
||||||
|
maxItems: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
turn: { type: 'integer', description: 'Turn counter — one per jump' },
|
||||||
|
},
|
||||||
|
required: ['systems', 'edges', 'turn'],
|
||||||
|
},
|
||||||
|
JumpRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
systemId: { type: 'integer', description: 'solar_systems.id of the jump target' },
|
||||||
|
},
|
||||||
|
required: ['systemId'],
|
||||||
|
},
|
||||||
|
Error: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
error: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['error'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user