Compare commits

...
10 Commits
Author SHA1 Message Date
kamilkrzywda 2a8cb4da06 Merge pull request 'v0.9.12 — admin button + direct DB table CRUD, no auth for now (#38)' (#42) from admin-db-crud into master
CI / build + unit + e2e (push) Successful in 2m41s
2026-09-29 23:53:45 +02:00
kamilkrzywda 0d2370850f feat(admin): DB CRUD panel + /api/admin endpoints (#38)
CI / build + unit + e2e (pull_request) Successful in 3m47s
- ADMIN button in the mission-control header opens a table picker + row grid with add/edit/delete (components/admin-panel.tsx), deliberately unauthenticated by explicit request
- /api/admin/tables and /api/admin/tables/{name}/rows driven off a name→table registry built from lib/db/schema.ts (lib/db/admin.ts) — no per-table code; 404 unknown table, 400 malformed payload, 409 constraint violation
- OpenAPI spec updated for all four admin operations + Admin* schemas
- unit: lib/db/admin.test.ts (registry, CRUD round-trips, composite PKs, error mapping); e2e: read-only admin.spec.ts
2026-09-29 21:48:15 +00:00
kamilkrzywda be11aac356 Merge pull request 'v0.9.11 — fix #35 e2e spec (tab restore + strict mode), test-only' (#41) from fix-navai-e2e into master
CI / build + unit + e2e (push) Successful in 2m42s
2026-09-29 17:44:09 +02:00
kamilkrzywda fdc5624b0c fix(e2e): make the #35 NAV AI spec survive tab restore and multi-match sublabels
CI / build + unit + e2e (pull_request) Successful in 3m7s
2026-09-29 15:37:45 +00:00
kamilkrzywda ff4ced9d71 Merge pull request 'v0.9.11 — NAVIGATION AI unlocks two-hop jumps (#35)' (#40) from navigation-ai into master
CI / build + unit + e2e (push) Failing after 2m40s
2026-09-29 16:57:43 +02:00
kamilkrzywda d14ffdcf2f feat(game): NAVIGATION AI unlocks two-hop jumps in one action (#35)
CI / build + unit + e2e (pull_request) Failing after 3m11s
2026-09-29 14:53:11 +00:00
kamilkrzywda d083b5b708 Merge pull request 'v0.9.10 — world map fills entire tab area, no scrollbar (#36)' (#39) from world-map-full-area into master
CI / build + unit + e2e (push) Successful in 3m22s
2026-09-29 16:09:15 +02:00
kamilkrzywda c012b0fe56 fix(ui): keep the OPERATIONS tab bar intact when tall tabs overflow the pane (#36)
CI / build + unit + e2e (pull_request) Successful in 2m30s
2026-09-29 14:03:27 +00:00
kamilkrzywda 6483d510ef Merge pull request 'v0.9.10 — world map fills entire tab area, no scrollbar (#36)' (#37) from world-map-full-area into master
CI / build + unit + e2e (push) Failing after 13m40s
2026-09-29 15:03:00 +02:00
kamilkrzywda bc6bc7dd07 fix(ui): scale WORLD MAP to fill its tab pane without an internal scrollbar (#36)
CI / build + unit + e2e (pull_request) Failing after 13m30s
2026-09-29 12:59:16 +00:00
18 changed files with 1335 additions and 52 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## v0.9.12 — 2026-09-29 21:40
- ADMIN button in the mission-control header (top right, next to the SHIP NOMINAL / SECTOR SCAN badges) opens a DB CRUD panel (#38): pick any of the ten game tables from `lib/db/schema.ts`, browse its rows in an HTML table, and add/edit/delete rows via a per-column form. Driven by new `/api/admin/` endpoints — `GET /api/admin/tables` (columns + row counts), `GET|POST|PATCH|DELETE /api/admin/tables/{name}/rows` — built off a name→table registry so there is no per-table code; unknown table names get 404, malformed payloads 400, constraint violations 409. Deliberately unauthenticated by explicit request (marked with a `ponytail:` comment).
- e2e: new read-only spec — click ADMIN, assert the panel lists every table the API reports, open `saves` and assert its rows render matching `/api/admin/tables/saves/rows` (no mutations in specs; CRUD is covered by unit tests in lib/db/admin.test.ts).
## v0.9.11 — 2026-09-29 14:49
- NAVIGATION AI now enables multi-system jumps (#35): once the existing root tech is researched, `/api/jump` accepts targets up to two hops away (BFS over jump routes) and a multi-hop jump costs one turn per hop — single-hop jumps keep their existing cost. On the WORLD MAP chart, two-hop systems become clickable JUMP fields with a distinct `// JUMP ×2` sublabel; without the tech the range stays adjacent-only as before.
- e2e: new spec researches NAVIGATION AI (scan loop + SKILL TREE UI) and jumps to a two-hop system in one action, asserting the world state and the +2 turn cost; the world-map button-count spec now derives its expected count from the save's tech state so it holds across retries on the shared CI DB.
## v0.9.10 — 2026-09-29 12:57
- The WORLD MAP now fills the entire available area of its tab (#36): in wide mode (pinned viewport, #21) the OPERATIONS Tabs are a full-height flex column and the world-map panel flexes to the bottom of the pane — the chart SVG sizes to the measured container instead of the old fixed 380px height, so there is no internal scrollbar on this tab at any window size. The scene keeps its viewBox and scales with the pane on resizes (pan/zoom stay accurate; re-fitting after a resize is intentionally not done). Narrow mode keeps the fixed fallback height since the page scrolls naturally there.
- Follow-up to that layout change (CI run 382): the OPERATIONS Tabs root uses `min-height: 100%` instead of `height: 100%`. With a capped height, a tall tab's content overflowed the flex column and shrank items down to their automatic minimum — the tablist's `overflow-x: auto` zeroes that minimum per CSS spec, so the bar collapsed to ~0px with its buttons clipped out of hit-testing (every subsequent tab click failed with "div intercepts pointer events"); a capped box also released the sticky pin on deep scrolls. With min-height the box grows to fit tall tabs — the bar keeps its natural height and stays pinned across the whole scroll range, while WORLD MAP still fills the pane exactly.
- e2e: the #31 spec no longer relies on WORLD MAP's overflow to force scrolling — it now switches to SHIP SYSTEMS, whose six always-present stacked system cards deterministically overflow the shrunk 1280×560 pane (waiting for the HULL card first), and asserts the same pinned tab bar.
## v0.9.9 — 2026-09-29 11:21 ## v0.9.9 — 2026-09-29 11:21
- The WORLD MAP chart is now a force-directed graph (d3-force) with pan + zoom (d3-zoom), replacing the hand-rolled static NodeGraph render (#28): nodes settle into a dynamic layout seeded from the API coordinates and visibly rearrange after every jump, then auto zoom-to-fit so the whole network stays framed in the chart box. Wheel/drag pans and zooms the scene; only systems adjacent to the current one remain clickable (same `role=button` + name contract as before), all nodes lock while a jump is in flight, and a real pan's trailing click never triggers an accidental jump. Jump flow (`POST /api/jump`, turn counter, `onJumped` refresh from #30) is unchanged; the now-orphaned hand-rolled `NodeGraph` component (world map was its only consumer) is deleted. - The WORLD MAP chart is now a force-directed graph (d3-force) with pan + zoom (d3-zoom), replacing the hand-rolled static NodeGraph render (#28): nodes settle into a dynamic layout seeded from the API coordinates and visibly rearrange after every jump, then auto zoom-to-fit so the whole network stays framed in the chart box. Wheel/drag pans and zooms the scene; only systems adjacent to the current one remain clickable (same `role=button` + name contract as before), all nodes lock while a jump is in flight, and a real pan's trailing click never triggers an accidental jump. Jump flow (`POST /api/jump`, turn counter, `onJumped` refresh from #30) is unchanged; the now-orphaned hand-rolled `NodeGraph` component (world map was its only consumer) is deleted.
+55
View File
@@ -0,0 +1,55 @@
import { getGameDb } from '@/lib/db/client'
import { AdminError, deleteAdminRow, insertAdminRow, readAdminRows, updateAdminRow } from '@/lib/db/admin'
// #38 — rows of one admin table: GET (capped list), POST (insert), PATCH ({key, values}), DELETE ({key}).
async function jsonBody(req: Request): Promise<Record<string, unknown>> {
let body: unknown
try {
body = await req.json()
} catch {
throw new AdminError(400, 'Invalid JSON body')
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) throw new AdminError(400, 'Body must be a JSON object')
return body as Record<string, unknown>
}
async function run<T>(fn: () => Promise<T>): Promise<Response> {
try {
return Response.json(await fn())
} catch (err) {
if (err instanceof AdminError) return Response.json({ error: err.message }, { status: err.status })
throw err
}
}
export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) {
const db = await getGameDb()
const { name } = await ctx.params
const url = new URL(req.url)
const limitRaw = url.searchParams.get('limit')
const offsetRaw = url.searchParams.get('offset')
const limit = limitRaw === null ? 50 : Number(limitRaw)
const offset = offsetRaw === null ? 0 : Number(offsetRaw)
return run(() => readAdminRows(db, name, limit, offset))
}
export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) {
const db = await getGameDb()
const { name } = await ctx.params
return run(async () => insertAdminRow(db, name, await jsonBody(req)))
}
export async function PATCH(req: Request, ctx: { params: Promise<{ name: string }> }) {
const db = await getGameDb()
const { name } = await ctx.params
return run(async () => {
const body = await jsonBody(req)
return updateAdminRow(db, name, body.key, body.values)
})
}
export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) {
const db = await getGameDb()
const { name } = await ctx.params
return run(async () => deleteAdminRow(db, name, (await jsonBody(req)).key))
}
+8
View File
@@ -0,0 +1,8 @@
import { getGameDb } from '@/lib/db/client'
import { listAdminTables } from '@/lib/db/admin'
// #38 — admin DB CRUD, no auth by explicit request (ponytail: add auth before exposing beyond the dev host).
export async function GET() {
const db = await getGameDb()
return Response.json(await listAdminTables(db))
}
+6
View File
@@ -160,6 +160,12 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Radix Tabs hides inactive content via the `hidden` attribute, but inline display styles (the
#36 pane-fill flex chain on the WORLD MAP tab) outrank the UA stylesheet — restore semantics. */
[hidden] {
display: none !important;
}
#root { #root {
min-height: 100vh; min-height: 100vh;
} }
+322
View File
@@ -0,0 +1,322 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
// #38 — direct DB CRUD for the admin. ponytail: deliberately unauthenticated (explicit request);
// add auth before this is reachable beyond the dev host.
interface AdminColumnInfo { name: string; type: string; pk: number }
interface AdminTableInfo { name: string; columns: AdminColumnInfo[]; count: number }
interface AdminRows { rows: Record<string, unknown>[]; total: number }
// Form state is all strings (one input per column); parsed on save against the column type.
function parseValue(raw: string, col: AdminColumnInfo): number | string | null {
const v = raw.trim()
if (v === '' || v.toLowerCase() === 'null') return null
if (/INT|REAL|FLOA|DOUB/.test(col.type)) {
const n = Number(v)
if (!Number.isFinite(n)) throw new Error(`'${col.name}' must be a number, got '${v}'`)
return col.type.includes('INT') ? Math.trunc(n) : n
}
return v
}
function cellText(value: unknown): string {
return value === null || value === undefined ? '—' : String(value)
}
export function AdminPanel({ onClose }: { onClose: () => void }) {
const [tables, setTables] = useState<AdminTableInfo[] | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [rows, setRows] = useState<AdminRows | null>(null)
// null = form closed; { key: null } = add row; { key: {...} } = edit that row.
const [editing, setEditing] = useState<{ key: Record<string, number> | null } | null>(null)
const [form, setForm] = useState<Record<string, string>>({})
const [error, setError] = useState<string | null>(null)
const table = tables?.find((t) => t.name === selected) ?? null
const refreshTables = useCallback(async () => {
try {
const res = await fetch('/api/admin/tables')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setTables((await res.json()) as AdminTableInfo[])
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [])
const refreshRows = useCallback(async (name: string) => {
try {
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setRows((await res.json()) as AdminRows)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}, [])
useEffect(() => {
void refreshTables()
}, [refreshTables])
useEffect(() => {
if (!selected) return
setRows(null)
void refreshRows(selected)
}, [selected, refreshRows])
const pkColumns = (t: AdminTableInfo): AdminColumnInfo[] => t.columns.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
const rowKey = (t: AdminTableInfo, row: Record<string, unknown>): Record<string, number> =>
Object.fromEntries(pkColumns(t).map((c) => [c.name, Number(row[c.name])]))
const openAdd = () => {
if (!table) return
setEditing({ key: null })
setForm(Object.fromEntries(table.columns.map((c) => [c.name, ''])))
setError(null)
}
const openEdit = (row: Record<string, unknown>) => {
if (!table) return
setEditing({ key: rowKey(table, row) })
setForm(Object.fromEntries(table.columns.map((c) => [c.name, row[c.name] == null ? '' : String(row[c.name])])))
setError(null)
}
const save = async () => {
if (!table || !editing) return
try {
const payload: Record<string, number | string | null> = {}
for (const col of table.columns) payload[col.name] = parseValue(form[col.name] ?? '', col)
const url = `/api/admin/tables/${encodeURIComponent(table.name)}/rows`
const res = await fetch(url, {
method: editing.key === null ? 'POST' : 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editing.key === null ? payload : { key: editing.key, values: payload }),
})
const body = (await res.json()) as { error?: string } & Record<string, unknown>
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
setEditing(null)
setError(null)
await Promise.all([refreshRows(table.name), refreshTables()])
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
const remove = async (row: Record<string, unknown>) => {
if (!table) return
try {
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table.name)}/rows`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: rowKey(table, row) }),
})
const body = (await res.json()) as { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
await Promise.all([refreshRows(table.name), refreshTables()])
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
const inputStyle: React.CSSProperties = {
width: '100%',
background: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
fontFamily: 'inherit',
fontSize: '0.68rem',
padding: '0.3rem 0.4rem',
outline: 'none',
}
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
background: 'rgba(0, 0, 0, 0.75)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<Panel notch="md" style={{ width: 'min(980px, 100%)', height: 'min(640px, 100%)', display: 'flex', flexDirection: 'column' }}>
<PanelHeader>
<PanelTitle>ADMIN // DB CRUD</PanelTitle>
<Badge variant="WARNING">NO AUTH</Badge>
<div style={{ marginLeft: 'auto' }}>
<Button size="SM" onClick={onClose}>CLOSE</Button>
</div>
</PanelHeader>
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '200px 1fr', gap: '1px', background: 'var(--border)', minHeight: 0 }}>
{/* ── TABLE LIST ── */}
<div style={{ background: 'var(--surface)', overflowY: 'auto', padding: '0.5rem' }}>
{!tables ? (
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
) : (
tables.map((t) => (
<button
key={t.name}
onClick={() => setSelected(t.name)}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
gap: '0.5rem',
padding: '0.4rem 0.5rem',
marginBottom: '2px',
background: selected === t.name ? 'var(--surface-raised)' : 'transparent',
border: `1px solid ${selected === t.name ? 'var(--color-green)' : 'var(--border)'}`,
color: selected === t.name ? 'var(--color-green)' : 'var(--text-secondary)',
fontFamily: 'inherit',
fontSize: '0.68rem',
letterSpacing: '0.06em',
cursor: 'pointer',
}}
>
<span>{t.name}</span>
<span style={{ color: 'var(--text-muted)' }}>{t.count}</span>
</button>
))
)}
</div>
{/* ── ROWS / EDITOR ── */}
<div style={{ background: 'var(--surface)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{!table ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SELECT A TABLE</span>
</PanelContent>
) : (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
}}
>
<span style={{ fontSize: '0.68rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>{table.name}</span>
{rows && (
<Badge variant="ACTIVE">
{rows.rows.length} / {rows.total}
</Badge>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.4rem' }}>
{!editing && <Button size="SM" variant="EXEC" onClick={openAdd}>ADD ROW</Button>}
{error && (
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', alignSelf: 'center' }}>{error}</span>
)}
</div>
</div>
{editing && (
<div style={{ padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.4rem' }}>
{table.columns.map((c) => (
<label key={c.name} style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
<span style={{ fontSize: '0.58rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>
{c.name.toUpperCase()}
{c.pk > 0 ? ` (PK${c.pk})` : ''} · {c.type}
</span>
<input
style={inputStyle}
value={form[c.name] ?? ''}
onChange={(e) => setForm((f) => ({ ...f, [c.name]: e.target.value }))}
/>
</label>
))}
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.5rem' }}>
<Button size="SM" variant="EXEC" onClick={() => void save()}>SAVE</Button>
<Button size="SM" onClick={() => setEditing(null)}>CANCEL</Button>
</div>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{!rows ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
</PanelContent>
) : rows.rows.length === 0 ? (
<PanelContent>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY TABLE</span>
</PanelContent>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.65rem' }}>
<thead>
<tr>
{table.columns.map((c) => (
<th
key={c.name}
style={{
position: 'sticky',
top: 0,
background: 'var(--surface-raised)',
color: 'var(--text-muted)',
textAlign: 'left',
padding: '0.35rem 0.5rem',
borderBottom: '1px solid var(--border)',
letterSpacing: '0.08em',
whiteSpace: 'nowrap',
}}
>
{c.name}
{c.pk > 0 ? ` (PK${c.pk})` : ''}
</th>
))}
<th style={{ position: 'sticky', top: 0, background: 'var(--surface-raised)', padding: '0.35rem 0.5rem', borderBottom: '1px solid var(--border)' }} />
</tr>
</thead>
<tbody>
{rows.rows.map((row) => (
<tr key={JSON.stringify(rowKey(table, row))}>
{table.columns.map((c) => (
<td
key={c.name}
style={{
padding: '0.3rem 0.5rem',
borderBottom: '1px solid var(--border)',
color: row[c.name] === null ? 'var(--text-muted)' : 'var(--text-secondary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '220px',
}}
>
{cellText(row[c.name])}
</td>
))}
<td style={{ padding: '0.3rem 0.5rem', borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', gap: '0.3rem' }}>
<Button size="SM" onClick={() => openEdit(row)}>EDIT</Button>
<Button size="SM" variant="ABORT" onClick={() => void remove(row)}>DEL</Button>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</div>
</div>
</Panel>
</div>
)
}
+33 -13
View File
@@ -1,7 +1,9 @@
'use client' 'use client'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { AdminPanel } from '@/components/admin-panel'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel' import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { StatCard } from '@/components/ui/stat-card' import { StatCard } from '@/components/ui/stat-card'
@@ -57,6 +59,8 @@ export function MissionControl() {
// #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until // #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until
// then so SSR and the first client render agree on the default tab (no hydration mismatch). // then so SSR and the first client render agree on the default tab (no hydration mismatch).
const [activeTab, setActiveTab] = useState<string | null>(null) const [activeTab, setActiveTab] = useState<string | null>(null)
// #38 — admin DB CRUD overlay, opened from the header.
const [adminOpen, setAdminOpen] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -130,6 +134,7 @@ export function MissionControl() {
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS // Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
// tab via the same pure helpers the API enforces (lib/game/tech.ts). // tab via the same pure helpers the API enforces (lib/game/tech.ts).
const researchedSlugs = new Set((techs ?? []).filter((t) => t.state === 'RESEARCHED').map((t) => t.slug)) const researchedSlugs = new Set((techs ?? []).filter((t) => t.state === 'RESEARCHED').map((t) => t.slug))
const navAi = researchedSlugs.has('NAV_AI') // #35 — two-hop systems become jumpable chart fields
return ( return (
<div <div
@@ -209,6 +214,9 @@ export function MissionControl() {
<Badge variant="SCANNING">SECTOR SCAN</Badge> <Badge variant="SCANNING">SECTOR SCAN</Badge>
{/* #38 — admin DB CRUD, no auth by design (see components/admin-panel.tsx) */}
<Button size="SM" onClick={() => setAdminOpen(true)}>ADMIN</Button>
<span <span
style={{ style={{
color: 'var(--color-green)', color: 'var(--color-green)',
@@ -224,6 +232,8 @@ export function MissionControl() {
</span> </span>
</header> </header>
{adminOpen && <AdminPanel onClose={() => setAdminOpen(false)} />}
{/* ── BODY ── */} {/* ── BODY ── */}
<div <div
style={{ style={{
@@ -383,23 +393,28 @@ export function MissionControl() {
</PanelHeader> </PanelHeader>
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}> <PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{/* Flex column at least pane-tall so a tab can fill it (#36). min-height, not height:
tall tabs grow the box to their content, which keeps the sticky tab bar's containing
block spanning the whole scroll range (a capped height releases the pin on deep
scrolls) and means items never overflow the box, so nothing gets shrink-squashed. */}
<Tabs <Tabs
value={activeTab ?? 'world-map'} value={activeTab ?? 'world-map'}
onValueChange={(v) => { onValueChange={(v) => {
setActiveTab(v) setActiveTab(v)
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
}} }}
style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}
> >
<TabsList <TabsList
style={{ style={{
padding: '0 1rem', padding: '0 1rem',
background: 'var(--surface-raised)', // opaque — content scrolls under it (#31) background: 'var(--surface-raised)', // opaque — content scrolls under it (#31)
overflowX: 'auto', overflowX: 'auto',
position: 'sticky', // #31 — pinned in both modes: to the top of the OPERATIONS position: 'sticky', // #31 — pinned in both modes: to the top of the OPERATIONS
top: 0, // scroll area (wide) and to the viewport when the body scrolls (narrow) top: 0, // scroll area (wide) and to the viewport when the body scrolls (narrow)
zIndex: 1, zIndex: 1,
}} }}
> >
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger> <TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
<TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger> <TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger>
<TabsTrigger value="crew">CREW</TabsTrigger> <TabsTrigger value="crew">CREW</TabsTrigger>
@@ -409,9 +424,14 @@ export function MissionControl() {
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger> <TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
</TabsList> </TabsList>
{/* WORLD MAP TAB — live data from /api/world */} {/* WORLD MAP TAB — live data from /api/world; fills the whole pane (#36). Wide mode:
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}> flex column so the panel can fill it with pure flex sizing (a min-height-only
<WorldMapPanel height={380} onJumped={refreshSystem} /> container breaks percentage resolution, so no height:'100%' anywhere in this chain). */}
<TabsContent
value="world-map"
style={{ margin: 0, padding: '1rem', ...(!narrow ? { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : {}) }}
>
<WorldMapPanel height={380} narrow={narrow} navAi={navAi} onJumped={refreshSystem} />
</TabsContent> </TabsContent>
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */} {/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
+42 -23
View File
@@ -6,15 +6,20 @@ import type { SimulationNodeDatum } from 'd3-force'
import { select } from 'd3-selection' import { select } from 'd3-selection'
import { zoom, zoomIdentity } from 'd3-zoom' import { zoom, zoomIdentity } from 'd3-zoom'
import type { WorldState } from '@/lib/game/save' import type { WorldState } from '@/lib/game/save'
import { reachableWithin } from '@/lib/game/world'
// WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with // WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with
// pan + zoom (d3-zoom), per #28. Clicking a system adjacent to the current one jumps there // pan + zoom (d3-zoom), per #28. Clicking a system within jump range of the current one jumps
// (same POST /api/jump flow as before); the current and unreachable systems are dimmed and not // there (same POST /api/jump flow as before; two hops away once NAVIGATION AI is researched,
// #35); the current and unreachable systems are dimmed and not
// clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount, // clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount,
// refetch via the jump response — no polling. // refetch via the jump response — no polling. In wide mode (#36) the chart fills the whole tab
// pane (no internal scrollbar); narrow keeps a fixed fallback height since the page scrolls naturally there.
interface WorldMapPanelProps { interface WorldMapPanelProps {
height?: number height?: number // fallback chart height when the pane gives no definite space (narrow mode)
narrow?: boolean
navAi?: boolean // #35 — NAVIGATION AI researched: two-hop systems are jumpable fields too
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
} }
@@ -44,7 +49,9 @@ const STATUS_COLOR: Record<NodeStatus, string> = {
offline: 'var(--border)', offline: 'var(--border)',
} }
export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) { export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJumped }: WorldMapPanelProps) {
// Wide mode: the flex chain (Tabs → TabsContent → this panel) provides a definite pane size to fill (#36).
const filling = !narrow
const [world, setWorld] = useState<WorldState | null>(null) const [world, setWorld] = useState<WorldState | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [jumpingId, setJumpingId] = useState<number | null>(null) const [jumpingId, setJumpingId] = useState<number | null>(null)
@@ -99,32 +106,35 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
const container = containerRef.current const container = containerRef.current
if (!container || !world) return if (!container || !world) return
// Measured pane size (#36): the flex chain gives a definite box in wide mode; `height` is the
// fallback when the container has no resolved size yet (narrow flow, or tab hidden on load).
const W = container.clientWidth || 800 const W = container.clientWidth || 800
const H = container.clientHeight || height
const current = world.systems.find((s) => s.current)! const current = world.systems.find((s) => s.current)!
// Systems one jump away — the only clickable chart fields (the same set as before). // Clickable chart fields: one hop away, or two hops once NAVIGATION AI is researched (#35).
const reachableIds = new Set( const hops = reachableWithin(world.edges, current.id, navAi ? 2 : 1)
world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
)
const nodes: ChartNode[] = world.systems.map((s) => ({ const nodes: ChartNode[] = world.systems.map((s) => ({
id: s.id, id: s.id,
name: s.name.toUpperCase(), name: s.name.toUpperCase(),
status: s.current ? 'current' : reachableIds.has(s.id) ? 'jumpable' : 'offline', status: s.current ? 'current' : hops.has(s.id) ? 'jumpable' : 'offline',
sublabel: s.current sublabel: s.current
? 'CURRENT' ? 'CURRENT'
: reachableIds.has(s.id) : hops.has(s.id)
? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') ? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hops.get(s.id) ?? 1) > 1 ? ' ×2' : ''}`
: s.discovered ? 'DISCOVERED' : 'UNKNOWN', : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
y: (s.y / 100) * height, y: (s.y / 100) * H,
})) }))
const links: ChartLink[] = world.edges.map(([a, b]) => ({ source: a, target: b })) const links: ChartLink[] = world.edges.map(([a, b]) => ({ source: a, target: b }))
// width/height 100% + fixed viewBox: the scene scales with the pane on window resizes without
// rebuilding (pan/zoom stay accurate — d3 maps pointers through the SVG transform matrix).
const svg = select(container) const svg = select(container)
.append('svg') .append('svg')
.attr('width', '100%') .attr('width', '100%')
.attr('height', height) .attr('height', '100%')
.attr('viewBox', `0 0 ${W} ${height}`) .attr('viewBox', `0 0 ${W} ${H}`)
.style('display', 'block') .style('display', 'block')
const root = svg.append('g') const root = svg.append('g')
@@ -162,9 +172,9 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2) maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2)
} }
const pad = 16 const pad = 16
const scale = Math.min(W / (maxX - minX + pad * 2), height / (maxY - minY + pad * 2)) const scale = Math.min(W / (maxX - minX + pad * 2), H / (maxY - minY + pad * 2))
const tx = W / 2 - scale * ((minX + maxX) / 2) const tx = W / 2 - scale * ((minX + maxX) / 2)
const ty = height / 2 - scale * ((minY + maxY) / 2) const ty = H / 2 - scale * ((minY + maxY) / 2)
svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale)) svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale))
} }
@@ -220,7 +230,7 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
const simulation = forceSimulation(nodes) const simulation = forceSimulation(nodes)
.force('link', forceLink<ChartNode, ChartLink>(links).id((d) => d.id).distance(130).strength(0.25)) .force('link', forceLink<ChartNode, ChartLink>(links).id((d) => d.id).distance(130).strength(0.25))
.force('charge', forceManyBody().strength(-420)) .force('charge', forceManyBody().strength(-420))
.force('center', forceCenter(W / 2, height / 2)) .force('center', forceCenter(W / 2, H / 2))
.force('collide', forceCollide(60)) .force('collide', forceCollide(60))
.on('end', fitToNodes) .on('end', fitToNodes)
.on('tick', () => { .on('tick', () => {
@@ -236,7 +246,7 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
simulation.stop() simulation.stop()
svg.remove() svg.remove()
} }
}, [world, jumpingId, jump, height]) }, [world, jumpingId, jump, height, navAi])
if (error && !world) { if (error && !world) {
return ( return (
@@ -256,10 +266,19 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
) )
} }
// flex (not height:'100%'): the pane chain is sized by min-height, where percentages don't resolve
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', ...(filling ? { flex: 1, minHeight: 0 } : {}) }}>
{/* Same wrapper + title bar as the old hand-rolled chart; d3 owns everything inside the box. */} {/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns everything inside it. */}
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', fontFamily: 'var(--font-mono)', overflow: 'hidden' }}> <div
style={{
border: '1px solid var(--border)',
background: 'var(--surface)',
fontFamily: 'var(--font-mono)',
overflow: 'hidden',
...(filling ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } : {}),
}}
>
<div <div
style={{ style={{
padding: '0.4rem 0.75rem', padding: '0.4rem 0.75rem',
@@ -273,7 +292,7 @@ export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
> >
GALACTIC CHART // JUMP ROUTES — TURN {world.turn} GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
</div> </div>
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }} /> <div ref={containerRef} style={{ width: '100%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
</div> </div>
{error && ( {error && (
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
// #38 — ADMIN DB CRUD panel. READ-ONLY on purpose: specs share the CI DB across retries, so no
// insert/update/delete here (the mutation endpoints are covered by unit tests in lib/db/admin.test.ts).
interface AdminColumnDto { name: string; type: string; pk: number }
interface AdminTableDto { name: string; columns: AdminColumnDto[]; count: number }
interface AdminRowsDto { rows: Record<string, unknown>[]; total: number }
test('ADMIN button opens the panel and lists every table (#38)', async ({ page }) => {
await page.goto('/');
// The ADMIN button sits in the header next to the SECTOR SCAN badge.
await page.getByRole('button', { name: 'ADMIN' }).click();
await expect(page.getByText('ADMIN // DB CRUD')).toBeVisible();
// Cross-check against the API: every table it lists must render in the panel (and vice versa).
const tables = (await (await page.request.get('/api/admin/tables')).json()) as AdminTableDto[];
expect(tables.length).toBeGreaterThanOrEqual(10);
for (const t of tables) {
await expect(page.getByRole('button', { name: new RegExp(`^${t.name}\\b`) })).toBeVisible();
}
});
test('opening a table renders its rows from the API (#38)', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'ADMIN' }).click();
// saves always has exactly one row (single-player by design) — deterministic across retries.
const api = (await (await page.request.get('/api/admin/tables/saves/rows')).json()) as AdminRowsDto;
expect(api.total).toBeGreaterThan(0);
await page.getByRole('button', { name: /^saves\b/ }).click();
// Column headers come from the API's column list.
for (const col of api.rows[0] ? Object.keys(api.rows[0]) : []) {
await expect(page.locator('table th', { hasText: col })).toBeVisible();
}
// Row count matches what the API reports, and a cell shows real data from it.
const body = page.locator('table tbody tr');
await expect(body).toHaveCount(api.rows.length);
await expect(page.locator('table tbody').getByText(String(api.rows[0].seed))).toBeVisible();
});
+77 -5
View File
@@ -87,8 +87,11 @@ test('tab bar stays pinned while panel content scrolls (#31)', async ({ page })
// Shrink the viewport so the center pane's content overflows its scroll area regardless of save state. // Shrink the viewport so the center pane's content overflows its scroll area regardless of save state.
await page.setViewportSize({ width: 1280, height: 560 }); await page.setViewportSize({ width: 1280, height: 560 });
// WORLD MAP is always tall enough to overflow (fixed-height chart) — deterministic across retries. // SHIP SYSTEMS always renders six stacked system cards (seeded at start, never removed) — tall
await page.getByRole('tab', { name: 'WORLD MAP' }).click(); // enough to overflow the shrunk pane on any save state, deterministic across retries. (#36 made
// WORLD MAP fill its pane instead of overflowing it, so it can no longer force the scroll.)
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
await expect(page.getByRole('heading', { name: 'HULL' })).toBeVisible();
const tablist = page.getByRole('tablist'); const tablist = page.getByRole('tablist');
await expect(tablist).toBeVisible(); await expect(tablist).toBeVisible();
@@ -114,16 +117,21 @@ test('tab bar stays pinned while panel content scrolls (#31)', async ({ page })
test('world map shows the generated starting system and its neighbours', async ({ page }) => { test('world map shows the generated starting system and its neighbours', async ({ page }) => {
const world = (await (await page.request.get('/api/world')).json()) as WorldState; const world = (await (await page.request.get('/api/world')).json()) as WorldState;
// #35 — once NAVIGATION AI is researched the chart also offers two-hop systems, so the expected
// button count depends on the save's tech state (the CI DB persists across retries).
const techs = (await (await page.request.get('/api/tech')).json()) as { slug: string; state: string }[];
const maxHops = techs.some((t) => t.slug === 'NAV_AI' && t.state === 'RESEARCHED') ? 2 : 1;
await page.goto('/'); await page.goto('/');
// Live chart title carries the turn counter. // Live chart title carries the turn counter.
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible(); await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
// The current system is marked, and every adjacent system is a clickable chart field (role=button). // The current system is marked, and every in-range system is a clickable chart field (role=button).
const current = world.systems.find((s) => s.current)!; const current = world.systems.find((s) => s.current)!;
await expect(page.getByText('CURRENT', { exact: true })).toBeVisible(); await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
const panel = page.getByRole('tabpanel'); const panel = page.getByRole('tabpanel');
const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save // Six on a fresh save without NAV AI; the two-hop ring joins once it is researched.
await expect(panel.getByRole('button')).toHaveCount(adjacent.length); const jumpable = world.systems.filter((s) => s.id !== current.id && shortestJumpPath(current.id, s.id, world.edges).length <= maxHops);
await expect(panel.getByRole('button')).toHaveCount(jumpable.length);
}); });
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => { test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
@@ -361,6 +369,70 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
await expect(weaponsCard.getByText('LV 1')).toBeVisible(); await expect(weaponsCard.getByText('LV 1')).toBeVisible();
}); });
test('NAVIGATION AI research unlocks two-hop jumps on the world map (#35)', async ({ page }) => {
// Research costs two resources (70% of scans drop one), so a short scan loop makes it affordable;
// jumping keeps it going if a system exhausts. Idempotent: a retry finds it already RESEARCHED.
let techs = (await (await page.request.get('/api/tech')).json()) as { id: number; slug: string; state: string }[];
const navAiState = () => techs.find((t) => t.slug === 'NAV_AI')!.state;
if (navAiState() !== 'RESEARCHED') {
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
let iterations = 0;
while (totalQty(ship.inventory, 'resource') < 2 && iterations < 40) {
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 {
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
}
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
}
expect(totalQty(ship.inventory, 'resource')).toBeGreaterThanOrEqual(2);
await page.goto('/');
// SKILL TREE tab: research NAVIGATION AI (two resources) — the card flips to RESEARCHED.
await page.getByRole('tab', { name: 'SKILL TREE' }).click();
const techCard = page.getByRole('heading', { name: 'NAVIGATION AI' }).locator('xpath=ancestor::div[3]');
await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites
await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // resources ≥ 2
await techCard.getByRole('button', { name: 'RESEARCH' }).click();
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
}
// Pick a system exactly two hops from the current one. A completely fresh save has none (the start
// region is only one hop deep), so jump once first — that expansion creates the two-hop territory.
let world = (await (await page.request.get('/api/world')).json()) as WorldState;
const findTwoHop = () => {
const currentId = world.systems.find((s) => s.current)!.id;
return world.systems.find((s) => shortestJumpPath(currentId, s.id, world.edges).length === 2);
};
let guard = 0;
while (!findTwoHop() && guard < 5) {
await jumpToFreshSystem(page); // +1 turn — jumping into undiscovered territory always expands the chart
world = (await (await page.request.get('/api/world')).json()) as WorldState;
guard++;
}
const target = findTwoHop()!;
expect(target).toBeDefined();
await page.goto('/'); // fresh chart with NAV AI researched — two-hop fields are clickable now
// #27 restores whatever tab a previous step persisted (SKILL TREE after the research above), so
// activate WORLD MAP explicitly before asserting on the chart.
await page.getByRole('tab', { name: 'WORLD MAP' }).click();
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
const panel = page.getByRole('tabpanel');
const turnBefore = world.turn;
// The two-hop systems render as distinct chart fields (// JUMP ×2 sublabel) — several at once, so .first().
await expect(panel.getByText(/(DISCOVERED|UNKNOWN) \/\/ JUMP ×2/).first()).toBeVisible();
await panel.getByRole('button', { name: target.name.toUpperCase() }).click();
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
expect(after.systems.find((s) => s.id === target.id)!.current).toBe(true); // world state updated in one action
expect(after.turn).toBe(turnBefore + 2); // the turn cost scales with hop count (#35)
});
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => { test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up
+128
View File
@@ -0,0 +1,128 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { getTableName } from 'drizzle-orm'
import { afterAll, describe, expect, it } from 'vitest'
import { openGameDb, type GameDb } from './client'
import * as schema from './schema'
import { AdminError, deleteAdminRow, insertAdminRow, isAdminTableName, listAdminTables, readAdminRows, updateAdminRow } from './admin'
const dirs: string[] = []
async function freshFile(): Promise<GameDb> {
const dir = await mkdtemp(path.join(tmpdir(), 'sw-admin-test-'))
dirs.push(dir)
return openGameDb(path.join(dir, 'test.db'))
}
afterAll(async () => {
for (const d of dirs) await rm(d, { recursive: true, force: true })
})
function expectAdminError(promise: Promise<unknown>, status: number): Promise<void> {
return promise.then(
() => {
throw new Error(`expected AdminError ${status}, resolved instead`)
},
(err) => {
expect(err).toBeInstanceOf(AdminError)
expect((err as AdminError).status).toBe(status)
},
)
}
describe('admin table registry', () => {
it('exposes every game table from schema.ts, keyed by SQL name', async () => {
const db = await freshFile()
const tables = await listAdminTables(db)
expect(tables.map((t) => t.name).sort()).toEqual(
Object.values(schema)
.map((s) => getTableName(s as Parameters<typeof getTableName>[0]))
.sort(),
)
expect(tables.every((t) => isAdminTableName(t.name))).toBe(true)
for (const bad of ['_meta', 'nope', 'Saves']) expect(isAdminTableName(bad)).toBe(false)
const saves = tables.find((t) => t.name === 'saves')!
expect(saves.columns.map((c) => c.name)).toEqual(['id', 'seed', 'turn', 'created_at', 'schema_version'])
expect(saves.count).toBe(0)
})
it('rejects unknown tables with 404 on every operation', async () => {
const db = await freshFile()
await expectAdminError(readAdminRows(db, 'nope'), 404)
await expectAdminError(insertAdminRow(db, 'nope', {}), 404)
await expectAdminError(updateAdminRow(db, 'nope', { id: 1 }, {}), 404)
await expectAdminError(deleteAdminRow(db, 'nope', { id: 1 }), 404)
})
})
describe('admin row CRUD (single-PK tables)', () => {
it('inserts, reads back and pages rows with limit/offset validation', async () => {
const db = await freshFile()
const row = await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: '2026-01-01T00:00:00Z' })
expect(row.id).toBe(1)
expect(row.seed).toBe('abc')
const all = await readAdminRows(db, 'saves')
expect(all.total).toBe(1)
expect(all.rows[0]).toMatchObject({ id: 1, seed: 'abc', turn: 0, schema_version: null })
for (const bad of [0, -1, 201]) await expectAdminError(readAdminRows(db, 'saves', bad), 400)
await expectAdminError(readAdminRows(db, 'saves', 50, -1), 400)
})
it('rejects unknown columns, missing required columns and wrong value types with 400', async () => {
const db = await freshFile()
await expectAdminError(insertAdminRow(db, 'saves', { bogus: 1 }), 400)
await expectAdminError(insertAdminRow(db, 'saves', { seed: 'x' }), 400) // created_at missing
await expectAdminError(insertAdminRow(db, 'saves', { seed: 'x', turn: 'nope', created_at: 't' }), 400)
await expectAdminError(insertAdminRow(db, 'saves', ['not', 'an', 'object']), 400)
})
it('updates by key and returns the fresh row; unknown keys are 404', async () => {
const db = await freshFile()
await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: 't' })
const updated = await updateAdminRow(db, 'saves', { id: 1 }, { turn: 5 })
expect(updated).toMatchObject({ id: 1, seed: 'abc', turn: 5 })
await expectAdminError(updateAdminRow(db, 'saves', { id: 99 }, { turn: 1 }), 404)
await expectAdminError(updateAdminRow(db, 'saves', {}, { turn: 1 }), 400) // missing pk column in key
await expectAdminError(updateAdminRow(db, 'saves', { id: 1 }, {}), 400) // nothing to set
})
it('deletes by key and returns the removed row; second delete is 404', async () => {
const db = await freshFile()
await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: 't' })
const deleted = await deleteAdminRow(db, 'saves', { id: 1 })
expect(deleted).toMatchObject({ id: 1, seed: 'abc' })
expect((await readAdminRows(db, 'saves')).total).toBe(0)
await expectAdminError(deleteAdminRow(db, 'saves', { id: 1 }), 404)
})
it('maps SQLite constraint violations to 409', async () => {
const db = await freshFile()
await insertAdminRow(db, 'crew', { save_id: 1, name: 'A', role: 'PILOT', trait_good: 'g', trait_bad: 'b', origin: 'STARTING CREW' })
// ship_systems has UNIQUE(save_id, type) — a duplicate is a constraint failure, not a crash.
await insertAdminRow(db, 'ship_systems', { save_id: 1, type: 'HULL', level: 0, capacity: 0 })
await expectAdminError(insertAdminRow(db, 'ship_systems', { save_id: 1, type: 'HULL', level: 1, capacity: 0 }), 409)
})
})
describe('admin row CRUD (composite-PK tables)', () => {
it('requires every PK column in the key for jump_routes', async () => {
const db = await freshFile()
const row = await insertAdminRow(db, 'jump_routes', { from_system: 1, to_system: 2 })
expect(row).toMatchObject({ from_system: 1, to_system: 2 })
// PK columns are NOT NULL without defaults — omitting one is a 400, not a SQLite error.
await expectAdminError(insertAdminRow(db, 'jump_routes', { from_system: 1 }), 400)
await expectAdminError(updateAdminRow(db, 'jump_routes', { from_system: 1 }, {}), 400)
const deleted = await deleteAdminRow(db, 'jump_routes', { from_system: 1, to_system: 2 })
expect(deleted).toMatchObject({ from_system: 1, to_system: 2 })
await expectAdminError(deleteAdminRow(db, 'jump_routes', { from_system: 1, to_system: 3 }), 404)
})
})
+236
View File
@@ -0,0 +1,236 @@
import { getTableName } from 'drizzle-orm'
import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
import * as schema from './schema'
import { persist, type GameDb } from './client'
// #38 — admin CRUD over the raw DB. Every game table is exposed through this registry; the SQL
// names come from the drizzle tables themselves (getTableName) so the list can't drift from
// lib/db/schema.ts. All queries run on the raw sql.js handle driven by PRAGMA table_info, so there
// is no per-table code — adding a table to schema.ts + this map is all it takes.
export const ADMIN_TABLES = {
saves: schema.saves,
solar_systems: schema.solarSystems,
jump_routes: schema.jumpRoutes,
planets: schema.planets,
inventory_items: schema.inventoryItems,
crew: schema.crew,
ship_systems: schema.shipSystems,
tech: schema.tech,
missions: schema.missions,
events: schema.events,
} as const satisfies Record<string, SQLiteTable>
const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t)))
export function isAdminTableName(name: string): boolean {
return TABLE_NAMES.has(name)
}
// Thrown by the helpers below; routes map it to a JSON error response with its status.
export class AdminError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
function assertTable(name: string): void {
if (!TABLE_NAMES.has(name)) throw new AdminError(404, `Unknown table '${name}'`)
}
type SqlParam = string | number | null
// Column metadata straight from SQLite. pk is the position within the primary key (0 = not a PK),
// so composite keys like jump_routes(from_system, to_system) come out ordered for free.
export interface AdminColumn {
name: string
type: string
notNull: boolean
hasDefault: boolean
pk: number
}
function tableColumns(db: GameDb, name: string): AdminColumn[] {
const res = db.raw.exec(`PRAGMA table_info(${name})`) // name validated by assertTable — no injection path
return (res[0]?.values ?? []).map((v) => ({
name: String(v[1]),
type: String(v[2]).toUpperCase(),
notNull: v[3] === 1,
hasDefault: v[4] !== null && v[4] !== undefined,
pk: Number(v[5]) || 0,
}))
}
function rowCount(db: GameDb, name: string): number {
return Number(db.raw.exec(`SELECT COUNT(*) FROM ${name}`)[0]?.values[0][0] ?? 0)
}
export interface AdminTableInfo {
name: string
// pk mirrors PRAGMA table_info: position within the primary key (0 = not one), so the UI can
// build row keys for any table, composite included.
columns: { name: string; type: string; pk: number }[]
count: number
}
export async function listAdminTables(db: GameDb): Promise<AdminTableInfo[]> {
const out: AdminTableInfo[] = []
for (const table of Object.values(ADMIN_TABLES)) {
const name = getTableName(table)
const cols = tableColumns(db, name)
out.push({ name, columns: cols.map((c) => ({ name: c.name, type: c.type, pk: c.pk })), count: rowCount(db, name) })
}
return out.sort((a, b) => a.name.localeCompare(b.name))
}
export interface AdminRows {
rows: Record<string, unknown>[]
total: number
}
const MAX_ROWS = 200
export async function readAdminRows(db: GameDb, name: string, limit = 50, offset = 0): Promise<AdminRows> {
assertTable(name)
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_ROWS) throw new AdminError(400, `limit must be an integer in [1, ${MAX_ROWS}]`)
if (!Number.isInteger(offset) || offset < 0) throw new AdminError(400, 'offset must be a non-negative integer')
const res = db.raw.exec(`SELECT * FROM ${name} LIMIT ? OFFSET ?`, [limit, offset])
return {
rows: (res[0]?.values ?? []).map((v) => Object.fromEntries(res[0].columns.map((c, i) => [c, v[i]]))),
total: rowCount(db, name),
}
}
// sql.js throws a plain Error on constraint violations (UNIQUE/FK/NOT NULL) — map those to 409.
function runWrite(db: GameDb, sql: string, params: SqlParam[]): void {
try {
db.raw.run(sql, params)
} catch (err) {
throw new AdminError(409, err instanceof Error ? err.message : String(err))
}
}
// JSON scalar → SQLite value for the column's declared type. Booleans fold into INTEGER columns
// (the schema stores flags as 0/1); null is only allowed where the column allows it.
function coerce(value: unknown, col: AdminColumn): SqlParam {
if (value === null) {
if (col.notNull && !col.hasDefault) throw new AdminError(400, `column '${col.name}' cannot be NULL`)
return null
}
const isInt = col.type.includes('INT')
const isReal = /REAL|FLOA|DOUB|NUM/.test(col.type)
if (isInt || isReal) {
const n = typeof value === 'boolean' ? (value ? 1 : 0) : typeof value === 'number' ? value : NaN
if (!Number.isFinite(n)) throw new AdminError(400, `column '${col.name}' must be a number`)
return isInt && !isReal ? Math.trunc(n) : n
}
if (typeof value !== 'string') throw new AdminError(400, `column '${col.name}' must be a string or null`)
return value
}
function checkKey(name: string, cols: AdminColumn[], key: unknown): Record<string, number> {
const pk = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
if (typeof key !== 'object' || key === null || Array.isArray(key)) throw new AdminError(400, `key must be an object with the primary key columns (${pk.map((c) => c.name).join(', ')})`)
const out: Record<string, number> = {}
for (const col of pk) {
const v = (key as Record<string, unknown>)[col.name]
if (typeof v !== 'number' || !Number.isInteger(v)) throw new AdminError(400, `key column '${col.name}' must be an integer`)
out[col.name] = v
}
return out
}
function pkWhere(cols: AdminColumn[], key: Record<string, number>): { sql: string; params: number[] } {
const pks = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
return {
sql: pks.map((c) => `${c.name} = ?`).join(' AND '),
params: pks.map((c) => key[c.name]),
}
}
function fetchRow(db: GameDb, name: string, whereSql: string, params: SqlParam[]): Record<string, unknown> | null {
const res = db.raw.exec(`SELECT * FROM ${name} WHERE ${whereSql}`, params)
const v = res[0]?.values[0]
return v ? Object.fromEntries(res[0].columns.map((c, i) => [c, v[i]])) : null
}
export async function insertAdminRow(db: GameDb, name: string, values: unknown): Promise<Record<string, unknown>> {
assertTable(name)
if (typeof values !== 'object' || values === null || Array.isArray(values)) throw new AdminError(400, 'body must be a JSON object of column → value')
const cols = tableColumns(db, name)
const byName = new Map(cols.map((c) => [c.name, c]))
const entries: [string, SqlParam][] = []
for (const [k, v] of Object.entries(values as Record<string, unknown>)) {
const col = byName.get(k)
if (!col) throw new AdminError(400, `Unknown column '${k}' in table '${name}'`)
entries.push([k, coerce(v, col)])
}
for (const col of cols) {
// Autoincrement single-PK ids are optional; everything else NOT NULL without a default is required.
const pks = cols.filter((c) => c.pk > 0)
if (!entries.some(([k]) => k === col.name) && col.notNull && !col.hasDefault && !(col.pk > 0 && pks.length === 1)) {
throw new AdminError(400, `Missing required column '${col.name}'`)
}
}
const names = entries.map(([k]) => k)
runWrite(db, `INSERT INTO ${name} (${names.join(', ')}) VALUES (${entries.map(() => '?').join(', ')})`, entries.map(([, v]) => v))
// Read last_insert_rowid() BEFORE persist(): sql.js's export() resets the per-connection value.
const lastId = Number(db.raw.exec('SELECT last_insert_rowid()')[0]?.values[0][0])
await persist(db)
// Read the row back: explicit PK values win, otherwise fall back to last_insert_rowid().
const pks = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
if (pks.length === 1 && !entries.some(([k]) => k === pks[0].name)) {
return fetchRow(db, name, `${pks[0].name} = ?`, [lastId])!
}
const key: Record<string, number> = {}
for (const c of pks) {
const e = entries.find(([k]) => k === c.name)
if (!e) throw new AdminError(400, `Missing primary key column '${c.name}'`)
key[c.name] = Number(e[1])
}
const where = pkWhere(cols, key)
return fetchRow(db, name, where.sql, where.params)!
}
export async function updateAdminRow(db: GameDb, name: string, key: unknown, values: unknown): Promise<Record<string, unknown>> {
assertTable(name)
const cols = tableColumns(db, name)
const byName = new Map(cols.map((c) => [c.name, c]))
const pkKey = checkKey(name, cols, key)
if (typeof values !== 'object' || values === null || Array.isArray(values)) throw new AdminError(400, 'values must be a JSON object of column → value')
const entries: [string, SqlParam][] = []
for (const [k, v] of Object.entries(values as Record<string, unknown>)) {
const col = byName.get(k)
if (!col) throw new AdminError(400, `Unknown column '${k}' in table '${name}'`)
entries.push([k, coerce(v, col)])
}
if (entries.length === 0) throw new AdminError(400, 'values must contain at least one column')
const where = pkWhere(cols, pkKey)
runWrite(db, `UPDATE ${name} SET ${entries.map(([k]) => `${k} = ?`).join(', ')} WHERE ${where.sql}`, [...entries.map(([, v]) => v), ...where.params])
await persist(db)
const row = fetchRow(db, name, where.sql, where.params)
if (!row) throw new AdminError(404, `No row in '${name}' with that key`)
return row
}
export async function deleteAdminRow(db: GameDb, name: string, key: unknown): Promise<Record<string, unknown>> {
assertTable(name)
const cols = tableColumns(db, name)
const pkKey = checkKey(name, cols, key)
const where = pkWhere(cols, pkKey)
const row = fetchRow(db, name, where.sql, where.params)
if (!row) throw new AdminError(404, `No row in '${name}' with that key`)
runWrite(db, `DELETE FROM ${name} WHERE ${where.sql}`, where.params)
await persist(db)
return row
}
+7 -4
View File
@@ -6,7 +6,7 @@ import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions' import { checkMissionCompletion, topUpMissionBoard } from './missions'
import { TECHS } from './tech' import { TECHS } from './tech'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world' import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
// Save lifecycle: auto-init on first hit, world state reads, and the jump action. // 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 // Single-player by design — exactly one save row ever exists, so every query below is
@@ -217,10 +217,13 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
if (current.id === target.id) throw new JumpError(409, 'Already at this system') if (current.id === target.id) throw new JumpError(409, 'Already at this system')
const routes = await db.db.select().from(schema.jumpRoutes) const routes = await db.db.select().from(schema.jumpRoutes)
const adjacent = routes.some((r) => r.fromSystem === current.id && r.toSystem === target.id) // #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop jump
if (!adjacent) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`) // costs one turn per hop, so single-hop jumps keep their existing cost.
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
const turn = save.turn + 1 const turn = save.turn + hopCount
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id)) await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id)) await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
+3 -1
View File
@@ -37,7 +37,9 @@ export const EFFECTS: Record<string, TechEffect & { description: string }> = {
BUILD_WEAPONS: { kind: 'unlock_build', shipType: 'WEAPONS', description: 'Unlocks building the WEAPONS system' }, BUILD_WEAPONS: { kind: 'unlock_build', shipType: 'WEAPONS', description: 'Unlocks building the WEAPONS system' },
REACTOR_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'REACTOR', bonus: 2, description: `Raises REACTOR max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` }, REACTOR_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'REACTOR', bonus: 2, description: `Raises REACTOR max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` },
HULL_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'HULL', bonus: 3, description: `Raises HULL max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` }, HULL_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'HULL', bonus: 3, description: `Raises HULL max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
NAV_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'NAV COMPUTER', bonus: 2, description: `Raises NAV COMPUTER max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` }, // #35 — the researched state of this tech also extends the jump range to 2 hops (enforced in
// lib/game/save.ts, which keys off the NAV_AI slug); the description is what the SKILL TREE card shows.
NAV_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'NAV COMPUTER', bonus: 2, description: `Raises NAV COMPUTER max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2}) and enables multi-system jumps (up to 2 hops in one action)` },
WEAPONS_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'WEAPONS', bonus: 2, description: `Raises WEAPONS max level by 2 (once built)` }, WEAPONS_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'WEAPONS', bonus: 2, description: `Raises WEAPONS max level by 2 (once built)` },
REACTOR_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'REACTOR', bonus: 3, description: `Raises REACTOR max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` }, REACTOR_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'REACTOR', bonus: 3, description: `Raises REACTOR max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
} }
+35 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world' import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, rollExploration } from './world'
describe('generateStartRegion', () => { describe('generateStartRegion', () => {
const region = generateStartRegion(mulberry32(42)) const region = generateStartRegion(mulberry32(42))
@@ -145,3 +145,37 @@ describe('rollExploration', () => {
expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--'])) expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--']))
}) })
}) })
describe('reachableWithin (#35 jump range)', () => {
// Chain 1—2—3—4 with a branch off 2: 2—5. Distances from 1: 2→1, 3/5→2, 4→3.
const edges: [number, number][] = [[1, 2], [2, 3], [3, 4], [2, 5]]
it('maxHops=1 returns only direct neighbours at distance 1 (the pre-NAV-AI range)', () => {
expect(reachableWithin(edges, 1, 1)).toEqual(new Map([[2, 1]]))
})
it('maxHops=2 adds the two-hop systems and stops before three', () => {
expect(reachableWithin(edges, 1, 2)).toEqual(new Map([ [2, 1], [3, 2], [5, 2] ]))
})
it('larger maxHops keeps walking until the graph is exhausted', () => {
expect(reachableWithin(edges, 1, 5)).toEqual(new Map([ [2, 1], [3, 2], [5, 2], [4, 3] ]))
})
it('never includes the origin and handles empty/isolated input', () => {
expect(reachableWithin(edges, 1, 3).has(1)).toBe(false)
expect(reachableWithin([], 1, 2)).toEqual(new Map())
expect(reachableWithin([[9, 10]], 1, 2)).toEqual(new Map())
})
it('accepts double-directed route rows (the save.ts input shape) identically', () => {
const directed: [number, number][] = [[1, 2], [2, 1], [2, 3], [3, 2]]
expect(reachableWithin(directed, 1, 2)).toEqual(new Map([ [2, 1], [3, 2] ]))
})
it('on a fresh start region every system is one hop away — two-hop targets only exist after an expansion', () => {
const region = generateStartRegion(mulberry32(42)) // edges index into [start, ...neighbors], start = 0
expect(reachableWithin(region.edges, 0, 1).size).toBe(region.neighbors.length)
expect(reachableWithin(region.edges, 0, 2).size).toBe(region.neighbors.length)
})
})
+30
View File
@@ -136,6 +136,36 @@ export function generateRing(
return { systems, edges: ringEdges(systems.length) } return { systems, edges: ringEdges(systems.length) }
} }
// BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count.
// `edges` may be undirected pairs (WorldState.edges) or double-directed route rows — duplicates are
// harmless because a visited system is never re-queued. fromId itself is not in the result.
export function reachableWithin(edges: [number, number][], fromId: number, maxHops: number): Map<number, number> {
const adj = new Map<number, number[]>()
for (const [a, b] of edges) {
if (!adj.has(a)) adj.set(a, [])
if (!adj.has(b)) adj.set(b, [])
adj.get(a)!.push(b)
adj.get(b)!.push(a)
}
const dist = new Map<number, number>()
const seen = new Set([fromId]) // the origin is never re-queued or reported
let frontier = [fromId]
for (let hop = 1; hop <= maxHops; hop++) {
const next: number[] = []
for (const id of frontier) {
for (const nb of adj.get(id) ?? []) {
if (!seen.has(nb)) {
seen.add(nb)
dist.set(nb, hop)
next.push(nb)
}
}
}
frontier = next
}
return dist
}
// --- Bodies (planets + moons) and exploration ------------------------------- // --- Bodies (planets + moons) and exploration -------------------------------
export interface WorldMoon { export interface WorldMoon {
+2
View File
@@ -5,6 +5,8 @@ import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents every public endpoint', () => { it('documents every public endpoint', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([ expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/admin/tables',
'/api/admin/tables/{name}/rows',
'/api/crew', '/api/crew',
'/api/events', '/api/events',
'/api/explore', '/api/explore',
+296 -4
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: { info: {
title: 'SpaceWanderer API', title: 'SpaceWanderer API',
description: description:
'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between adjacent solar systems.', 'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between solar systems within range (adjacent by default; two hops once NAVIGATION AI is researched).',
// Sourced from package.json so it stays in sync with every version bump. // Sourced from package.json so it stays in sync with every version bump.
version: pkg.version, version: pkg.version,
}, },
@@ -52,9 +52,9 @@ export const openApiSpec = {
}, },
'/api/jump': { '/api/jump': {
post: { post: {
summary: 'Jump to an adjacent solar system', summary: 'Jump to a solar system within range',
description: description:
'Validates that the target is a jump route away from the current system, sets it as current (discovering it if new and generating its neighbours), increments the turn counter and writes a jump event. Returns the fresh world state.', 'Validates that the target is within jump range of the current system — one hop away, or two hops once NAVIGATION AI is researched (#35) — sets it as current (discovering it if new and generating its neighbours), advances the turn counter by the hop count and writes a jump event. Returns the fresh world state.',
operationId: 'jumpToSystem', operationId: 'jumpToSystem',
requestBody: { requestBody: {
required: true, required: true,
@@ -90,7 +90,7 @@ export const openApiSpec = {
}, },
}, },
409: { 409: {
description: 'Target is not adjacent to the current system (or is the current system itself)', description: 'Target is outside jump range of the current system — more than one hop away, or more than two hops without NAVIGATION AI researched (or is the current system itself)',
content: { content: {
'application/json': { 'application/json': {
schema: { $ref: '#/components/schemas/Error' }, schema: { $ref: '#/components/schemas/Error' },
@@ -557,6 +557,240 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/admin/tables': {
get: {
summary: 'Admin: list DB tables',
description:
"The game's SQLite tables exposed by the admin CRUD (#38) — name, columns (with primary-key positions) and row count. No auth by design.",
operationId: 'adminListTables',
responses: {
200: {
description: 'Table list with column metadata and row counts',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } },
},
},
},
},
},
},
'/api/admin/tables/{name}/rows': {
get: {
summary: 'Admin: read rows of a table',
description: 'Rows of one admin table as objects keyed by column name, capped at 200 per page.',
operationId: 'adminReadRows',
parameters: [
{
name: 'name',
in: 'path',
required: true,
schema: { type: 'string' },
description: "Table name as listed by /api/admin/tables (e.g. 'saves')",
},
{
name: 'limit',
in: 'query',
required: false,
schema: { type: 'integer', minimum: 1, maximum: 200 },
description: 'Rows per page (default 50)',
},
{
name: 'offset',
in: 'query',
required: false,
schema: { type: 'integer', minimum: 0 },
description: 'Row offset for paging (default 0)',
},
],
responses: {
200: {
description: 'Rows plus the total row count of the table',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AdminRows' },
},
},
},
400: {
description: 'limit/offset out of range',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: "Unknown table name",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
post: {
summary: 'Admin: insert a row',
description: "Insert one row. Body is an object of column → value (numbers for INTEGER/REAL columns, strings or null for TEXT); autoincrement ids may be omitted.",
operationId: 'adminInsertRow',
parameters: [
{
name: 'name',
in: 'path',
required: true,
schema: { type: 'string' },
description: "Table name as listed by /api/admin/tables",
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AdminRowValues' },
},
},
},
responses: {
200: {
description: 'The inserted row',
content: {
'application/json': {
schema: { type: 'object' },
},
},
},
400: {
description: 'Malformed body, unknown column, missing required column or wrong value type',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: "Unknown table name",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'Constraint violation (UNIQUE or foreign key)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
patch: {
summary: 'Admin: update a row',
description: "Update one row by primary key. Body is { key, values } — key holds the table's PK columns (composite keys supported), values the new column → value pairs.",
operationId: 'adminUpdateRow',
parameters: [
{
name: 'name',
in: 'path',
required: true,
schema: { type: 'string' },
description: "Table name as listed by /api/admin/tables",
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AdminRowUpdate' },
},
},
},
responses: {
200: {
description: 'The updated row',
content: {
'application/json': {
schema: { type: 'object' },
},
},
},
400: {
description: 'Malformed body, bad key or unknown column',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: "Unknown table name, or no row with that key",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'Constraint violation (UNIQUE or foreign key)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
delete: {
summary: 'Admin: delete a row',
description: "Delete one row by primary key. Body is { key } with the table's PK columns.",
operationId: 'adminDeleteRow',
parameters: [
{
name: 'name',
in: 'path',
required: true,
schema: { type: 'string' },
description: "Table name as listed by /api/admin/tables",
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/AdminRowKey' },
},
},
},
responses: {
200: {
description: 'The deleted row',
content: {
'application/json': {
schema: { type: 'object' },
},
},
},
400: {
description: 'Malformed body or bad key',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: "Unknown table name, or no row with that key",
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
}, },
components: { components: {
schemas: { schemas: {
@@ -790,6 +1024,64 @@ export const openApiSpec = {
}, },
], ],
}, },
AdminColumn: {
type: 'object',
properties: {
name: { type: 'string', description: "SQL column name, e.g. 'save_id'" },
type: { type: 'string', enum: ['INTEGER', 'REAL', 'TEXT'], description: 'Declared SQLite type' },
pk: { type: 'integer', description: 'Position within the primary key (0 = not a PK); composite keys come out ordered' },
},
required: ['name', 'type', 'pk'],
},
AdminTable: {
type: 'object',
properties: {
name: { type: 'string', description: "SQL table name, e.g. 'saves'" },
columns: { type: 'array', items: { $ref: '#/components/schemas/AdminColumn' } },
count: { type: 'integer', description: 'Current row count' },
},
required: ['name', 'columns', 'count'],
},
AdminRows: {
type: 'object',
properties: {
rows: {
type: 'array',
description: 'Rows as objects keyed by column name (null for NULL cells)',
items: { type: 'object' },
},
total: { type: 'integer', description: 'Total row count of the table (before paging)' },
},
required: ['rows', 'total'],
},
AdminRowValues: {
type: 'object',
additionalProperties: true,
description: "Column → value pairs for an insert — numbers for INTEGER/REAL columns, strings or null for TEXT; autoincrement ids may be omitted",
},
AdminRowKey: {
type: 'object',
properties: {
key: {
type: 'object',
additionalProperties: { type: 'integer' },
description: "The table's primary-key columns (composite keys supported)",
},
},
required: ['key'],
},
AdminRowUpdate: {
allOf: [
{ $ref: '#/components/schemas/AdminRowKey' },
{
type: 'object',
properties: {
values: { $ref: '#/components/schemas/AdminRowValues' },
},
required: ['values'],
},
],
},
Error: { Error: {
type: 'object', type: 'object',
properties: { properties: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "space-wanderer", "name": "space-wanderer",
"version": "0.9.9", "version": "0.9.12",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",