Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcaeb4dded | ||
|
|
7e975566ab | ||
|
|
de0cb45b09 | ||
|
|
fce27ff6ca | ||
|
|
5fe306d64d | ||
|
|
0910891995 | ||
|
|
c7580d92bd | ||
|
|
f5cba45b42 | ||
|
|
59ccf5674e | ||
|
|
60947d3cda | ||
|
|
08a15a09cf | ||
|
|
a2c8586618 | ||
|
|
7a9ff110f3 | ||
|
|
ccd156b7c6 | ||
|
|
a6a5e827a1 | ||
|
|
0ddba27333 | ||
|
|
1a50ffb2f9 | ||
|
|
82d41b87be | ||
|
|
8153007668 | ||
|
|
3aa27ffd89 | ||
|
|
cbab17047b | ||
|
|
172f167a15 |
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.0 — 2026-09-28 23:46
|
||||
- SHIP SYSTEMS tab — expand / upgrade / build ship systems + save backfill plumbing (issue #8): the first spend-side game loop on top of issue #7's crew, plus a root-cause fix for saves created by older code lacking data newer code seeds. Single-player, no auth, by design.
|
||||
- Backfill plumbing: `schema_version` INTEGER added to `saves` (NULL = v1). `ensureSave` now runs ordered IDEMPOTENT backfill steps for anything below current — v2: every system without planets gets its generated bodies (same `(seed, systemId)` derivation as fresh generation), v3: starting crew if no rows. New games get the current version directly. `lib/db/client.ts` open() carries an idempotent `ALTER TABLE ... ADD COLUMN` guard for files created before the column existed — old saves open fine and backfill on first read, so DB wipes are no longer needed when deploying new versions.
|
||||
- New table (`lib/db/schema.ts` + DDL): `ship_systems` (id/save_id/type/level/capacity, UNIQUE(save_id,type)). `built` is derived, not stored — level > 0 means built; unbuilt rows sit at level/capacity 0. Starting ship seeded on new game AND as backfill step v4: core types (HULL, REACTOR, LIFE SUPPORT, NAV COMPUTER, COMMS ARRAY) built at base values, WEAPONS unbuilt until a build action.
|
||||
- Cost model (`lib/game/ship-costs.ts`, pure — imported by both the API and the UI so affordability can't drift): UPGRADE costs `level` units of any resource (capped at MAX_LEVEL 5), EXPAND costs `capacity` units of any resource (capped at MAX_CAPACITY 10), BUILD costs a flat 3 parts. Consumption takes from the oldest cargo row of the required kind first, deleting rows that hit zero; an unaffordable action mutates nothing.
|
||||
- Ship actions (`lib/game/ship.ts`): `upgradeSystem` / `expandSystem` / `buildSystem` — validate (404 unknown id/type, 409 not built / already built / at cap, 422 insufficient inventory), spend the cost, bump level/capacity or build at base values, cost one turn and write an event each.
|
||||
- New endpoints: `GET /api/ship` → `{systems:[{id,type,level,capacity,built}],inventory,turn}`; `POST /api/ship/upgrade {systemId}`, `POST /api/ship/expand {systemId}`, `POST /api/ship/build {type}` — each returns the fresh ship state. OpenAPI spec in code updated (10 paths).
|
||||
- SHIP SYSTEMS tab now renders REAL data: cards show type + level/capacity with UPGRADE / EXPAND buttons per built card and BUILD NEW for unbuilt types, disabled when unaffordable or capped; each action's response is applied directly (no polling). Left column mirrors live state too — the SHIP SYSTEMS panel lists every system with its status/level/capacity + an N ONLINE badge, and VESSEL METRICS bars are driven by real values (hull/reactor from system levels, crew roster vs a cap of 6). Placeholder constants for both removed; other tabs keep theirs until their issues land.
|
||||
- Tests: unit — backfill upgrades a hand-built v1 save in place (deterministic vs fresh generation, idempotent), starting ship seeding, cost validation with no mutation on failure, exact oldest-first inventory deduction, level/capacity caps, build-once, turn + event per action; e2e — SHIP SYSTEMS tab performs one affordable UPGRADE/BUILD via the UI after a bounded scan loop until something is affordable (asserting card + left column reflect it), and ship state survives the mid-run container restart.
|
||||
|
||||
## 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).
|
||||
|
||||
+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. As of v0.3.0 the WORLD MAP tab renders live data from `GET /api/world` (seeded world generation in `lib/game/`, save/jump/events via Drizzle/sql.js — single player, one save, no auth); the other tabs keep local placeholder constants until their own issues land. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
|
||||
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Game tables (`saves`, `solar_systems`, `jump_routes`, `events`) live since v0.3.0 — single-player save with seeded world generation + turn counter; DDL in `lib/db/client.ts` must stay in sync with `lib/db/schema.ts`.
|
||||
- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart, NodeGraph, Progress), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. As of v0.2.0 the operations panel is a 7-tab visual template (WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE, MISSIONS, EVENT LOG) built on NodeGraph + Card widgets. As of v0.3.0 the WORLD MAP tab renders live data from `GET /api/world` (seeded world generation in `lib/game/`, save/jump/events via Drizzle/sql.js — single player, one save, no auth); as of v0.4.0 the LOCAL SYSTEM tab also renders live data (`GET /api/system` + `POST /api/explore` — per-system planet/moon generation and deterministic exploration into inventory). The other tabs keep local placeholder constants until their own issues land. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
|
||||
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Game tables (`saves`, `solar_systems`, `jump_routes`, `planets`, `inventory_items`, `events`) live since v0.3.0/v0.4.0 — single-player save with seeded world + per-system body generation, turn counter and exploration; DDL in `lib/db/client.ts` must stay in sync with `lib/db/schema.ts`.
|
||||
- Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only.
|
||||
- Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer).
|
||||
- Plain React hooks for client state, no external store. **NO auth/accounts/multiplayer by design** (single player only) — there is no users/sessions table and no session headers; a "session" means the local single-player save slot in the DB, not an identity system. Do not copy Flight-Ops's lib/auth.ts / SignInScreen / admin patterns.
|
||||
|
||||
@@ -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,28 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, buildSystem } from '@/lib/game/ship'
|
||||
|
||||
// Build an unbuilt ship system type at base values. Body: { "type": string }.
|
||||
// Costs inventory items + a turn; returns the fresh ship 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 type = (body as { type?: unknown }).type
|
||||
if (typeof type !== 'string' || type.length === 0) {
|
||||
return Response.json({ error: 'type must be a non-empty string' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await buildSystem(db, type))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, expandSystem } from '@/lib/game/ship'
|
||||
|
||||
// Raise a built ship system's capacity by one (capped at max capacity). Body: { "systemId": number }.
|
||||
// Costs inventory items + a turn; returns the fresh ship 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 expandSystem(db, systemId))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { getShipState } from '@/lib/game/ship'
|
||||
|
||||
// The ship's systems + cargo hold + turn; auto-initializes on first hit.
|
||||
export async function GET() {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await getShipState(db))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, upgradeSystem } from '@/lib/game/ship'
|
||||
|
||||
// Level up a built ship system by one (capped at max level). Body: { "systemId": number }.
|
||||
// Costs inventory items + a turn; returns the fresh ship 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 upgradeSystem(db, systemId))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
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,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>
|
||||
)
|
||||
}
|
||||
+70
-198
@@ -11,12 +11,19 @@ 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 { ShipSystemsPanel } from '@/components/ship-systems'
|
||||
import { WorldMapPanel } from '@/components/world-map'
|
||||
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
||||
import type { CrewDto } from '@/lib/game/save'
|
||||
import type { ShipState } from '@/lib/game/ship'
|
||||
|
||||
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
||||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
||||
// WORLD MAP tab renders live data from /api/world; the other tabs keep placeholder content
|
||||
// until their own issues land. Single player, no auth, by design.
|
||||
// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system,
|
||||
// /api/crew, /api/ship); 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(
|
||||
@@ -36,43 +43,6 @@ function now(): string {
|
||||
|
||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
||||
|
||||
const shipSystems: SystemEntry[] = [
|
||||
{ name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
|
||||
{ name: 'LIFE SUPPORT', status: 'ACTIVE' },
|
||||
{ name: 'NAV COMPUTER', status: 'SCANNING' },
|
||||
{ name: 'WEAPONS', status: 'OFFLINE' },
|
||||
{ name: 'COMMS', status: 'ACTIVE' },
|
||||
]
|
||||
|
||||
// LOCAL SYSTEM — explorable worlds of the current system (regenerated per save after each jump, later).
|
||||
const explorablePlanets = [
|
||||
{
|
||||
name: 'AURELLE', type: 'TERRESTRIAL // OCEAN CONTINENTS', state: 'UNEXPLORED' as const,
|
||||
moons: [{ name: 'PALEMOON', type: 'ICE MOON // FROST PLAINS', state: 'UNEXPLORED' as const }],
|
||||
},
|
||||
{
|
||||
name: 'BRIMSTONE', type: 'GAS GIANT // CLOUD DECKS', state: 'SCANNED' as const,
|
||||
moons: [{ name: 'ASHFALL', type: 'VOLCANIC MOON // SULFUR VEINS', state: 'UNEXPLORED' as const }],
|
||||
},
|
||||
{ name: 'CINDERROCK', type: 'ROCKY // IRON RICH', state: 'UNEXPLORED' as const, moons: [] },
|
||||
{ name: 'DRIFTBAND', type: 'ASTEROID FIELD // HAZARD', state: 'HAZARD' as const, moons: [] },
|
||||
]
|
||||
|
||||
const crew = [
|
||||
{ name: 'CMDR. E. VANCE', role: 'COMMANDER', good: 'STEADY UNDER PRESSURE', bad: 'RIGID — SLOW TO ADAPT' },
|
||||
{ name: 'LT. M. OKONKWO', role: 'PILOT', good: 'RECKLESS COURAGE', bad: 'SHORT FUSE' },
|
||||
{ name: 'DR. I. SORENSEN', role: 'MEDIC', good: 'CALM IN A CRISIS', bad: 'OVERCAUTIOUS' },
|
||||
{ name: 'R. "SPARK" DELACRUZ', role: 'ENGINEER', good: 'FIXES ANYTHING', bad: 'CARELESS WITH FUSES' },
|
||||
]
|
||||
|
||||
const shipSystemCards = [
|
||||
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
{ name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
{ name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] },
|
||||
{ name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
]
|
||||
|
||||
const skillCards = [
|
||||
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
||||
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
||||
@@ -108,6 +78,36 @@ const logLines: TerminalLine[] = [
|
||||
export function MissionControl() {
|
||||
const narrow = useNarrow()
|
||||
const [tick, setTick] = useState(0)
|
||||
// Ship state is owned here (not in the tab panel) so the left column mirrors ship actions
|
||||
// immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count
|
||||
// feeds the roster bar — refetched on page load, like the other panels.
|
||||
const [ship, setShip] = useState<ShipState | null>(null)
|
||||
const [crewCount, setCrewCount] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/ship')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as ShipState
|
||||
})
|
||||
.then((s) => {
|
||||
if (!cancelled) setShip(s)
|
||||
})
|
||||
.catch(() => {}) // left column + tab keep their loading state on failure
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as CrewDto[]
|
||||
})
|
||||
.then((c) => {
|
||||
if (!cancelled) setCrewCount(c.length)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
@@ -276,11 +276,25 @@ export function MissionControl() {
|
||||
<PanelHeader>
|
||||
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Badge variant="ACTIVE">4 ONLINE</Badge>
|
||||
<Badge variant={ship && ship.systems.some((s) => s.built) ? 'ACTIVE' : 'OFFLINE'}>
|
||||
{ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<StatusGrid systems={shipSystems} />
|
||||
{ship ? (
|
||||
<StatusGrid
|
||||
systems={ship.systems.map((s) => ({
|
||||
name: s.type,
|
||||
status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const),
|
||||
detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '0.5rem 0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel notch="sm">
|
||||
@@ -288,11 +302,17 @@ export function MissionControl() {
|
||||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||
</PanelHeader>
|
||||
<PanelContent>
|
||||
{ship ? (
|
||||
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
||||
// crew roster vs the display cap.
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||
<Progress label="HULL INTEGRITY" value={87} />
|
||||
<Progress label="REACTOR OUTPUT" value={94} />
|
||||
<Progress label="CREW ROSTER / 6" value={50} />
|
||||
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
|
||||
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
|
||||
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||
)}
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
</div>
|
||||
@@ -339,167 +359,19 @@ export function MissionControl() {
|
||||
<WorldMapPanel height={380} />
|
||||
</TabsContent>
|
||||
|
||||
{/* LOCAL SYSTEM TAB */}
|
||||
{/* LOCAL SYSTEM TAB — live data from /api/system */}
|
||||
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
EXPLORABLE WORLDS // FIND THINGS HERE (PLACEHOLDER)
|
||||
</div>
|
||||
{explorablePlanets.flatMap((planet) => [
|
||||
{ name: planet.name, type: planet.type, state: planet.state, depth: 0 },
|
||||
...planet.moons.map((moon) => ({
|
||||
name: moon.name,
|
||||
type: moon.type,
|
||||
state: moon.state,
|
||||
depth: 1,
|
||||
})),
|
||||
]).map((row, i, rows) => (
|
||||
<div
|
||||
key={row.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: row.depth
|
||||
? '0.6rem 0.75rem 0.6rem 1.75rem'
|
||||
: '0.6rem 0.75rem',
|
||||
borderBottom:
|
||||
i < rows.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.68rem',
|
||||
color: row.depth ? 'var(--text-muted)' : 'var(--text-secondary)',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{row.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.57rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{row.type}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
row.state === 'UNEXPLORED'
|
||||
? 'SCANNING'
|
||||
: row.state === 'HAZARD'
|
||||
? 'WARNING'
|
||||
: 'ACTIVE'
|
||||
}
|
||||
>
|
||||
{row.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<LocalSystemPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* CREW TAB */}
|
||||
{/* 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 */}
|
||||
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
||||
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{shipSystemCards.map((system) => (
|
||||
<Card key={system.name}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{system.name}</CardTitle>
|
||||
<Badge variant={system.status} className="ml-auto">
|
||||
{system.level}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
MODULE BAY // PLACEHOLDER READOUT
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-2">
|
||||
{system.actions.map((action) => (
|
||||
<Button key={action} variant="OUTLINE" size="SM">
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} />
|
||||
</TabsContent>
|
||||
|
||||
{/* SKILL TREE TAB */}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
|
||||
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
|
||||
|
||||
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left
|
||||
// column mirrors actions immediately; this panel applies each action's response directly via
|
||||
// onChanged (no polling). Buttons are disabled when unaffordable or capped, using the same
|
||||
// cost constants the API enforces (lib/game/ship-costs.ts).
|
||||
|
||||
interface ShipSystemsPanelProps {
|
||||
ship: ShipState | null
|
||||
narrow?: boolean
|
||||
onChanged: (next: ShipState) => void
|
||||
}
|
||||
|
||||
export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
||||
|
||||
if (!ship) {
|
||||
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 SHIP SYSTEMS…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const resources = totalQty(ship.inventory, 'resource')
|
||||
const parts = totalQty(ship.inventory, 'part')
|
||||
|
||||
async function act(url: string, body: unknown) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
const data = (await res.json()) as ShipState & { error?: string }
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||
onChanged(data) // response is the fresh ship state — apply directly
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{error && (
|
||||
<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' }}>{error.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
|
||||
{ship.systems.map((system) => (
|
||||
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} onAct={act} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemCard({
|
||||
system,
|
||||
resources,
|
||||
parts,
|
||||
busy,
|
||||
onAct,
|
||||
}: {
|
||||
system: ShipSystemDto
|
||||
resources: number
|
||||
parts: number
|
||||
busy: boolean
|
||||
onAct: (url: string, body: unknown) => void
|
||||
}) {
|
||||
const upgradeDisabled = busy || !system.built || system.level >= MAX_LEVEL || resources < upgradeCost(system.level)
|
||||
const expandDisabled = busy || !system.built || system.capacity >= MAX_CAPACITY || resources < expandCost(system.capacity)
|
||||
const buildDisabled = busy || system.built || parts < BUILD_PART_COST
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{system.type}</CardTitle>
|
||||
<Badge variant={system.built ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
|
||||
{system.built ? `LV ${system.level}` : 'OFFLINE'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
|
||||
{system.built ? `LEVEL ${system.level} · CAPACITY ${system.capacity}` : 'NOT INSTALLED'}
|
||||
</p>
|
||||
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
{system.built
|
||||
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
|
||||
: `BUILD ${BUILD_PART_COST}× PARTS`}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-2">
|
||||
{system.built ? (
|
||||
<>
|
||||
<Button variant="OUTLINE" size="SM" disabled={upgradeDisabled} onClick={() => onAct('/api/ship/upgrade', { systemId: system.id })}>
|
||||
UPGRADE
|
||||
</Button>
|
||||
<Button variant="OUTLINE" size="SM" disabled={expandDisabled} onClick={() => onAct('/api/ship/expand', { systemId: system.id })}>
|
||||
EXPAND
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="OUTLINE" size="SM" disabled={buildDisabled} onClick={() => onAct('/api/ship/build', { type: system.type })}>
|
||||
BUILD NEW
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
+156
-1
@@ -1,8 +1,18 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
||||
interface InventoryItemDto { kind: string; name: string; qty: number }
|
||||
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number }
|
||||
interface FoundItem { kind: string; name: string; qty: number }
|
||||
interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown }
|
||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
|
||||
|
||||
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
|
||||
|
||||
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -49,6 +59,151 @@ test('jumping to an adjacent system updates the world state and turn counter', a
|
||||
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||
});
|
||||
|
||||
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
|
||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
||||
|
||||
// Header carries the real system name + turn counter.
|
||||
await expect(page.getByText(`EXPLORABLE WORLDS // ${sys.systemName.toUpperCase()} — TURN ${sys.turn}`)).toBeVisible();
|
||||
// One EXPLORE button per unexplored body (planets and moons alike).
|
||||
const exploreButtons = page.getByRole('button', { name: /\/\/ EXPLORE$/ });
|
||||
await expect(exploreButtons.first()).toBeVisible();
|
||||
await expect(exploreButtons).toHaveCount(sys.planets.filter((p) => !p.explored).length);
|
||||
});
|
||||
|
||||
test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => {
|
||||
const before = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
const target = before.planets.find((p) => !p.explored)!;
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
||||
await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click();
|
||||
|
||||
const after = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
expect(after.turn).toBe(before.turn + 1);
|
||||
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true);
|
||||
|
||||
// UI reflects it: the row badge flipped to SCANNED and its EXPLORE button is gone.
|
||||
await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0);
|
||||
await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible();
|
||||
});
|
||||
|
||||
test('exploration yields finds that accumulate in the inventory', async ({ page }) => {
|
||||
// Scan bodies until one yields something (most scans do; jumping keeps it going if a whole
|
||||
// system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible.
|
||||
const found = new Map<string, number>(); // kind|name -> qty
|
||||
let iterations = 0;
|
||||
while (found.size === 0 && iterations < 60) {
|
||||
iterations++;
|
||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
const unexplored = sys.planets.filter((p) => !p.explored);
|
||||
if (unexplored.length > 0) {
|
||||
const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } });
|
||||
expect(res.status()).toBe(200);
|
||||
for (const item of ((await res.json()) as ExploreResult).outcome) {
|
||||
found.set(`${item.kind}|${item.name}`, item.qty);
|
||||
}
|
||||
} else {
|
||||
// Current system fully scanned — jump somewhere and keep scanning.
|
||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const currentId = world.systems.find((s) => s.current)!.id;
|
||||
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
|
||||
}
|
||||
}
|
||||
expect(found.size).toBeGreaterThan(0);
|
||||
|
||||
// Every find landed in the inventory with at least that qty.
|
||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
for (const [key, qty] of found) {
|
||||
const [kind, name] = key.split('|');
|
||||
expect(sys.inventory.find((i) => i.kind === kind && i.name === name)?.qty).toBeGreaterThanOrEqual(qty);
|
||||
}
|
||||
});
|
||||
|
||||
test('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('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => {
|
||||
// Scan bodies until something is affordable: an upgrade costs `level` resources (at least one),
|
||||
// a build costs three parts. Jumping keeps it going if a whole system is exhausted.
|
||||
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level);
|
||||
const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built) : undefined);
|
||||
|
||||
let iterations = 0;
|
||||
while (!upgradeTarget() && !buildTarget() && 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) {
|
||||
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
}
|
||||
|
||||
const upgrade = upgradeTarget();
|
||||
const build = !upgrade ? buildTarget() : undefined;
|
||||
expect(upgrade ?? build).toBeDefined(); // the bounded scan always finds something to spend on
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||
|
||||
if (upgrade) {
|
||||
const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]');
|
||||
await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled();
|
||||
await card.getByRole('button', { name: 'UPGRADE' }).click();
|
||||
|
||||
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
const row = after.systems.find((s) => s.id === upgrade.id)!;
|
||||
expect(row.level).toBe(upgrade.level + 1);
|
||||
expect(after.turn).toBe(ship.turn + 1);
|
||||
|
||||
// The card badge flipped to the new level and the left column mirrors it.
|
||||
await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible();
|
||||
await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible();
|
||||
} else {
|
||||
const target = build!;
|
||||
const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]');
|
||||
await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
||||
await card.getByRole('button', { name: 'BUILD NEW' }).click();
|
||||
|
||||
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
const row = after.systems.find((s) => s.id === target.id)!;
|
||||
expect(row.built).toBe(true);
|
||||
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 });
|
||||
|
||||
// The card badge flipped OFFLINE → LV 1 and the left column's online count went up.
|
||||
await expect(card.getByText('LV 1')).toBeVisible();
|
||||
await expect(page.getByText(`${after.systems.filter((s) => s.built).length} ONLINE`)).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);
|
||||
|
||||
+46
-5
@@ -8,9 +8,14 @@ import { expect, test } from '@playwright/test';
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
||||
|
||||
// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
||||
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||
|
||||
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
@@ -28,7 +33,30 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||
const jumpRes = await page.request.post('/api/jump', { data: { systemId: targetId } });
|
||||
expect(jumpRes.status()).toBe(200);
|
||||
const worldJumped = (await jumpRes.json()) as WorldState;
|
||||
|
||||
// Exploration baseline: scan one body so explored state + inventory must survive too.
|
||||
// (Earlier specs may have exhausted the current system, so jump until an unscanned body shows up.)
|
||||
let sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
for (let i = 0; !sysBefore.planets.some((p) => !p.explored) && i < 20; i++) {
|
||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const curId = world.systems.find((s) => s.current)!.id;
|
||||
const e = world.edges.find(([a, b]) => a === curId || b === curId)!;
|
||||
const tId = e[0] === curId ? e[1] : e[0];
|
||||
expect((await page.request.post('/api/jump', { data: { systemId: tId } })).status()).toBe(200);
|
||||
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
}
|
||||
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
|
||||
const exploreRes = await page.request.post('/api/explore', { data: { planetId: targetPlanet.id } });
|
||||
expect(exploreRes.status()).toBe(200);
|
||||
const sysExplored = (await exploreRes.json()) as SystemState;
|
||||
|
||||
// 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
|
||||
|
||||
// Ship baseline: the systems (levels/capacities) + cargo hold must survive too.
|
||||
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship 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' });
|
||||
@@ -50,10 +78,23 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
expect(after).toBeDefined();
|
||||
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
||||
|
||||
// Game state survived: same turn counter and jumped-to current system as before the restart.
|
||||
// Game state survived: same turn counter and current system as before the restart.
|
||||
const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
expect(worldAfter.turn).toBe(worldJumped.turn);
|
||||
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||
expect(worldAfter.turn).toBe(sysExplored.turn);
|
||||
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId);
|
||||
|
||||
// Exploration state survived too: the scanned body is still explored.
|
||||
const sysAfter = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
expect(sysAfter.turn).toBe(sysExplored.turn);
|
||||
expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true);
|
||||
|
||||
// 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);
|
||||
|
||||
// Ship state survived too: same systems, levels/capacities and cargo hold.
|
||||
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
expect(shipAfter).toEqual(shipBefore);
|
||||
|
||||
// The page itself serves again with the landing title.
|
||||
await page.goto('/');
|
||||
|
||||
+47
-1
@@ -24,7 +24,8 @@ 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
|
||||
created_at TEXT NOT NULL,
|
||||
schema_version INTEGER -- which save-schema version this row was backfilled up to (NULL = v1)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS solar_systems (
|
||||
@@ -43,6 +44,43 @@ CREATE TABLE IF NOT EXISTS jump_routes (
|
||||
PRIMARY KEY (from_system, to_system)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
system_id INTEGER NOT NULL REFERENCES solar_systems(id),
|
||||
parent_id INTEGER, -- NULL for a planet, set to the parent planet's id for a moon
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
hazard INTEGER NOT NULL DEFAULT 0,
|
||||
explored INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
kind TEXT NOT NULL, -- 'resource' | 'part'
|
||||
name TEXT NOT NULL,
|
||||
qty INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 ship_systems (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
type TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0, -- 0 = not built yet ('built' is derived: level > 0)
|
||||
capacity INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (save_id, type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
@@ -70,6 +108,14 @@ async function open(file: string): Promise<GameDb> {
|
||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||
|
||||
// Idempotent evolution for files created by older code: CREATE TABLE IF NOT EXISTS above cannot
|
||||
// add columns, so check and add any that are missing. This is what makes DB wipes unnecessary —
|
||||
// old saves open fine and get backfilled by ensureSave (see lib/game/save.ts).
|
||||
const saveCols = raw.exec('PRAGMA table_info(saves)')[0]?.values.map((v) => v[1]) ?? [];
|
||||
if (!saveCols.includes('schema_version')) {
|
||||
raw.exec('ALTER TABLE saves ADD COLUMN schema_version INTEGER');
|
||||
}
|
||||
|
||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||
|
||||
// First run: stamp when this DB was created and land the file on disk so the ./db volume
|
||||
|
||||
+54
-1
@@ -1,4 +1,4 @@
|
||||
import { integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { integer, primaryKey, real, sqliteTable, text, uniqueIndex } 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.
|
||||
@@ -11,6 +11,9 @@ export const saves = sqliteTable('saves', {
|
||||
seed: text('seed').notNull(),
|
||||
turn: integer('turn').notNull().default(0),
|
||||
createdAt: text('created_at').notNull(),
|
||||
// Which schema version this save was last brought up to (NULL on saves from before the
|
||||
// column existed = v1). ensureSave runs ordered idempotent backfill steps for anything below.
|
||||
schemaVersion: integer('schema_version'),
|
||||
})
|
||||
|
||||
export const solarSystems = sqliteTable('solar_systems', {
|
||||
@@ -35,6 +38,56 @@ export const jumpRoutes = sqliteTable(
|
||||
(t) => [primaryKey({ columns: [t.fromSystem, t.toSystem] })],
|
||||
)
|
||||
|
||||
// Bodies of a solar system: planets (parent_id NULL) and moons (parent_id = their planet's
|
||||
// id). Generated when the system is generated, so every system has 2–5 planets, some with
|
||||
// 1–3 moons. Insertion order is display order — a moon always follows its parent row.
|
||||
export const planets = sqliteTable('planets', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
systemId: integer('system_id').notNull().references(() => solarSystems.id),
|
||||
parentId: integer('parent_id'), // NULL for a planet, set for a moon
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull(),
|
||||
hazard: integer('hazard', { mode: 'boolean' }).notNull().default(false),
|
||||
explored: integer('explored', { mode: 'boolean' }).notNull().default(false),
|
||||
})
|
||||
|
||||
// Dumb-simple cargo hold on the save: what exploration has found so far. SHIP SYSTEMS (#8)
|
||||
// will spend parts from here later; nothing consumes it yet.
|
||||
export const inventoryItems = sqliteTable('inventory_items', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||
kind: text('kind').notNull(), // 'resource' | 'part'
|
||||
name: text('name').notNull(),
|
||||
qty: integer('qty').notNull().default(1),
|
||||
})
|
||||
|
||||
// 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(),
|
||||
})
|
||||
|
||||
// The ship's systems, one row per known type (see SHIP_TYPES in lib/game/ship-costs.ts).
|
||||
// `built` is derived, not stored: level > 0 means built — unbuilt rows sit at level/capacity 0.
|
||||
export const shipSystems = sqliteTable(
|
||||
'ship_systems',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||
type: text('type').notNull(),
|
||||
level: integer('level').notNull().default(0), // 0 = not built yet
|
||||
capacity: integer('capacity').notNull().default(0),
|
||||
},
|
||||
(t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)],
|
||||
)
|
||||
|
||||
// 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', {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+259
-1
@@ -2,8 +2,13 @@ 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 { eq } from 'drizzle-orm'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import { getWorldState, jumpToSystem, type WorldState } from './save'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
@@ -130,3 +135,256 @@ describe('jumpToSystem success path', () => {
|
||||
expect(again.systems).toHaveLength(after.systems.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSystemState', () => {
|
||||
it('generates bodies for every system on new game; moons nest under a parent planet only', async () => {
|
||||
const { db } = await freshFile()
|
||||
const state = await getSystemState(db)
|
||||
|
||||
// Current (start) system: 2–5 planets, all unexplored yet.
|
||||
const planetRows = state.planets.filter((p) => p.parent === null)
|
||||
expect(planetRows.length).toBeGreaterThanOrEqual(2)
|
||||
expect(planetRows.length).toBeLessThanOrEqual(5)
|
||||
const planetIds = new Set(planetRows.map((p) => p.id))
|
||||
for (const row of state.planets) {
|
||||
if (row.parent !== null) expect(planetIds.has(row.parent)).toBe(true) // moons hang off a planet in the same system
|
||||
expect(row.explored).toBe(false)
|
||||
}
|
||||
|
||||
// Every one of the 7 fresh-save systems got bodies.
|
||||
const counts = db.raw.exec('SELECT system_id, COUNT(*) FROM planets GROUP BY system_id')
|
||||
expect(counts[0].values).toHaveLength(7)
|
||||
for (const [, n] of counts[0].values) expect(Number(n)).toBeGreaterThanOrEqual(2)
|
||||
|
||||
expect(state.inventory).toEqual([]) // nothing found before any scan
|
||||
expect(state.turn).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('explorePlanet', () => {
|
||||
it('rejects unknown planets with 404 and already-explored ones with 409, mutating nothing on failure', async () => {
|
||||
const { db } = await freshFile()
|
||||
const state = await getSystemState(db)
|
||||
const target = state.planets[0]
|
||||
|
||||
await expect(explorePlanet(db, 999_999)).rejects.toMatchObject({ status: 404 })
|
||||
await explorePlanet(db, target.id)
|
||||
await expect(explorePlanet(db, target.id)).rejects.toMatchObject({ status: 409 })
|
||||
|
||||
const after = await getSystemState(db)
|
||||
expect(after.turn).toBe(1) // only the successful scan costs a turn
|
||||
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true)
|
||||
})
|
||||
|
||||
it('marks explored, bumps the turn, lands finds in inventory and writes an event', async () => {
|
||||
const { db } = await freshFile()
|
||||
const state = await getSystemState(db)
|
||||
const target = state.planets[0]
|
||||
|
||||
const result = await explorePlanet(db, target.id)
|
||||
|
||||
expect(result.turn).toBe(1)
|
||||
// Fresh save: inventory is exactly this scan's haul.
|
||||
for (const item of result.outcome) {
|
||||
expect(result.inventory.find((i) => i.kind === item.kind && i.name === item.name)?.qty).toBe(item.qty)
|
||||
}
|
||||
expect(result.inventory.length).toBe(result.outcome.length)
|
||||
|
||||
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
|
||||
expect(rows[0].values).toEqual([['new_game', 0], ['explore', 1]])
|
||||
})
|
||||
|
||||
it('is deterministic per save+planet and accumulates qty across scans (known seed)', async () => {
|
||||
// Hand-built save with a known seed so every outcome is recomputable offline.
|
||||
const { db } = await freshFile()
|
||||
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: new Date().toISOString() }).returning()
|
||||
const [sys] = await db.db
|
||||
.insert(schema.solarSystems)
|
||||
.values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true })
|
||||
.returning({ id: schema.solarSystems.id })
|
||||
|
||||
const planetIds: number[] = []
|
||||
for (const body of generatePlanets(bodyRng('42', sys.id))) {
|
||||
const [p] = await db.db
|
||||
.insert(schema.planets)
|
||||
.values({ systemId: sys.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard })
|
||||
.returning({ id: schema.planets.id })
|
||||
planetIds.push(p.id)
|
||||
}
|
||||
|
||||
const expected = planetIds.map((id) => rollExploration(explorationRng('42', id)))
|
||||
for (let i = 0; i < planetIds.length; i++) {
|
||||
const result = await explorePlanet(db, planetIds[i])
|
||||
expect(result.outcome).toEqual(expected[i]) // same save+planet → same haul
|
||||
}
|
||||
|
||||
// Inventory accumulates: qty per kind+name equals the sum over all scans.
|
||||
const totals = new Map<string, number>()
|
||||
for (const items of expected) {
|
||||
for (const it of items) totals.set(`${it.kind}|${it.name}`, (totals.get(`${it.kind}|${it.name}`) ?? 0) + it.qty)
|
||||
}
|
||||
const state = await getSystemState(db)
|
||||
expect(state.inventory.length).toBe(totals.size)
|
||||
for (const item of state.inventory) {
|
||||
expect(item.qty).toBe(totals.get(`${item.kind}|${item.name}`))
|
||||
}
|
||||
expect(state.turn).toBe(planetIds.length) // one turn per scan
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('backfill (old saves)', () => {
|
||||
// Planets/moons of one system in insertion order, nested by parent — ids are excluded so a
|
||||
// regenerated set compares equal to the original even though autoincrement moved on.
|
||||
function bodyShape(rows: { id: number; name: string; type: string; hazard: boolean; parentId: number | null }[]) {
|
||||
return rows
|
||||
.filter((r) => r.parentId === null)
|
||||
.map((p) => ({ name: p.name, type: p.type, hazard: p.hazard, moons: rows.filter((m) => m.parentId === p.id).map((m) => [m.name, m.type, m.hazard]) }))
|
||||
}
|
||||
|
||||
it('upgrades a v1 save (no bodies, no crew) in place — deterministic and idempotent', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db) // new game: everything seeded at the current version
|
||||
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
const rowsFor = (systemId: number) => db.db.select().from(schema.planets).where(eq(schema.planets.systemId, systemId))
|
||||
const beforeBodies: Record<number, unknown> = {}
|
||||
for (const s of systems) {
|
||||
const rows = await rowsFor(s.id)
|
||||
beforeBodies[s.id] = bodyShape(rows.sort((a, b) => a.id - b.id))
|
||||
}
|
||||
const crewOf = (rows: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }[]) =>
|
||||
rows.map((c) => ({ name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) // ids excluded — re-seed moves autoincrement on
|
||||
const beforeCrew = crewOf(await db.db.select().from(schema.crew))
|
||||
const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) =>
|
||||
rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on
|
||||
const beforeShip = shipOf(await db.db.select().from(schema.shipSystems))
|
||||
|
||||
// Simulate a save created by pre-v0.4 code: no bodies, no crew, no ship, no version stamp (NULL).
|
||||
await db.db.delete(schema.planets)
|
||||
await db.db.delete(schema.crew)
|
||||
await db.db.delete(schema.shipSystems)
|
||||
await db.db.update(schema.saves).set({ schemaVersion: null })
|
||||
|
||||
const world = await getWorldState(db) // ensureSave → backfill runs
|
||||
expect(world.systems).toHaveLength(7)
|
||||
|
||||
for (const s of systems) {
|
||||
const rows = await rowsFor(s.id)
|
||||
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id]) // identical to fresh generation
|
||||
}
|
||||
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
|
||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||
|
||||
const [save] = await db.db.select().from(schema.saves)
|
||||
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||
|
||||
// Idempotent: a second pass changes nothing.
|
||||
const planetCount = (await db.db.select().from(schema.planets)).length
|
||||
await getWorldState(db)
|
||||
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
|
||||
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
|
||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||
for (const s of systems) {
|
||||
const rows = await rowsFor(s.id)
|
||||
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves an up-to-date save untouched', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db)
|
||||
const planetCount = (await db.db.select().from(schema.planets)).length
|
||||
const [save] = await db.db.select().from(schema.saves)
|
||||
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||
|
||||
await getWorldState(db) // second read: no backfill work to do
|
||||
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
|
||||
})
|
||||
|
||||
it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db)
|
||||
const rows = (await db.db.select().from(schema.shipSystems)).sort((a, b) => a.type.localeCompare(b.type))
|
||||
expect(rows).toHaveLength(SHIP_TYPES.length)
|
||||
for (const def of SHIP_TYPES) {
|
||||
const row = rows.find((r) => r.type === def.type)!
|
||||
if (def.builtAtStart) {
|
||||
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
|
||||
} else {
|
||||
expect(row.level).toBe(0) // unbuilt until a BUILD action
|
||||
expect(row.capacity).toBe(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+219
-16
@@ -2,7 +2,9 @@ import { eq } from 'drizzle-orm'
|
||||
import type { GameDb } from '@/lib/db/client'
|
||||
import { persist } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { generateRing, generateStartRegion, mulberry32 } from './world'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
||||
|
||||
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
||||
// Single-player by design — exactly one save row ever exists, so every query below is
|
||||
@@ -44,12 +46,34 @@ function expansionRng(seed: string, turn: number): () => number {
|
||||
return mulberry32((Number(seed) + turn * 1_000_003) >>> 0)
|
||||
}
|
||||
|
||||
async function insertSystems(db: GameDb, saveId: number, systems: { name: string; x: number; y: number }[], discovered: boolean): Promise<Map<string, number>> {
|
||||
const rows = await db.db
|
||||
.insert(schema.solarSystems)
|
||||
.values(systems.map((s) => ({ saveId, name: s.name, x: s.x, y: s.y, discovered })))
|
||||
.returning({ id: schema.solarSystems.id, name: schema.solarSystems.name })
|
||||
return new Map(rows.map((r) => [r.name, r.id]))
|
||||
// The bodies of one system (planets + moons), derived from (seed, system id) via bodyRng —
|
||||
// independent of generation order and every other system. Shared by new-game creation and the
|
||||
// v2 backfill so both produce identical rows for a given save+system.
|
||||
async function insertBodiesForSystem(db: GameDb, systemId: number, seed: string): Promise<void> {
|
||||
for (const body of generatePlanets(bodyRng(seed, systemId))) {
|
||||
const [planet] = await db.db.insert(schema.planets).values({ systemId, 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, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inserts one system together with its generated bodies, so a system is never left without
|
||||
// worlds to explore.
|
||||
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 })
|
||||
await insertBodiesForSystem(db, row.id, seed)
|
||||
return row.id
|
||||
}
|
||||
|
||||
// v2 backfill: every system without planets gets its generated bodies. Idempotent — systems
|
||||
// that already have rows are skipped, so a second run changes nothing.
|
||||
async function ensureBodiesForAllSystems(db: GameDb, saveId: number, seed: string): Promise<void> {
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
for (const system of systems) {
|
||||
const rows = await db.db.select({ id: schema.planets.id }).from(schema.planets).where(eq(schema.planets.systemId, system.id))
|
||||
if (rows.length === 0) await insertBodiesForSystem(db, system.id, seed)
|
||||
}
|
||||
}
|
||||
|
||||
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
|
||||
@@ -61,29 +85,83 @@ async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (ind
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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' })
|
||||
}
|
||||
}
|
||||
|
||||
// What each schema version added to a save. Saves created by older code lack the data newer
|
||||
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
|
||||
// current — each step is idempotent (a no-op when its data already exists). When #9/#10 add
|
||||
// seeding, bump SCHEMA_VERSION and append a step here.
|
||||
export const SCHEMA_VERSION = 4 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship
|
||||
|
||||
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
||||
// unbuilt (level/capacity 0) until a BUILD action.
|
||||
async function seedStartingShip(db: GameDb, saveId: number): Promise<void> {
|
||||
for (const def of SHIP_TYPES) {
|
||||
await db.db.insert(schema.shipSystems).values({ saveId, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
|
||||
// Safe to call on every request (single player, one process: check-then-insert cannot race).
|
||||
export async function ensureSave(db: GameDb): Promise<void> {
|
||||
const existing = await db.db.select().from(schema.saves).limit(1)
|
||||
if (existing.length > 0) return
|
||||
if (existing.length === 0) {
|
||||
await createNewGame(db)
|
||||
return
|
||||
}
|
||||
await backfillSave(db, existing[0])
|
||||
}
|
||||
|
||||
async function createNewGame(db: GameDb): Promise<void> {
|
||||
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()
|
||||
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso, schemaVersion: SCHEMA_VERSION }).returning()
|
||||
// All inserted as undiscovered first; the start is then marked discovered + current.
|
||||
const ids = await insertSystems(db, save.id, [region.start, ...region.neighbors], false)
|
||||
const startId = ids.get(region.start.name)!
|
||||
const startId = await insertSystemWithBodies(db, save.id, region.start, seed)
|
||||
const neighborIds: number[] = []
|
||||
for (const n of region.neighbors) neighborIds.push(await insertSystemWithBodies(db, save.id, n, seed))
|
||||
await db.db.update(schema.solarSystems).set({ discovered: true, isCurrent: true }).where(eq(schema.solarSystems.id, startId))
|
||||
|
||||
const resolve = (index: number) => (index === 0 ? startId : ids.get(region.neighbors[index - 1].name)!)
|
||||
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
|
||||
await insertRoutes(db, region.edges, resolve)
|
||||
await seedStartingCrew(db, save.id, seed)
|
||||
await seedStartingShip(db, save.id)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Ordered idempotent backfill for a save created by older code. A NULL version means v1.
|
||||
async function backfillSave(db: GameDb, save: { id: number; seed: string; schemaVersion: number | null }): Promise<void> {
|
||||
const from = save.schemaVersion ?? 1
|
||||
let version = from
|
||||
|
||||
if (version < 2) {
|
||||
await ensureBodiesForAllSystems(db, save.id, save.seed) // bodies derive from (seed, system id) — identical to fresh generation
|
||||
version = 2
|
||||
}
|
||||
if (version < 3) {
|
||||
if ((await db.db.select().from(schema.crew)).length === 0) await seedStartingCrew(db, save.id, save.seed)
|
||||
version = 3
|
||||
}
|
||||
if (version < 4) {
|
||||
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
|
||||
version = 4
|
||||
}
|
||||
|
||||
if (from !== SCHEMA_VERSION) {
|
||||
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
||||
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)
|
||||
@@ -134,8 +212,9 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
|
||||
const rng = expansionRng(save.seed, turn)
|
||||
const takenNames = new Set(systems.map((s) => s.name))
|
||||
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
|
||||
const ids = await insertSystems(db, save.id, ring.systems, false)
|
||||
const resolve = (index: number) => (index === 0 ? target.id : ids.get(ring.systems[index - 1].name)!)
|
||||
const ringIds: number[] = []
|
||||
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
|
||||
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
|
||||
await insertRoutes(db, ring.edges, resolve)
|
||||
}
|
||||
|
||||
@@ -145,3 +224,127 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
|
||||
|
||||
return getWorldState(db)
|
||||
}
|
||||
|
||||
// --- Local system + exploration ---------------------------------------------
|
||||
|
||||
export interface PlanetDto {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
hazard: boolean
|
||||
explored: boolean
|
||||
parent: number | null // NULL for a planet, the parent planet's id for a moon
|
||||
}
|
||||
|
||||
export interface InventoryItemDto {
|
||||
kind: 'resource' | 'part'
|
||||
name: string
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface SystemState {
|
||||
systemId: number
|
||||
systemName: string
|
||||
planets: PlanetDto[] // insertion order = display order (a moon follows its parent row)
|
||||
inventory: InventoryItemDto[]
|
||||
turn: number
|
||||
}
|
||||
|
||||
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||
export class ExploreError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function readSystemState(db: GameDb): Promise<SystemState> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
const current = systems.find((s) => s.isCurrent)!
|
||||
const planets = (await db.db.select().from(schema.planets).where(eq(schema.planets.systemId, current.id))).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
hazard: p.hazard,
|
||||
explored: p.explored,
|
||||
parent: p.parentId,
|
||||
}))
|
||||
const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty }))
|
||||
return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn }
|
||||
}
|
||||
|
||||
export async function getSystemState(db: GameDb): Promise<SystemState> {
|
||||
await ensureSave(db)
|
||||
return readSystemState(db)
|
||||
}
|
||||
|
||||
// --- 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,48 @@
|
||||
// Pure ship-system model: the known system types, base values and the action cost model.
|
||||
// No DB imports — both lib/game/ship.ts (server) and the frontend import from here so the
|
||||
// UI's affordability/cap logic can never drift from what the API enforces.
|
||||
|
||||
export interface ShipTypeDef {
|
||||
type: string
|
||||
builtAtStart: boolean // part of the starting ship? (otherwise unbuilt until BUILD)
|
||||
}
|
||||
|
||||
// The known system types — display order. Reuses the placeholder SHIP SYSTEMS tab card names
|
||||
// for continuity, plus HULL so the vessel-metrics hull bar can be driven by a real level.
|
||||
export const SHIP_TYPES: ShipTypeDef[] = [
|
||||
{ type: 'HULL', builtAtStart: true },
|
||||
{ type: 'REACTOR', builtAtStart: true },
|
||||
{ type: 'LIFE SUPPORT', builtAtStart: true },
|
||||
{ type: 'NAV COMPUTER', builtAtStart: true },
|
||||
{ type: 'COMMS ARRAY', builtAtStart: true },
|
||||
{ type: 'WEAPONS', builtAtStart: false },
|
||||
]
|
||||
|
||||
export const BASE_LEVEL = 1 // what a system starts at when built (starting ship or BUILD)
|
||||
export const BASE_CAPACITY = 2
|
||||
export const MAX_LEVEL = 5 // UPGRADE cap
|
||||
export const MAX_CAPACITY = 10 // EXPAND cap
|
||||
|
||||
// Display-only cap for the VESSEL METRICS crew roster bar (no gameplay effect yet).
|
||||
export const CREW_CAPACITY = 6
|
||||
|
||||
// Cost model — all costs are in inventory items, consumed from the oldest cargo row of the
|
||||
// required kind first (see spendItems in lib/game/ship.ts):
|
||||
// UPGRADE {systemId} — `level` units of any resource (scales with current level)
|
||||
// EXPAND {systemId} — `capacity` units of any resource (scales with current capacity)
|
||||
// BUILD {type} — a flat amount of parts (parts are rarer than resources, so builds
|
||||
// are the bigger investment)
|
||||
export function upgradeCost(level: number): number {
|
||||
return level
|
||||
}
|
||||
|
||||
export function expandCost(capacity: number): number {
|
||||
return capacity
|
||||
}
|
||||
|
||||
export const BUILD_PART_COST = 3
|
||||
|
||||
// Total qty of one kind across the cargo hold — used for affordability checks.
|
||||
export function totalQty(items: { kind: string; qty: number }[], kind: 'resource' | 'part'): number {
|
||||
return items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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 { eq } from 'drizzle-orm'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { buildSystem, expandSystem, getShipState, upgradeSystem, type ShipState } from './ship'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-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 })
|
||||
})
|
||||
|
||||
async function addItem(db: GameDb, kind: 'resource' | 'part', name: string, qty: number): Promise<void> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind, name, qty })
|
||||
}
|
||||
|
||||
const systemOf = (state: ShipState, type: string): ShipState['systems'][number] => state.systems.find((s) => s.type === type)!
|
||||
|
||||
describe('getShipState', () => {
|
||||
it('new game: starting ship in display order — core types built at base values, WEAPONS unbuilt', async () => {
|
||||
const { db } = await freshFile()
|
||||
const state = await getShipState(db)
|
||||
|
||||
expect(state.systems.map((s) => s.type)).toEqual(SHIP_TYPES.map((t) => t.type)) // display order follows SHIP_TYPES
|
||||
for (const def of SHIP_TYPES) {
|
||||
const row = systemOf(state, def.type)
|
||||
if (def.builtAtStart) {
|
||||
expect(row.built).toBe(true)
|
||||
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
|
||||
} else {
|
||||
expect(row.built).toBe(false) // unbuilt until a BUILD action
|
||||
expect(row.level).toBe(0)
|
||||
expect(row.capacity).toBe(0)
|
||||
}
|
||||
}
|
||||
expect(state.inventory).toEqual([])
|
||||
expect(state.turn).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upgradeSystem', () => {
|
||||
it('rejects with 422 and mutates nothing when the hold is empty', async () => {
|
||||
const { db } = await freshFile()
|
||||
const before = await getShipState(db)
|
||||
const reactorId = systemOf(before, 'REACTOR').id
|
||||
|
||||
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 422 })
|
||||
|
||||
const after = await getShipState(db)
|
||||
expect(systemOf(after, 'REACTOR').level).toBe(BASE_LEVEL) // no level change…
|
||||
expect(after.turn).toBe(before.turn) // …and no turn spent on a failed action
|
||||
})
|
||||
|
||||
it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await expect(upgradeSystem(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||
const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id
|
||||
await expect(upgradeSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
|
||||
it('consumes the cost from the oldest resource row first (deleting rows that hit zero), costs a turn and writes an event', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await addItem(db, 'resource', 'IRON ORE', 1) // oldest — fully consumed by the cost of 1
|
||||
await addItem(db, 'resource', 'CRYSTAL DUST', 5)
|
||||
|
||||
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
|
||||
const state = await upgradeSystem(db, reactorId) // level 1 → cost 1 resource
|
||||
|
||||
expect(systemOf(state, 'REACTOR').level).toBe(BASE_LEVEL + 1)
|
||||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 5]]) // IRON ORE row deleted
|
||||
expect(state.turn).toBe(1)
|
||||
|
||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'upgrade')!
|
||||
expect(event.turn).toBe(1)
|
||||
expect(event.message).toContain('REACTOR')
|
||||
})
|
||||
|
||||
it('caps at MAX_LEVEL with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
|
||||
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) // cost path covered above
|
||||
|
||||
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 })
|
||||
expect((await getShipState(db)).turn).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandSystem', () => {
|
||||
it('raises capacity by one for `capacity` resources; caps at MAX_CAPACITY with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await addItem(db, 'resource', 'IRON ORE', BASE_CAPACITY + 1) // cost is the current capacity (2), one left over
|
||||
|
||||
const hullId = systemOf(await getShipState(db), 'HULL').id
|
||||
const state = await expandSystem(db, hullId)
|
||||
expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1)
|
||||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]])
|
||||
|
||||
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
|
||||
await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
|
||||
it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await expect(expandSystem(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||
const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id
|
||||
await expect(expandSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSystem', () => {
|
||||
it('builds an unbuilt type at base values for a flat parts cost; a second build is 409 and unknown types are 404', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
|
||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 422 }) // no parts in the hold yet
|
||||
|
||||
await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST)
|
||||
const state = await buildSystem(db, 'WEAPONS')
|
||||
const weapons = systemOf(state, 'WEAPONS')
|
||||
expect(weapons.built).toBe(true)
|
||||
expect({ level: weapons.level, capacity: weapons.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
|
||||
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // parts row deleted
|
||||
expect(state.turn).toBe(1)
|
||||
|
||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
|
||||
expect(event.message).toContain('WEAPONS')
|
||||
|
||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
|
||||
await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { persist, type GameDb } from '@/lib/db/client'
|
||||
import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs'
|
||||
import { ensureSave, type InventoryItemDto } from './save'
|
||||
|
||||
export interface ShipSystemDto {
|
||||
id: number
|
||||
type: string
|
||||
level: number
|
||||
capacity: number
|
||||
built: boolean // derived — level > 0 (unbuilt rows sit at level/capacity 0)
|
||||
}
|
||||
|
||||
// The ship's full state for the SHIP SYSTEMS tab and left column.
|
||||
export interface ShipState {
|
||||
systems: ShipSystemDto[] // display order follows SHIP_TYPES
|
||||
inventory: InventoryItemDto[]
|
||||
turn: number
|
||||
}
|
||||
|
||||
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||
export class ShipError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function readShipState(db: GameDb): Promise<ShipState> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const rows = await db.db.select().from(schema.shipSystems)
|
||||
const orderOf = (type: string) => {
|
||||
const i = SHIP_TYPES.findIndex((t) => t.type === type)
|
||||
return i === -1 ? 99 : i // unknown types (shouldn't exist) sort last
|
||||
}
|
||||
const systems = [...rows]
|
||||
.sort((a, b) => orderOf(a.type) - orderOf(b.type))
|
||||
.map((r) => ({ id: r.id, type: r.type, level: r.level, capacity: r.capacity, built: r.level > 0 }))
|
||||
const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty }))
|
||||
return { systems, inventory, turn: save.turn }
|
||||
}
|
||||
|
||||
// The ship's systems + cargo hold + turn. Auto-initializes like the other reads.
|
||||
export async function getShipState(db: GameDb): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// Consume `amount` units of one kind from the oldest cargo rows first (id order); rows that hit
|
||||
// zero are deleted. The total is checked up front, so an insufficient hold mutates nothing.
|
||||
async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise<void> {
|
||||
const rows = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.kind === kind).sort((a, b) => a.id - b.id)
|
||||
if (rows.reduce((sum, r) => sum + r.qty, 0) < amount) {
|
||||
throw new ShipError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
|
||||
}
|
||||
let remaining = amount
|
||||
for (const row of rows) {
|
||||
if (remaining <= 0) break
|
||||
const take = Math.min(row.qty, remaining)
|
||||
remaining -= take
|
||||
if (take >= row.qty) await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.id, row.id))
|
||||
else await db.db.update(schema.inventoryItems).set({ qty: row.qty - take }).where(eq(schema.inventoryItems.id, row.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type: string, message: string): Promise<void> {
|
||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, saveId))
|
||||
await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() })
|
||||
}
|
||||
|
||||
// UPGRADE — level up a built system by one (capped at MAX_LEVEL). Cost: `level` units of any resource.
|
||||
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
||||
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
||||
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
||||
if (target.level >= MAX_LEVEL) throw new ShipError(409, `${target.type} is at max level (${MAX_LEVEL})`)
|
||||
|
||||
const cost = upgradeCost(target.level)
|
||||
await spendItems(db, save.id, 'resource', cost)
|
||||
const turn = save.turn + 1
|
||||
await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id))
|
||||
await bumpTurnAndEvent(db, save.id, turn, 'upgrade', `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`)
|
||||
await persist(db)
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// EXPAND — raise a built system's capacity by one (capped at MAX_CAPACITY). Cost: `capacity` units of any resource.
|
||||
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
||||
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
||||
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
||||
if (target.capacity >= MAX_CAPACITY) throw new ShipError(409, `${target.type} is at max capacity (${MAX_CAPACITY})`)
|
||||
|
||||
const cost = expandCost(target.capacity)
|
||||
await spendItems(db, save.id, 'resource', cost)
|
||||
const turn = save.turn + 1
|
||||
await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id))
|
||||
await bumpTurnAndEvent(db, save.id, turn, 'expand', `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`)
|
||||
await persist(db)
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
|
||||
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
|
||||
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
|
||||
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
|
||||
|
||||
await spendItems(db, save.id, 'part', BUILD_PART_COST)
|
||||
const turn = save.turn + 1
|
||||
await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id))
|
||||
await bumpTurnAndEvent(db, save.id, turn, 'build', `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`)
|
||||
await persist(db)
|
||||
return readShipState(db)
|
||||
}
|
||||
+56
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateRing, generateStartRegion, makeName, mulberry32 } from './world'
|
||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world'
|
||||
|
||||
describe('generateStartRegion', () => {
|
||||
const region = generateStartRegion(mulberry32(42))
|
||||
@@ -77,3 +77,58 @@ describe('generateRing (expansion)', () => {
|
||||
expect(taken.size).toBe(40)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generatePlanets', () => {
|
||||
it('gives every system 2–5 planets and keeps body names unique within the system', () => {
|
||||
for (const seed of [1, 7, 42, 999]) {
|
||||
const planets = generatePlanets(bodyRng(String(seed), seed))
|
||||
expect(planets.length).toBeGreaterThanOrEqual(2)
|
||||
expect(planets.length).toBeLessThanOrEqual(5)
|
||||
|
||||
const names: string[] = []
|
||||
for (const p of planets) {
|
||||
names.push(p.name, ...p.moons.map((m) => m.name))
|
||||
expect(['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']).toContain(p.type)
|
||||
for (const m of p.moons) {
|
||||
expect(['MOON', 'ICE MOON', 'VOLCANIC MOON']).toContain(m.type)
|
||||
}
|
||||
}
|
||||
expect(new Set(names).size).toBe(names.length) // planets + moons share one name pool per system
|
||||
}
|
||||
})
|
||||
|
||||
it('is deterministic per (seed, system id) and differs across systems', () => {
|
||||
const a = generatePlanets(bodyRng('42', 7))
|
||||
expect(generatePlanets(bodyRng('42', 7))).toEqual(a) // same save+system → same bodies
|
||||
const b = generatePlanets(bodyRng('43', 7))
|
||||
expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different world
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollExploration', () => {
|
||||
it('is deterministic for the same save+planet and yields valid items', () => {
|
||||
const first = rollExploration(explorationRng('42', 13))
|
||||
expect(rollExploration(explorationRng('42', 13))).toEqual(first)
|
||||
|
||||
for (const item of first) {
|
||||
expect(['resource', 'part']).toContain(item.kind)
|
||||
if (item.kind === 'resource') {
|
||||
expect(item.qty).toBeGreaterThanOrEqual(1)
|
||||
expect(item.qty).toBeLessThanOrEqual(4)
|
||||
} else {
|
||||
expect(item.qty).toBe(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('across many planets produces every outcome shape: resource, part, both, or nothing', () => {
|
||||
const shapes = new Set<string>()
|
||||
for (let planetId = 1; planetId <= 200; planetId++) {
|
||||
const found = rollExploration(explorationRng('42', planetId))
|
||||
const hasResource = found.some((f) => f.kind === 'resource')
|
||||
const hasPart = found.some((f) => f.kind === 'part')
|
||||
shapes.add(`${hasResource ? 'r' : '-'}${hasPart ? 'p' : '-'}`)
|
||||
}
|
||||
expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,3 +123,105 @@ export function generateRing(
|
||||
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
|
||||
return { systems, edges: ringEdges(systems.length) }
|
||||
}
|
||||
|
||||
// --- Bodies (planets + moons) and exploration -------------------------------
|
||||
|
||||
export interface WorldMoon {
|
||||
name: string
|
||||
type: string
|
||||
hazard: boolean
|
||||
}
|
||||
|
||||
export interface WorldPlanet {
|
||||
name: string
|
||||
type: string
|
||||
hazard: boolean
|
||||
moons: WorldMoon[]
|
||||
}
|
||||
|
||||
// Derive an independent PRNG stream for one entity from the save seed, so a system's bodies
|
||||
// and a planet's exploration haul are reproducible offline (tests recompute them directly).
|
||||
export function bodyRng(seed: string, systemId: number): () => number {
|
||||
return mulberry32((Number(seed) + Math.imul(systemId, 0x9e3779b1)) >>> 0)
|
||||
}
|
||||
|
||||
export function explorationRng(seed: string, planetId: number): () => number {
|
||||
return mulberry32((Number(seed) + Math.imul(planetId, 0x85ebca6b)) >>> 0)
|
||||
}
|
||||
|
||||
const BODY_NAMES = [
|
||||
'AURELLE', 'BRIMSTONE', 'CINDERROCK', 'DRIFTBAND', 'PALEMOON', 'ASHFALL', 'VESPERINE', 'KORR',
|
||||
'MIRELLA', 'SABLET', 'EMBERLING', 'FROSTHOLD', 'GALEN', 'IRONVEIN', 'JUNO', 'KESTREL',
|
||||
'LUMEN', 'NADIR', 'OPALINE', 'PYRE', 'QUILL', 'RUSTBARK', 'SIROCCO', 'TARN',
|
||||
'UMBRA', 'VELVETINE', 'WYRM', 'ZEPHYR', 'HALCYON', 'MORROW', 'ONYX', 'THALASSA',
|
||||
]
|
||||
|
||||
const PLANET_TYPES = ['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']
|
||||
const MOON_TYPES = ['MOON', 'ICE MOON', 'VOLCANIC MOON']
|
||||
|
||||
const HAZARD_CHANCE = 0.25
|
||||
const MOON_CHANCE = 0.6 // a planet has any moons at all
|
||||
const PLANET_COUNT_MIN = 2
|
||||
const PLANET_COUNT_SPREAD = 4 // 2..5 planets per system
|
||||
|
||||
// A body name unique within its system (planets and moons share one pool). Max bodies per
|
||||
// system is 5 + 5*3 = 20 < BODY_NAMES.length, so the loop always terminates.
|
||||
function makeBodyName(rng: () => number, taken: Set<string>): string {
|
||||
for (;;) {
|
||||
const name = title(BODY_NAMES[Math.floor(rng() * BODY_NAMES.length)])
|
||||
if (!taken.has(name)) {
|
||||
taken.add(name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, arr: T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]
|
||||
}
|
||||
|
||||
// The bodies of one system: 2–5 planets, some with 1–3 moons. Pure — the caller derives the
|
||||
// rng from (save seed, system id) so a given system always gets the same bodies.
|
||||
export function generatePlanets(rng: () => number): WorldPlanet[] {
|
||||
const taken = new Set<string>()
|
||||
const planets: WorldPlanet[] = []
|
||||
for (let i = 0; i < PLANET_COUNT_MIN + Math.floor(rng() * PLANET_COUNT_SPREAD); i++) {
|
||||
const name = makeBodyName(rng, taken)
|
||||
const type = pick(rng, PLANET_TYPES)
|
||||
const hazard = rng() < HAZARD_CHANCE
|
||||
const moons: WorldMoon[] = []
|
||||
if (rng() < MOON_CHANCE) {
|
||||
for (let m = 0; m < 1 + Math.floor(rng() * 3); m++) {
|
||||
moons.push({ name: makeBodyName(rng, taken), type: pick(rng, MOON_TYPES), hazard: rng() < HAZARD_CHANCE })
|
||||
}
|
||||
}
|
||||
planets.push({ name, type, hazard, moons })
|
||||
}
|
||||
return planets
|
||||
}
|
||||
|
||||
export interface FoundItem {
|
||||
kind: 'resource' | 'part'
|
||||
name: string
|
||||
qty: number
|
||||
}
|
||||
|
||||
const RESOURCE_NAMES = ['IRON ORE', 'CRYSTAL SHARDS', 'GAS CELLS', 'WATER ICE', 'RARE EARTH']
|
||||
const PART_NAMES = ['NAV MODULE', 'HULL PLATE', 'REACTOR CORE', 'COMMS ARRAY', 'ENGINE TUNING']
|
||||
|
||||
const RESOURCE_CHANCE = 0.7
|
||||
const PART_CHANCE = 0.3 // parts are rarer than resources
|
||||
|
||||
// The haul from scanning one body: a resource and/or a part, or nothing (~21% of scans).
|
||||
// Pure — the caller derives the rng from (save seed, planet id), so the same save+planet
|
||||
// always yields the same outcome.
|
||||
export function rollExploration(rng: () => number): FoundItem[] {
|
||||
const found: FoundItem[] = []
|
||||
if (rng() < RESOURCE_CHANCE) {
|
||||
found.push({ kind: 'resource', name: pick(rng, RESOURCE_NAMES), qty: 1 + Math.floor(rng() * 4) }) // 1..4
|
||||
}
|
||||
if (rng() < PART_CHANCE) {
|
||||
found.push({ kind: 'part', name: pick(rng, PART_NAMES), qty: 1 })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
+61
-1
@@ -4,7 +4,67 @@ import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents every public endpoint', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/jump', '/api/version', '/api/world']);
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/crew',
|
||||
'/api/explore',
|
||||
'/api/jump',
|
||||
'/api/ship',
|
||||
'/api/ship/build',
|
||||
'/api/ship/expand',
|
||||
'/api/ship/upgrade',
|
||||
'/api/system',
|
||||
'/api/version',
|
||||
'/api/world',
|
||||
]);
|
||||
});
|
||||
|
||||
it('describes the ship endpoints as ShipState in and out, with error responses on the actions', () => {
|
||||
const ship = openApiSpec.paths['/api/ship'].get.responses[200].content['application/json'].schema;
|
||||
expect(ship.$ref).toBe('#/components/schemas/ShipState');
|
||||
|
||||
const system = openApiSpec.components.schemas.ShipSystem.properties;
|
||||
expect(Object.keys(system)).toEqual(['id', 'type', 'level', 'capacity', 'built']);
|
||||
|
||||
for (const [path, request] of [
|
||||
['/api/ship/upgrade', '#/components/schemas/UpgradeRequest'],
|
||||
['/api/ship/expand', '#/components/schemas/ExpandRequest'],
|
||||
['/api/ship/build', '#/components/schemas/BuildRequest'],
|
||||
] as const) {
|
||||
const post = openApiSpec.paths[path].post;
|
||||
expect(post.requestBody.content['application/json'].schema.$ref).toBe(request);
|
||||
expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ShipState');
|
||||
for (const status of ['400', '404', '409', '422'] as const) {
|
||||
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
+407
@@ -100,6 +100,284 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/system': {
|
||||
get: {
|
||||
summary: 'Current system state',
|
||||
description:
|
||||
'The bodies of the current solar system — planets and their moons (nested via parent) with hazard/explored flags — plus the save inventory and turn counter. Auto-initializes a new game when no save exists yet.',
|
||||
operationId: 'getSystem',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current system state',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/SystemState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/explore': {
|
||||
post: {
|
||||
summary: 'Explore a planet or moon',
|
||||
description:
|
||||
'Scans one body of the save with a deterministic haul (a resource and/or a part, or nothing), marks it explored, increments the turn counter, accumulates finds into inventory and writes an explore event. Returns the fresh system state plus this scan outcome.',
|
||||
operationId: 'explorePlanet',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ExploreRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Fresh system state plus the scan outcome',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ExploreResult' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Malformed body or non-integer planetId',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown planet id',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: 'Body was already explored (each can only be scanned once)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/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' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/ship': {
|
||||
get: {
|
||||
summary: 'Ship state',
|
||||
description:
|
||||
"The ship's systems (built flag derived from level), the cargo hold and the turn counter. The starting ship is seeded on new game — core types built at base values, the rest unbuilt until a build action. Auto-initializes a new game when no save exists yet.",
|
||||
operationId: 'getShip',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current ship state',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ShipState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/ship/upgrade': {
|
||||
post: {
|
||||
summary: 'Upgrade a ship system',
|
||||
description:
|
||||
"Level up a built ship system by one (capped at max level). Costs `level` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.",
|
||||
operationId: 'upgradeShipSystem',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/UpgradeRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Fresh ship state after the upgrade',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ShipState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Malformed body or non-integer systemId',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown ship system id',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: 'System is not built or already at max level',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: {
|
||||
description: 'Not enough resources in the cargo hold for the cost',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/ship/expand': {
|
||||
post: {
|
||||
summary: 'Expand a ship system',
|
||||
description:
|
||||
"Raise a built ship system's capacity by one (capped at max capacity). Costs `capacity` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.",
|
||||
operationId: 'expandShipSystem',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ExpandRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Fresh ship state after the expansion',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ShipState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Malformed body or non-integer systemId',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown ship system id',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: 'System is not built or already at max capacity',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: {
|
||||
description: 'Not enough resources in the cargo hold for the cost',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/ship/build': {
|
||||
post: {
|
||||
summary: 'Build a ship system',
|
||||
description:
|
||||
'Build an unbuilt ship system type at base values. Costs a flat amount of parts, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.',
|
||||
operationId: 'buildShipSystem',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/BuildRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Fresh ship state after the build',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ShipState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Malformed body or empty type',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown ship system type',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: 'System is already built',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: {
|
||||
description: 'Not enough parts in the cargo hold for the cost',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
@@ -147,6 +425,135 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['systemId'],
|
||||
},
|
||||
Planet: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string', description: 'Body class, e.g. TERRESTRIAL or GAS GIANT' },
|
||||
hazard: { type: 'boolean' },
|
||||
explored: { type: 'boolean', description: 'True once scanned — each body can only be explored once' },
|
||||
parent: { type: ['integer', 'null'], nullable: true, description: 'NULL for a planet, the parent planet id for a moon' },
|
||||
},
|
||||
required: ['id', 'name', 'type', 'hazard', 'explored', 'parent'],
|
||||
},
|
||||
InventoryItem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['resource', 'part'] },
|
||||
name: { type: 'string' },
|
||||
qty: { type: 'integer' },
|
||||
},
|
||||
required: ['kind', 'name', 'qty'],
|
||||
},
|
||||
FoundItem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['resource', 'part'] },
|
||||
name: { type: 'string' },
|
||||
qty: { type: 'integer' },
|
||||
},
|
||||
required: ['kind', 'name', 'qty'],
|
||||
},
|
||||
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'],
|
||||
},
|
||||
ShipSystem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
type: { type: 'string', description: "System type, e.g. REACTOR or WEAPONS" },
|
||||
level: { type: 'integer', description: '0 when not built yet' },
|
||||
capacity: { type: 'integer', description: '0 when not built yet' },
|
||||
built: { type: 'boolean', description: 'Derived — true when level > 0' },
|
||||
},
|
||||
required: ['id', 'type', 'level', 'capacity', 'built'],
|
||||
},
|
||||
ShipState: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
systems: {
|
||||
type: 'array',
|
||||
description: "The ship's systems in display order",
|
||||
items: { $ref: '#/components/schemas/ShipSystem' },
|
||||
},
|
||||
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
||||
turn: { type: 'integer', description: 'Turn counter — one per jump, exploration or ship action' },
|
||||
},
|
||||
required: ['systems', 'inventory', 'turn'],
|
||||
},
|
||||
UpgradeRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
systemId: { type: 'integer', description: 'ship_systems.id of the built system to level up' },
|
||||
},
|
||||
required: ['systemId'],
|
||||
},
|
||||
ExpandRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
systemId: { type: 'integer', description: 'ship_systems.id of the built system to expand' },
|
||||
},
|
||||
required: ['systemId'],
|
||||
},
|
||||
BuildRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: "Ship system type to build, e.g. WEAPONS (see ShipSystem.type)" },
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
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: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space-wanderer",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user