Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8cb4da06 | ||
|
|
0d2370850f | ||
|
|
be11aac356 | ||
|
|
fdc5624b0c | ||
|
|
ff4ced9d71 | ||
|
|
d14ffdcf2f | ||
|
|
d083b5b708 | ||
|
|
c012b0fe56 | ||
|
|
6483d510ef | ||
|
|
bc6bc7dd07 | ||
|
|
3a786913df | ||
|
|
978a1d119d | ||
|
|
a57c590bae | ||
|
|
1710e50b63 | ||
|
|
1bb04d2a51 | ||
|
|
e27ff7015e | ||
|
|
e6c88b10c2 | ||
|
|
f4690bc1a2 | ||
|
|
4b030716f7 | ||
|
|
06f8a28e96 | ||
|
|
36ff412f0b | ||
|
|
c35edc60d0 | ||
|
|
f545792c36 | ||
|
|
593cca5dce | ||
|
|
d2fb252a98 | ||
|
|
bf6d8714e6 | ||
|
|
32dda04e92 | ||
|
|
ef1d282770 | ||
|
|
18ca5eae6c | ||
|
|
b05ac32da6 | ||
|
|
85627201f8 | ||
|
|
fb11a8ef81 | ||
|
|
37b193ffb8 | ||
|
|
760a45fc4b | ||
|
|
0b435a583b | ||
|
|
29e8ac6909 | ||
|
|
21abf8768f | ||
|
|
8d678d73ce | ||
|
|
0d5858695a |
@@ -1,5 +1,69 @@
|
||||
# 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
|
||||
- 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.
|
||||
|
||||
## v0.9.8 — 2026-09-29 10:20
|
||||
- The OPERATIONS tab bar now stays pinned while its content scrolls (#31): the TabsList is `position: sticky; top: 0` with a z-index above the panel content, so it pins to the top of the OPERATIONS scroll area in wide mode (where only the center pane scrolls, #21) and to the viewport when the body scrolls in narrow mode. Its existing opaque background (`--surface-raised`) keeps scrolled content from showing through; no layout restructure needed — #21 behavior is untouched.
|
||||
- e2e: new smoke spec — shrink the viewport so WORLD MAP's fixed-height chart overflows the pane, scroll the panel to the bottom (asserting a real offset), and check the tablist's bounding box hasn't moved.
|
||||
|
||||
## v0.9.7 — 2026-09-29 09:59
|
||||
- LOCAL SYSTEM no longer shows the previous system after a jump (#30): WorldMapPanel owns the world state and applied the `/api/jump` response locally, so MissionControl's lifted `/api/system` state (which drives the LOCAL SYSTEM tab, CARGO HOLD and the SECTOR row) went stale until a page refresh. The panel now takes an `onJumped` callback that MissionControl wires to a `/api/system` refetch after every successful jump — same failure policy as the mount fetch (keep old state on error).
|
||||
- e2e: new smoke spec — click-to-jump on WORLD MAP, wait for the chart's turn counter to update, switch to LOCAL SYSTEM and assert its header shows the NEW current system from `/api/world` (no `page.reload()` workaround; one jump per run/retry, same pattern as the existing jump test).
|
||||
|
||||
## v0.9.6 — 2026-09-29 09:41
|
||||
- The active operations tab now survives a page refresh (#27): the selected tab ID is persisted to `localStorage` (`sw-active-tab`) on every change and restored on mount when it matches one of the seven valid tab IDs (unknown/stale values fall back to WORLD MAP). The Radix Tabs are controlled for this; SSR and first client render still agree on the default, so there is no hydration mismatch.
|
||||
- e2e: new smoke spec — click CREW, reload, assert `aria-selected=true` again (fresh context per run/retry, no API state touched, idempotent).
|
||||
|
||||
## v0.9.5 — 2026-09-29 09:13
|
||||
- Fixed the production freeze reported after the v0.9.3 (#21) deploy ("entire UI system is now fucked"). Root cause was NOT the #21 layout change (verified healthy across all 7 tabs and multiple viewports — no clipping, middle pane scrolls internally, side panes fixed): it was world name generation. `makeName` draws from a finite pool of 16×8=128 system names with an unbounded rejection loop, so once a save grew past ~128 systems (turns of play / e2e jump loops), any jump into an undiscovered system spun forever on Node's single thread — sql.js runs synchronously, so the whole process blocked and EVERY API route hung, leaving all panels stuck at LOADING.
|
||||
- `makeName` (lib/game/world.ts) now falls back to numbered variants (`Kepler Drift 2`) once the pool is exhausted, so it always terminates; `makeCrewName` (lib/game/crew.ts) gets the same guard — the roster has no cap and its 16×22=352 pool would eventually exhaust too.
|
||||
- Tests: unit — both name generators terminate with unique numbered names after full pool exhaustion (80 tests green, was 78); e2e unchanged — no spec depends on the old behavior and a fresh CI stack never approaches 128 systems.
|
||||
|
||||
## v0.9.4 — 2026-09-29 08:29
|
||||
- Top bar (#23): the `◄ SHIP` label in the mission control header is replaced with the app version number (e.g. `v0.9.4`). The header reads it from `package.json` via the same `@/package.json` import that `/api/version` and the OpenAPI spec use, so there is a single source of truth — no second hardcoded copy.
|
||||
- e2e: no spec changes needed — no spec asserted on the old `◄ SHIP` label (the landing-page test only checks the SpaceWanderer heading). All 15 specs compile unchanged.
|
||||
|
||||
## v0.9.3 — 2026-09-29 08:20
|
||||
- Layout (#21): only the middle column scrolls now — the app is pinned to the viewport (`height: 100vh` + `overflow: hidden` on the root in wide mode) so body-level scrolling no longer moves the side panes. Left/right columns keep their existing `overflow-y: auto`, so any overflowing content (e.g. a long planet list) scrolls inside its own track instead.
|
||||
- Added `min-height: 0` to the BODY wrapper and the 3-column grid — flex/grid items default to a content-based minimum size, which is what let the page grow past the viewport before.
|
||||
- The middle OPERATIONS panel became a flex column with its PanelContent as the scroll area (`flex: 1; min-height: 0; overflow-y: auto`): long tab content scrolls inside the panel while the "OPERATIONS // SHIP COMMAND" header stays pinned — without this, the Panel root's default `overflow: hidden` would have clipped it.
|
||||
- Narrow layout (<900px) is unchanged: stacked columns with natural body scroll (the viewport pin applies to wide mode only).
|
||||
- e2e: no spec changes needed — none use mouse.wheel/scrollTo, and Playwright scrolls click targets into view within any scroll container. All 15 specs compile unchanged.
|
||||
|
||||
## v0.9.2 — 2026-09-29 08:00
|
||||
- WORLD MAP (#19): the bottom JUMP ROUTES button list is gone — jumps now happen by clicking galactic chart fields. Systems adjacent to the current one (the same set as before, undiscovered included) are clickable nodes with a pointer cursor and a `// JUMP` sublabel; the current system stays bright green with CURRENT, unreachable systems are dimmed (OFFLINE) and not clickable.
|
||||
- NodeGraph (`components/ui/node-graph.tsx`) gained two optional, backward-compatible additions: an `onNodeClick(id)` prop that turns non-disabled nodes into buttons (click + Enter/Space, role=button, aria-label, focusable), and a per-node `disabled` flag — while a jump is in flight every node locks, mirroring the old disabled-button behaviour.
|
||||
- The jump flow itself is unchanged: same POST /api/jump call, world state refreshed from the response (turn counter, current system, new routes), event log entry written server-side as before.
|
||||
- e2e: two smoke tests touched the removed buttons and were updated — "world map shows…" now asserts the adjacent systems render as clickable chart fields (role=button count matches API adjacency) and "jumping to an adjacent system" clicks the target node by its accessible name. All 15 specs compile; `jumpToFreshSystem` is API-driven and needed no change.
|
||||
|
||||
## v0.9.1 — 2026-09-29 06:47
|
||||
- UI layout pass (visual only — no game logic or API changes): CARGO HOLD // INVENTORY moved out of the LOCAL SYSTEM tab into a persistent panel in the right column; the UPCOMING ENCOUNTERS placeholder was removed entirely; the SHIP DATA placeholder was merged into VESSEL METRICS.
|
||||
- Grid: `260px / 1fr / 240px` → `320px / 1fr / 360px` (left pane ~+23%, right pane ~50% wider).
|
||||
- Cargo state wiring follows the existing lifted-state pattern: MissionControl fetches `/api/system` on mount and owns the SystemState; LocalSystemPanel receives it as a prop, keeps its planet list + scan result + explore action, and reports each scan's response up via `onChanged` (same as ShipSystemsPanel) — so CARGO HOLD mirrors scans live with no polling.
|
||||
- VESSEL METRICS now carries the two SHIP DATA rows that had real values: SECTOR (current system name from the lifted state) and TURN (`ship.turn`). The rest was dropped — HULL INTEGRITY / REACTOR OUTPUT / CREW ACTIVE duplicate the existing progress bars, TOTAL MODULES duplicates the left column's "N ONLINE" badge, DRIFT VELOCITY has no game equivalent.
|
||||
- e2e: no assertion changes needed — the LOCAL SYSTEM tests only assert panel internals (header, EXPLORE buttons, SCAN RESULT) and API state; CARGO HOLD / UPCOMING ENCOUNTERS / SHIP DATA were never referenced by any spec. All 15 specs compile unchanged.
|
||||
|
||||
## v0.9.0 — 2026-09-29 02:47
|
||||
- EVENT LOG tab — the last of the seven tabs to go live (issue #11): the terminal now renders the save's real game events instead of a placeholder feed. Every action already appends an event row (new game, jump, scan, research, ship upgrade/expand/build, mission accept + completion); this issue adds the read side and the rendering.
|
||||
- New endpoint: `GET /api/events` → latest N events, newest first (`{id, turn, type, message, createdAt}`), auto-initializes like all other reads. Optional `?limit=` (default 50, cap 200; non-integers or out-of-range values → 400). OpenAPI spec in code updated (14 → 15 paths) with the Event schema enumerating the eight event types.
|
||||
- Read model (`lib/game/events.ts`): `getEvents(db, limit)` — ORDER BY id DESC LIMIT N over the existing append-only events table; `parseEventLimit` for the query param. No schema change — the events table and all its writers already existed.
|
||||
- EVENT LOG tab: self-fetching EventLogPanel (MissionPanel pattern — refetches on each tab open). One terminal line per event: `T<turn> // <MESSAGE>` uppercased, with the event's local time as the timestamp; new_game lines render as system lines. Placeholder feed + now() removed from mission-control.tsx.
|
||||
- Tests: unit — parseEventLimit default/accept/reject (400) and getEvents newest-first ordering + slicing; writer coverage gaps closed so every action type is asserted with correct turn/type/message (expand had no event assertion at all; build and mission-complete lacked the turn check); e2e — after a jump + scan, both events appear in GET /api/events with the newer one first and the terminal renders both lines (idempotent across retries: asserts on this run's own entries by turn/message, never absolute counts), plus the latest five log lines come back identical (ids included) after the mid-run container restart.
|
||||
|
||||
## v0.8.0 — 2026-09-29 01:56
|
||||
- MISSIONS tab — contract generation, acceptance and completion (issue #10): the first objective loop on top of issue #9's research. The board is generated deterministically from the save seed; one mission is ACTIVE at a time; completion is checked after every jump and scan and grants its reward into the cargo hold.
|
||||
- New table (`lib/db/schema.ts` + DDL): `missions` (id/save_id/type/description/target_system_id/condition_kind/progress_target/progress/reward_kind/reward_qty/state). Seeded on new game AND as backfill step v6 — same pattern as tech; saves created by older code get their board on first read. `SCHEMA_VERSION` 5 → 6.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ensureSave } from '@/lib/game/save'
|
||||
import { EventError, getEvents, parseEventLimit } from '@/lib/game/events'
|
||||
|
||||
// The latest game events, newest first; auto-initializes on first hit.
|
||||
export async function GET(req: Request) {
|
||||
const db = await getGameDb()
|
||||
await ensureSave(db) // getEvents assumes a save exists
|
||||
|
||||
let limit: number
|
||||
try {
|
||||
limit = parseEventLimit(new URL(req.url).searchParams.get('limit'))
|
||||
} catch (err) {
|
||||
if (err instanceof EventError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return Response.json(await getEvents(db, limit))
|
||||
}
|
||||
@@ -160,6 +160,12 @@ body {
|
||||
-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 {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "^16.3.5",
|
||||
"react": "^19.3.0",
|
||||
@@ -20,6 +23,9 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/d3-selection": "^3.0.12",
|
||||
"@types/d3-zoom": "^3.0.9",
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
@@ -221,6 +227,16 @@
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-selection": ["@types/d3-selection@3.0.12", "", {}, "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw=="],
|
||||
|
||||
"@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
|
||||
@@ -295,6 +311,28 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||
|
||||
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||
|
||||
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
|
||||
|
||||
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||
import type { EventDto } from '@/lib/game/events'
|
||||
|
||||
// EVENT LOG tab — live data. Fetches /api/events on mount (Radix Tabs unmounts inactive tabs, so it
|
||||
// refetches whenever the tab is opened). One terminal line per event: T<turn> // <MESSAGE>, newest first.
|
||||
|
||||
export function EventLogPanel() {
|
||||
const [lines, setLines] = useState<TerminalLine[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/events')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as EventDto[]
|
||||
})
|
||||
.then((events) => {
|
||||
if (!cancelled) {
|
||||
setLines(
|
||||
events.map((e) => ({
|
||||
type: e.type === 'new_game' ? 'system' : 'output',
|
||||
text: `T${e.turn} // ${e.message.toUpperCase()}`,
|
||||
timestamp: new Date(e.createdAt).toLocaleTimeString('en-GB', { hour12: false }),
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
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' }}>{error.toUpperCase()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!lines) {
|
||||
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 EVENT LOG…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Terminal lines={lines} title="SHIP LOG" height="22rem" style={{ width: '100%' }} />
|
||||
)
|
||||
}
|
||||
+37
-86
@@ -1,69 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, 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.
|
||||
// EXPLORE button per unexplored row. The parent (MissionControl) owns the /api/system state so
|
||||
// the right column's CARGO HOLD mirrors scans immediately; this panel reports each scan's
|
||||
// response up via onChanged and keeps only its own UI bits locally (no polling). Turn-based:
|
||||
// one scan costs a turn, each body once.
|
||||
|
||||
export function LocalSystemPanel() {
|
||||
const [state, setState] = useState<SystemState | null>(null)
|
||||
interface LocalSystemPanelProps {
|
||||
system: SystemState | null
|
||||
onChanged: (next: SystemState) => void
|
||||
}
|
||||
|
||||
export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) {
|
||||
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}`)
|
||||
onChanged(body) // fresh state straight from the response — parent mirrors it to CARGO HOLD
|
||||
setLastOutcome(body.outcome)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setExploringId(null)
|
||||
}
|
||||
},
|
||||
[onChanged]
|
||||
)
|
||||
|
||||
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) {
|
||||
if (!system) {
|
||||
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>
|
||||
@@ -88,9 +69,9 @@ export function LocalSystemPanel() {
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
EXPLORABLE WORLDS // {state.systemName.toUpperCase()} — TURN {state.turn}
|
||||
EXPLORABLE WORLDS // {system.systemName.toUpperCase()} — TURN {system.turn}
|
||||
</div>
|
||||
{state.planets.map((row, i) => {
|
||||
{system.planets.map((row, i) => {
|
||||
const depth = row.parent !== null ? 1 : 0
|
||||
const badge = badgeFor(row.explored, row.hazard)
|
||||
return (
|
||||
@@ -102,7 +83,7 @@ export function LocalSystemPanel() {
|
||||
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',
|
||||
borderBottom: i < system.planets.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
@@ -117,7 +98,7 @@ export function LocalSystemPanel() {
|
||||
{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}
|
||||
{depth ? `MOON OF ${system.planets.find((p) => p.id === row.parent)?.name ?? '?'} // ${row.type}` : row.type}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
@@ -147,36 +128,6 @@ export function LocalSystemPanel() {
|
||||
</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>
|
||||
|
||||
+152
-165
@@ -1,29 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AdminPanel } from '@/components/admin-panel'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { StatCard } from '@/components/ui/stat-card'
|
||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||
import { CrewPanel } from '@/components/crew-panel'
|
||||
import { EventLogPanel } from '@/components/event-log'
|
||||
import { LocalSystemPanel } from '@/components/local-system'
|
||||
import { MissionPanel } from '@/components/missions'
|
||||
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||
import { SkillTreePanel } from '@/components/skill-tree'
|
||||
import { WorldMapPanel } from '@/components/world-map'
|
||||
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
||||
import type { CrewDto } from '@/lib/game/save'
|
||||
import type { CrewDto, SystemState } from '@/lib/game/save'
|
||||
import type { ShipState } from '@/lib/game/ship'
|
||||
import type { TechDto } from '@/lib/game/tech'
|
||||
import pkg from '@/package.json'
|
||||
|
||||
// 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, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE and MISSIONS tabs render live data
|
||||
// (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions); the EVENT LOG tab
|
||||
// keeps placeholder content until its own issue lands. Single player, no auth, by design.
|
||||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven
|
||||
// tabs render live data (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions,
|
||||
// /api/events); the right column's CARGO HOLD renders from the lifted /api/system state. Single
|
||||
// player, no auth, by design.
|
||||
|
||||
function useNarrow(threshold = 900): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
@@ -37,46 +40,27 @@ function useNarrow(threshold = 900): boolean {
|
||||
return narrow
|
||||
}
|
||||
|
||||
function now(): string {
|
||||
return new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||
}
|
||||
|
||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
||||
|
||||
const encounters = [
|
||||
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
||||
{ name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
|
||||
{ name: 'ANOMALY FIELD', when: 'T-48:12', id: '#E-104' },
|
||||
]
|
||||
|
||||
const shipData = [
|
||||
{ label: 'TOTAL MODULES', value: '34' },
|
||||
{ label: 'HULL INTEGRITY', value: '87%' },
|
||||
{ label: 'REACTOR OUTPUT', value: '94.2%' },
|
||||
{ label: 'CREW ACTIVE', value: '3 / 6' },
|
||||
{ label: 'SECTOR', value: '7G // DRIFT LANE' },
|
||||
{ label: 'DRIFT VELOCITY', value: '0.4 KM/S' },
|
||||
{ label: 'TURN', value: '12' },
|
||||
]
|
||||
|
||||
const logLines: TerminalLine[] = [
|
||||
{ type: 'system', text: 'SPACEWANDERER // INITIALIZING SHIP SYSTEMS...', timestamp: now() },
|
||||
{ type: 'output', text: 'HULL SCAN COMPLETE — INTEGRITY 87%', timestamp: now() },
|
||||
{ type: 'output', text: 'REACTOR OUTPUT STABLE AT 94.2%', timestamp: now() },
|
||||
{ type: 'output', text: 'CREW MANIFEST LOADED — 3 ACTIVE PERSONNEL', timestamp: now() },
|
||||
{ type: 'warn', text: 'WEAPONS OFFLINE — MODULE NOT INSTALLED', timestamp: now() },
|
||||
{ type: 'system', text: 'ALL SYSTEMS NOMINAL. AWAITING FIRST TURN.', timestamp: now() },
|
||||
]
|
||||
// Must match the TabsTrigger values in the render below — used to validate the tab persisted for
|
||||
// #27 (an unknown stored value falls back to the default).
|
||||
const TAB_IDS = ['world-map', 'local-system', 'crew', 'ship-systems', 'skill-tree', 'missions', 'event-log']
|
||||
const ACTIVE_TAB_KEY = 'sw-active-tab'
|
||||
|
||||
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.
|
||||
// Ship + system state is owned here (not in the tab panels): the SHIP SYSTEMS tab mirrors ship
|
||||
// actions via setShip, and the right column's CARGO HOLD renders from the lifted inventory —
|
||||
// the LOCAL SYSTEM tab reports each scan's response up via setSystem. Crew count feeds the
|
||||
// roster bar — refetched on page load, like the other panels.
|
||||
const [ship, setShip] = useState<ShipState | null>(null)
|
||||
const [system, setSystem] = useState<SystemState | null>(null)
|
||||
const [techs, setTechs] = useState<TechDto[] | null>(null)
|
||||
const [crewCount, setCrewCount] = useState<number | null>(null)
|
||||
// #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).
|
||||
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||
// #38 — admin DB CRUD overlay, opened from the header.
|
||||
const [adminOpen, setAdminOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -89,6 +73,15 @@ export function MissionControl() {
|
||||
if (!cancelled) setShip(s)
|
||||
})
|
||||
.catch(() => {}) // left column + tab keep their loading state on failure
|
||||
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) setSystem(s)
|
||||
})
|
||||
.catch(() => {})
|
||||
fetch('/api/tech')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -117,16 +110,40 @@ export function MissionControl() {
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// #27 — restore the persisted tab once on mount (validated against TAB_IDS).
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(ACTIVE_TAB_KEY)
|
||||
if (stored && TAB_IDS.includes(stored)) setActiveTab(stored)
|
||||
}, [])
|
||||
|
||||
// #30 — re-fetch /api/system after a jump: WorldMapPanel owns the world state, so without this
|
||||
// the lifted system state stays on the previous system until a page refresh. Same failure
|
||||
// policy as the mount fetch — keep the old state on error.
|
||||
const refreshSystem = useCallback(() => {
|
||||
fetch('/api/system')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as SystemState
|
||||
})
|
||||
.then((s) => setSystem(s))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||
|
||||
// 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).
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
// Wide layout is pinned to the viewport so only the columns scroll internally (#21);
|
||||
// narrow keeps natural flow with body scroll.
|
||||
height: narrow ? undefined : '100vh',
|
||||
overflow: narrow ? undefined : 'hidden',
|
||||
background: 'var(--background)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -157,7 +174,7 @@ export function MissionControl() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
◄ SHIP
|
||||
v{pkg.version}
|
||||
</span>
|
||||
|
||||
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
||||
@@ -197,6 +214,9 @@ export function MissionControl() {
|
||||
|
||||
<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
|
||||
style={{
|
||||
color: 'var(--color-green)',
|
||||
@@ -212,10 +232,13 @@ export function MissionControl() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{adminOpen && <AdminPanel onClose={() => setAdminOpen(false)} />}
|
||||
|
||||
{/* ── BODY ── */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0, // let the grid below be constrained to the viewport instead of growing it
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1px',
|
||||
@@ -262,8 +285,9 @@ export function MissionControl() {
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0, // same — the columns' overflowY:auto then scrolls inside their tracks
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
|
||||
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
|
||||
gap: '1px',
|
||||
background: 'var(--border)',
|
||||
}}
|
||||
@@ -311,11 +335,34 @@ export function MissionControl() {
|
||||
<PanelContent>
|
||||
{ship ? (
|
||||
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
||||
// crew roster vs the display cap.
|
||||
// crew roster vs the display cap; SECTOR/TURN rows folded in from the old SHIP DATA
|
||||
// placeholder (the rest of it duplicated these bars or had no game equivalent).
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||
<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 style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{[
|
||||
{ label: 'SECTOR', value: (system?.systemName ?? '—').toUpperCase() },
|
||||
{ label: 'TURN', value: String(ship.turn) },
|
||||
].map((item, i) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.4rem 0',
|
||||
borderBottom: i < 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>{item.label}</span>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--color-green)', textShadow: 'var(--text-glow-green)', letterSpacing: '0.04em', fontWeight: 600 }}>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||
@@ -335,7 +382,9 @@ export function MissionControl() {
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Panel notch="md" style={{ flex: 1 }}>
|
||||
{/* flex column + minHeight:0 so the tab content scrolls inside the panel (#21) —
|
||||
the Panel root's default overflow:hidden would otherwise clip it */}
|
||||
<Panel notch="md" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<PanelHeader>
|
||||
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||
@@ -343,15 +392,29 @@ export function MissionControl() {
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<PanelContent style={{ padding: 0 }}>
|
||||
<Tabs defaultValue="world-map">
|
||||
<TabsList
|
||||
style={{
|
||||
padding: '0 1rem',
|
||||
background: 'var(--surface-raised)',
|
||||
overflowX: '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
|
||||
value={activeTab ?? 'world-map'}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v)
|
||||
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
|
||||
}}
|
||||
style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}
|
||||
>
|
||||
<TabsList
|
||||
style={{
|
||||
padding: '0 1rem',
|
||||
background: 'var(--surface-raised)', // opaque — content scrolls under it (#31)
|
||||
overflowX: 'auto',
|
||||
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)
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
|
||||
<TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger>
|
||||
<TabsTrigger value="crew">CREW</TabsTrigger>
|
||||
@@ -361,14 +424,19 @@ export function MissionControl() {
|
||||
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* WORLD MAP TAB — live data from /api/world */}
|
||||
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
||||
<WorldMapPanel height={380} />
|
||||
{/* WORLD MAP TAB — live data from /api/world; fills the whole pane (#36). Wide mode:
|
||||
flex column so the panel can fill it with pure flex sizing (a min-height-only
|
||||
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>
|
||||
|
||||
{/* LOCAL SYSTEM TAB — live data from /api/system */}
|
||||
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
|
||||
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||||
<LocalSystemPanel />
|
||||
<LocalSystemPanel system={system} onChanged={setSystem} />
|
||||
</TabsContent>
|
||||
|
||||
{/* CREW TAB — live data from /api/crew */}
|
||||
@@ -393,19 +461,14 @@ export function MissionControl() {
|
||||
|
||||
{/* EVENT LOG TAB */}
|
||||
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
|
||||
<Terminal
|
||||
lines={logLines}
|
||||
title="SHIP LOG // PLACEHOLDER FEED"
|
||||
height="22rem"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<EventLogPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: UPCOMING ENCOUNTERS + SHIP DATA ── */}
|
||||
{/* ── RIGHT: CARGO HOLD (persistent — inventory lifted from /api/system) ── */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background)',
|
||||
@@ -418,110 +481,34 @@ export function MissionControl() {
|
||||
>
|
||||
<Panel notch="sm">
|
||||
<PanelHeader>
|
||||
<PanelTitle>UPCOMING ENCOUNTERS</PanelTitle>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Badge variant="SCANNING">{encounters.length} QUEUED</Badge>
|
||||
</div>
|
||||
<PanelTitle>CARGO HOLD // INVENTORY</PanelTitle>
|
||||
{system && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Badge variant={system.inventory.length > 0 ? 'ACTIVE' : 'OFFLINE'}>
|
||||
{system.inventory.reduce((n, i) => n + i.qty, 0)} UNITS
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</PanelHeader>
|
||||
<PanelContent style={{ padding: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{encounters.map((enc, i) => {
|
||||
const isNext = i === 0
|
||||
const isLast = i === encounters.length - 1
|
||||
return (
|
||||
<div
|
||||
key={enc.id}
|
||||
style={{
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderBottom: isLast ? 'none' : '1px solid var(--border)',
|
||||
background: isNext ? 'var(--surface-raised)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.68rem',
|
||||
color: isNext ? 'var(--text-secondary)' : 'var(--text-muted)',
|
||||
letterSpacing: '0.04em',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{enc.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.57rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{enc.when} // {enc.id}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={isNext ? 'WARNING' : 'SCANNING'}>
|
||||
{isNext ? 'NEXT' : 'SCHED'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
|
||||
<Panel notch="sm">
|
||||
<PanelHeader>
|
||||
<PanelTitle>SHIP DATA</PanelTitle>
|
||||
</PanelHeader>
|
||||
<PanelContent>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{shipData.map((item, i) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.4rem 0',
|
||||
borderBottom:
|
||||
i < shipData.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--color-green)',
|
||||
textShadow: 'var(--text-glow-green)',
|
||||
letterSpacing: '0.04em',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
{!system ? (
|
||||
<div style={{ padding: '0.6rem 0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||
</div>
|
||||
) : system.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>
|
||||
) : (
|
||||
system.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>
|
||||
))
|
||||
)}
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type NodeGraphVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||
export type NodeStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'NEUTRAL'
|
||||
|
||||
export interface NodeGraphNode {
|
||||
id: string
|
||||
label: string
|
||||
x: number // 0–100 coordinate space
|
||||
y: number
|
||||
status?: NodeStatus
|
||||
sublabel?: string
|
||||
}
|
||||
|
||||
export interface NodeGraphEdge {
|
||||
from: string
|
||||
to: string
|
||||
label?: string
|
||||
animated?: boolean
|
||||
}
|
||||
|
||||
export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
nodes: NodeGraphNode[]
|
||||
edges: NodeGraphEdge[]
|
||||
variant?: NodeGraphVariant
|
||||
title?: string
|
||||
height?: number
|
||||
directed?: boolean
|
||||
}
|
||||
|
||||
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
||||
DEFAULT: 'var(--text-secondary)',
|
||||
ACTIVE: 'var(--color-green)',
|
||||
WARNING: 'var(--color-amber)',
|
||||
CRITICAL: 'var(--color-red)',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<NodeStatus, string> = {
|
||||
ACTIVE: 'var(--color-green)',
|
||||
OFFLINE: 'var(--border)',
|
||||
WARNING: 'var(--color-amber)',
|
||||
CRITICAL: 'var(--color-red)',
|
||||
NEUTRAL: 'var(--text-muted)',
|
||||
}
|
||||
|
||||
const NODE_W = 100
|
||||
const NODE_H = 36
|
||||
|
||||
const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
nodes,
|
||||
edges,
|
||||
variant = 'ACTIVE',
|
||||
title,
|
||||
height = 320,
|
||||
directed = false,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const [svgW, setSvgW] = React.useState(600)
|
||||
const [hovered, setHovered] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const ro = new ResizeObserver(entries => {
|
||||
setSvgW(entries[0].contentRect.width)
|
||||
})
|
||||
ro.observe(containerRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const color = VARIANT_COLORS[variant]
|
||||
const markerId = `arrow-${variant}`
|
||||
|
||||
function nodePx(node: NodeGraphNode) {
|
||||
return (node.x / 100) * svgW
|
||||
}
|
||||
|
||||
function nodePy(node: NodeGraphNode) {
|
||||
return (node.y / 100) * height
|
||||
}
|
||||
|
||||
function edgeEndpoints(fromNode: NodeGraphNode, toNode: NodeGraphNode) {
|
||||
const fx = nodePx(fromNode)
|
||||
const fy = nodePy(fromNode)
|
||||
const tx = nodePx(toNode)
|
||||
const ty = nodePy(toNode)
|
||||
const dx = tx - fx
|
||||
const dy = ty - fy
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < 1) return { x1: fx, y1: fy, x2: tx, y2: ty }
|
||||
|
||||
const ux = dx / dist
|
||||
const uy = dy / dist
|
||||
const hw = NODE_W / 2
|
||||
const hh = NODE_H / 2
|
||||
|
||||
const tW = Math.abs(ux) > 0.001 ? hw / Math.abs(ux) : Infinity
|
||||
const tH = Math.abs(uy) > 0.001 ? hh / Math.abs(uy) : Infinity
|
||||
const t = Math.min(tW, tH)
|
||||
|
||||
return {
|
||||
x1: fx + ux * t,
|
||||
y1: fy + uy * t,
|
||||
x2: tx - ux * t,
|
||||
y2: ty - uy * t,
|
||||
}
|
||||
}
|
||||
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
||||
const hasAnimatedEdge = edges.some(e => e.animated)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{title && (
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
|
||||
<svg width={svgW} height={height} style={{ display: 'block' }}>
|
||||
<defs>
|
||||
{directed && (
|
||||
<marker
|
||||
id={markerId}
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
refX="6"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M 0 0 L 6 3 L 0 6 Z" fill={color} />
|
||||
</marker>
|
||||
)}
|
||||
</defs>
|
||||
|
||||
{hasAnimatedEdge && (
|
||||
<style>{`
|
||||
@keyframes nodeGraphDash { to { stroke-dashoffset: -8; } }
|
||||
`}</style>
|
||||
)}
|
||||
|
||||
{/* Edges */}
|
||||
{edges.map((edge, ei) => {
|
||||
const fromNode = nodeMap.get(edge.from)
|
||||
const toNode = nodeMap.get(edge.to)
|
||||
if (!fromNode || !toNode) return null
|
||||
|
||||
const { x1, y1, x2, y2 } = edgeEndpoints(fromNode, toNode)
|
||||
const mx = (x1 + x2) / 2
|
||||
const my = (y1 + y2) / 2
|
||||
|
||||
return (
|
||||
<g key={ei}>
|
||||
<line
|
||||
x1={x1.toFixed(1)}
|
||||
y1={y1.toFixed(1)}
|
||||
x2={x2.toFixed(1)}
|
||||
y2={y2.toFixed(1)}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
opacity={0.5}
|
||||
strokeDasharray={edge.animated ? '4 4' : undefined}
|
||||
markerEnd={directed ? `url(#${markerId})` : undefined}
|
||||
style={edge.animated
|
||||
? { animation: 'nodeGraphDash 0.5s linear infinite' }
|
||||
: undefined}
|
||||
/>
|
||||
{edge.label && (
|
||||
<text
|
||||
x={mx.toFixed(1)}
|
||||
y={my.toFixed(1)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
fill="var(--text-muted)"
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{nodes.map(node => {
|
||||
const cx = nodePx(node)
|
||||
const cy = nodePy(node)
|
||||
const isHovered = hovered === node.id
|
||||
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onMouseEnter={() => setHovered(node.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: 'default' }}
|
||||
>
|
||||
{/* Background */}
|
||||
<rect
|
||||
x={(cx - NODE_W / 2).toFixed(1)}
|
||||
y={(cy - NODE_H / 2).toFixed(1)}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
fill="var(--surface-raised)"
|
||||
/>
|
||||
{/* Hover tint */}
|
||||
{isHovered && (
|
||||
<rect
|
||||
x={(cx - NODE_W / 2).toFixed(1)}
|
||||
y={(cy - NODE_H / 2).toFixed(1)}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
fill={statusColor}
|
||||
fillOpacity={0.12}
|
||||
/>
|
||||
)}
|
||||
{/* Border */}
|
||||
<rect
|
||||
x={(cx - NODE_W / 2).toFixed(1)}
|
||||
y={(cy - NODE_H / 2).toFixed(1)}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
fill="none"
|
||||
stroke={statusColor}
|
||||
strokeWidth={isHovered ? 1.5 : 1}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={cx.toFixed(1)}
|
||||
y={(node.sublabel ? cy - 5 : cy).toFixed(1)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
fill={statusColor}
|
||||
fontFamily="var(--font-mono)"
|
||||
fontWeight="600"
|
||||
>
|
||||
{node.label.toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Sublabel */}
|
||||
{node.sublabel && (
|
||||
<text
|
||||
x={cx.toFixed(1)}
|
||||
y={(cy + 8).toFixed(1)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
fill="var(--text-muted)"
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{node.sublabel}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
NodeGraph.displayName = 'NodeGraph'
|
||||
|
||||
export { NodeGraph }
|
||||
+218
-49
@@ -1,23 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NodeGraph } from '@/components/ui/node-graph'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from 'd3-force'
|
||||
import type { SimulationNodeDatum } from 'd3-force'
|
||||
import { select } from 'd3-selection'
|
||||
import { zoom, zoomIdentity } from 'd3-zoom'
|
||||
import type { WorldState } from '@/lib/game/save'
|
||||
import { reachableWithin } from '@/lib/game/world'
|
||||
|
||||
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and
|
||||
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not
|
||||
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount,
|
||||
// refetch via the jump response — no polling.
|
||||
// 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 within jump range of the current one jumps
|
||||
// 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,
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
|
||||
export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
||||
type NodeStatus = 'current' | 'jumpable' | 'offline'
|
||||
|
||||
// d3-force mutates these in place; the whole scene is rebuilt from scratch on every world change.
|
||||
interface ChartNode extends SimulationNodeDatum {
|
||||
id: number
|
||||
name: string // already uppercased — doubles as the accessible label
|
||||
status: NodeStatus
|
||||
sublabel: string
|
||||
}
|
||||
|
||||
// forceLink replaces the numeric ids with node references once initialized.
|
||||
interface ChartLink {
|
||||
source: number | ChartNode
|
||||
target: number | ChartNode
|
||||
}
|
||||
|
||||
const NODE_W = 100
|
||||
const NODE_H = 36
|
||||
|
||||
// Palette carried over from the old hand-rolled chart.
|
||||
const STATUS_COLOR: Record<NodeStatus, string> = {
|
||||
current: 'var(--color-green)',
|
||||
jumpable: 'var(--text-muted)',
|
||||
offline: 'var(--border)',
|
||||
}
|
||||
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -37,7 +75,12 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Ref guard so a double-click can't start two jumps before the re-render locks the nodes.
|
||||
const jumpingRef = useRef(false)
|
||||
|
||||
const jump = useCallback(async (systemId: number) => {
|
||||
if (jumpingRef.current) return
|
||||
jumpingRef.current = true
|
||||
setJumpingId(systemId)
|
||||
try {
|
||||
const res = await fetch('/api/jump', {
|
||||
@@ -48,12 +91,162 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
||||
const body = (await res.json()) as WorldState & { error?: string }
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||
setWorld(body) // fresh state straight from the response — no refetch needed
|
||||
onJumped?.() // #30 — parent re-fetches /api/system (LOCAL SYSTEM + CARGO HOLD mirror it)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
jumpingRef.current = false
|
||||
setJumpingId(null)
|
||||
}
|
||||
}, [])
|
||||
}, [onJumped])
|
||||
|
||||
// Rebuild the chart on every world/lock change — the force layout re-settles from the API's
|
||||
// coordinates, so nodes visibly rearrange after each jump (#28).
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
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 H = container.clientHeight || height
|
||||
const current = world.systems.find((s) => s.current)!
|
||||
// Clickable chart fields: one hop away, or two hops once NAVIGATION AI is researched (#35).
|
||||
const hops = reachableWithin(world.edges, current.id, navAi ? 2 : 1)
|
||||
|
||||
const nodes: ChartNode[] = world.systems.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name.toUpperCase(),
|
||||
status: s.current ? 'current' : hops.has(s.id) ? 'jumpable' : 'offline',
|
||||
sublabel: s.current
|
||||
? 'CURRENT'
|
||||
: hops.has(s.id)
|
||||
? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hops.get(s.id) ?? 1) > 1 ? ' ×2' : ''}`
|
||||
: s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
||||
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
|
||||
y: (s.y / 100) * H,
|
||||
}))
|
||||
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)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%')
|
||||
.attr('viewBox', `0 0 ${W} ${H}`)
|
||||
.style('display', 'block')
|
||||
|
||||
const root = svg.append('g')
|
||||
const edgeLayer = root.append('g')
|
||||
const nodeLayer = root.append('g')
|
||||
|
||||
// Pan + zoom (#28): wheel/drag on the chart, transform applied to the scene root. d3-zoom does
|
||||
// NOT swallow the trailing click after a drag, so track real pans and ignore node clicks that
|
||||
// end one (mousedown on a node, tiny drag, release over the same node).
|
||||
let panned = false
|
||||
const zoomBehavior = zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.15, 5])
|
||||
.on('start', () => {
|
||||
panned = false
|
||||
})
|
||||
.on('zoom', (event) => {
|
||||
root.attr('transform', event.transform.toString())
|
||||
// Any user gesture (drag or trackpad pan); a clean click's own mousedown re-fires
|
||||
// 'start' and resets this before its click lands. Programmatic fits have no sourceEvent.
|
||||
if (event.sourceEvent) panned = true
|
||||
})
|
||||
svg.call(zoomBehavior)
|
||||
|
||||
// The settled force layout is bigger than the chart box for large saves, so once the
|
||||
// simulation stops, zoom-to-fit: frame every node inside the container with padding.
|
||||
const fitToNodes = () => {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const n of nodes) {
|
||||
minX = Math.min(minX, (n.x ?? 0) - NODE_W / 2)
|
||||
minY = Math.min(minY, (n.y ?? 0) - NODE_H / 2)
|
||||
maxX = Math.max(maxX, (n.x ?? 0) + NODE_W / 2)
|
||||
maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2)
|
||||
}
|
||||
const pad = 16
|
||||
const scale = Math.min(W / (maxX - minX + pad * 2), H / (maxY - minY + pad * 2))
|
||||
const tx = W / 2 - scale * ((minX + maxX) / 2)
|
||||
const ty = H / 2 - scale * ((minY + maxY) / 2)
|
||||
svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale))
|
||||
}
|
||||
|
||||
const edgeSel = edgeLayer.selectAll('line').data(links).enter().append('line')
|
||||
.attr('stroke', 'var(--color-green)')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('opacity', 0.5)
|
||||
|
||||
const nodeSel = nodeLayer.selectAll<SVGGElement, ChartNode>('g').data(nodes).enter().append('g')
|
||||
|
||||
// Same accessible contract as the old chart: only jumpable nodes are role=button with their
|
||||
// name as the accessible label (e2e clicks them by role/name); all locked while a jump is in flight.
|
||||
nodeSel.filter((d) => d.status === 'jumpable' && jumpingId === null)
|
||||
.attr('role', 'button')
|
||||
.attr('tabindex', 0)
|
||||
.attr('aria-label', (d) => d.name)
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (event: MouseEvent, d) => {
|
||||
if (!panned) void jump(d.id) // a real pan ends with a click too — don't jump on that one
|
||||
})
|
||||
.on('keydown', (event: KeyboardEvent, d) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
void jump(d.id)
|
||||
}
|
||||
})
|
||||
|
||||
nodeSel.each(function (d) {
|
||||
const g = select(this)
|
||||
const color = STATUS_COLOR[d.status]
|
||||
g.append('rect')
|
||||
.attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
|
||||
.attr('width', NODE_W).attr('height', NODE_H)
|
||||
.attr('fill', 'var(--surface-raised)')
|
||||
g.append('rect')
|
||||
.attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
|
||||
.attr('width', NODE_W).attr('height', NODE_H)
|
||||
.attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1)
|
||||
g.append('text')
|
||||
.attr('x', 0).attr('y', -5)
|
||||
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
|
||||
.attr('font-size', 10).attr('fill', color)
|
||||
.style('font-family', 'var(--font-mono)').style('font-weight', '600')
|
||||
.text(d.name)
|
||||
g.append('text')
|
||||
.attr('x', 0).attr('y', 8)
|
||||
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
|
||||
.attr('font-size', 8).attr('fill', 'var(--text-muted)')
|
||||
.style('font-family', 'var(--font-mono)')
|
||||
.text(d.sublabel)
|
||||
})
|
||||
|
||||
const simulation = forceSimulation(nodes)
|
||||
.force('link', forceLink<ChartNode, ChartLink>(links).id((d) => d.id).distance(130).strength(0.25))
|
||||
.force('charge', forceManyBody().strength(-420))
|
||||
.force('center', forceCenter(W / 2, H / 2))
|
||||
.force('collide', forceCollide(60))
|
||||
.on('end', fitToNodes)
|
||||
.on('tick', () => {
|
||||
edgeSel
|
||||
.attr('x1', (d) => (d.source as ChartNode).x ?? 0)
|
||||
.attr('y1', (d) => (d.source as ChartNode).y ?? 0)
|
||||
.attr('x2', (d) => (d.target as ChartNode).x ?? 0)
|
||||
.attr('y2', (d) => (d.target as ChartNode).y ?? 0)
|
||||
nodeSel.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`)
|
||||
})
|
||||
|
||||
return () => {
|
||||
simulation.stop()
|
||||
svg.remove()
|
||||
}
|
||||
}, [world, jumpingId, jump, height, navAi])
|
||||
|
||||
if (error && !world) {
|
||||
return (
|
||||
@@ -68,36 +261,24 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
||||
if (!world) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING CHART…</span>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>SCANNING CHART…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const current = world.systems.find((s) => s.current)!
|
||||
const reachable = world.edges
|
||||
.filter(([a, b]) => a === current.id || b === current.id)
|
||||
.map(([a, b]) => (a === current.id ? b : a))
|
||||
.map((id) => world.systems.find((s) => s.id === id)!)
|
||||
|
||||
// flex (not height:'100%'): the pane chain is sized by min-height, where percentages don't resolve
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<NodeGraph
|
||||
nodes={world.systems.map((s) => ({
|
||||
id: String(s.id),
|
||||
label: s.name,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE',
|
||||
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
||||
}))}
|
||||
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
|
||||
variant="ACTIVE"
|
||||
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
|
||||
height={height}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', ...(filling ? { flex: 1, minHeight: 0 } : {}) }}>
|
||||
{/* 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',
|
||||
...(filling ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } : {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem',
|
||||
@@ -109,21 +290,9 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
JUMP ROUTES // FROM {current.name.toUpperCase()}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', padding: '0.6rem 0.75rem' }}>
|
||||
{reachable.map((s) => (
|
||||
<Button
|
||||
key={s.id}
|
||||
variant="OUTLINE"
|
||||
size="SM"
|
||||
disabled={jumpingId !== null}
|
||||
onClick={() => void jump(s.id)}
|
||||
>
|
||||
{jumpingId === s.id ? 'JUMPING…' : `${s.name.toUpperCase()} // JUMP`}
|
||||
</Button>
|
||||
))}
|
||||
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
|
||||
</div>
|
||||
<div ref={containerRef} style={{ width: '100%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
+187
-6
@@ -13,6 +13,7 @@ interface CrewDto { id: number; name: string; role: string; traitGood: string; t
|
||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
|
||||
interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string }
|
||||
interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string }
|
||||
|
||||
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
|
||||
|
||||
@@ -64,16 +65,73 @@ test('game template tabs render (scificn-ui component layer wired in)', async ({
|
||||
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||||
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
|
||||
// The placeholder feed was replaced by the real log (v0.9.0): at least one T<turn> // <MESSAGE> line
|
||||
// must render — every save has the turn-0 new_game event, so this holds on a fresh stack and across retries.
|
||||
await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('active tab survives a page reload (#27)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Fresh context → nothing stored yet, so CREW starts unselected (the default is WORLD MAP).
|
||||
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'false');
|
||||
await page.getByRole('tab', { name: 'CREW' }).click();
|
||||
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await page.reload();
|
||||
// The tab is restored from localStorage on mount — no API state involved, so this holds across retries.
|
||||
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('tab bar stays pinned while panel content scrolls (#31)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Shrink the viewport so the center pane's content overflows its scroll area regardless of save state.
|
||||
await page.setViewportSize({ width: 1280, height: 560 });
|
||||
|
||||
// SHIP SYSTEMS always renders six stacked system cards (seeded at start, never removed) — tall
|
||||
// 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');
|
||||
await expect(tablist).toBeVisible();
|
||||
const before = await tablist.boundingBox();
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
// Scroll the nearest scroll container above the tab list all the way down.
|
||||
const scrolled = await page.evaluate(() => {
|
||||
let el = document.querySelector('[role="tablist"]')?.parentElement ?? null;
|
||||
while (el && !['auto', 'scroll'].includes(getComputedStyle(el).overflowY)) el = el.parentElement;
|
||||
if (!el) throw new Error('no scroll container above the tab list');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
return el.scrollTop;
|
||||
});
|
||||
expect(scrolled).toBeGreaterThan(50); // content really overflowed — guards against a vacuous pass
|
||||
|
||||
await expect(tablist).toBeVisible();
|
||||
const after = await tablist.boundingBox();
|
||||
expect(after).not.toBeNull();
|
||||
// Pinned: the tab bar's viewport position is unchanged even though the content scrolled away (#31).
|
||||
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2);
|
||||
});
|
||||
|
||||
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;
|
||||
// #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('/');
|
||||
// Live chart title carries the turn counter; a fresh save sits at the start with six jump routes.
|
||||
// Live chart title carries the turn counter.
|
||||
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
|
||||
const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ });
|
||||
await expect(jumpButtons.first()).toBeVisible();
|
||||
await expect(jumpButtons).toHaveCount(6);
|
||||
// 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)!;
|
||||
await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
|
||||
const panel = page.getByRole('tabpanel');
|
||||
// Six on a fresh save without NAV AI; the two-hop ring joins once it is researched.
|
||||
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 }) => {
|
||||
@@ -84,13 +142,38 @@ test('jumping to an adjacent system updates the world state and turn counter', a
|
||||
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: `${targetName.toUpperCase()} // JUMP` }).click();
|
||||
// Click the target's chart field — clickable nodes are role=button with their name as accessible label.
|
||||
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
||||
|
||||
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
expect(after.turn).toBe(before.turn + 1);
|
||||
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||
});
|
||||
|
||||
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
|
||||
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const currentId = before.systems.find((s) => s.current)!.id;
|
||||
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||
|
||||
await page.goto('/');
|
||||
// Jump through the UI: click the target's chart field on WORLD MAP (the default tab).
|
||||
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
||||
|
||||
// Wait until the jump response is applied to the chart — server state is committed by then, so
|
||||
// the /api/world read below can't race the in-flight POST.
|
||||
await expect(page.getByText(`GALACTIC CHART // JUMP ROUTES — TURN ${before.turn + 1}`)).toBeVisible();
|
||||
|
||||
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const newCurrent = after.systems.find((s) => s.current)!;
|
||||
expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed
|
||||
|
||||
// Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30).
|
||||
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
||||
await expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible();
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -286,6 +369,70 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
|
||||
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 }) => {
|
||||
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
|
||||
@@ -362,6 +509,40 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn
|
||||
expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty);
|
||||
});
|
||||
|
||||
test('EVENT LOG tab shows real game events, newest first', async ({ page }) => {
|
||||
// Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and
|
||||
// message), never on absolute counts — earlier attempts leave their events behind.
|
||||
const afterJump = await jumpToFreshSystem(page);
|
||||
const jumpedName = afterJump.systems.find((s) => s.current)!.name;
|
||||
const jumpTurn = afterJump.turn;
|
||||
|
||||
// Scan one body — if the current system is exhausted (leftover from a previous attempt), hop again first.
|
||||
let sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
if (!sys.planets.some((p) => !p.explored)) {
|
||||
await jumpToFreshSystem(page); // fresh systems always have unscanned bodies
|
||||
sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
}
|
||||
const planet = sys.planets.find((p) => !p.explored)!;
|
||||
const exploreRes = await page.request.post('/api/explore', { data: { planetId: planet.id } });
|
||||
expect(exploreRes.status()).toBe(200);
|
||||
const explored = (await exploreRes.json()) as ExploreResult;
|
||||
|
||||
// API: this run's jump + scan are in the log, and the newer one (the scan) comes first.
|
||||
const events = (await (await page.request.get('/api/events')).json()) as EventDto[];
|
||||
const exploreEvent = events.find((e) => e.turn === explored.turn && e.type === 'explore');
|
||||
expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`));
|
||||
const jumpEvent = events.find((e) => e.type === 'jump' && e.message === `Jumped to ${jumpedName}`);
|
||||
expect(jumpEvent).toBeDefined(); // system names are unique, so the message pins this run's jump
|
||||
expect(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!));
|
||||
|
||||
// UI: the terminal renders both lines (T<turn> // <MESSAGE>, uppercased).
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||||
const panel = page.getByRole('tabpanel');
|
||||
await expect(panel.getByText(new RegExp(`T${explored.turn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible();
|
||||
await expect(panel.getByText(new RegExp(`T${jumpTurn} // JUMPED TO ${jumpedName.toUpperCase()}`))).toBeVisible();
|
||||
});
|
||||
|
||||
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||
const res = await page.request.get('/api/version');
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ShipSystemDto { id: number; type: string; level: number; capacity: num
|
||||
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||
interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] }
|
||||
interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string }
|
||||
interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string }
|
||||
|
||||
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
@@ -59,6 +60,11 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
const missionsBefore = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
||||
expect(missionsBefore.length).toBeGreaterThan(0); // the board is always seeded on new game
|
||||
|
||||
// Event log baseline: append-only, and nothing writes between this snapshot and the restart — so the
|
||||
// latest lines must come back byte-identical (ids included) after it.
|
||||
const eventsBefore = (await (await page.request.get('/api/events?limit=5')).json()) as EventDto[];
|
||||
expect(eventsBefore.length).toBeGreaterThan(0);
|
||||
|
||||
// Restart the app container mid-run — the named DB volume must survive it.
|
||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
||||
|
||||
@@ -105,6 +111,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
const missionsAfter = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
||||
expect(missionsAfter).toEqual(missionsBefore);
|
||||
|
||||
// Event log survived too: same latest lines, in the same order.
|
||||
const eventsAfter = (await (await page.request.get('/api/events?limit=5')).json()) as EventDto[];
|
||||
expect(eventsAfter).toEqual(eventsBefore);
|
||||
|
||||
// The page itself serves again with the landing title.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
+1
-1
@@ -20,7 +20,7 @@ export const solarSystems = sqliteTable('solar_systems', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||
name: text('name').notNull(),
|
||||
// 0–100 coordinate space, rendered directly by the NodeGraph component.
|
||||
// 0–100 coordinate space; seeds the force-directed WORLD MAP layout (components/world-map.tsx).
|
||||
x: real('x').notNull(),
|
||||
y: real('y').notNull(),
|
||||
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
|
||||
|
||||
@@ -71,4 +71,17 @@ describe('makeCrewName', () => {
|
||||
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
|
||||
expect(taken.size).toBe(40)
|
||||
})
|
||||
|
||||
it('terminates with numbered variants once all 352 base names are taken', () => {
|
||||
const rng = startingCrewRng('7')
|
||||
const taken = new Set<string>()
|
||||
for (let i = 0; i < 352; i++) makeCrewName(rng, taken) // exhaust the whole pool
|
||||
expect(taken.size).toBe(352)
|
||||
|
||||
const a = makeCrewName(rng, taken) // must return instead of spinning forever
|
||||
const b = makeCrewName(rng, taken)
|
||||
expect(a).not.toBe(b)
|
||||
expect(taken.has(a)).toBe(true)
|
||||
expect(taken.has(b)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+15
-5
@@ -49,13 +49,23 @@ function pick<T>(rng: () => number, arr: T[]): T {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The pool is finite (16×22=352) and the roster has no cap, so — like makeName — fall back to
|
||||
// numbered variants once every base is taken instead of spinning forever.
|
||||
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
|
||||
const base = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
|
||||
if (!taken.has(base)) {
|
||||
taken.add(base)
|
||||
return base
|
||||
}
|
||||
if (taken.size >= FIRST_NAMES.length * LAST_NAMES.length) {
|
||||
for (let n = 2; ; n++) {
|
||||
const name = `${base} ${n}`
|
||||
if (!taken.has(name)) {
|
||||
taken.add(name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { getWorldState } from './save'
|
||||
import { DEFAULT_LIMIT, EventError, MAX_LIMIT, getEvents, parseEventLimit } from './events'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'sw-events-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 })
|
||||
})
|
||||
|
||||
// Appends `count` synthetic events (turns 1..count) on top of the turn-0 new_game event.
|
||||
async function seedEvents(db: GameDb, count: number): Promise<void> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
for (let i = 1; i <= count; i++) {
|
||||
await db.db.insert(schema.events).values({ saveId: save.id, turn: i, type: 'test', message: `event ${i}`, createdAt: new Date(Date.UTC(2026, 8, 29, 0, i)).toISOString() })
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseEventLimit', () => {
|
||||
it('defaults to DEFAULT_LIMIT when the param is absent', () => {
|
||||
expect(parseEventLimit(null)).toBe(DEFAULT_LIMIT)
|
||||
})
|
||||
|
||||
it('accepts integers in [1, MAX_LIMIT]', () => {
|
||||
expect(parseEventLimit('1')).toBe(1)
|
||||
expect(parseEventLimit(String(MAX_LIMIT))).toBe(MAX_LIMIT)
|
||||
expect(parseEventLimit('25')).toBe(25)
|
||||
})
|
||||
|
||||
it('rejects non-integers and out-of-range values with a 400', () => {
|
||||
for (const bad of ['abc', '', '0', '-3', String(MAX_LIMIT + 1), '2.5']) {
|
||||
try {
|
||||
parseEventLimit(bad)
|
||||
expect.unreachable(`limit ${JSON.stringify(bad)} should have been rejected`)
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(EventError)
|
||||
expect((err as EventError).status).toBe(400)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEvents', () => {
|
||||
it('returns the latest events newest-first, sliced to limit', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db) // auto-initializes the save (turn-0 new_game event)
|
||||
await seedEvents(db, 5)
|
||||
|
||||
const all = await getEvents(db, DEFAULT_LIMIT)
|
||||
expect(all.map((e) => e.turn)).toEqual([5, 4, 3, 2, 1, 0]) // newest first, turn-0 line included
|
||||
expect(all[all.length - 1].type).toBe('new_game')
|
||||
|
||||
const two = await getEvents(db, 2)
|
||||
expect(two.map((e) => [e.turn, e.message])).toEqual([[5, 'event 5'], [4, 'event 4']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { desc } from 'drizzle-orm'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import type { GameDb } from '@/lib/db/client'
|
||||
|
||||
export interface EventDto {
|
||||
id: number
|
||||
turn: number
|
||||
type: string
|
||||
message: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const DEFAULT_LIMIT = 50
|
||||
export const MAX_LIMIT = 200
|
||||
|
||||
export class EventError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Parses the optional ?limit= query param. Absent → default; anything that is not an integer in
|
||||
// [1, MAX_LIMIT] is a 400 (the route maps EventError to its status).
|
||||
export function parseEventLimit(raw: string | null): number {
|
||||
if (raw === null) return DEFAULT_LIMIT
|
||||
const limit = Number(raw)
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
||||
throw new EventError(400, `limit must be an integer between 1 and ${MAX_LIMIT}`)
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// The latest N events of the save, newest first. Assumes a save exists (the route calls ensureSave).
|
||||
export async function getEvents(db: GameDb, limit: number): Promise<EventDto[]> {
|
||||
const rows = await db.db.select().from(schema.events).orderBy(desc(schema.events.id)).limit(limit)
|
||||
return rows.map((e) => ({ id: e.id, turn: e.turn, type: e.type, message: e.message, createdAt: e.createdAt }))
|
||||
}
|
||||
@@ -185,6 +185,7 @@ describe('completion', () => {
|
||||
expect(reward.qty).toBe(2)
|
||||
|
||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))!
|
||||
expect(event.turn).toBe(4) // accept (1) + three scans (2–4); completion is written by the third scan
|
||||
expect(event.message).toContain('Scan 3 bodies')
|
||||
})
|
||||
|
||||
|
||||
+7
-4
@@ -6,7 +6,7 @@ import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { checkMissionCompletion, topUpMissionBoard } from './missions'
|
||||
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.
|
||||
// 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')
|
||||
|
||||
const routes = await db.db.select().from(schema.jumpRoutes)
|
||||
const adjacent = routes.some((r) => r.fromSystem === current.id && r.toSystem === target.id)
|
||||
if (!adjacent) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
|
||||
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop jump
|
||||
// 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: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
|
||||
|
||||
|
||||
@@ -111,6 +111,10 @@ describe('expandSystem', () => {
|
||||
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]])
|
||||
|
||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'expand')!
|
||||
expect(event.turn).toBe(1)
|
||||
expect(event.message).toContain('HULL')
|
||||
|
||||
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 })
|
||||
})
|
||||
@@ -144,6 +148,7 @@ describe('buildSystem', () => {
|
||||
expect(state.turn).toBe(1)
|
||||
|
||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
|
||||
expect(event.turn).toBe(1)
|
||||
expect(event.message).toContain('WEAPONS')
|
||||
|
||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
|
||||
|
||||
+3
-1
@@ -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' },
|
||||
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})` },
|
||||
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)` },
|
||||
REACTOR_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'REACTOR', bonus: 3, description: `Raises REACTOR max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
|
||||
}
|
||||
|
||||
+48
-1
@@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
const region = generateStartRegion(mulberry32(42))
|
||||
@@ -76,6 +76,19 @@ describe('generateRing (expansion)', () => {
|
||||
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
||||
expect(taken.size).toBe(40)
|
||||
})
|
||||
|
||||
it('terminates with numbered variants once all 128 base names are taken', () => {
|
||||
const rng = mulberry32(9)
|
||||
const taken = new Set<string>()
|
||||
for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool
|
||||
expect(taken.size).toBe(128)
|
||||
|
||||
const a = makeName(rng, taken) // must return instead of spinning forever
|
||||
const b = makeName(rng, taken)
|
||||
expect(a).not.toBe(b)
|
||||
expect(taken.has(a)).toBe(true)
|
||||
expect(taken.has(b)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generatePlanets', () => {
|
||||
@@ -132,3 +145,37 @@ describe('rollExploration', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
+48
-6
@@ -3,7 +3,7 @@
|
||||
|
||||
export interface WorldSystem {
|
||||
name: string
|
||||
x: number // 0–100 coordinate space, rendered directly by NodeGraph
|
||||
x: number // 0–100 coordinate space; seeds the force-directed WORLD MAP layout
|
||||
y: number
|
||||
}
|
||||
|
||||
@@ -36,13 +36,25 @@ function title(word: string): string {
|
||||
return word.charAt(0) + word.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one.
|
||||
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one. The base
|
||||
// pool is finite (16×8=128), so once every base is taken the loop would spin forever — which
|
||||
// froze the whole server on a save that had grown past 128 systems (synchronous sql.js runs on
|
||||
// Node's single thread). Numbered variants keep names unique and terminate.
|
||||
export function makeName(rng: () => number, taken: Set<string>): string {
|
||||
for (;;) {
|
||||
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
||||
if (!taken.has(name)) {
|
||||
taken.add(name)
|
||||
return name
|
||||
const base = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
||||
if (!taken.has(base)) {
|
||||
taken.add(base)
|
||||
return base
|
||||
}
|
||||
if (taken.size >= FIRST.length * SECOND.length) {
|
||||
for (let n = 2; ; n++) {
|
||||
const name = `${base} ${n}`
|
||||
if (!taken.has(name)) {
|
||||
taken.add(name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +136,36 @@ export function generateRing(
|
||||
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 -------------------------------
|
||||
|
||||
export interface WorldMoon {
|
||||
|
||||
@@ -5,7 +5,10 @@ import { openApiSpec } from './openapi';
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents every public endpoint', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/admin/tables',
|
||||
'/api/admin/tables/{name}/rows',
|
||||
'/api/crew',
|
||||
'/api/events',
|
||||
'/api/explore',
|
||||
'/api/jump',
|
||||
'/api/missions',
|
||||
@@ -77,6 +80,22 @@ describe('OpenAPI spec', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('describes GET /api/events → Event[] with a limit query param and a 400 error response', () => {
|
||||
const events = openApiSpec.paths['/api/events'].get.responses[200].content['application/json'].schema;
|
||||
expect(events.type).toBe('array');
|
||||
expect((events.items as { $ref: string }).$ref).toBe('#/components/schemas/Event');
|
||||
|
||||
const props = openApiSpec.components.schemas.Event.properties;
|
||||
expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']);
|
||||
expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission']);
|
||||
|
||||
const get = openApiSpec.paths['/api/events'].get;
|
||||
const limit = (get.parameters ?? []).find((p) => p.name === 'limit');
|
||||
expect(limit?.in).toBe('query');
|
||||
expect(limit?.required).toBe(false);
|
||||
expect(get.responses['400'].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');
|
||||
|
||||
+341
-4
@@ -8,7 +8,7 @@ export const openApiSpec = {
|
||||
info: {
|
||||
title: 'SpaceWanderer API',
|
||||
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.
|
||||
version: pkg.version,
|
||||
},
|
||||
@@ -52,9 +52,9 @@ export const openApiSpec = {
|
||||
},
|
||||
'/api/jump': {
|
||||
post: {
|
||||
summary: 'Jump to an adjacent solar system',
|
||||
summary: 'Jump to a solar system within range',
|
||||
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',
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -90,7 +90,7 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
@@ -523,6 +523,274 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/events': {
|
||||
get: {
|
||||
summary: 'Event log',
|
||||
description: "The save's game events, newest first — every action (new game, jump, scan, research, ship work, mission) appends one. Auto-initializes a new game when no save exists yet.",
|
||||
operationId: 'getEvents',
|
||||
parameters: [
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: { type: 'integer', minimum: 1, maximum: 200 },
|
||||
description: 'How many of the latest events to return (default 50)',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Latest events, newest first',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/Event' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'limit is not an integer in [1, 200]',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/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: {
|
||||
schemas: {
|
||||
@@ -682,6 +950,17 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state'],
|
||||
},
|
||||
Event: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
turn: { type: 'integer', description: 'The save turn the action cost (new_game is 0)' },
|
||||
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] },
|
||||
message: { type: 'string' },
|
||||
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
|
||||
},
|
||||
required: ['id', 'turn', 'type', 'message', 'createdAt'],
|
||||
},
|
||||
UpgradeRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -745,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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space-wanderer",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
@@ -15,6 +15,9 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "^16.3.5",
|
||||
"react": "^19.3.0",
|
||||
@@ -25,11 +28,14 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/d3-selection": "^3.0.12",
|
||||
"@types/d3-zoom": "^3.0.9",
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user