Compare commits

...
8 Commits
17 changed files with 906 additions and 120 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## v0.4.0 — 2026-09-28 22:17
- LOCAL SYSTEM tab — planet list + exploration to find things (issue #6): the first in-system game loop on top of issue #5's save/world foundation. Single-player, no auth, by design.
- New tables (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `planets` (id/system_id/parent_id/name/type/hazard/explored — planets have parent_id NULL, moons point at their planet) and `inventory_items` (id/save_id/kind[name]/qty — the dumb-simple cargo hold SHIP SYSTEMS (#8) will spend from later).
- Planet generation hooks into world gen (`lib/game/world.ts`, pure + seeded): each system gets 2–5 planets, some with 1–3 moons, generated when the system is inserted via `bodyRng(seed, systemId)` — so a given system always has the same bodies regardless of order. Bodies are stored in display order (a moon follows its parent row).
- Exploration (`lib/game/save.ts`): `explorePlanet` rolls a deterministic haul from `explorationRng(seed, planetId)` — a resource and/or a part (qty), or nothing (~21% of scans) — marks the body explored (each once), costs a turn, accumulates finds into inventory (same kind+name bumps qty) and writes an explore event.
- New endpoints: `GET /api/system` → `{ systemId, systemName, planets[{id,name,type,hazard,explored,parent}], inventory, turn }`; `POST /api/explore {planetId}` → fresh system state + `outcome` (400 malformed body, 404 unknown planet, 409 already explored). OpenAPI spec in code (`lib/openapi.ts`) updated for both.
- LOCAL SYSTEM tab now renders REAL data: fetches `/api/system` on mount, draws the staggered body list (planets base level, moons indented + dimmed with their parent's name), badges reflect real state (UNEXPLORED / SCANNED / HAZARD) and an EXPLORE button per unexplored row; explore responses apply directly (no polling). A CARGO HOLD panel shows the live inventory. Placeholder constants removed for this tab only — other tabs keep theirs until their issues land.
## 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).
+2 -2
View File
@@ -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. 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`.
- **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); as of v0.4.0 the LOCAL SYSTEM tab also renders live data (`GET /api/system` + `POST /api/explore` — per-system planet/moon generation and deterministic exploration into inventory). 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`, `planets`, `inventory_items`, `events`) live since v0.3.0/v0.4.0 — single-player save with seeded world + per-system body generation, turn counter and exploration; 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.
+28
View File
@@ -0,0 +1,28 @@
import { getGameDb } from '@/lib/db/client'
import { ExploreError, explorePlanet } from '@/lib/game/save'
// Scan one body of the current system. Body: { "planetId": number }. Returns the fresh
// system state plus what this scan found. Costs a turn; each body can only be scanned once.
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 planetId = (body as { planetId?: unknown }).planetId
if (typeof planetId !== 'number' || !Number.isInteger(planetId)) {
return Response.json({ error: 'planetId must be an integer' }, { status: 400 })
}
try {
const db = await getGameDb()
return Response.json(await explorePlanet(db, planetId))
} catch (err) {
if (err instanceof ExploreError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err
}
}
+8
View File
@@ -0,0 +1,8 @@
import { getGameDb } from '@/lib/db/client'
import { getSystemState } from '@/lib/game/save'
// The current system's bodies (planets + moons) and the save's inventory; auto-initializes on first hit.
export async function GET() {
const db = await getGameDb()
return Response.json(await getSystemState(db))
}
+187
View File
@@ -0,0 +1,187 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { ExploreResult, SystemState } from '@/lib/game/save'
import type { FoundItem } from '@/lib/game/world'
// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an
// EXPLORE button per unexplored row. Fetches /api/system on mount; explore responses are
// applied directly (no polling). Turn-based: one scan costs a turn, each body once.
export function LocalSystemPanel() {
const [state, setState] = useState<SystemState | null>(null)
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [exploringId, setExploringId] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetch('/api/system')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as SystemState
})
.then((s) => {
if (!cancelled) setState(s)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [])
const explore = useCallback(async (planetId: number) => {
setExploringId(planetId)
try {
const res = await fetch('/api/explore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ planetId }),
})
const body = (await res.json()) as ExploreResult & { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
setState(body) // fresh state straight from the response — no refetch needed
setLastOutcome(body.outcome)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setExploringId(null)
}
}, [])
if (error && !state) {
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' }}>
SYSTEM SCAN OFFLINE // {error.toUpperCase()}
</span>
</div>
)
}
if (!state) {
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 SYSTEM…</span>
</div>
)
}
const badgeFor = (explored: boolean, hazard: boolean) =>
explored ? { label: 'SCANNED', variant: 'ACTIVE' as const } : hazard ? { label: 'HAZARD', variant: 'WARNING' as const } : { label: 'UNEXPLORED', variant: 'SCANNING' as const }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<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',
}}
>
EXPLORABLE WORLDS // {state.systemName.toUpperCase()} — TURN {state.turn}
</div>
{state.planets.map((row, i) => {
const depth = row.parent !== null ? 1 : 0
const badge = badgeFor(row.explored, row.hazard)
return (
<div
key={row.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem',
padding: depth ? '0.6rem 0.75rem 0.6rem 1.75rem' : '0.6rem 0.75rem',
borderBottom: i < state.planets.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<div style={{ overflow: 'hidden' }}>
<div
style={{
fontSize: '0.68rem',
color: depth ? 'var(--text-muted)' : 'var(--text-secondary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
{row.name}
</div>
<div style={{ fontSize: '0.57rem', color: 'var(--text-muted)', letterSpacing: '0.06em', marginTop: '2px' }}>
{depth ? `MOON OF ${state.planets.find((p) => p.id === row.parent)?.name ?? '?'} // ${row.type}` : row.type}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Badge variant={badge.variant}>{badge.label}</Badge>
{!row.explored && (
<Button
variant="OUTLINE"
size="SM"
disabled={exploringId !== null}
onClick={() => void explore(row.id)}
>
{exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // EXPLORE`}
</Button>
)}
</div>
</div>
)
})}
</div>
{lastOutcome !== null && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
SCAN RESULT //{' '}
{lastOutcome.length > 0 ? lastOutcome.map((i) => `${i.qty}× ${i.name.toUpperCase()}`).join(', ') : 'NOTHING FOUND'}
</span>
</div>
)}
<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',
}}
>
CARGO HOLD // INVENTORY
</div>
{state.inventory.length === 0 ? (
<div style={{ padding: '0.6rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY — EXPLORE WORLDS TO FIND THINGS</span>
</div>
) : (
state.inventory.map((item) => (
<div key={`${item.kind}-${item.name}`} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{item.name} <span style={{ color: 'var(--text-muted)' }}>// {item.kind}</span>
</span>
<Badge variant="ACTIVE">×{item.qty}</Badge>
</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' }}>SCAN FAILED // {error.toUpperCase()}</span>
</div>
)}
</div>
)
}
+5 -96
View File
@@ -11,12 +11,13 @@ 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 { LocalSystemPanel } from '@/components/local-system'
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). 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.
// WORLD MAP and LOCAL SYSTEM tabs render live data (/api/world, /api/system); 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(
@@ -44,20 +45,6 @@ const shipSystems: SystemEntry[] = [
{ name: 'COMMS', status: 'ACTIVE' },
]
// LOCAL SYSTEM — explorable worlds of the current system (regenerated per save after each jump, later).
const explorablePlanets = [
{
name: 'AURELLE', type: 'TERRESTRIAL // OCEAN CONTINENTS', state: 'UNEXPLORED' as const,
moons: [{ name: 'PALEMOON', type: 'ICE MOON // FROST PLAINS', state: 'UNEXPLORED' as const }],
},
{
name: 'BRIMSTONE', type: 'GAS GIANT // CLOUD DECKS', state: 'SCANNED' as const,
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 crew = [
{ 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' },
@@ -339,87 +326,9 @@ export function MissionControl() {
<WorldMapPanel height={380} />
</TabsContent>
{/* LOCAL SYSTEM TAB */}
{/* LOCAL SYSTEM TAB — live data from /api/system */}
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
<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',
}}
>
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
style={{
fontSize: '0.57rem',
color: 'var(--text-muted)',
letterSpacing: '0.06em',
marginTop: '2px',
}}
>
{row.type}
</div>
</div>
<Badge
variant={
row.state === 'UNEXPLORED'
? 'SCANNING'
: row.state === 'HAZARD'
? 'WARNING'
: 'ACTIVE'
}
>
{row.state}
</Badge>
</div>
))}
</div>
<LocalSystemPanel />
</TabsContent>
{/* CREW TAB */}
+71 -1
View File
@@ -1,8 +1,13 @@
import { expect, test } from '@playwright/test';
// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained.
// Mirrors the /api/world + /api/system response shapes (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 }
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
interface InventoryItemDto { kind: string; name: string; qty: number }
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number }
interface FoundItem { kind: string; name: string; qty: number }
interface ExploreResult extends SystemState { outcome: FoundItem[] }
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
await page.goto('/');
@@ -49,6 +54,71 @@ test('jumping to an adjacent system updates the world state and turn counter', a
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
});
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
await page.goto('/');
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
// Header carries the real system name + turn counter.
await expect(page.getByText(`EXPLORABLE WORLDS // ${sys.systemName.toUpperCase()} — TURN ${sys.turn}`)).toBeVisible();
// One EXPLORE button per unexplored body (planets and moons alike).
const exploreButtons = page.getByRole('button', { name: /\/\/ EXPLORE$/ });
await expect(exploreButtons.first()).toBeVisible();
await expect(exploreButtons).toHaveCount(sys.planets.filter((p) => !p.explored).length);
});
test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => {
const before = (await (await page.request.get('/api/system')).json()) as SystemState;
const target = before.planets.find((p) => !p.explored)!;
await page.goto('/');
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click();
const after = (await (await page.request.get('/api/system')).json()) as SystemState;
expect(after.turn).toBe(before.turn + 1);
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true);
// UI reflects it: the row badge flipped to SCANNED and its EXPLORE button is gone.
await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0);
await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible();
});
test('exploration yields finds that accumulate in the inventory', async ({ page }) => {
// Scan bodies until one yields something (most scans do; jumping keeps it going if a whole
// system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible.
const found = new Map<string, number>(); // kind|name -> qty
let iterations = 0;
while (found.size === 0 && iterations < 60) {
iterations++;
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
const unexplored = sys.planets.filter((p) => !p.explored);
if (unexplored.length > 0) {
const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } });
expect(res.status()).toBe(200);
for (const item of ((await res.json()) as ExploreResult).outcome) {
found.set(`${item.kind}|${item.name}`, item.qty);
}
} else {
// Current system fully scanned — jump somewhere and keep scanning.
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = world.systems.find((s) => s.current)!.id;
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
}
}
expect(found.size).toBeGreaterThan(0);
// Every find landed in the inventory with at least that qty.
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
for (const [key, qty] of found) {
const [kind, name] = key.split('|');
expect(sys.inventory.find((i) => i.kind === kind && i.name === name)?.qty).toBeGreaterThanOrEqual(qty);
}
});
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);
+27 -5
View File
@@ -8,9 +8,11 @@ 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.
// Mirrors the /api/world + /api/system response shapes (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 }
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
test.setTimeout(180_000);
@@ -28,7 +30,22 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
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;
// Exploration baseline: scan one body so explored state + inventory must survive too.
// (Earlier specs may have exhausted the current system, so jump until an unscanned body shows up.)
let sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
for (let i = 0; !sysBefore.planets.some((p) => !p.explored) && i < 20; i++) {
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const curId = world.systems.find((s) => s.current)!.id;
const e = world.edges.find(([a, b]) => a === curId || b === curId)!;
const tId = e[0] === curId ? e[1] : e[0];
expect((await page.request.post('/api/jump', { data: { systemId: tId } })).status()).toBe(200);
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
}
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
const exploreRes = await page.request.post('/api/explore', { data: { planetId: targetPlanet.id } });
expect(exploreRes.status()).toBe(200);
const sysExplored = (await exploreRes.json()) as SystemState;
// Restart the app container mid-run — the named DB volume must survive it.
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
@@ -50,10 +67,15 @@ 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.
// Game state survived: same turn counter and 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);
expect(worldAfter.turn).toBe(sysExplored.turn);
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId);
// Exploration state survived too: the scanned body is still explored.
const sysAfter = (await (await page.request.get('/api/system')).json()) as SystemState;
expect(sysAfter.turn).toBe(sysExplored.turn);
expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true);
// The page itself serves again with the landing title.
await page.goto('/');
+18
View File
@@ -43,6 +43,24 @@ CREATE TABLE IF NOT EXISTS jump_routes (
PRIMARY KEY (from_system, to_system)
);
CREATE TABLE IF NOT EXISTS planets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
system_id INTEGER NOT NULL REFERENCES solar_systems(id),
parent_id INTEGER, -- NULL for a planet, set to the parent planet's id for a moon
name TEXT NOT NULL,
type TEXT NOT NULL,
hazard INTEGER NOT NULL DEFAULT 0,
explored INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS inventory_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
kind TEXT NOT NULL, -- 'resource' | 'part'
name TEXT NOT NULL,
qty INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
+23
View File
@@ -35,6 +35,29 @@ export const jumpRoutes = sqliteTable(
(t) => [primaryKey({ columns: [t.fromSystem, t.toSystem] })],
)
// Bodies of a solar system: planets (parent_id NULL) and moons (parent_id = their planet's
// id). Generated when the system is generated, so every system has 2–5 planets, some with
// 1–3 moons. Insertion order is display order — a moon always follows its parent row.
export const planets = sqliteTable('planets', {
id: integer('id').primaryKey({ autoIncrement: true }),
systemId: integer('system_id').notNull().references(() => solarSystems.id),
parentId: integer('parent_id'), // NULL for a planet, set for a moon
name: text('name').notNull(),
type: text('type').notNull(),
hazard: integer('hazard', { mode: 'boolean' }).notNull().default(false),
explored: integer('explored', { mode: 'boolean' }).notNull().default(false),
})
// Dumb-simple cargo hold on the save: what exploration has found so far. SHIP SYSTEMS (#8)
// will spend parts from here later; nothing consumes it yet.
export const inventoryItems = sqliteTable('inventory_items', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
kind: text('kind').notNull(), // 'resource' | 'part'
name: text('name').notNull(),
qty: integer('qty').notNull().default(1),
})
// 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', {
+99 -1
View File
@@ -3,7 +3,9 @@ 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'
import * as schema from '@/lib/db/schema'
import { getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
const dirs: string[] = []
@@ -130,3 +132,99 @@ describe('jumpToSystem success path', () => {
expect(again.systems).toHaveLength(after.systems.length)
})
})
describe('getSystemState', () => {
it('generates bodies for every system on new game; moons nest under a parent planet only', async () => {
const { db } = await freshFile()
const state = await getSystemState(db)
// Current (start) system: 2–5 planets, all unexplored yet.
const planetRows = state.planets.filter((p) => p.parent === null)
expect(planetRows.length).toBeGreaterThanOrEqual(2)
expect(planetRows.length).toBeLessThanOrEqual(5)
const planetIds = new Set(planetRows.map((p) => p.id))
for (const row of state.planets) {
if (row.parent !== null) expect(planetIds.has(row.parent)).toBe(true) // moons hang off a planet in the same system
expect(row.explored).toBe(false)
}
// Every one of the 7 fresh-save systems got bodies.
const counts = db.raw.exec('SELECT system_id, COUNT(*) FROM planets GROUP BY system_id')
expect(counts[0].values).toHaveLength(7)
for (const [, n] of counts[0].values) expect(Number(n)).toBeGreaterThanOrEqual(2)
expect(state.inventory).toEqual([]) // nothing found before any scan
expect(state.turn).toBe(0)
})
})
describe('explorePlanet', () => {
it('rejects unknown planets with 404 and already-explored ones with 409, mutating nothing on failure', async () => {
const { db } = await freshFile()
const state = await getSystemState(db)
const target = state.planets[0]
await expect(explorePlanet(db, 999_999)).rejects.toMatchObject({ status: 404 })
await explorePlanet(db, target.id)
await expect(explorePlanet(db, target.id)).rejects.toMatchObject({ status: 409 })
const after = await getSystemState(db)
expect(after.turn).toBe(1) // only the successful scan costs a turn
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true)
})
it('marks explored, bumps the turn, lands finds in inventory and writes an event', async () => {
const { db } = await freshFile()
const state = await getSystemState(db)
const target = state.planets[0]
const result = await explorePlanet(db, target.id)
expect(result.turn).toBe(1)
// Fresh save: inventory is exactly this scan's haul.
for (const item of result.outcome) {
expect(result.inventory.find((i) => i.kind === item.kind && i.name === item.name)?.qty).toBe(item.qty)
}
expect(result.inventory.length).toBe(result.outcome.length)
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
expect(rows[0].values).toEqual([['new_game', 0], ['explore', 1]])
})
it('is deterministic per save+planet and accumulates qty across scans (known seed)', async () => {
// Hand-built save with a known seed so every outcome is recomputable offline.
const { db } = await freshFile()
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: new Date().toISOString() }).returning()
const [sys] = await db.db
.insert(schema.solarSystems)
.values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true })
.returning({ id: schema.solarSystems.id })
const planetIds: number[] = []
for (const body of generatePlanets(bodyRng('42', sys.id))) {
const [p] = await db.db
.insert(schema.planets)
.values({ systemId: sys.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard })
.returning({ id: schema.planets.id })
planetIds.push(p.id)
}
const expected = planetIds.map((id) => rollExploration(explorationRng('42', id)))
for (let i = 0; i < planetIds.length; i++) {
const result = await explorePlanet(db, planetIds[i])
expect(result.outcome).toEqual(expected[i]) // same save+planet → same haul
}
// Inventory accumulates: qty per kind+name equals the sum over all scans.
const totals = new Map<string, number>()
for (const items of expected) {
for (const it of items) totals.set(`${it.kind}|${it.name}`, (totals.get(`${it.kind}|${it.name}`) ?? 0) + it.qty)
}
const state = await getSystemState(db)
expect(state.inventory.length).toBe(totals.size)
for (const item of state.inventory) {
expect(item.qty).toBe(totals.get(`${item.kind}|${item.name}`))
}
expect(state.turn).toBe(planetIds.length) // one turn per scan
})
})
+113 -12
View File
@@ -2,7 +2,7 @@ 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'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } 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
@@ -44,12 +44,18 @@ 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]))
// Inserts one system together with its generated bodies (planets + moons), so a system is
// never left without worlds to explore. Bodies derive from (seed, system id) via bodyRng —
// independent of generation order and every other system.
async function insertSystemWithBodies(db: GameDb, saveId: number, ws: { name: string; x: number; y: number }, seed: string): Promise<number> {
const [row] = await db.db.insert(schema.solarSystems).values({ saveId, name: ws.name, x: ws.x, y: ws.y }).returning({ id: schema.solarSystems.id })
for (const body of generatePlanets(bodyRng(seed, row.id))) {
const [planet] = await db.db.insert(schema.planets).values({ systemId: row.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id })
for (const moon of body.moons) {
await db.db.insert(schema.planets).values({ systemId: row.id, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
}
}
return row.id
}
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
@@ -73,11 +79,12 @@ export async function ensureSave(db: GameDb): Promise<void> {
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)!
const startId = await insertSystemWithBodies(db, save.id, region.start, seed)
const neighborIds: number[] = []
for (const n of region.neighbors) neighborIds.push(await insertSystemWithBodies(db, save.id, n, seed))
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)!)
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
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 })
@@ -134,8 +141,9 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
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)!)
const ringIds: number[] = []
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
await insertRoutes(db, ring.edges, resolve)
}
@@ -145,3 +153,96 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
return getWorldState(db)
}
// --- Local system + exploration ---------------------------------------------
export interface PlanetDto {
id: number
name: string
type: string
hazard: boolean
explored: boolean
parent: number | null // NULL for a planet, the parent planet's id for a moon
}
export interface InventoryItemDto {
kind: 'resource' | 'part'
name: string
qty: number
}
export interface SystemState {
systemId: number
systemName: string
planets: PlanetDto[] // insertion order = display order (a moon follows its parent row)
inventory: InventoryItemDto[]
turn: number
}
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
export class ExploreError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
async function readSystemState(db: GameDb): Promise<SystemState> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const current = systems.find((s) => s.isCurrent)!
const planets = (await db.db.select().from(schema.planets).where(eq(schema.planets.systemId, current.id))).map((p) => ({
id: p.id,
name: p.name,
type: p.type,
hazard: p.hazard,
explored: p.explored,
parent: p.parentId,
}))
const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty }))
return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn }
}
export async function getSystemState(db: GameDb): Promise<SystemState> {
await ensureSave(db)
return readSystemState(db)
}
export interface ExploreResult extends SystemState {
outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing)
}
// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored,
// costs a turn, accumulates finds into inventory and writes an event. Any existing planet
// may be scanned (the UI only offers the current system's rows).
export async function explorePlanet(db: GameDb, planetId: number): Promise<ExploreResult> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const planets = await db.db.select().from(schema.planets)
const target = planets.find((p) => p.id === planetId)
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
const outcome = rollExploration(explorationRng(save.seed, target.id))
const turn = save.turn + 1
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
for (const item of outcome) {
// Accumulate: the same kind+name on this save bumps qty instead of adding a row.
const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === item.kind && i.name === item.name)
if (existing) {
await db.db.update(schema.inventoryItems).set({ qty: existing.qty + item.qty }).where(eq(schema.inventoryItems.id, existing.id))
} else {
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty })
}
}
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
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: 'explore', message: `Explored ${target.name} — found ${foundText}`, createdAt: new Date().toISOString() })
await persist(db)
return { ...(await readSystemState(db)), outcome }
}
+56 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { generateRing, generateStartRegion, makeName, mulberry32 } from './world'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world'
describe('generateStartRegion', () => {
const region = generateStartRegion(mulberry32(42))
@@ -77,3 +77,58 @@ describe('generateRing (expansion)', () => {
expect(taken.size).toBe(40)
})
})
describe('generatePlanets', () => {
it('gives every system 2–5 planets and keeps body names unique within the system', () => {
for (const seed of [1, 7, 42, 999]) {
const planets = generatePlanets(bodyRng(String(seed), seed))
expect(planets.length).toBeGreaterThanOrEqual(2)
expect(planets.length).toBeLessThanOrEqual(5)
const names: string[] = []
for (const p of planets) {
names.push(p.name, ...p.moons.map((m) => m.name))
expect(['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']).toContain(p.type)
for (const m of p.moons) {
expect(['MOON', 'ICE MOON', 'VOLCANIC MOON']).toContain(m.type)
}
}
expect(new Set(names).size).toBe(names.length) // planets + moons share one name pool per system
}
})
it('is deterministic per (seed, system id) and differs across systems', () => {
const a = generatePlanets(bodyRng('42', 7))
expect(generatePlanets(bodyRng('42', 7))).toEqual(a) // same save+system → same bodies
const b = generatePlanets(bodyRng('43', 7))
expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different world
})
})
describe('rollExploration', () => {
it('is deterministic for the same save+planet and yields valid items', () => {
const first = rollExploration(explorationRng('42', 13))
expect(rollExploration(explorationRng('42', 13))).toEqual(first)
for (const item of first) {
expect(['resource', 'part']).toContain(item.kind)
if (item.kind === 'resource') {
expect(item.qty).toBeGreaterThanOrEqual(1)
expect(item.qty).toBeLessThanOrEqual(4)
} else {
expect(item.qty).toBe(1)
}
}
})
it('across many planets produces every outcome shape: resource, part, both, or nothing', () => {
const shapes = new Set<string>()
for (let planetId = 1; planetId <= 200; planetId++) {
const found = rollExploration(explorationRng('42', planetId))
const hasResource = found.some((f) => f.kind === 'resource')
const hasPart = found.some((f) => f.kind === 'part')
shapes.add(`${hasResource ? 'r' : '-'}${hasPart ? 'p' : '-'}`)
}
expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--']))
})
})
+102
View File
@@ -123,3 +123,105 @@ export function generateRing(
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
return { systems, edges: ringEdges(systems.length) }
}
// --- Bodies (planets + moons) and exploration -------------------------------
export interface WorldMoon {
name: string
type: string
hazard: boolean
}
export interface WorldPlanet {
name: string
type: string
hazard: boolean
moons: WorldMoon[]
}
// Derive an independent PRNG stream for one entity from the save seed, so a system's bodies
// and a planet's exploration haul are reproducible offline (tests recompute them directly).
export function bodyRng(seed: string, systemId: number): () => number {
return mulberry32((Number(seed) + Math.imul(systemId, 0x9e3779b1)) >>> 0)
}
export function explorationRng(seed: string, planetId: number): () => number {
return mulberry32((Number(seed) + Math.imul(planetId, 0x85ebca6b)) >>> 0)
}
const BODY_NAMES = [
'AURELLE', 'BRIMSTONE', 'CINDERROCK', 'DRIFTBAND', 'PALEMOON', 'ASHFALL', 'VESPERINE', 'KORR',
'MIRELLA', 'SABLET', 'EMBERLING', 'FROSTHOLD', 'GALEN', 'IRONVEIN', 'JUNO', 'KESTREL',
'LUMEN', 'NADIR', 'OPALINE', 'PYRE', 'QUILL', 'RUSTBARK', 'SIROCCO', 'TARN',
'UMBRA', 'VELVETINE', 'WYRM', 'ZEPHYR', 'HALCYON', 'MORROW', 'ONYX', 'THALASSA',
]
const PLANET_TYPES = ['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']
const MOON_TYPES = ['MOON', 'ICE MOON', 'VOLCANIC MOON']
const HAZARD_CHANCE = 0.25
const MOON_CHANCE = 0.6 // a planet has any moons at all
const PLANET_COUNT_MIN = 2
const PLANET_COUNT_SPREAD = 4 // 2..5 planets per system
// A body name unique within its system (planets and moons share one pool). Max bodies per
// system is 5 + 5*3 = 20 < BODY_NAMES.length, so the loop always terminates.
function makeBodyName(rng: () => number, taken: Set<string>): string {
for (;;) {
const name = title(BODY_NAMES[Math.floor(rng() * BODY_NAMES.length)])
if (!taken.has(name)) {
taken.add(name)
return name
}
}
}
function pick<T>(rng: () => number, arr: T[]): T {
return arr[Math.floor(rng() * arr.length)]
}
// The bodies of one system: 2–5 planets, some with 1–3 moons. Pure — the caller derives the
// rng from (save seed, system id) so a given system always gets the same bodies.
export function generatePlanets(rng: () => number): WorldPlanet[] {
const taken = new Set<string>()
const planets: WorldPlanet[] = []
for (let i = 0; i < PLANET_COUNT_MIN + Math.floor(rng() * PLANET_COUNT_SPREAD); i++) {
const name = makeBodyName(rng, taken)
const type = pick(rng, PLANET_TYPES)
const hazard = rng() < HAZARD_CHANCE
const moons: WorldMoon[] = []
if (rng() < MOON_CHANCE) {
for (let m = 0; m < 1 + Math.floor(rng() * 3); m++) {
moons.push({ name: makeBodyName(rng, taken), type: pick(rng, MOON_TYPES), hazard: rng() < HAZARD_CHANCE })
}
}
planets.push({ name, type, hazard, moons })
}
return planets
}
export interface FoundItem {
kind: 'resource' | 'part'
name: string
qty: number
}
const RESOURCE_NAMES = ['IRON ORE', 'CRYSTAL SHARDS', 'GAS CELLS', 'WATER ICE', 'RARE EARTH']
const PART_NAMES = ['NAV MODULE', 'HULL PLATE', 'REACTOR CORE', 'COMMS ARRAY', 'ENGINE TUNING']
const RESOURCE_CHANCE = 0.7
const PART_CHANCE = 0.3 // parts are rarer than resources
// The haul from scanning one body: a resource and/or a part, or nothing (~21% of scans).
// Pure — the caller derives the rng from (save seed, planet id), so the same save+planet
// always yields the same outcome.
export function rollExploration(rng: () => number): FoundItem[] {
const found: FoundItem[] = []
if (rng() < RESOURCE_CHANCE) {
found.push({ kind: 'resource', name: pick(rng, RESOURCE_NAMES), qty: 1 + Math.floor(rng() * 4) }) // 1..4
}
if (rng() < PART_CHANCE) {
found.push({ kind: 'part', name: pick(rng, PART_NAMES), qty: 1 })
}
return found
}
+22 -1
View File
@@ -4,7 +4,28 @@ import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents every public endpoint', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/jump', '/api/version', '/api/world']);
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/explore',
'/api/jump',
'/api/system',
'/api/version',
'/api/world',
]);
});
it('describes the system endpoints as SystemState in and out', () => {
const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema;
expect(system.$ref).toBe('#/components/schemas/SystemState');
const explore = openApiSpec.paths['/api/explore'].post;
expect(explore.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreRequest');
expect(explore.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreResult');
// ExploreResult extends SystemState with the scan outcome.
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: { $ref?: string }[] };
expect(exploreResult.allOf[0].$ref).toBe('#/components/schemas/SystemState');
for (const status of ['400', '404', '409'] as const) {
expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
}
});
it('describes the world endpoints as WorldState in and out', () => {
+136
View File
@@ -100,6 +100,74 @@ export const openApiSpec = {
},
},
},
'/api/system': {
get: {
summary: 'Current system state',
description:
'The bodies of the current solar system — planets and their moons (nested via parent) with hazard/explored flags — plus the save inventory and turn counter. Auto-initializes a new game when no save exists yet.',
operationId: 'getSystem',
responses: {
200: {
description: 'Current system state',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SystemState' },
},
},
},
},
},
},
'/api/explore': {
post: {
summary: 'Explore a planet or moon',
description:
'Scans one body of the save with a deterministic haul (a resource and/or a part, or nothing), marks it explored, increments the turn counter, accumulates finds into inventory and writes an explore event. Returns the fresh system state plus this scan outcome.',
operationId: 'explorePlanet',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ExploreRequest' },
},
},
},
responses: {
200: {
description: 'Fresh system state plus the scan outcome',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ExploreResult' },
},
},
},
400: {
description: 'Malformed body or non-integer planetId',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: 'Unknown planet id',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'Body was already explored (each can only be scanned once)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
},
components: {
schemas: {
@@ -147,6 +215,74 @@ export const openApiSpec = {
},
required: ['systemId'],
},
Planet: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
type: { type: 'string', description: 'Body class, e.g. TERRESTRIAL or GAS GIANT' },
hazard: { type: 'boolean' },
explored: { type: 'boolean', description: 'True once scanned — each body can only be explored once' },
parent: { type: ['integer', 'null'], nullable: true, description: 'NULL for a planet, the parent planet id for a moon' },
},
required: ['id', 'name', 'type', 'hazard', 'explored', 'parent'],
},
InventoryItem: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['resource', 'part'] },
name: { type: 'string' },
qty: { type: 'integer' },
},
required: ['kind', 'name', 'qty'],
},
FoundItem: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['resource', 'part'] },
name: { type: 'string' },
qty: { type: 'integer' },
},
required: ['kind', 'name', 'qty'],
},
SystemState: {
type: 'object',
properties: {
systemId: { type: 'integer' },
systemName: { type: 'string' },
planets: {
type: 'array',
description: 'Planets and moons in display order — a moon always follows its parent row',
items: { $ref: '#/components/schemas/Planet' },
},
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
turn: { type: 'integer', description: 'Turn counter — one per jump or exploration' },
},
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'],
},
ExploreRequest: {
type: 'object',
properties: {
planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' },
},
required: ['planetId'],
},
ExploreResult: {
allOf: [
{ $ref: '#/components/schemas/SystemState' },
{
type: 'object',
properties: {
outcome: {
type: 'array',
description: 'What this scan found — may be empty (some scans find nothing)',
items: { $ref: '#/components/schemas/FoundItem' },
},
},
required: ['outcome'],
},
],
},
Error: {
type: 'object',
properties: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space-wanderer",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",