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 ( +
+ + CHART OFFLINE // {error.toUpperCase()} + +
+ ) + } + + if (!world) { + return ( +
+ SCANNING CHART… +
+ ) + } + + 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 ( +
+ ({ + 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%' }} + /> + +
+
+ JUMP ROUTES // FROM {current.name.toUpperCase()} +
+
+ {reachable.map((s) => ( + + ))} +
+
+ + {error && ( +
+ + JUMP FAILED // {error.toUpperCase()} + +
+ )} +
+ ) +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index e912262..a6d22fa 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,5 +1,9 @@ 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 }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); @@ -21,6 +25,30 @@ test('tabs switch panels client-side (event log terminal)', async ({ page }) => 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 }) => { const res = await page.request.get('/api/version'); expect(res.status()).toBe(200); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index 0e0a046..3e22db7 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -8,6 +8,10 @@ import { expect, test } from '@playwright/test'; const repoRoot = path.join(__dirname, '..'); 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.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 }; 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. 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!.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. await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); diff --git a/lib/db/client.ts b/lib/db/client.ts index 54b2984..6c5bb10 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -14,12 +14,43 @@ export interface GameDb { file: string; } -// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. Game tables land with -// the turn engine; _meta is DB plumbing (not game state): a brand-new sql.js database exports -// as ZERO bytes, so without at least one real table there would be nothing valid to persist -// on the ./db volume from first boot. +// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. _meta is DB plumbing +// (not game state): a brand-new sql.js database exports as ZERO bytes, so without at least +// one real table there would be nothing valid to persist on the ./db volume from first boot. const DDL = ` 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 diff --git a/lib/db/schema.ts b/lib/db/schema.ts index f7f7410..201e40c 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,6 +1,47 @@ -// Drizzle schema for game state. EMPTY on purpose at scaffold stage (v0.1.0) — no game -// tables yet; the first turn-engine/ship/crew tables land with the first game systems. +import { integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +// 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 // 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(), +}) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts new file mode 100644 index 0000000..011ef9a --- /dev/null +++ b/lib/game/save.test.ts @@ -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) + }) +}) diff --git a/lib/game/save.ts b/lib/game/save.ts new file mode 100644 index 0000000..d0d57cc --- /dev/null +++ b/lib/game/save.ts @@ -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> { + 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 { + 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 { + 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 { + 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() + 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 { + 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) +} diff --git a/lib/game/world.test.ts b/lib/game/world.test.ts new file mode 100644 index 0000000..4987da1 --- /dev/null +++ b/lib/game/world.test.ts @@ -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() + for (let i = 0; i < 40; i++) makeName(rng, taken) + expect(taken.size).toBe(40) + }) +}) diff --git a/lib/game/world.ts b/lib/game/world.ts new file mode 100644 index 0000000..24fba68 --- /dev/null +++ b/lib/game/world.ts @@ -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 { + 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, + 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() + 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, + takenPositions: WorldSystem[], +): RingRegion { + const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions) + return { systems, edges: ringEdges(systems.length) } +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 1a2c35b..07cc610 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -3,8 +3,20 @@ import { describe, expect, it } from 'vitest'; import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { - it('documents exactly the one public endpoint (scaffold stage)', () => { - expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/version']); + it('documents every public endpoint', () => { + 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', () => { diff --git a/lib/openapi.ts b/lib/openapi.ts index e2e9262..3c7450e 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -8,7 +8,7 @@ export const openApiSpec = { info: { title: 'SpaceWanderer API', 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. 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: { schemas: { @@ -42,6 +110,50 @@ export const openApiSpec = { }, 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'], + }, }, }, }; diff --git a/package.json b/package.json index 9a2f6a1..7df96b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.2.2", + "version": "0.3.0", "private": true, "scripts": { "dev": "next dev -p 3000",