Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5cba45b42 | ||
|
|
59ccf5674e | ||
|
|
60947d3cda | ||
|
|
08a15a09cf | ||
|
|
a2c8586618 | ||
|
|
7a9ff110f3 | ||
|
|
ccd156b7c6 | ||
|
|
a6a5e827a1 | ||
|
|
0ddba27333 | ||
|
|
1a50ffb2f9 | ||
|
|
82d41b87be | ||
|
|
8153007668 | ||
|
|
3aa27ffd89 | ||
|
|
cbab17047b | ||
|
|
172f167a15 | ||
|
|
b79b77131f | ||
|
|
f36820ddff | ||
|
|
6a623c738a | ||
|
|
80b493746f | ||
|
|
37c3800479 | ||
|
|
cc6abe8f36 | ||
|
|
58ac64af4f | ||
|
|
1634c0602e | ||
|
|
922a20707b | ||
|
|
aa3c5f5501 | ||
|
|
7f48fd0e5e | ||
|
|
0573e6d719 | ||
|
|
2651981044 |
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## v0.5.0 — 2026-09-28 22:51
|
||||
- CREW tab — crew roster with good/bad traits (issue #7): the ship's people on top of issue #6's exploration loop. Single-player, no auth, by design. Display + arrival only — no dismiss/assign actions yet.
|
||||
- New table (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `crew` (id/save_id/name/role/trait_good/trait_bad/origin). Origin is 'STARTING CREW' for the seeded crew or '<planet>, <system>' for members found while exploring.
|
||||
- Pure seeded crew generation (`lib/game/crew.ts`, same pattern as world.ts): name pools (16 first × 22 last, unique per roster), role pool of 8, good/bad trait pools of 10 each. `generateStartingCrew` gives a new game 2–3 members with unique names and roles; `rollCrewMember` finds a joining member on ~18% of scans (`CREW_CHANCE = 0.18`).
|
||||
- Starting crew is seeded in `ensureSave` from the save seed via `startingCrewRng(seed)` — same per-save derivation pattern as bodies/exploration, so a given seed always starts with the same crew.
|
||||
- Exploration extended: the crew roll draws from the SAME per-planet rng stream as the resource/part haul (called after it in `explorePlanet`), so outcomes stay deterministic per save+planet and existing hauls are unchanged. Found members join the roster with origin = '<planet>, <system>'; each body can only be scanned once, so no duplicate joins.
|
||||
- New endpoint: `GET /api/crew` → `[{id,name,role,trait_good,trait_bad,origin}]` in join order (auto-initializes like the others). `POST /api/explore` responses now also carry `crewFound` (the joining member or null). OpenAPI spec in code (`lib/openapi.ts`) updated for both.
|
||||
- CREW tab now renders REAL data: fetches `/api/crew` on mount (Radix unmounts inactive tabs, so opening the tab always refetches — no polling), cards show name + role, the good (+) and bad (−) traits and an ORIGIN line. Placeholder crew constants removed for this tab only — other tabs keep theirs until their issues land.
|
||||
|
||||
## 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).
|
||||
- Seeded world generation (`lib/game/world.ts`, pure functions): a save's seed deterministically generates the starting system plus a ring of six undiscovered neighbours with jump routes; jumping into an undiscovered system grows the chart with four more neighbours around it, so there is always somewhere to jump and a route back.
|
||||
- Save lifecycle (`lib/game/save.ts`): auto-initializes on first API hit when no save exists, exposes world state, validates jumps (adjacency required — non-adjacent/unknown rejected), sets current, increments the turn counter, writes events, persists durably.
|
||||
- New endpoints: `GET /api/world` → `{ systems, edges, turn }`; `POST /api/jump { systemId }` → fresh world state (400 malformed body, 404 unknown system, 409 not adjacent). OpenAPI spec in code (`lib/openapi.ts`) updated for both.
|
||||
- WORLD MAP tab now renders REAL data: fetches `/api/world` on mount, draws the live NodeGraph (current = ACTIVE/CURRENT, discovered = NEUTRAL/DISCOVERED, unknown = OFFLINE/UNKNOWN) with a jump button per adjacent system; placeholder map constants removed for this tab only — other tabs keep their placeholders until their own issues land.
|
||||
|
||||
## v0.2.2 — 2026-09-28 21:02
|
||||
- LOCAL SYSTEM tab: explorable-worlds list now staggers moons under their parent planet (indented rows) — ASHFALL attached under BRIMSTONE, new PALEMOON moon under AURELLE; badges unchanged.
|
||||
|
||||
## v0.2.1 — 2026-09-28 20:22
|
||||
- LOCAL SYSTEM tab: removed the system node graph (star/planets/moons) — the tab now shows only the explorable-worlds list at full panel width. WORLD MAP keeps its node graph; `components/ui/node-graph.tsx` unchanged and still in use there.
|
||||
|
||||
## v0.2.0 — 2026-09-28 19:19
|
||||
- Game interface template (visual only, zero game logic): the operations panel in `components/mission-control.tsx` is now seven tabs — WORLD MAP (NodeGraph of 10 placeholder solar systems linked by jump routes), LOCAL SYSTEM (NodeGraph of star + planets/moons plus an explorable-worlds list), CREW (crew cards with name/role and good/bad trait placeholders), SHIP SYSTEMS (system cards with inert EXPAND / UPGRADE / BUILD NEW affordances), SKILL TREE (tech cards unlocking new system builds/upgrades), MISSIONS (previous NEXT MISSION content), EVENT LOG (ship log terminal). All data is local placeholder constants.
|
||||
- Left column: VESSEL METRICS progress rings replaced with scificn Progress bracket bars; SHIP SYSTEMS status panel, header stat cards and right column unchanged.
|
||||
|
||||
+2
-2
@@ -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); 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.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { getCrewState } from '@/lib/game/save'
|
||||
|
||||
// The ship's crew roster: starting crew plus members found while exploring. Auto-initializes on first hit.
|
||||
export async function GET() {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await getCrewState(db))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { JumpError, jumpToSystem } from '@/lib/game/save'
|
||||
|
||||
// Jump to an adjacent solar system. Body: { "systemId": number }. Returns the fresh world state.
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const systemId = (body as { systemId?: unknown }).systemId
|
||||
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await jumpToSystem(db, systemId))
|
||||
} catch (err) {
|
||||
if (err instanceof JumpError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { 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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { CrewDto } from '@/lib/game/save'
|
||||
|
||||
// CREW tab — the ship's roster: starting crew plus members found while exploring. Fetches
|
||||
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
|
||||
// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet.
|
||||
|
||||
interface CrewPanelProps {
|
||||
narrow?: boolean
|
||||
}
|
||||
|
||||
export function CrewPanel({ narrow }: CrewPanelProps) {
|
||||
const [roster, setRoster] = useState<CrewDto[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as CrewDto[]
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setRoster(r)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error && !roster) {
|
||||
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' }}>
|
||||
CREW MANIFEST OFFLINE // {error.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!roster) {
|
||||
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' }}>LOADING CREW MANIFEST…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{roster.map((member) => (
|
||||
<Card key={member.id}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{member.name}</CardTitle>
|
||||
<Badge variant="ACTIVE" className="ml-auto">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{member.role}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.traitGood}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.traitBad}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
ORIGIN // {member.origin.toUpperCase()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+12
-198
@@ -5,17 +5,20 @@ 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 { CrewPanel } from '@/components/crew-panel'
|
||||
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), 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, LOCAL SYSTEM and CREW tabs render live data (/api/world, /api/system, /api/crew);
|
||||
// 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,68 +46,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 — the current system (regenerated per save after each jump, later).
|
||||
const localSystemNodes: NodeGraphNode[] = [
|
||||
{ id: 'star', label: 'Kepler-442', x: 50, y: 50, status: 'ACTIVE', sublabel: 'G2 STAR' },
|
||||
{ id: 'aurelle', label: 'Aurelle', x: 18, y: 30, status: 'ACTIVE', sublabel: 'TERRESTRIAL' },
|
||||
{ id: 'brimstone', label: 'Brimstone', x: 76, y: 24, status: 'NEUTRAL', sublabel: 'GAS GIANT' },
|
||||
{ id: 'ashfall', label: 'Ashfall', x: 90, y: 52, status: 'WARNING', sublabel: 'MOON' },
|
||||
{ id: 'cinderrock', label: 'Cinderrock', x: 74, y: 80, status: 'NEUTRAL', sublabel: 'ROCKY' },
|
||||
{ id: 'driftband', label: 'Driftband', x: 30, y: 78, status: 'WARNING', sublabel: 'ASTEROID RING' },
|
||||
{ id: 'palemoon', label: 'Palemoon', x: 8, y: 54, status: 'NEUTRAL', sublabel: 'MOON' },
|
||||
]
|
||||
|
||||
const localSystemEdges: NodeGraphEdge[] = [
|
||||
{ from: 'star', to: 'aurelle' },
|
||||
{ from: 'star', to: 'brimstone' },
|
||||
{ from: 'brimstone', to: 'ashfall', label: 'MOON' },
|
||||
{ from: 'star', to: 'cinderrock' },
|
||||
{ from: 'star', to: 'driftband' },
|
||||
{ from: 'aurelle', to: 'palemoon', label: 'MOON' },
|
||||
]
|
||||
|
||||
const explorablePlanets = [
|
||||
{ name: 'AURELLE', type: 'TERRESTRIAL // OCEAN CONTINENTS', state: 'UNEXPLORED' as const },
|
||||
{ name: 'BRIMSTONE', type: 'GAS GIANT // CLOUD DECKS', state: 'SCANNED' as const },
|
||||
{ name: 'ASHFALL (MOON)', type: 'VOLCANIC MOON // SULFUR VEINS', state: 'UNEXPLORED' as const },
|
||||
{ name: 'CINDERROCK', type: 'ROCKY // IRON RICH', state: 'UNEXPLORED' as const },
|
||||
{ name: 'DRIFTBAND', type: 'ASTEROID FIELD // HAZARD', state: 'HAZARD' as const },
|
||||
]
|
||||
|
||||
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' },
|
||||
{ name: 'DR. I. SORENSEN', role: 'MEDIC', good: 'CALM IN A CRISIS', bad: 'OVERCAUTIOUS' },
|
||||
{ name: 'R. "SPARK" DELACRUZ', role: 'ENGINEER', good: 'FIXES ANYTHING', bad: 'CARELESS WITH FUSES' },
|
||||
]
|
||||
|
||||
const shipSystemCards = [
|
||||
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
@@ -374,146 +315,19 @@ export function MissionControl() {
|
||||
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* WORLD MAP TAB */}
|
||||
{/* WORLD MAP TAB — live data from /api/world */}
|
||||
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
||||
<NodeGraph
|
||||
nodes={worldMapNodes}
|
||||
edges={worldMapEdges}
|
||||
variant="ACTIVE"
|
||||
title="GALACTIC CHART // JUMP ROUTES (PLACEHOLDER)"
|
||||
height={380}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<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={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<NodeGraph
|
||||
nodes={localSystemNodes}
|
||||
edges={localSystemEdges}
|
||||
variant="ACTIVE"
|
||||
title="KEPLER DRIFT // LOCAL SYSTEM (PLACEHOLDER)"
|
||||
height={280}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
EXPLORABLE WORLDS // FIND THINGS HERE (PLACEHOLDER)
|
||||
</div>
|
||||
{explorablePlanets.map((planet, i) => (
|
||||
<div
|
||||
key={planet.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderBottom:
|
||||
i < explorablePlanets.length - 1
|
||||
? '1px solid var(--border)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.68rem',
|
||||
color: 'var(--text-secondary)',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{planet.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.57rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{planet.type}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
planet.state === 'UNEXPLORED'
|
||||
? 'SCANNING'
|
||||
: planet.state === 'HAZARD'
|
||||
? 'WARNING'
|
||||
: 'ACTIVE'
|
||||
}
|
||||
>
|
||||
{planet.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<LocalSystemPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* CREW TAB */}
|
||||
{/* CREW TAB — live data from /api/crew */}
|
||||
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{crew.map((member) => (
|
||||
<Card key={member.name}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{member.name}</CardTitle>
|
||||
<Badge variant="ACTIVE" className="ml-auto">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{member.role}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.good}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.bad}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<CrewPanel narrow={narrow} />
|
||||
</TabsContent>
|
||||
|
||||
{/* SHIP SYSTEMS TAB */}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NodeGraph } from '@/components/ui/node-graph'
|
||||
import type { WorldState } from '@/lib/game/save'
|
||||
|
||||
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and
|
||||
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not
|
||||
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount,
|
||||
// refetch via the jump response — no polling.
|
||||
|
||||
interface WorldMapPanelProps {
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
||||
const [world, setWorld] = useState<WorldState | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/world')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as WorldState
|
||||
})
|
||||
.then((state) => {
|
||||
if (!cancelled) setWorld(state)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const jump = useCallback(async (systemId: number) => {
|
||||
setJumpingId(systemId)
|
||||
try {
|
||||
const res = await fetch('/api/jump', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ systemId }),
|
||||
})
|
||||
const body = (await res.json()) as WorldState & { error?: string }
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||
setWorld(body) // fresh state straight from the response — no refetch needed
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setJumpingId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error && !world) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||
CHART OFFLINE // {error.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!world) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING CHART…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const current = world.systems.find((s) => s.current)!
|
||||
const reachable = world.edges
|
||||
.filter(([a, b]) => a === current.id || b === current.id)
|
||||
.map(([a, b]) => (a === current.id ? b : a))
|
||||
.map((id) => world.systems.find((s) => s.id === id)!)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<NodeGraph
|
||||
nodes={world.systems.map((s) => ({
|
||||
id: String(s.id),
|
||||
label: s.name,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE',
|
||||
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
||||
}))}
|
||||
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
|
||||
variant="ACTIVE"
|
||||
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
|
||||
height={height}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
JUMP ROUTES // FROM {current.name.toUpperCase()}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', padding: '0.6rem 0.75rem' }}>
|
||||
{reachable.map((s) => (
|
||||
<Button
|
||||
key={s.id}
|
||||
variant="OUTLINE"
|
||||
size="SM"
|
||||
disabled={jumpingId !== null}
|
||||
onClick={() => void jump(s.id)}
|
||||
>
|
||||
{jumpingId === s.id ? 'JUMPING…' : `${s.name.toUpperCase()} // JUMP`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||
JUMP FAILED // {error.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// 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[]; crewFound: unknown }
|
||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||
|
||||
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||
@@ -21,6 +31,114 @@ 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('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('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => {
|
||||
const roster = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
||||
expect(roster.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'CREW' }).click();
|
||||
|
||||
for (const member of roster) {
|
||||
const heading = page.getByRole('heading', { name: member.name });
|
||||
await expect(heading).toBeVisible();
|
||||
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
|
||||
const card = heading.locator('xpath=ancestor::div[3]');
|
||||
await expect(card.getByText(member.role)).toBeVisible();
|
||||
await expect(card.getByText(member.traitGood)).toBeVisible();
|
||||
await expect(card.getByText(member.traitBad)).toBeVisible();
|
||||
await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -8,6 +8,13 @@ import { expect, test } from '@playwright/test';
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
||||
|
||||
// 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 }
|
||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||
|
||||
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
@@ -17,6 +24,34 @@ 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);
|
||||
|
||||
// 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;
|
||||
|
||||
// Crew baseline: the roster (starting crew + any finds so far) must survive too.
|
||||
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
||||
expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
||||
|
||||
// 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 +72,20 @@ 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 current system as before the restart.
|
||||
const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
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);
|
||||
|
||||
// Crew roster survived too: same members, traits and origins (no re-seed on restart).
|
||||
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
||||
expect(crewAfter).toEqual(crewBefore);
|
||||
|
||||
// The page itself serves again with the landing title.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||
|
||||
+63
-4
@@ -14,12 +14,71 @@ 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 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 crew (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
trait_good TEXT NOT NULL,
|
||||
trait_bad TEXT NOT NULL,
|
||||
origin TEXT NOT NULL -- 'STARTING CREW' or '<planet>, <system>' for members found while exploring
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
+80
-3
@@ -1,6 +1,83 @@
|
||||
// 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] })],
|
||||
)
|
||||
|
||||
// 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),
|
||||
})
|
||||
|
||||
// The ship's crew roster: the starting crew (seeded on new game) plus members found while
|
||||
// exploring planets/moons. Display-only for now — no dismiss/assign actions yet (#7 scope).
|
||||
export const crew = sqliteTable('crew', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||
name: text('name').notNull(),
|
||||
role: text('role').notNull(),
|
||||
traitGood: text('trait_good').notNull(),
|
||||
traitBad: text('trait_bad').notNull(),
|
||||
// Where the member came from: 'STARTING CREW' or '<planet>, <system>' for found members.
|
||||
origin: text('origin').notNull(),
|
||||
})
|
||||
|
||||
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
||||
// writers and a read endpoint.
|
||||
export const events = sqliteTable('events', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||
turn: integer('turn').notNull(),
|
||||
type: text('type').notNull(),
|
||||
message: text('message').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CREW_CHANCE, generateStartingCrew, makeCrewName, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { explorationRng } from './world'
|
||||
|
||||
describe('generateStartingCrew', () => {
|
||||
it('gives a new game 2–3 members, each with exactly one good and one bad trait', () => {
|
||||
for (const seed of [1, 7, 42, 999]) {
|
||||
const crew = generateStartingCrew(startingCrewRng(String(seed)))
|
||||
expect(crew.length).toBeGreaterThanOrEqual(2)
|
||||
expect(crew.length).toBeLessThanOrEqual(3)
|
||||
|
||||
for (const member of crew) {
|
||||
expect(member.name.trim().length).toBeGreaterThan(0)
|
||||
expect(member.role.trim().length).toBeGreaterThan(0)
|
||||
expect(member.traitGood.trim().length).toBeGreaterThan(0)
|
||||
expect(member.traitBad.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
// Names are unique within the crew; roles too (drawn without replacement).
|
||||
expect(new Set(crew.map((m) => m.name)).size).toBe(crew.length)
|
||||
expect(new Set(crew.map((m) => m.role)).size).toBe(crew.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('is deterministic under a fixed seed and differs across seeds', () => {
|
||||
const a = generateStartingCrew(startingCrewRng('42'))
|
||||
expect(generateStartingCrew(startingCrewRng('42'))).toEqual(a) // same save → same crew
|
||||
const b = generateStartingCrew(startingCrewRng('43'))
|
||||
expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different crew
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollCrewMember', () => {
|
||||
it('finds a member with the documented probability and never reuses a taken name', () => {
|
||||
const rng = startingCrewRng('42')
|
||||
let found = 0
|
||||
for (let i = 0; i < 1000; i++) found += rollCrewMember(rng, new Set<string>()) ? 1 : 0
|
||||
// 18% ± a comfortable band — catches an inverted or missing chance without flaking.
|
||||
expect(found).toBeGreaterThan(90)
|
||||
expect(found).toBeLessThan(270)
|
||||
|
||||
const taken = new Set(['Ava Vance'])
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const member = rollCrewMember(startingCrewRng(String(i)), taken)
|
||||
if (member) expect(member.name).not.toBe('Ava Vance') // never reuses a roster name
|
||||
}
|
||||
})
|
||||
|
||||
it('is deterministic per save+planet when drawn from the exploration stream', () => {
|
||||
const first = rollCrewMember(explorationRng('42', 13), new Set<string>())
|
||||
expect(rollCrewMember(explorationRng('42', 13), new Set<string>())).toEqual(first) // same save+planet → same result
|
||||
|
||||
// Across many planets both outcomes occur (someone is found, someone isn't).
|
||||
let found = 0
|
||||
for (let planetId = 1; planetId <= 200; planetId++) {
|
||||
if (rollCrewMember(explorationRng('42', planetId), new Set<string>())) found++
|
||||
}
|
||||
expect(found).toBeGreaterThan(0)
|
||||
expect(found).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('keeps the crew chance documented and sane', () => {
|
||||
expect(CREW_CHANCE).toBeGreaterThanOrEqual(0.15)
|
||||
expect(CREW_CHANCE).toBeLessThanOrEqual(0.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeCrewName', () => {
|
||||
it('never reuses a taken name and always terminates', () => {
|
||||
const rng = startingCrewRng('7')
|
||||
const taken = new Set<string>()
|
||||
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
|
||||
expect(taken.size).toBe(40)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// Pure, seeded crew generation — no DB, no Date, no Math.random. Same seed → same starting
|
||||
// crew; the exploration crew roll draws from the caller's per-planet rng stream so a found
|
||||
// member is deterministic per save+planet (same derivation as the resource/part haul).
|
||||
|
||||
import { mulberry32 } from './world'
|
||||
|
||||
export interface CrewMember {
|
||||
name: string
|
||||
role: string
|
||||
traitGood: string
|
||||
traitBad: string
|
||||
}
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'AVA', 'BEN', 'CASS', 'DANA', 'ELI', 'FERN', 'GIDEON', 'HALYA',
|
||||
'IVAR', 'JUNO', 'KAI', 'LORE', 'MARA', 'NICO', 'ODESSA', 'PETRA',
|
||||
]
|
||||
|
||||
const LAST_NAMES = [
|
||||
'VANCE', 'OKONKWO', 'SORENSEN', 'DELACRUZ', 'HALLOWAY', 'IBARRA', 'KOVAC', 'LINDQVIST',
|
||||
'MERCER', 'NADEAU', 'ORLOV', 'QUINTANA', 'REYES', 'STALKE', 'TANAKA', 'UNDERWOOD',
|
||||
'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL',
|
||||
]
|
||||
|
||||
const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN']
|
||||
|
||||
const GOOD_TRAITS = [
|
||||
'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING',
|
||||
'SHARP SHOOTER', 'READS STARS BLIND', 'QUICK HANDS', 'IRON WILL',
|
||||
'FINDS HIDDEN THINGS', 'TALKS TO ANYONE',
|
||||
]
|
||||
|
||||
const BAD_TRAITS = [
|
||||
'RIGID — SLOW TO ADAPT', 'SHORT FUSE', 'OVERCAUTIOUS', 'CARELESS WITH FUSES',
|
||||
'SLOWS THE CREW DOWN', 'ARGUES WITH EVERYONE', 'FORGETS TO EAT', 'JINXED IN A DOGFIGHT',
|
||||
'HOARDS RATIONS', 'DOUBTS THE CAPTAIN',
|
||||
]
|
||||
|
||||
const STARTING_CREW_MIN = 2
|
||||
const STARTING_CREW_SPREAD = 2 // 2..3 starting members
|
||||
export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans)
|
||||
|
||||
function title(word: string): string {
|
||||
return word.charAt(0) + word.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, arr: T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]
|
||||
}
|
||||
|
||||
// A name unique against `taken` (the current roster), claimed into the set on success.
|
||||
// 16×22 = 352 combinations — a roster realistically never approaches that size.
|
||||
export function makeCrewName(rng: () => number, taken: Set<string>): string {
|
||||
for (;;) {
|
||||
const name = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
|
||||
if (!taken.has(name)) {
|
||||
taken.add(name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The crew a new game starts with: 2–3 members, unique names and roles (roles are drawn
|
||||
// without replacement). Pure — the caller derives the rng from the save seed.
|
||||
export function generateStartingCrew(rng: () => number): CrewMember[] {
|
||||
const taken = new Set<string>()
|
||||
const roles = [...ROLES]
|
||||
const crew: CrewMember[] = []
|
||||
for (let i = 0; i < STARTING_CREW_MIN + Math.floor(rng() * STARTING_CREW_SPREAD); i++) {
|
||||
const role = roles.splice(Math.floor(rng() * roles.length), 1)[0]
|
||||
crew.push({ name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) })
|
||||
}
|
||||
return crew
|
||||
}
|
||||
|
||||
// The exploration crew roll: with CREW_CHANCE a member is found and joins the ship. Draws
|
||||
// from the SAME per-planet rng stream as the resource/part haul (called after it in
|
||||
// save.ts), so the outcome stays deterministic per save+planet. `taken` is the current
|
||||
// roster's names, keeping every name on the ship unique.
|
||||
export function rollCrewMember(rng: () => number, taken: Set<string>): CrewMember | null {
|
||||
if (rng() >= CREW_CHANCE) return null
|
||||
const role = pick(rng, ROLES)
|
||||
return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) }
|
||||
}
|
||||
|
||||
// Independent PRNG stream for the starting crew of one save (same pattern as bodyRng /
|
||||
// explorationRng in world.ts — a fixed offset keeps it away from every other derivation).
|
||||
export function startingCrewRng(seed: string): () => number {
|
||||
return mulberry32((Number(seed) + 0x51ed270b) >>> 0)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
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 * as schema from '@/lib/db/schema'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCrewState', () => {
|
||||
it('seeds a starting crew of 2–3 members derived from the save seed', async () => {
|
||||
const { db } = await freshFile()
|
||||
const crew = await getCrewState(db) // ensureSave runs inside, so this also seeds the crew
|
||||
|
||||
expect(crew.length).toBeGreaterThanOrEqual(2)
|
||||
expect(crew.length).toBeLessThanOrEqual(3)
|
||||
for (const member of crew) {
|
||||
expect(member.origin).toBe('STARTING CREW')
|
||||
expect(member.name.trim()).not.toBe('')
|
||||
expect(member.role.trim()).not.toBe('')
|
||||
expect(member.traitGood.trim()).not.toBe('')
|
||||
expect(member.traitBad.trim()).not.toBe('')
|
||||
}
|
||||
|
||||
// The roster is exactly the pure generation for this save's seed.
|
||||
const [save] = await db.db.select().from(schema.saves)
|
||||
const expected = generateStartingCrew(startingCrewRng(save.seed))
|
||||
expect(crew.map((c) => c.name)).toEqual(expected.map((m) => m.name))
|
||||
expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role))
|
||||
expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('explorePlanet crew finds', () => {
|
||||
it('lands a found member in the roster with the planet+system origin and never joins twice (known seed)', async () => {
|
||||
// Hand-built save with a known seed so the crew roll 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 })
|
||||
|
||||
// Find a small planet id whose exploration roll finds crew (deterministic — recomputed here,
|
||||
// mirroring save.ts: the item haul draws first, then the crew roll on the same stream).
|
||||
let crewPlanetId = -1
|
||||
for (let id = 1; id <= 50 && crewPlanetId === -1; id++) {
|
||||
const rng = explorationRng('42', id)
|
||||
rollExploration(rng)
|
||||
if (rollCrewMember(rng, new Set<string>())) crewPlanetId = id
|
||||
}
|
||||
expect(crewPlanetId).toBeGreaterThan(0)
|
||||
|
||||
const planetName = `P${crewPlanetId}`
|
||||
for (let i = 1; i <= crewPlanetId; i++) {
|
||||
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
|
||||
}
|
||||
|
||||
const before = await getCrewState(db) // empty — this save was hand-built, no starting crew seeded
|
||||
const result = await explorePlanet(db, crewPlanetId)
|
||||
|
||||
expect(result.crewFound).not.toBeNull()
|
||||
const expectedRng = explorationRng('42', crewPlanetId)
|
||||
rollExploration(expectedRng) // consume the item haul draws first, exactly as save.ts does
|
||||
const expectedMember = rollCrewMember(expectedRng, new Set<string>())! // same save+planet → same member
|
||||
expect(result.crewFound!.name).toBe(expectedMember.name)
|
||||
expect(result.crewFound!.role).toBe(expectedMember.role)
|
||||
expect([result.crewFound!.traitGood, result.crewFound!.traitBad]).toEqual([expectedMember.traitGood, expectedMember.traitBad])
|
||||
expect(result.crewFound!.origin).toBe(`${planetName}, Test System`)
|
||||
|
||||
const after = await getCrewState(db)
|
||||
expect(after.length).toBe(before.length + 1) // exactly one join
|
||||
expect(after.find((c) => c.id === result.crewFound!.id)).toMatchObject({ name: expectedMember.name, origin: `${planetName}, Test System` })
|
||||
|
||||
// The body is already explored → a second scan is rejected and no duplicate joins.
|
||||
await expect(explorePlanet(db, crewPlanetId)).rejects.toMatchObject({ status: 409 })
|
||||
expect((await getCrewState(db)).length).toBe(before.length + 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
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 { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Seeds the starting crew for a fresh save: 2–3 members derived from the seed alone.
|
||||
async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promise<void> {
|
||||
for (const member of generateStartingCrew(startingCrewRng(seed))) {
|
||||
await db.db.insert(schema.crew).values({ saveId, name: member.name, role: member.role, traitGood: member.traitGood, traitBad: member.traitBad, origin: 'STARTING CREW' })
|
||||
}
|
||||
}
|
||||
|
||||
// Creates the save + starting region when the DB has none. No-op otherwise — safe to call
|
||||
// on every request (single player, one process: check-then-insert cannot race in practice).
|
||||
export async function ensureSave(db: GameDb): Promise<void> {
|
||||
const existing = await db.db.select().from(schema.saves).limit(1)
|
||||
if (existing.length > 0) return
|
||||
|
||||
const seed = newSeed()
|
||||
const region = generateStartRegion(mulberry32(Number(seed)))
|
||||
const nowIso = new Date().toISOString()
|
||||
|
||||
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso }).returning()
|
||||
// All inserted as undiscovered first; the start is then marked discovered + current.
|
||||
const 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 : neighborIds[index - 1])
|
||||
await insertRoutes(db, region.edges, resolve)
|
||||
await seedStartingCrew(db, save.id, seed)
|
||||
|
||||
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
||||
await persist(db)
|
||||
}
|
||||
|
||||
export async function getWorldState(db: GameDb): Promise<WorldState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
const routes = await db.db.select().from(schema.jumpRoutes)
|
||||
|
||||
// Dedupe the stored directed pairs into sorted undirected edges.
|
||||
const seen = new Set<string>()
|
||||
const edges: [number, number][] = []
|
||||
for (const r of routes) {
|
||||
const a = Math.min(r.fromSystem, r.toSystem)
|
||||
const b = Math.max(r.fromSystem, r.toSystem)
|
||||
const key = `${a}-${b}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
edges.push([a, b])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
systems: systems.map((s) => ({ id: s.id, name: s.name, x: s.x, y: s.y, discovered: s.discovered, current: s.isCurrent })),
|
||||
edges,
|
||||
turn: save.turn,
|
||||
}
|
||||
}
|
||||
|
||||
export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
|
||||
const target = systems.find((s) => s.id === systemId)
|
||||
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
|
||||
const current = systems.find((s) => s.isCurrent)!
|
||||
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
|
||||
|
||||
const routes = await db.db.select().from(schema.jumpRoutes)
|
||||
const adjacent = routes.some((r) => r.fromSystem === current.id && r.toSystem === target.id)
|
||||
if (!adjacent) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
|
||||
|
||||
const turn = save.turn + 1
|
||||
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
|
||||
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
|
||||
|
||||
if (!target.discovered) {
|
||||
// Growing the chart: a ring of new (undiscovered) neighbours around the newly reached
|
||||
// system, so there is always somewhere to jump next and a route back.
|
||||
const rng = expansionRng(save.seed, turn)
|
||||
const takenNames = new Set(systems.map((s) => s.name))
|
||||
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
|
||||
const 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
|
||||
// --- Crew roster --------------------------------------------------------------
|
||||
|
||||
export interface CrewDto {
|
||||
id: number
|
||||
name: string
|
||||
role: string
|
||||
traitGood: string
|
||||
traitBad: string
|
||||
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring
|
||||
}
|
||||
|
||||
// The full roster in join order (starting crew first, then finds). Auto-initializes like the other reads.
|
||||
export async function getCrewState(db: GameDb): Promise<CrewDto[]> {
|
||||
await ensureSave(db)
|
||||
return (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
|
||||
}
|
||||
|
||||
export interface ExploreResult extends SystemState {
|
||||
outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing)
|
||||
crewFound: CrewDto | null // a member who joined the ship on this scan (null when none)
|
||||
}
|
||||
|
||||
// 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. The same per-planet
|
||||
// rng stream also rolls for a crew member (~18% of scans) who joins the roster. 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 rng = explorationRng(save.seed, target.id)
|
||||
const outcome = rollExploration(rng) // resource/part haul first…
|
||||
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
|
||||
const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
let crewFound: CrewDto | null = null
|
||||
if (foundMember) {
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
|
||||
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
|
||||
crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
|
||||
}
|
||||
|
||||
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
|
||||
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
|
||||
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}${crewText}`, createdAt: new Date().toISOString() })
|
||||
await persist(db)
|
||||
|
||||
return { ...(await readSystemState(db)), outcome, crewFound }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world'
|
||||
|
||||
describe('generateStartRegion', () => {
|
||||
const region = generateStartRegion(mulberry32(42))
|
||||
|
||||
it('produces a starting system plus six neighbours', () => {
|
||||
expect(region.neighbors).toHaveLength(6)
|
||||
expect(region.start.x).toBe(50)
|
||||
expect(region.start.y).toBe(50)
|
||||
})
|
||||
|
||||
it('gives every system a unique name and in-bounds coordinates', () => {
|
||||
const all = [region.start, ...region.neighbors]
|
||||
const names = all.map((s) => s.name)
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
for (const s of all) {
|
||||
expect(s.x).toBeGreaterThanOrEqual(0)
|
||||
expect(s.x).toBeLessThanOrEqual(100)
|
||||
expect(s.y).toBeGreaterThanOrEqual(0)
|
||||
expect(s.y).toBeLessThanOrEqual(100)
|
||||
}
|
||||
})
|
||||
|
||||
it('connects the start to every neighbour and closes the ring cycle', () => {
|
||||
const n = region.neighbors.length
|
||||
// anchor↔each member (n edges) + ring cycle (n edges)
|
||||
expect(region.edges).toHaveLength(n * 2)
|
||||
for (let i = 0; i < n; i++) {
|
||||
expect(region.edges).toContainEqual([0, i + 1])
|
||||
expect(region.edges).toContainEqual([i + 1, ((i + 1) % n) + 1])
|
||||
}
|
||||
// every edge endpoint is a valid index into [start, ...neighbors]
|
||||
for (const [a, b] of region.edges) {
|
||||
expect(a).toBeGreaterThanOrEqual(0)
|
||||
expect(a).toBeLessThan(n + 1)
|
||||
expect(b).toBeGreaterThanOrEqual(0)
|
||||
expect(b).toBeLessThan(n + 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('is deterministic under a fixed seed and differs across seeds', () => {
|
||||
const again = generateStartRegion(mulberry32(42))
|
||||
expect(again).toEqual(region)
|
||||
const other = generateStartRegion(mulberry32(43))
|
||||
expect(other.start.name !== region.start.name || other.neighbors[0].name !== region.neighbors[0].name).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateRing (expansion)', () => {
|
||||
it('adds four systems spaced away from the anchor and existing positions', () => {
|
||||
const takenNames = new Set(['Kepler Drift'])
|
||||
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
|
||||
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
|
||||
|
||||
expect(ring.systems).toHaveLength(4)
|
||||
const ringNames = ring.systems.map((s) => s.name)
|
||||
for (const s of ring.systems) {
|
||||
expect(Math.hypot(s.x - 50, s.y - 50)).toBeGreaterThanOrEqual(16)
|
||||
expect(s.name).not.toBe('Kepler Drift') // never reuses a pre-taken name
|
||||
}
|
||||
expect(new Set(ringNames).size).toBe(4) // and makeName claimed them into the set
|
||||
for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
|
||||
expect(ring.edges).toHaveLength(8) // anchor↔each + cycle
|
||||
for (const [a, b] of ring.edges) {
|
||||
expect(a).toBeGreaterThanOrEqual(0)
|
||||
expect(a).toBeLessThan(5)
|
||||
expect(b).toBeGreaterThanOrEqual(0)
|
||||
expect(b).toBeLessThan(5)
|
||||
}
|
||||
})
|
||||
|
||||
it('never reuses a taken name', () => {
|
||||
const rng = mulberry32(1)
|
||||
const taken = new Set<string>()
|
||||
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
||||
expect(taken.size).toBe(40)
|
||||
})
|
||||
})
|
||||
|
||||
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', '--']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
// Pure, seeded world generation — no DB, no Date, no Math.random. The same seed always
|
||||
// yields the same world, which is what makes unit tests (and save regeneration) deterministic.
|
||||
|
||||
export interface WorldSystem {
|
||||
name: string
|
||||
x: number // 0–100 coordinate space, rendered directly by NodeGraph
|
||||
y: number
|
||||
}
|
||||
|
||||
export const START_X = 50
|
||||
export const START_Y = 50
|
||||
|
||||
const START_RING = 6 // neighbours around the starting system on a new game
|
||||
const EXPAND_RING = 4 // new neighbours generated when jumping into an undiscovered system
|
||||
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
|
||||
|
||||
// mulberry32 — small fast seeded PRNG; plenty for name/position picking.
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const FIRST = [
|
||||
'KEPLER', 'VESPER', 'HOLLOW', 'CINDER', 'LANTERN', 'SABLE', 'ORPHEUS', 'MIRE',
|
||||
'THALASSA', 'KUIPER', 'AURELLE', 'BRIMSTONE', 'EMBER', 'FROST', 'GALE', 'IRON',
|
||||
]
|
||||
const SECOND = ['DRIFT', 'REACH', 'GATE', 'FIELD', 'VOID', 'DEEP', 'BEND', 'SHARD']
|
||||
|
||||
function title(word: string): string {
|
||||
return word.charAt(0) + word.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one.
|
||||
export function makeName(rng: () => number, taken: Set<string>): string {
|
||||
for (;;) {
|
||||
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
||||
if (!taken.has(name)) {
|
||||
taken.add(name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(v: number): number {
|
||||
return Math.min(96, Math.max(4, v))
|
||||
}
|
||||
|
||||
// Places `count` systems on a ring around (cx,cy), nudging each candidate until it is at
|
||||
// least MIN_SPACING away from every known position. After 12 tries it accepts the last
|
||||
// candidate anyway — ponytail: a slightly crowded node beats an infinite loop; with ≤6
|
||||
// rings per region and spacing 16 in a 0–100 box this never triggers in practice.
|
||||
function placeRing(
|
||||
rng: () => number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
count: number,
|
||||
takenNames: Set<string>,
|
||||
takenPositions: WorldSystem[],
|
||||
): WorldSystem[] {
|
||||
const placed: WorldSystem[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const baseAngle = (i / count) * Math.PI * 2 + (rng() - 0.5) * 0.6
|
||||
let x = cx
|
||||
let y = cy
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
const angle = baseAngle + attempt * 0.45
|
||||
const radius = 26 + rng() * 8
|
||||
x = clamp(cx + Math.cos(angle) * radius)
|
||||
y = clamp(cy + Math.sin(angle) * radius)
|
||||
if ([...takenPositions, ...placed].every((p) => Math.hypot(p.x - x, p.y - y) >= MIN_SPACING)) break
|
||||
}
|
||||
placed.push({ name: makeName(rng, takenNames), x, y })
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
|
||||
// cycle connecting consecutive members (so every system has ≥2 routes and you can always
|
||||
// get back). Pairs are indices into [anchor, ...members].
|
||||
function ringEdges(n: number): [number, number][] {
|
||||
const edges: [number, number][] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
edges.push([0, i + 1])
|
||||
edges.push([i + 1, ((i + 1) % n) + 1])
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
export interface StartRegion {
|
||||
start: WorldSystem
|
||||
neighbors: WorldSystem[]
|
||||
edges: [number, number][] // indices into [start, ...neighbors]
|
||||
}
|
||||
|
||||
// New game: the starting system at chart centre plus a ring of undiscovered neighbours.
|
||||
export function generateStartRegion(rng: () => number): StartRegion {
|
||||
const taken = new Set<string>()
|
||||
const start: WorldSystem = { name: makeName(rng, taken), x: START_X, y: START_Y }
|
||||
const neighbors = placeRing(rng, START_X, START_Y, START_RING, taken, [start])
|
||||
return { start, neighbors, edges: ringEdges(neighbors.length) }
|
||||
}
|
||||
|
||||
export interface RingRegion {
|
||||
systems: WorldSystem[]
|
||||
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
|
||||
}
|
||||
|
||||
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
|
||||
// neighbours around it. `takenNames`/`takenPositions` are every system already in the save.
|
||||
export function generateRing(
|
||||
rng: () => number,
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
takenNames: Set<string>,
|
||||
takenPositions: WorldSystem[],
|
||||
): RingRegion {
|
||||
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
|
||||
return { systems, edges: ringEdges(systems.length) }
|
||||
}
|
||||
|
||||
// --- 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
|
||||
}
|
||||
+49
-2
@@ -3,8 +3,55 @@ 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/crew',
|
||||
'/api/explore',
|
||||
'/api/jump',
|
||||
'/api/system',
|
||||
'/api/version',
|
||||
'/api/world',
|
||||
]);
|
||||
});
|
||||
|
||||
it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => {
|
||||
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
||||
expect(crew.type).toBe('array');
|
||||
expect((crew.items as { $ref: string }).$ref).toBe('#/components/schemas/CrewMember');
|
||||
|
||||
const member = openApiSpec.components.schemas.CrewMember.properties;
|
||||
expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']);
|
||||
|
||||
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] };
|
||||
const extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).properties;
|
||||
expect(Object.keys(extra)).toEqual(['outcome', 'crewFound']);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
|
||||
+283
-1
@@ -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,160 @@ 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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/crew': {
|
||||
get: {
|
||||
summary: 'Crew roster',
|
||||
description:
|
||||
'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring planets/moons. Each member has one good and one bad trait and an origin. Auto-initializes a new game when no save exists yet.',
|
||||
operationId: 'getCrew',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Crew roster',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/CrewMember' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
@@ -42,6 +196,134 @@ 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'],
|
||||
},
|
||||
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'],
|
||||
},
|
||||
CrewMember: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
name: { type: 'string' },
|
||||
role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' },
|
||||
trait_good: { type: 'string', description: 'The member good trait' },
|
||||
trait_bad: { type: 'string', description: 'The member bad trait' },
|
||||
origin: { type: 'string', description: "'STARTING CREW' or '<planet>, <system>' for members found while exploring" },
|
||||
},
|
||||
required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'],
|
||||
},
|
||||
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' },
|
||||
},
|
||||
crewFound: {
|
||||
description: 'A crew member who joined the ship on this scan (~18% of scans), null when none',
|
||||
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
|
||||
},
|
||||
},
|
||||
required: ['outcome', 'crewFound'],
|
||||
},
|
||||
],
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
},
|
||||
required: ['error'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space-wanderer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user