diff --git a/CHANGELOG.md b/CHANGELOG.md
index f707cc7..9c562c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# 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.
diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md
index a591d83..be2f8a7 100644
--- a/ORCHESTRATOR.md
+++ b/ORCHESTRATOR.md
@@ -46,8 +46,8 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
## 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.
- 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, 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; all data are local placeholder constants until real game systems 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.
+- **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. 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.
- 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.
diff --git a/app/api/jump/route.ts b/app/api/jump/route.ts
new file mode 100644
index 0000000..68bfd59
--- /dev/null
+++ b/app/api/jump/route.ts
@@ -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
+ }
+}
diff --git a/app/api/world/route.ts b/app/api/world/route.ts
new file mode 100644
index 0000000..d395736
--- /dev/null
+++ b/app/api/world/route.ts
@@ -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))
+}
diff --git a/components/mission-control.tsx b/components/mission-control.tsx
index b034ade..605cc5a 100644
--- a/components/mission-control.tsx
+++ b/components/mission-control.tsx
@@ -5,17 +5,18 @@ import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
-import { NodeGraph, type NodeGraphEdge, type NodeGraphNode } from '@/components/ui/node-graph'
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
import { Progress } from '@/components/ui/progress'
import { StatCard } from '@/components/ui/stat-card'
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
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
-// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui), with
-// placeholder SpaceWanderer content — no game logic yet. Single player, no auth, by design.
+// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
+// 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 {
const [narrow, setNarrow] = useState(
@@ -43,33 +44,6 @@ const shipSystems: SystemEntry[] = [
{ name: 'COMMS', status: 'ACTIVE' },
]
-// WORLD MAP — every node is a solar system, edges are jump routes.
-const worldMapNodes: NodeGraphNode[] = [
- { id: 'kepler', label: 'Kepler Drift', x: 50, y: 48, status: 'ACTIVE', sublabel: 'CURRENT' },
- { id: 'vesper', label: 'Vesper Reach', x: 22, y: 26, status: 'NEUTRAL', sublabel: '3 WORLDS' },
- { id: 'hollow', label: 'Hollow Gate', x: 74, y: 20, status: 'NEUTRAL', sublabel: '5 WORLDS' },
- { id: 'cinder', label: 'Cinder Field', x: 86, y: 56, status: 'WARNING', sublabel: 'RADIATION' },
- { id: 'lantern', label: 'Lantern Void', x: 30, y: 74, status: 'NEUTRAL', sublabel: '2 WORLDS' },
- { id: 'sable', label: 'Sable Reach', x: 8, y: 46, status: 'OFFLINE', sublabel: 'UNKNOWN' },
- { id: 'orpheus', label: 'Orpheus Deep', x: 52, y: 10, status: 'NEUTRAL', sublabel: '7 WORLDS' },
- { id: 'mire', label: 'Mire Station', x: 68, y: 84, status: 'ACTIVE', sublabel: 'OUTPOST' },
- { id: 'thalassa', label: 'Thalassa Bend', x: 12, y: 86, status: 'NEUTRAL', sublabel: '4 WORLDS' },
- { id: 'kuiper', label: 'Kuiper Shard', x: 90, y: 30, status: 'CRITICAL', sublabel: 'WARZONE' },
-]
-
-const worldMapEdges: NodeGraphEdge[] = [
- { from: 'kepler', to: 'vesper', animated: true },
- { from: 'kepler', to: 'hollow', label: 'JUMP-2' },
- { from: 'kepler', to: 'lantern', animated: true },
- { from: 'kepler', to: 'mire' },
- { from: 'vesper', to: 'orpheus' },
- { from: 'hollow', to: 'kuiper', label: 'JUMP-1' },
- { from: 'cinder', to: 'kuiper' },
- { from: 'lantern', to: 'thalassa' },
- { from: 'sable', to: 'vesper' },
- { from: 'mire', to: 'cinder' },
-]
-
// LOCAL SYSTEM — explorable worlds of the current system (regenerated per save after each jump, later).
const explorablePlanets = [
{
@@ -360,16 +334,9 @@ export function MissionControl() {
EVENT LOG
- {/* WORLD MAP TAB */}
+ {/* WORLD MAP TAB — live data from /api/world */}
-
+
{/* LOCAL SYSTEM TAB */}
diff --git a/components/world-map.tsx b/components/world-map.tsx
new file mode 100644
index 0000000..7395be3
--- /dev/null
+++ b/components/world-map.tsx
@@ -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(null)
+ const [error, setError] = useState(null)
+ const [jumpingId, setJumpingId] = useState(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 (
+