Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6c828e40a6 | ||
|
|
b5a072c9aa | ||
|
|
85eeeecc4a | ||
|
|
70f95145ae | ||
|
|
684a126a5b | ||
|
|
786a5ec001 | ||
|
|
f356ffbe93 | ||
|
|
5657e8938a | ||
|
|
80885c8ad7 | ||
|
|
a62ed2cf95 | ||
|
|
672e99bd96 | ||
|
|
94e9fc728c | ||
|
|
7b0227cde9 | ||
|
|
8818f0928d | ||
|
|
4ef924edd6 | ||
|
|
bd16d3fc47 |
@@ -1,5 +1,80 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Pure mission model (`lib/game/missions.ts`, no save.ts import — the completion hooks are called FROM save.ts, one-way): three types — DELIVER (jump to a target system), EXPLORE (scan N = 2–4 bodies), FIND (haul in an item of one kind). `missionRng(seed, turn)` anchors generation exactly like world expansion does; every action costs exactly one turn, so the stream never repeats. The board is always topped up to ≥3 AVAILABLE after every action (`topUpMissionBoard`, idempotent); DELIVER targets only discovered non-current systems (none on a fresh game → initial contracts are EXPLORE/FIND).
|
||||||
|
- Acceptance & completion: `acceptMission` — 409 while another mission is ACTIVE or the contract isn't AVAILABLE, costs one turn like every other action and writes an event. `checkMissionCompletion` runs inside jumpToSystem/explorePlanet before persist (one atomic write): DELIVER completes on arrival at its target system, EXPLORE counts scans (capped), FIND completes when a haul contains its kind; completion marks DONE, grants the reward as one neutral 'MISSION REWARD' row of the mission's kind (spenders consume by kind only) and writes an event.
|
||||||
|
- New endpoints: `GET /api/missions` → `[{id,type,description,targetSystemId,conditionKind,progressTarget,progress,rewardKind,rewardQty,state}]`; `POST /api/missions/{id}/accept` → fresh board (400 non-integer id, 404 unknown, 409 wrong state / active conflict). OpenAPI spec in code updated (12 → 14 paths) with the Mission schema.
|
||||||
|
- MISSIONS tab now renders REAL data: one card per contract — type title + state badge (AVAILABLE/ACTIVE/DONE), CONTRACT // #id line, objective, reward line and an EXPLORE-only progress line; ACCEPT is enabled only when the contract is AVAILABLE and nothing else is ACTIVE, each response applied directly (no polling). Placeholder encounter content removed.
|
||||||
|
- Tests: unit — deterministic generation per type + reward range over 200 draws, same-stream determinism, DELIVER target selection / null on a fresh game, top-up idempotency + MIN_AVAILABLE floor, v6 backfill on pre-mission saves (idempotent), accept validation (404/409) + turn + event + re-top-up, per-type completion through real jump/explore actions incl. wrong-system non-completion and reward accumulation into one row; e2e — MISSIONS tab accepts a contract via the UI (card flips ACTIVE, button disables), completes it via API (scan loop for EXPLORE/FIND, BFS jump path for DELIVER) and asserts DONE + the reward landed in the hold, plus the mission board survives the mid-run container restart.
|
||||||
|
|
||||||
|
## v0.7.0 — 2026-09-29 00:38
|
||||||
|
- SKILL TREE tab — technology research unlocks (issue #9): the second spend-side loop on top of issue #8's ship systems. Researching a tech costs inventory items + a turn and its unlock effect then gates or raises ship actions, so the two tabs interlock: researching WEAPONS SYSTEMS is what makes building WEAPONS possible at all.
|
||||||
|
- New table (`lib/db/schema.ts` + DDL): `tech` (id/save_id/slug/name/cost_kind/cost_qty/prerequisites/effect/researched, UNIQUE(save_id,slug)). Definitions are seeded from the pure module on new game AND as backfill step v5 — same pattern as ship_systems; saves created by older code get the tree on first read. `SCHEMA_VERSION` 4 → 5.
|
||||||
|
- Pure tech model (`lib/game/tech.ts`, no DB imports — imported by both server and UI so research/cap/gate logic can't drift): six techs in two short chains (WEAPONS SYSTEMS → PLASMA CANNON, REACTOR OVERCLOCK → FUSION CORE) plus four roots. Effects: WEAPONS SYSTEMS gates building WEAPONS; the other five raise one system type's UPGRADE level cap or EXPAND capacity cap by 2–3 over the base caps. Helpers compute per-tech state (RESEARCHED / AVAILABLE / LOCKED + missing prereq names), effective caps and build gates from a set of researched slugs.
|
||||||
|
- Research action (`lib/game/research.ts`): `getTechState` → the full tree with per-save state; `researchTech` validates prerequisites (409, naming what is missing) and the cost against the cargo hold (422), spends it through the same oldest-row-first path as ship actions, marks the tech researched, costs a turn and writes an event.
|
||||||
|
- Ship-action gating (`lib/game/ship.ts`): `buildSystem('WEAPONS')` now 409s "requires the WEAPONS SYSTEMS tech" until it is researched; UPGRADE/EXPAND use the effective caps from researched techs, so a raised cap re-enables buttons that were capped.
|
||||||
|
- New endpoints: `GET /api/tech` → `[{id,slug,name,costKind,costQty,prereqs,effect,state,missingPrereqs}]`; `POST /api/research {techId}` → fresh tech tree state (400 malformed body, 404 unknown id, 409 already researched / prereq missing, 422 insufficient hold). OpenAPI spec in code updated (12 paths).
|
||||||
|
- SKILL TREE tab now renders REAL data: one card per tech with a state badge, cost line, UNLOCKS line and — when locked — the blocking prerequisite(s); RESEARCH is enabled only for AVAILABLE techs whose cost the cargo hold can pay, and each response is applied directly (no polling). SHIP SYSTEMS cards pick up the same pure helpers: effective caps drive UPGRADE/EXPAND disabling and gated builds show a red `REQUIRES // <TECH>` line with BUILD NEW disabled. Placeholder skill cards removed; MissionControl owns both states so the tabs stay in sync.
|
||||||
|
- Tests: unit — tree seeding on new game + v5 backfill (deterministic, idempotent), per-tech state incl. locked-with-missing-prereq names, research cost validation with no mutation on failure, exact oldest-first deduction + turn + event + persistence, prereq chain unlock, WEAPONS build gated 409 → succeeds after research, REACTOR upgrade cap raised by REACTOR OVERCLOCK; e2e — SKILL TREE tab researches WEAPONS SYSTEMS via the UI (card flips to RESEARCHED) and the previously-disabled BUILD NEW on WEAPONS becomes enabled and builds, plus tech state survives the mid-run container restart.
|
||||||
|
|
||||||
## v0.6.0 — 2026-09-28 23:46
|
## v0.6.0 — 2026-09-28 23:46
|
||||||
- SHIP SYSTEMS tab — expand / upgrade / build ship systems + save backfill plumbing (issue #8): the first spend-side game loop on top of issue #7's crew, plus a root-cause fix for saves created by older code lacking data newer code seeds. Single-player, no auth, by design.
|
- SHIP SYSTEMS tab — expand / upgrade / build ship systems + save backfill plumbing (issue #8): the first spend-side game loop on top of issue #7's crew, plus a root-cause fix for saves created by older code lacking data newer code seeds. Single-player, no auth, by design.
|
||||||
- Backfill plumbing: `schema_version` INTEGER added to `saves` (NULL = v1). `ensureSave` now runs ordered IDEMPOTENT backfill steps for anything below current — v2: every system without planets gets its generated bodies (same `(seed, systemId)` derivation as fresh generation), v3: starting crew if no rows. New games get the current version directly. `lib/db/client.ts` open() carries an idempotent `ALTER TABLE ... ADD COLUMN` guard for files created before the column existed — old saves open fine and backfill on first read, so DB wipes are no longer needed when deploying new versions.
|
- Backfill plumbing: `schema_version` INTEGER added to `saves` (NULL = v1). `ensureSave` now runs ordered IDEMPOTENT backfill steps for anything below current — v2: every system without planets gets its generated bodies (same `(seed, systemId)` derivation as fresh generation), v3: starting crew if no rows. New games get the current version directly. `lib/db/client.ts` open() carries an idempotent `ALTER TABLE ... ADD COLUMN` guard for files created before the column existed — old saves open fine and backfill on first read, so DB wipes are no longer needed when deploying new versions.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { ensureSave } from '@/lib/game/save'
|
||||||
|
import { acceptMission, MissionError } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// Accept an AVAILABLE mission — it becomes the single active one. Costs a turn; returns the fresh board.
|
||||||
|
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await ensureSave(db) // acceptMission assumes a save exists
|
||||||
|
|
||||||
|
const { id } = await ctx.params
|
||||||
|
const missionId = Number(id)
|
||||||
|
if (!Number.isInteger(missionId)) {
|
||||||
|
return Response.json({ error: 'mission id must be an integer' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Response.json(await acceptMission(db, missionId))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MissionError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { ensureSave } from '@/lib/game/save'
|
||||||
|
import { getMissions } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// The full mission board for the single active save; auto-initializes on first hit.
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await ensureSave(db) // top-up inside getMissions assumes a save exists
|
||||||
|
return Response.json(await getMissions(db))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { ResearchError, researchTech } from '@/lib/game/research'
|
||||||
|
|
||||||
|
// Research one tech. Body: { "techId": number }. Costs inventory items + a turn; returns the fresh tech tree state.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const techId = (body as { techId?: unknown }).techId
|
||||||
|
if (typeof techId !== 'number' || !Number.isInteger(techId)) {
|
||||||
|
return Response.json({ error: 'techId must be an integer' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
return Response.json(await researchTech(db, techId))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ResearchError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { getTechState } from '@/lib/game/research'
|
||||||
|
|
||||||
|
// The full tech tree with per-save state (RESEARCHED / AVAILABLE / LOCKED + missing prereqs). Auto-initializes on first hit.
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
return Response.json(await getTechState(db))
|
||||||
|
}
|
||||||
@@ -160,6 +160,12 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Radix Tabs hides inactive content via the `hidden` attribute, but inline display styles (the
|
||||||
|
#36 pane-fill flex chain on the WORLD MAP tab) outrank the UA stylesheet — restore semantics. */
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.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",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
@@ -20,6 +23,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.2.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/node": "^22.20.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^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/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/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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,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%' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
+21
-70
@@ -1,40 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { ExploreResult, SystemState } from '@/lib/game/save'
|
import type { ExploreResult, SystemState } from '@/lib/game/save'
|
||||||
import type { FoundItem } from '@/lib/game/world'
|
import type { FoundItem } from '@/lib/game/world'
|
||||||
|
|
||||||
// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an
|
// 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
|
// EXPLORE button per unexplored row. The parent (MissionControl) owns the /api/system state so
|
||||||
// applied directly (no polling). Turn-based: one scan costs a turn, each body once.
|
// 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() {
|
interface LocalSystemPanelProps {
|
||||||
const [state, setState] = useState<SystemState | null>(null)
|
system: SystemState | null
|
||||||
|
onChanged: (next: SystemState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) {
|
||||||
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
|
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [exploringId, setExploringId] = useState<number | null>(null)
|
const [exploringId, setExploringId] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const explore = useCallback(
|
||||||
let cancelled = false
|
async (planetId: number) => {
|
||||||
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)
|
setExploringId(planetId)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/explore', {
|
const res = await fetch('/api/explore', {
|
||||||
@@ -44,26 +33,18 @@ export function LocalSystemPanel() {
|
|||||||
})
|
})
|
||||||
const body = (await res.json()) as ExploreResult & { error?: string }
|
const body = (await res.json()) as ExploreResult & { error?: string }
|
||||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
setState(body) // fresh state straight from the response — no refetch needed
|
onChanged(body) // fresh state straight from the response — parent mirrors it to CARGO HOLD
|
||||||
setLastOutcome(body.outcome)
|
setLastOutcome(body.outcome)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
setExploringId(null)
|
setExploringId(null)
|
||||||
}
|
}
|
||||||
}, [])
|
},
|
||||||
|
[onChanged]
|
||||||
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 (
|
return (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
<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>
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING SYSTEM…</span>
|
||||||
@@ -88,9 +69,9 @@ export function LocalSystemPanel() {
|
|||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
EXPLORABLE WORLDS // {state.systemName.toUpperCase()} — TURN {state.turn}
|
EXPLORABLE WORLDS // {system.systemName.toUpperCase()} — TURN {system.turn}
|
||||||
</div>
|
</div>
|
||||||
{state.planets.map((row, i) => {
|
{system.planets.map((row, i) => {
|
||||||
const depth = row.parent !== null ? 1 : 0
|
const depth = row.parent !== null ? 1 : 0
|
||||||
const badge = badgeFor(row.explored, row.hazard)
|
const badge = badgeFor(row.explored, row.hazard)
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +83,7 @@ export function LocalSystemPanel() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
padding: depth ? '0.6rem 0.75rem 0.6rem 1.75rem' : '0.6rem 0.75rem',
|
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' }}>
|
<div style={{ overflow: 'hidden' }}>
|
||||||
@@ -117,7 +98,7 @@ export function LocalSystemPanel() {
|
|||||||
{row.name}
|
{row.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.57rem', color: 'var(--text-muted)', letterSpacing: '0.06em', marginTop: '2px' }}>
|
<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>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
@@ -147,36 +128,6 @@ export function LocalSystemPanel() {
|
|||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
<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>
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>SCAN FAILED // {error.toUpperCase()}</span>
|
||||||
|
|||||||
+156
-254
@@ -1,29 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { StatCard } from '@/components/ui/stat-card'
|
import { StatCard } from '@/components/ui/stat-card'
|
||||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
|
||||||
import { CrewPanel } from '@/components/crew-panel'
|
import { CrewPanel } from '@/components/crew-panel'
|
||||||
|
import { EventLogPanel } from '@/components/event-log'
|
||||||
import { LocalSystemPanel } from '@/components/local-system'
|
import { LocalSystemPanel } from '@/components/local-system'
|
||||||
|
import { MissionPanel } from '@/components/missions'
|
||||||
import { ShipSystemsPanel } from '@/components/ship-systems'
|
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||||
|
import { SkillTreePanel } from '@/components/skill-tree'
|
||||||
import { WorldMapPanel } from '@/components/world-map'
|
import { WorldMapPanel } from '@/components/world-map'
|
||||||
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
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 { 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
|
// 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
|
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven
|
||||||
// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system,
|
// tabs render live data (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions,
|
||||||
// /api/crew, /api/ship); the other tabs keep placeholder content until their own issues land.
|
// /api/events); the right column's CARGO HOLD renders from the lifted /api/system state. Single
|
||||||
// Single player, no auth, by design.
|
// player, no auth, by design.
|
||||||
|
|
||||||
function useNarrow(threshold = 900): boolean {
|
function useNarrow(threshold = 900): boolean {
|
||||||
const [narrow, setNarrow] = useState(
|
const [narrow, setNarrow] = useState(
|
||||||
@@ -37,52 +38,25 @@ function useNarrow(threshold = 900): boolean {
|
|||||||
return narrow
|
return narrow
|
||||||
}
|
}
|
||||||
|
|
||||||
function now(): string {
|
// Must match the TabsTrigger values in the render below — used to validate the tab persisted for
|
||||||
return new Date().toLocaleTimeString('en-GB', { hour12: false })
|
// #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'
|
||||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
|
||||||
|
|
||||||
const skillCards = [
|
|
||||||
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
|
||||||
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
|
||||||
{ name: 'CREW TRAINING PROTOCOLS', unlocks: 'RETRAIN CREW TRAITS', state: 'LOCKED' as const },
|
|
||||||
{ name: 'JUMP DRIVE TUNING', unlocks: 'SHORTER JUMP COOLDOWN', state: 'LOCKED' as const },
|
|
||||||
]
|
|
||||||
|
|
||||||
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() },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function MissionControl() {
|
export function MissionControl() {
|
||||||
const narrow = useNarrow()
|
const narrow = useNarrow()
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
// Ship state is owned here (not in the tab panel) so the left column mirrors ship actions
|
// Ship + system state is owned here (not in the tab panels): the SHIP SYSTEMS tab mirrors ship
|
||||||
// immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count
|
// actions via setShip, and the right column's CARGO HOLD renders from the lifted inventory —
|
||||||
// feeds the roster bar — refetched on page load, like the other panels.
|
// 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 [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)
|
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)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -95,6 +69,24 @@ export function MissionControl() {
|
|||||||
if (!cancelled) setShip(s)
|
if (!cancelled) setShip(s)
|
||||||
})
|
})
|
||||||
.catch(() => {}) // left column + tab keep their loading state on failure
|
.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}`)
|
||||||
|
return (await res.json()) as TechDto[]
|
||||||
|
})
|
||||||
|
.then((t) => {
|
||||||
|
if (!cancelled) setTechs(t)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
fetch('/api/crew')
|
fetch('/api/crew')
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
@@ -114,12 +106,39 @@ export function MissionControl() {
|
|||||||
return () => clearInterval(id)
|
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 })
|
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))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minHeight: '100vh',
|
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)',
|
background: 'var(--background)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -150,7 +169,7 @@ export function MissionControl() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
◄ SHIP
|
v{pkg.version}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
||||||
@@ -209,6 +228,7 @@ export function MissionControl() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: 0, // let the grid below be constrained to the viewport instead of growing it
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '1px',
|
gap: '1px',
|
||||||
@@ -255,8 +275,9 @@ export function MissionControl() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: 0, // same — the columns' overflowY:auto then scrolls inside their tracks
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
|
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
|
||||||
gap: '1px',
|
gap: '1px',
|
||||||
background: 'var(--border)',
|
background: 'var(--border)',
|
||||||
}}
|
}}
|
||||||
@@ -304,11 +325,34 @@ export function MissionControl() {
|
|||||||
<PanelContent>
|
<PanelContent>
|
||||||
{ship ? (
|
{ship ? (
|
||||||
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
// 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' }}>
|
<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="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="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)} />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
@@ -328,7 +372,9 @@ export function MissionControl() {
|
|||||||
overflowY: 'auto',
|
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>
|
<PanelHeader>
|
||||||
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||||
@@ -336,13 +382,27 @@ export function MissionControl() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<PanelContent style={{ padding: 0 }}>
|
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<Tabs defaultValue="world-map">
|
{/* 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
|
<TabsList
|
||||||
style={{
|
style={{
|
||||||
padding: '0 1rem',
|
padding: '0 1rem',
|
||||||
background: 'var(--surface-raised)',
|
background: 'var(--surface-raised)', // opaque — content scrolls under it (#31)
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
|
position: 'sticky', // #31 — pinned in both modes: to the top of the OPERATIONS
|
||||||
|
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="world-map">WORLD MAP</TabsTrigger>
|
||||||
@@ -354,14 +414,19 @@ export function MissionControl() {
|
|||||||
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* WORLD MAP TAB — live data from /api/world */}
|
{/* WORLD MAP TAB — live data from /api/world; fills the whole pane (#36). Wide mode:
|
||||||
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
flex column so the panel can fill it with pure flex sizing (a min-height-only
|
||||||
<WorldMapPanel height={380} />
|
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} onJumped={refreshSystem} />
|
||||||
</TabsContent>
|
</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' }}>
|
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<LocalSystemPanel />
|
<LocalSystemPanel system={system} onChanged={setSystem} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* CREW TAB — live data from /api/crew */}
|
{/* CREW TAB — live data from /api/crew */}
|
||||||
@@ -371,116 +436,29 @@ export function MissionControl() {
|
|||||||
|
|
||||||
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
||||||
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} />
|
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} researchedSlugs={researchedSlugs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* SKILL TREE TAB */}
|
{/* SKILL TREE TAB — live data from /api/tech (state owned by MissionControl) */}
|
||||||
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{skillCards.map((skill) => (
|
|
||||||
<Card key={skill.name}>
|
|
||||||
<CardHeader>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<CardTitle>{skill.name}</CardTitle>
|
|
||||||
<Badge variant={skill.state === 'AVAILABLE' ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
|
|
||||||
{skill.state}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
|
||||||
UNLOCKS: {skill.unlocks}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button variant="OUTLINE" size="SM">
|
|
||||||
RESEARCH
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* MISSIONS TAB */}
|
{/* MISSIONS TAB — live data from /api/missions */}
|
||||||
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<MissionPanel />
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
|
||||||
>
|
|
||||||
<Alert variant="STATUS">
|
|
||||||
<AlertTitle>ENCOUNTER #E-102 — DERELICT FREIGHTER</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Placeholder briefing. Real encounter resolution lands with the turn engine.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: narrow ? '1fr' : '1fr 1fr',
|
|
||||||
gap: '0.625rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: 'DESTINATION', value: 'SECTOR 7G // DRIFT LANE' },
|
|
||||||
{ label: 'ETA', value: 'T-07:14 (PLACEHOLDER)' },
|
|
||||||
{ label: 'THREAT LEVEL', value: 'LOW — UNMANNED' },
|
|
||||||
{ label: 'CARGO MANIFEST', value: 'UNKNOWN' },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.55rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
marginBottom: '0.35rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* EVENT LOG TAB */}
|
{/* EVENT LOG TAB */}
|
||||||
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<Terminal
|
<EventLogPanel />
|
||||||
lines={logLines}
|
|
||||||
title="SHIP LOG // PLACEHOLDER FEED"
|
|
||||||
height="22rem"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── RIGHT: UPCOMING ENCOUNTERS + SHIP DATA ── */}
|
{/* ── RIGHT: CARGO HOLD (persistent — inventory lifted from /api/system) ── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--background)',
|
background: 'var(--background)',
|
||||||
@@ -493,110 +471,34 @@ export function MissionControl() {
|
|||||||
>
|
>
|
||||||
<Panel notch="sm">
|
<Panel notch="sm">
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<PanelTitle>UPCOMING ENCOUNTERS</PanelTitle>
|
<PanelTitle>CARGO HOLD // INVENTORY</PanelTitle>
|
||||||
|
{system && (
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
<Badge variant="SCANNING">{encounters.length} QUEUED</Badge>
|
<Badge variant={system.inventory.length > 0 ? 'ACTIVE' : 'OFFLINE'}>
|
||||||
</div>
|
{system.inventory.reduce((n, i) => n + i.qty, 0)} UNITS
|
||||||
</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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PanelContent>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel notch="sm">
|
|
||||||
<PanelHeader>
|
|
||||||
<PanelTitle>SHIP DATA</PanelTitle>
|
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<PanelContent>
|
<PanelContent style={{ padding: 0 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
{!system ? (
|
||||||
{shipData.map((item, i) => (
|
<div style={{ padding: '0.6rem 0.75rem' }}>
|
||||||
<div
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
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}
|
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
</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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import type { MissionDto } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// MISSIONS tab — live data. Fetches /api/missions on mount (Radix Tabs unmounts inactive tabs, so it
|
||||||
|
// refetches whenever the tab is opened); accept responses are applied directly (no polling). ACCEPT is
|
||||||
|
// enabled only when the contract is AVAILABLE and no other mission is ACTIVE — mirroring what
|
||||||
|
// POST /api/missions/{id}/accept enforces.
|
||||||
|
|
||||||
|
export function MissionPanel() {
|
||||||
|
const [missions, setMissions] = useState<MissionDto[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null) // one action at a time — turn-based game
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/missions')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as MissionDto[]
|
||||||
|
})
|
||||||
|
.then((m) => {
|
||||||
|
if (!cancelled) setMissions(m)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const accept = useCallback(async (mission: MissionDto) => {
|
||||||
|
setBusyId(mission.id)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/missions/${mission.id}/accept`, { method: 'POST' })
|
||||||
|
const data = (await res.json()) as MissionDto[] & { error?: string }
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||||
|
setMissions(data) // response is the fresh board — apply directly
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!missions && !error) {
|
||||||
|
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 MISSIONS…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = (missions ?? []).find((m) => m.state === 'ACTIVE')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{error && (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem' }}>
|
||||||
|
{(missions ?? []).map((mission) => (
|
||||||
|
<MissionCard key={mission.id} mission={mission} busy={busyId === mission.id} blockedByActive={!!active && active.id !== mission.id} onAccept={accept} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissionCard({
|
||||||
|
mission,
|
||||||
|
busy,
|
||||||
|
blockedByActive,
|
||||||
|
onAccept,
|
||||||
|
}: {
|
||||||
|
mission: MissionDto
|
||||||
|
busy: boolean
|
||||||
|
blockedByActive: boolean
|
||||||
|
onAccept: (mission: MissionDto) => void
|
||||||
|
}) {
|
||||||
|
const acceptDisabled = busy || mission.state !== 'AVAILABLE' || blockedByActive
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{mission.type}</CardTitle>
|
||||||
|
<Badge variant={mission.state === 'ACTIVE' ? 'ACTIVE' : mission.state === 'AVAILABLE' ? 'SCANNING' : 'OFFLINE'} className="ml-auto">
|
||||||
|
{mission.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>CONTRACT // #{mission.id}</p>
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>{mission.description.toUpperCase()}</p>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.08em' }}>
|
||||||
|
REWARD {mission.rewardQty}× {mission.rewardKind === 'resource' ? 'RESOURCES' : 'PARTS'}
|
||||||
|
</p>
|
||||||
|
{mission.type === 'EXPLORE' && (
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
PROGRESS // {mission.progress}/{mission.progressTarget}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="OUTLINE" size="SM" disabled={acceptDisabled} onClick={() => onAccept(mission)}>
|
||||||
|
ACCEPT
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,21 +4,24 @@ import { useState } from 'react'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
|
import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
|
||||||
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
|
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
|
||||||
|
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from '@/lib/game/tech'
|
||||||
|
|
||||||
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left
|
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left
|
||||||
// column mirrors actions immediately; this panel applies each action's response directly via
|
// column mirrors actions immediately; this panel applies each action's response directly via
|
||||||
// onChanged (no polling). Buttons are disabled when unaffordable or capped, using the same
|
// onChanged (no polling). Buttons are disabled when unaffordable, capped or gated by an
|
||||||
// cost constants the API enforces (lib/game/ship-costs.ts).
|
// unresearched tech, using the same pure helpers the API enforces (lib/game/ship-costs.ts +
|
||||||
|
// lib/game/tech.ts).
|
||||||
|
|
||||||
interface ShipSystemsPanelProps {
|
interface ShipSystemsPanelProps {
|
||||||
ship: ShipState | null
|
ship: ShipState | null
|
||||||
narrow?: boolean
|
narrow?: boolean
|
||||||
onChanged: (next: ShipState) => void
|
onChanged: (next: ShipState) => void
|
||||||
|
researchedSlugs: Set<string> // slugs of researched techs — raises caps, lifts build gates
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelProps) {
|
export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: ShipSystemsPanelProps) {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelPr
|
|||||||
)}
|
)}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
|
||||||
{ship.systems.map((system) => (
|
{ship.systems.map((system) => (
|
||||||
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} onAct={act} />
|
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} researchedSlugs={researchedSlugs} onAct={act} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,17 +72,23 @@ function SystemCard({
|
|||||||
resources,
|
resources,
|
||||||
parts,
|
parts,
|
||||||
busy,
|
busy,
|
||||||
|
researchedSlugs,
|
||||||
onAct,
|
onAct,
|
||||||
}: {
|
}: {
|
||||||
system: ShipSystemDto
|
system: ShipSystemDto
|
||||||
resources: number
|
resources: number
|
||||||
parts: number
|
parts: number
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
researchedSlugs: Set<string>
|
||||||
onAct: (url: string, body: unknown) => void
|
onAct: (url: string, body: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
const upgradeDisabled = busy || !system.built || system.level >= MAX_LEVEL || resources < upgradeCost(system.level)
|
const maxLevel = effectiveLevelCap(system.type, researchedSlugs)
|
||||||
const expandDisabled = busy || !system.built || system.capacity >= MAX_CAPACITY || resources < expandCost(system.capacity)
|
const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs)
|
||||||
const buildDisabled = busy || system.built || parts < BUILD_PART_COST
|
const gate = buildGate(system.type, researchedSlugs) // name of the required tech, or null when ungated/researched
|
||||||
|
|
||||||
|
const upgradeDisabled = busy || !system.built || system.level >= maxLevel || resources < upgradeCost(system.level)
|
||||||
|
const expandDisabled = busy || !system.built || system.capacity >= maxCapacity || resources < expandCost(system.capacity)
|
||||||
|
const buildDisabled = busy || system.built || gate !== null || parts < BUILD_PART_COST
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -100,6 +109,9 @@ function SystemCard({
|
|||||||
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
|
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
|
||||||
: `BUILD ${BUILD_PART_COST}× PARTS`}
|
: `BUILD ${BUILD_PART_COST}× PARTS`}
|
||||||
</p>
|
</p>
|
||||||
|
{!system.built && gate !== null && (
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>REQUIRES // {gate}</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="gap-2">
|
<CardFooter className="gap-2">
|
||||||
{system.built ? (
|
{system.built ? (
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { totalQty } from '@/lib/game/ship-costs'
|
||||||
|
import type { ShipState } from '@/lib/game/ship'
|
||||||
|
import type { TechDto } from '@/lib/game/tech'
|
||||||
|
|
||||||
|
// SKILL TREE tab — live data. The parent (MissionControl) owns the tech state + ship state; this panel
|
||||||
|
// applies each research response directly via onResearched (no polling). RESEARCH is enabled only when
|
||||||
|
// the tech is AVAILABLE and its cost is payable from the cargo hold, mirroring what POST /api/research enforces.
|
||||||
|
|
||||||
|
interface SkillTreePanelProps {
|
||||||
|
techs: TechDto[] | null
|
||||||
|
ship: ShipState | null
|
||||||
|
narrow?: boolean
|
||||||
|
onResearched: (next: TechDto[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreePanelProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
||||||
|
|
||||||
|
if (!techs || !ship) {
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING SKILL TREE…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = totalQty(ship.inventory, 'resource')
|
||||||
|
const parts = totalQty(ship.inventory, 'part')
|
||||||
|
|
||||||
|
async function research(tech: TechDto) {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id }) })
|
||||||
|
const data = (await res.json()) as TechDto[] & { error?: string }
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||||
|
onResearched(data) // response is the fresh tech tree state — apply directly
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{error && (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
|
||||||
|
{techs.map((tech) => (
|
||||||
|
<TechCard key={tech.slug} tech={tech} resources={resources} parts={parts} busy={busy} onResearch={research} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechCard({
|
||||||
|
tech,
|
||||||
|
resources,
|
||||||
|
parts,
|
||||||
|
busy,
|
||||||
|
onResearch,
|
||||||
|
}: {
|
||||||
|
tech: TechDto
|
||||||
|
resources: number
|
||||||
|
parts: number
|
||||||
|
busy: boolean
|
||||||
|
onResearch: (tech: TechDto) => void
|
||||||
|
}) {
|
||||||
|
const affordable = tech.costKind === 'resource' ? resources >= tech.costQty : parts >= tech.costQty
|
||||||
|
const researchDisabled = busy || tech.state !== 'AVAILABLE' || !affordable
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{tech.name}</CardTitle>
|
||||||
|
<Badge variant={tech.state === 'RESEARCHED' ? 'ACTIVE' : tech.state === 'AVAILABLE' ? 'SCANNING' : 'OFFLINE'} className="ml-auto">
|
||||||
|
{tech.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
|
||||||
|
COST {tech.costQty}× {tech.costKind === 'resource' ? 'RESOURCES' : 'PARTS'}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>UNLOCKS // {tech.effect}</p>
|
||||||
|
{tech.state === 'LOCKED' && (
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>REQUIRES // {tech.missingPrereqs.join(' + ')}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="OUTLINE" size="SM" disabled={researchDisabled} onClick={() => onResearch(tech)}>
|
||||||
|
RESEARCH
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
+217
-49
@@ -1,23 +1,58 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from 'd3-force'
|
||||||
import { NodeGraph } from '@/components/ui/node-graph'
|
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 type { WorldState } from '@/lib/game/save'
|
||||||
|
|
||||||
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and
|
// WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with
|
||||||
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not
|
// pan + zoom (d3-zoom), per #28. Clicking a system adjacent to the current one jumps there
|
||||||
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount,
|
// (same POST /api/jump flow as before); the current and unreachable systems are dimmed and not
|
||||||
// refetch via the jump response — no polling.
|
// 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 {
|
interface WorldMapPanelProps {
|
||||||
height?: number
|
height?: number // fallback chart height when the pane gives no definite space (narrow mode)
|
||||||
|
narrow?: boolean
|
||||||
|
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, onJumped }: WorldMapPanelProps) {
|
||||||
|
// Wide mode: the flex chain (Tabs → TabsContent → this panel) provides a definite pane size to fill (#36).
|
||||||
|
const filling = !narrow
|
||||||
const [world, setWorld] = useState<WorldState | null>(null)
|
const [world, setWorld] = useState<WorldState | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -37,7 +72,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) => {
|
const jump = useCallback(async (systemId: number) => {
|
||||||
|
if (jumpingRef.current) return
|
||||||
|
jumpingRef.current = true
|
||||||
setJumpingId(systemId)
|
setJumpingId(systemId)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/jump', {
|
const res = await fetch('/api/jump', {
|
||||||
@@ -48,12 +88,164 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
|||||||
const body = (await res.json()) as WorldState & { error?: string }
|
const body = (await res.json()) as WorldState & { error?: string }
|
||||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
setWorld(body) // fresh state straight from the response — no refetch needed
|
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) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
|
jumpingRef.current = false
|
||||||
setJumpingId(null)
|
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)!
|
||||||
|
// Systems one jump away — the only clickable chart fields (the same set as before).
|
||||||
|
const reachableIds = new Set(
|
||||||
|
world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
|
||||||
|
)
|
||||||
|
|
||||||
|
const nodes: ChartNode[] = world.systems.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name.toUpperCase(),
|
||||||
|
status: s.current ? 'current' : reachableIds.has(s.id) ? 'jumpable' : 'offline',
|
||||||
|
sublabel: s.current
|
||||||
|
? 'CURRENT'
|
||||||
|
: reachableIds.has(s.id)
|
||||||
|
? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP')
|
||||||
|
: 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])
|
||||||
|
|
||||||
if (error && !world) {
|
if (error && !world) {
|
||||||
return (
|
return (
|
||||||
@@ -68,36 +260,24 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
|||||||
if (!world) {
|
if (!world) {
|
||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = world.systems.find((s) => s.current)!
|
// flex (not height:'100%'): the pane chain is sized by min-height, where percentages don't resolve
|
||||||
const reachable = world.edges
|
|
||||||
.filter(([a, b]) => a === current.id || b === current.id)
|
|
||||||
.map(([a, b]) => (a === current.id ? b : a))
|
|
||||||
.map((id) => world.systems.find((s) => s.id === id)!)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', ...(filling ? { flex: 1, minHeight: 0 } : {}) }}>
|
||||||
<NodeGraph
|
{/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns everything inside it. */}
|
||||||
nodes={world.systems.map((s) => ({
|
<div
|
||||||
id: String(s.id),
|
style={{
|
||||||
label: s.name,
|
border: '1px solid var(--border)',
|
||||||
x: s.x,
|
background: 'var(--surface)',
|
||||||
y: s.y,
|
fontFamily: 'var(--font-mono)',
|
||||||
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE',
|
overflow: 'hidden',
|
||||||
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
...(filling ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } : {}),
|
||||||
}))}
|
}}
|
||||||
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '0.4rem 0.75rem',
|
padding: '0.4rem 0.75rem',
|
||||||
@@ -109,21 +289,9 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
|||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
JUMP ROUTES // FROM {current.name.toUpperCase()}
|
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', padding: '0.6rem 0.75rem' }}>
|
|
||||||
{reachable.map((s) => (
|
|
||||||
<Button
|
|
||||||
key={s.id}
|
|
||||||
variant="OUTLINE"
|
|
||||||
size="SM"
|
|
||||||
disabled={jumpingId !== null}
|
|
||||||
onClick={() => void jump(s.id)}
|
|
||||||
>
|
|
||||||
{jumpingId === s.id ? 'JUMPING…' : `${s.name.toUpperCase()} // JUMP`}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div ref={containerRef} style={{ width: '100%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// Shared e2e helpers — kept out of the specs so both files can reuse them.
|
||||||
|
|
||||||
|
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||||
|
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||||||
|
|
||||||
|
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
|
||||||
|
// undiscovered system generates 4 fresh neighbours (lib/game/world.ts), so fresh territory is
|
||||||
|
// always available — picking the FIRST edge instead ping-pongs between exhausted systems, because
|
||||||
|
// edges come back in insertion order and the oldest route out of a system is usually the one that
|
||||||
|
// led here. Falls back to any non-current adjacent system if none is undiscovered.
|
||||||
|
export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
||||||
|
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
const currentId = world.systems.find((s) => s.current)!.id;
|
||||||
|
|
||||||
|
const adjacentIds = new Set<number>();
|
||||||
|
for (const [a, b] of world.edges) {
|
||||||
|
if (a === currentId) adjacentIds.add(b);
|
||||||
|
else if (b === currentId) adjacentIds.add(a);
|
||||||
|
}
|
||||||
|
const candidates = [...adjacentIds].map((id) => world.systems.find((s) => s.id === id)!);
|
||||||
|
const target = candidates.find((s) => !s.discovered) ?? candidates[0];
|
||||||
|
expect(target).toBeDefined(); // every system has at least one route out (ring generation)
|
||||||
|
|
||||||
|
const res = await page.request.post('/api/jump', { data: { systemId: target.id } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as WorldState;
|
||||||
|
}
|
||||||
+277
-7
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { jumpToFreshSystem } from './helpers';
|
||||||
|
|
||||||
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||||
@@ -11,9 +12,41 @@ interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: u
|
|||||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||||
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
|
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);
|
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
|
||||||
|
|
||||||
|
// BFS over the undirected jump routes — [next, ..., target], empty when already there.
|
||||||
|
const shortestJumpPath = (fromId: number, toId: number, edges: [number, 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 prev = new Map<number, number>();
|
||||||
|
const seen = new Set([fromId]);
|
||||||
|
const queue = [fromId];
|
||||||
|
while (queue.length > 0 && !seen.has(toId)) {
|
||||||
|
const cur = queue.shift()!;
|
||||||
|
for (const next of adj.get(cur) ?? []) {
|
||||||
|
if (!seen.has(next)) {
|
||||||
|
seen.add(next);
|
||||||
|
prev.set(next, cur);
|
||||||
|
queue.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(seen.has(toId)).toBe(true); // the world graph is connected by construction
|
||||||
|
const path: number[] = [];
|
||||||
|
for (let cur = toId; cur !== fromId; cur = prev.get(cur)!) {
|
||||||
|
path.unshift(cur);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
@@ -32,16 +65,68 @@ test('game template tabs render (scificn-ui component layer wired in)', async ({
|
|||||||
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
|
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
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 }) => {
|
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;
|
||||||
|
|
||||||
await page.goto('/');
|
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();
|
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
|
||||||
const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ });
|
// The current system is marked, and every adjacent system is a clickable chart field (role=button).
|
||||||
await expect(jumpButtons.first()).toBeVisible();
|
const current = world.systems.find((s) => s.current)!;
|
||||||
await expect(jumpButtons).toHaveCount(6);
|
await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
|
||||||
|
const panel = page.getByRole('tabpanel');
|
||||||
|
const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save
|
||||||
|
await expect(panel.getByRole('button')).toHaveCount(adjacent.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
|
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
|
||||||
@@ -52,13 +137,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;
|
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||||
|
|
||||||
await page.goto('/');
|
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;
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
expect(after.turn).toBe(before.turn + 1);
|
expect(after.turn).toBe(before.turn + 1);
|
||||||
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
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 }) => {
|
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;
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
|
|
||||||
@@ -148,7 +258,9 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card
|
|||||||
// a build costs three parts. Jumping keeps it going if a whole system is exhausted.
|
// a build costs three parts. Jumping keeps it going if a whole system is exhausted.
|
||||||
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level);
|
const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level);
|
||||||
const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built) : undefined);
|
// WEAPONS is gated behind the unresearched WEAPONS SYSTEMS tech — its build path is covered by
|
||||||
|
// the research test below, so this one only targets ungated types.
|
||||||
|
const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built && s.type !== 'WEAPONS') : undefined);
|
||||||
|
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
while (!upgradeTarget() && !buildTarget() && iterations < 60) {
|
while (!upgradeTarget() && !buildTarget() && iterations < 60) {
|
||||||
@@ -204,6 +316,164 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => {
|
||||||
|
// Research costs two parts, building WEAPONS three — scan until the hold has five. Parts drop on
|
||||||
|
// ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts.
|
||||||
|
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
let iterations = 0;
|
||||||
|
while (totalQty(ship.inventory, 'part') < 5 && iterations < 80) {
|
||||||
|
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, 'part')).toBeGreaterThanOrEqual(5);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// SHIP SYSTEMS tab: BUILD NEW on WEAPONS is disabled — gated behind the unresearched tech.
|
||||||
|
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||||
|
const weaponsCard = page.getByRole('heading', { name: 'WEAPONS' }).locator('xpath=ancestor::div[3]');
|
||||||
|
await expect(weaponsCard.getByText('REQUIRES // WEAPONS SYSTEMS')).toBeVisible();
|
||||||
|
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeDisabled();
|
||||||
|
|
||||||
|
// SKILL TREE tab: research WEAPONS SYSTEMS (two parts) — the card flips to RESEARCHED.
|
||||||
|
await page.getByRole('tab', { name: 'SKILL TREE' }).click();
|
||||||
|
const techCard = page.getByRole('heading', { name: 'WEAPONS SYSTEMS' }).locator('xpath=ancestor::div[3]');
|
||||||
|
await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites
|
||||||
|
await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // parts ≥ 2
|
||||||
|
const turnBefore = ship.turn;
|
||||||
|
await techCard.getByRole('button', { name: 'RESEARCH' }).click();
|
||||||
|
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
|
||||||
|
|
||||||
|
const afterResearch = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
expect(afterResearch.turn).toBe(turnBefore + 1); // research costs a turn
|
||||||
|
|
||||||
|
// The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and builds.
|
||||||
|
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||||
|
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
||||||
|
await weaponsCard.getByRole('button', { name: 'BUILD NEW' }).click();
|
||||||
|
|
||||||
|
const afterBuild = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
expect(afterBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(true);
|
||||||
|
await expect(weaponsCard.getByText('LV 1')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('tab', { name: 'MISSIONS' }).click();
|
||||||
|
const acceptButtons = page.getByRole('button', { name: 'ACCEPT' });
|
||||||
|
const panel = page.getByRole('tabpanel');
|
||||||
|
|
||||||
|
// Idempotent across retries sharing a DB: if a previous attempt already accepted a mission, it's still
|
||||||
|
// ACTIVE and every ACCEPT button is disabled — complete that one instead of accepting again.
|
||||||
|
const leftover = board.find((m) => m.state === 'ACTIVE');
|
||||||
|
let picked: MissionDto;
|
||||||
|
|
||||||
|
if (leftover) {
|
||||||
|
picked = leftover;
|
||||||
|
// No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE".
|
||||||
|
await expect(panel.getByText('ACTIVE')).toBeVisible();
|
||||||
|
for (const button of await acceptButtons.all()) {
|
||||||
|
await expect(button).toBeDisabled();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Prefer a scan-completable contract — fresh boards are all EXPLORE/FIND (DELIVER only appears once systems are discovered).
|
||||||
|
picked = board.find((m) => m.state === 'AVAILABLE' && m.type !== 'DELIVER') ?? board.find((m) => m.state === 'AVAILABLE')!;
|
||||||
|
const idx = board.indexOf(picked);
|
||||||
|
|
||||||
|
// One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active).
|
||||||
|
await expect(acceptButtons.nth(idx)).toBeEnabled();
|
||||||
|
await acceptButtons.nth(idx).click();
|
||||||
|
|
||||||
|
// UI flipped: exactly one ACTIVE badge on the board and the accepted card's button is now disabled.
|
||||||
|
// No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE".
|
||||||
|
await expect(panel.getByText('ACTIVE')).toBeVisible();
|
||||||
|
await expect(acceptButtons.nth(idx)).toBeDisabled();
|
||||||
|
|
||||||
|
// API confirms it: exactly one ACTIVE mission, the picked one (accepting cost a turn).
|
||||||
|
const boardAfterAccept = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
||||||
|
expect(boardAfterAccept.filter((m) => m.state === 'ACTIVE')).toEqual([boardAfterAccept.find((m) => m.id === picked.id)!]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline of the reward kind in the hold — scans during completion may add more, so assert >=.
|
||||||
|
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
const baseline = totalQty(shipBefore.inventory, picked.rewardKind);
|
||||||
|
|
||||||
|
// Complete it. EXPLORE/FIND: scan until done, jumping when a system exhausts (parts drop on ~30% of
|
||||||
|
// scans, so P(a FIND-part misses in 80 scans) ≈ 0.7^80 — negligible). DELIVER: follow the shortest
|
||||||
|
// jump path to its target (edges only ever get added, so the precomputed path stays valid).
|
||||||
|
let deliverPath: number[] = [];
|
||||||
|
if (picked.type === 'DELIVER') {
|
||||||
|
const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
deliverPath = shortestJumpPath(worldNow.systems.find((s) => s.current)!.id, picked.targetSystemId!, worldNow.edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = 'ACTIVE';
|
||||||
|
let iterations = 0;
|
||||||
|
while (state !== 'DONE' && iterations < 80) {
|
||||||
|
iterations++;
|
||||||
|
if (deliverPath.length > 0) {
|
||||||
|
expect((await page.request.post('/api/jump', { data: { systemId: deliverPath.shift()! } })).status()).toBe(200);
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = ((await (await page.request.get('/api/missions')).json()) as MissionDto[]).find((m) => m.id === picked.id)?.state ?? 'ACTIVE';
|
||||||
|
}
|
||||||
|
expect(state).toBe('DONE'); // the bounded loop always completes: scans are plentiful, jump paths finite
|
||||||
|
|
||||||
|
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
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 }) => {
|
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||||
const res = await page.request.get('/api/version');
|
const res = await page.request.get('/api/version');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
|||||||
+34
-15
@@ -1,6 +1,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { jumpToFreshSystem } from './helpers';
|
||||||
|
|
||||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
||||||
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
||||||
@@ -8,14 +9,15 @@ import { expect, test } from '@playwright/test';
|
|||||||
const repoRoot = path.join(__dirname, '..');
|
const repoRoot = path.join(__dirname, '..');
|
||||||
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
||||||
|
|
||||||
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
// Mirrors the /api/system response shape (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
|
||||||
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
|
||||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
||||||
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||||
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
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('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
@@ -27,22 +29,13 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
|
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
|
|
||||||
// Game-state baseline: jump once so there is real progress that must survive the restart.
|
// Game-state baseline: jump once so there is real progress that must survive the restart.
|
||||||
const worldBefore = (await (await page.request.get('/api/world')).json()) as WorldState;
|
await jumpToFreshSystem(page);
|
||||||
const currentId = worldBefore.systems.find((s) => s.current)!.id;
|
|
||||||
const edge = worldBefore.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
|
||||||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
|
||||||
const jumpRes = await page.request.post('/api/jump', { data: { systemId: targetId } });
|
|
||||||
expect(jumpRes.status()).toBe(200);
|
|
||||||
|
|
||||||
// Exploration baseline: scan one body so explored state + inventory must survive too.
|
// Exploration baseline: scan one body so explored state + inventory must survive too.
|
||||||
// (Earlier specs may have exhausted the current system, so jump until an unscanned body shows up.)
|
// (Earlier specs may have exhausted the current system, so jump until an unscanned body shows up.)
|
||||||
let sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
let sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
for (let i = 0; !sysBefore.planets.some((p) => !p.explored) && i < 20; i++) {
|
for (let i = 0; !sysBefore.planets.some((p) => !p.explored) && i < 20; i++) {
|
||||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||||||
const curId = world.systems.find((s) => s.current)!.id;
|
|
||||||
const e = world.edges.find(([a, b]) => a === curId || b === curId)!;
|
|
||||||
const tId = e[0] === curId ? e[1] : e[0];
|
|
||||||
expect((await page.request.post('/api/jump', { data: { systemId: tId } })).status()).toBe(200);
|
|
||||||
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
}
|
}
|
||||||
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
|
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
|
||||||
@@ -58,6 +51,20 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship is always seeded
|
expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship is always seeded
|
||||||
|
|
||||||
|
// Tech baseline: the researched flags must survive too.
|
||||||
|
const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
||||||
|
expect(techBefore.length).toBeGreaterThan(0); // the tech tree is always seeded
|
||||||
|
|
||||||
|
// Mission board baseline: contracts, states and progress must survive too. The GET tops the board
|
||||||
|
// up first — a no-op here because every action leaves at least MIN_AVAILABLE AVAILABLE contracts.
|
||||||
|
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.
|
// Restart the app container mid-run — the named DB volume must survive it.
|
||||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
|
||||||
@@ -79,7 +86,7 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
||||||
|
|
||||||
// Game state survived: same turn counter and current system as before the restart.
|
// Game state survived: same turn counter and current system as before the restart.
|
||||||
const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const worldAfter = (await (await page.request.get('/api/world')).json()) as { turn: number; systems: { id: number; current: boolean }[] };
|
||||||
expect(worldAfter.turn).toBe(sysExplored.turn);
|
expect(worldAfter.turn).toBe(sysExplored.turn);
|
||||||
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId);
|
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId);
|
||||||
|
|
||||||
@@ -96,6 +103,18 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
expect(shipAfter).toEqual(shipBefore);
|
expect(shipAfter).toEqual(shipBefore);
|
||||||
|
|
||||||
|
// Tech tree survived too: same researched flags (no re-seed on restart).
|
||||||
|
const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
||||||
|
expect(techAfter).toEqual(techBefore);
|
||||||
|
|
||||||
|
// Mission board survived too: same contracts, states and progress (no re-seed or re-top-up on restart).
|
||||||
|
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.
|
// The page itself serves again with the landing title.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
|
|||||||
@@ -81,6 +81,33 @@ CREATE TABLE IF NOT EXISTS ship_systems (
|
|||||||
UNIQUE (save_id, type)
|
UNIQUE (save_id, type)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tech (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
slug TEXT NOT NULL, -- stable key, e.g. WEAPONS_SYSTEMS
|
||||||
|
name TEXT NOT NULL, -- display name, e.g. 'WEAPONS SYSTEMS'
|
||||||
|
cost_kind TEXT NOT NULL, -- 'resource' | 'part'
|
||||||
|
cost_qty INTEGER NOT NULL DEFAULT 1,
|
||||||
|
prerequisites TEXT NOT NULL DEFAULT '', -- comma-separated tech slugs, '' when none
|
||||||
|
effect TEXT NOT NULL, -- effect slug — semantics in EFFECTS (lib/game/tech.ts)
|
||||||
|
researched INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (save_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS missions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
type TEXT NOT NULL, -- 'DELIVER' | 'EXPLORE' | 'FIND'
|
||||||
|
description TEXT NOT NULL, -- human-readable objective, e.g. "Jump to Kepler Drift"
|
||||||
|
target_system_id INTEGER, -- DELIVER only — destination system (NULL otherwise)
|
||||||
|
condition_kind TEXT, -- FIND only — 'resource' | 'part' (NULL otherwise)
|
||||||
|
progress_target INTEGER NOT NULL DEFAULT 1, -- EXPLORE: scans required; 1 for the others
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0, -- scans counted since acceptance; 0/1 for DELIVER/FIND
|
||||||
|
reward_kind TEXT NOT NULL, -- 'resource' | 'part'
|
||||||
|
reward_qty INTEGER NOT NULL DEFAULT 1,
|
||||||
|
state TEXT NOT NULL DEFAULT 'AVAILABLE' -- 'AVAILABLE' | 'ACTIVE' | 'DONE'
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
|||||||
+39
-1
@@ -20,7 +20,7 @@ export const solarSystems = sqliteTable('solar_systems', {
|
|||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
name: text('name').notNull(),
|
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(),
|
x: real('x').notNull(),
|
||||||
y: real('y').notNull(),
|
y: real('y').notNull(),
|
||||||
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
|
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
|
||||||
@@ -88,6 +88,44 @@ export const shipSystems = sqliteTable(
|
|||||||
(t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)],
|
(t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The technology tree, one row per known tech (see TECHS in lib/game/tech.ts). Definitions are
|
||||||
|
// seeded from the pure module on new game / backfill v5 — same pattern as ship_systems.
|
||||||
|
// `researched` is the per-save progress; prerequisites/effect are stored for completeness but
|
||||||
|
// the canonical semantics live in lib/game/tech.ts (EFFECTS).
|
||||||
|
export const tech = sqliteTable(
|
||||||
|
'tech',
|
||||||
|
{
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
slug: text('slug').notNull(), // stable key, e.g. WEAPONS_SYSTEMS
|
||||||
|
name: text('name').notNull(), // display name, e.g. 'WEAPONS SYSTEMS'
|
||||||
|
costKind: text('cost_kind').notNull(), // 'resource' | 'part'
|
||||||
|
costQty: integer('cost_qty').notNull().default(1),
|
||||||
|
prerequisites: text('prerequisites').notNull().default(''), // comma-separated tech slugs, '' when none
|
||||||
|
effect: text('effect').notNull(), // effect slug — semantics in EFFECTS (lib/game/tech.ts)
|
||||||
|
researched: integer('researched', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)],
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mission board (#10): contracts generated deterministically from the save seed, one ACTIVE
|
||||||
|
// at a time. `type` drives completion semantics (see advanceMission in lib/game/missions.ts):
|
||||||
|
// DELIVER jumps to target_system_id, EXPLORE counts scans up to progress_target, FIND completes
|
||||||
|
// when a scan's haul contains condition_kind. The reward lands in inventory_items on completion.
|
||||||
|
export const missions = sqliteTable('missions', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
type: text('type').notNull(), // 'DELIVER' | 'EXPLORE' | 'FIND'
|
||||||
|
description: text('description').notNull(), // human-readable objective, e.g. "Jump to Kepler Drift"
|
||||||
|
targetSystemId: integer('target_system_id'), // DELIVER only — destination system (NULL otherwise)
|
||||||
|
conditionKind: text('condition_kind'), // FIND only — 'resource' | 'part' (NULL otherwise)
|
||||||
|
progressTarget: integer('progress_target').notNull().default(1), // EXPLORE: scans required; 1 for the others
|
||||||
|
progress: integer('progress').notNull().default(0), // scans counted since acceptance; 0/1 for DELIVER/FIND
|
||||||
|
rewardKind: text('reward_kind').notNull(), // 'resource' | 'part'
|
||||||
|
rewardQty: integer('reward_qty').notNull().default(1),
|
||||||
|
state: text('state').notNull().default('AVAILABLE'), // 'AVAILABLE' | 'ACTIVE' | 'DONE'
|
||||||
|
})
|
||||||
|
|
||||||
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
||||||
// writers and a read endpoint.
|
// writers and a read endpoint.
|
||||||
export const events = sqliteTable('events', {
|
export const events = sqliteTable('events', {
|
||||||
|
|||||||
@@ -71,4 +71,17 @@ describe('makeCrewName', () => {
|
|||||||
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
|
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
|
||||||
expect(taken.size).toBe(40)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+12
-2
@@ -49,16 +49,26 @@ function pick<T>(rng: () => number, arr: T[]): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A name unique against `taken` (the current roster), claimed into the set on success.
|
// 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 {
|
export function makeCrewName(rng: () => number, taken: Set<string>): string {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const name = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
|
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)) {
|
if (!taken.has(name)) {
|
||||||
taken.add(name)
|
taken.add(name)
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The crew a new game starts with: 2–3 members, unique names and roles (roles are drawn
|
// The crew a new game starts with: 2–3 members, unique names and roles (roles are drawn
|
||||||
// without replacement). Pure — the caller derives the rng from the save seed.
|
// without replacement). Pure — the caller derives the rng from the save seed.
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { explorationRng, mulberry32, rollExploration } from './world'
|
||||||
|
import { SCHEMA_VERSION, explorePlanet, getWorldState, jumpToSystem } from './save'
|
||||||
|
import { acceptMission, MIN_AVAILABLE, REWARD_ITEM_NAME, generateMission, missionRng, pickMissionType, topUpMissionBoard, type MissionDef, type MissionSystem } from './missions'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-missions-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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// A snapshot with one discovered non-current system to deliver to.
|
||||||
|
const SYSTEMS: MissionSystem[] = [
|
||||||
|
{ id: 1, name: 'Start', discovered: true, isCurrent: true },
|
||||||
|
{ id: 2, name: 'Alpha', discovered: true, isCurrent: false },
|
||||||
|
{ id: 3, name: 'Beta', discovered: false, isCurrent: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('generateMission (pure)', () => {
|
||||||
|
it('DELIVER targets a discovered non-current system and returns null when none exists', () => {
|
||||||
|
const def = generateMission('DELIVER', mulberry32(7), SYSTEMS)!
|
||||||
|
expect(def.type).toBe('DELIVER')
|
||||||
|
expect(def.targetSystemId).toBe(2) // the only candidate
|
||||||
|
expect(def.description).toContain('Alpha')
|
||||||
|
|
||||||
|
expect(generateMission('DELIVER', mulberry32(7), [SYSTEMS[0]])).toBeNull() // only the current system is discovered
|
||||||
|
})
|
||||||
|
|
||||||
|
it('EXPLORE targets 2–4 scans, FIND one item kind; rewards are always 1–3', () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const e = generateMission('EXPLORE', mulberry32(i), SYSTEMS)!
|
||||||
|
expect(e.progressTarget).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(e.progressTarget).toBeLessThanOrEqual(4)
|
||||||
|
expect(e.description).toMatch(/^Scan [2-4] bodies$/)
|
||||||
|
|
||||||
|
const f = generateMission('FIND', mulberry32(i), SYSTEMS)!
|
||||||
|
expect(['resource', 'part']).toContain(f.conditionKind)
|
||||||
|
expect(f.description).toContain(f.conditionKind!)
|
||||||
|
|
||||||
|
for (const d of [e, f]) {
|
||||||
|
expect(d.rewardQty).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(d.rewardQty).toBeLessThanOrEqual(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic — the same (seed, turn) stream generates identical missions', () => {
|
||||||
|
const a = generateMission('DELIVER', missionRng('42', 5), SYSTEMS)
|
||||||
|
const b = generateMission('DELIVER', missionRng('42', 5), SYSTEMS)
|
||||||
|
expect(a).toEqual(b)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pickMissionType (pure)', () => {
|
||||||
|
it('never picks DELIVER without a target, and does pick it when one exists', () => {
|
||||||
|
for (let i = 0; i < 200; i++) expect(pickMissionType(mulberry32(i), false)).not.toBe('DELIVER')
|
||||||
|
|
||||||
|
const types = new Set(Array.from({ length: 200 }, (_, i) => pickMissionType(mulberry32(i), true)))
|
||||||
|
expect(types.has('DELIVER')).toBe(true)
|
||||||
|
for (const t of types) expect(['DELIVER', 'EXPLORE', 'FIND']).toContain(t)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('topUpMissionBoard', () => {
|
||||||
|
it('seeds a fresh game with MIN_AVAILABLE AVAILABLE non-DELIVER missions and is idempotent', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game — createNewGame already topped the board up
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows).toHaveLength(MIN_AVAILABLE)
|
||||||
|
for (const r of rows) {
|
||||||
|
expect(r.state).toBe('AVAILABLE')
|
||||||
|
expect(r.type).not.toBe('DELIVER') // only the start system is discovered yet — no targets exist
|
||||||
|
}
|
||||||
|
|
||||||
|
await topUpMissionBoard(db) // a second pass inserts nothing
|
||||||
|
expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates deterministically from (seed, turn) on a hand-crafted save', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
// A v6 save with start (current), one discovered neighbour and one undiscovered.
|
||||||
|
await db.db.insert(schema.saves).values({ seed: '42', turn: 5, createdAt: new Date().toISOString(), schemaVersion: SCHEMA_VERSION })
|
||||||
|
const [start] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Start', x: 50, y: 50, discovered: true, isCurrent: true }).returning()
|
||||||
|
const [alpha] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Alpha', x: 60, y: 50, discovered: true, isCurrent: false }).returning()
|
||||||
|
const [beta] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Beta', x: 40, y: 50 }).returning()
|
||||||
|
|
||||||
|
await topUpMissionBoard(db)
|
||||||
|
|
||||||
|
// Replay the same pure pipeline with the same (seed, turn) stream — must match row for row.
|
||||||
|
const snapshot: MissionSystem[] = [
|
||||||
|
{ id: start.id, name: 'Start', discovered: true, isCurrent: true },
|
||||||
|
{ id: alpha.id, name: 'Alpha', discovered: true, isCurrent: false },
|
||||||
|
{ id: beta.id, name: 'Beta', discovered: false, isCurrent: false },
|
||||||
|
]
|
||||||
|
const rng = missionRng('42', 5)
|
||||||
|
const expected: MissionDef[] = []
|
||||||
|
while (expected.length < MIN_AVAILABLE) {
|
||||||
|
const type = pickMissionType(rng, snapshot.some((s) => s.discovered && !s.isCurrent))
|
||||||
|
expected.push(generateMission(type, rng, snapshot)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows.map((r) => ({ type: r.type, description: r.description, targetSystemId: r.targetSystemId, conditionKind: r.conditionKind, progressTarget: r.progressTarget, rewardKind: r.rewardKind, rewardQty: r.rewardQty }))).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inserts a mission row directly so tests can control exactly what the board holds.
|
||||||
|
async function insertMission(db: GameDb, fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number }): Promise<number> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db.insert(schema.missions).values({ saveId: save.id, type: fields.type, description: fields.description, targetSystemId: fields.targetSystemId ?? null, conditionKind: fields.conditionKind ?? null, progressTarget: fields.progressTarget ?? 1, rewardKind: fields.rewardKind ?? 'resource', rewardQty: fields.rewardQty ?? 1 }).returning()
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('acceptMission', () => {
|
||||||
|
it('rejects unknown ids with 404 and a second accept while one is active with 409 naming the active mission', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game — board seeded
|
||||||
|
|
||||||
|
await expect(acceptMission(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||||
|
|
||||||
|
const [a, b] = (await db.db.select().from(schema.missions))
|
||||||
|
await acceptMission(db, a.id)
|
||||||
|
await expect(acceptMission(db, a.id)).rejects.toMatchObject({ status: 409, message: /already active/i }) // re-accepting the active one
|
||||||
|
await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates an AVAILABLE contract, costs a turn, writes an event and keeps the board topped up', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [a] = await db.db.select().from(schema.missions)
|
||||||
|
|
||||||
|
const board = await acceptMission(db, a.id)
|
||||||
|
|
||||||
|
expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE')
|
||||||
|
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up
|
||||||
|
const [after] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
expect(after.turn).toBe(save.turn + 1)
|
||||||
|
|
||||||
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')!
|
||||||
|
expect(event.message).toContain('Accepted mission')
|
||||||
|
expect(event.turn).toBe(save.turn + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('completion', () => {
|
||||||
|
it('EXPLORE counts scans, completes at the target and grants the reward into the hold', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game — empty inventory, all bodies unexplored
|
||||||
|
const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 3 bodies', progressTarget: 3, rewardKind: 'part', rewardQty: 2 })
|
||||||
|
|
||||||
|
await acceptMission(db, id)
|
||||||
|
const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null)
|
||||||
|
await explorePlanet(db, planets[0].id)
|
||||||
|
let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('ACTIVE')
|
||||||
|
expect(mission.progress).toBe(1)
|
||||||
|
|
||||||
|
await explorePlanet(db, planets[1].id)
|
||||||
|
mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('ACTIVE')
|
||||||
|
expect(mission.progress).toBe(2)
|
||||||
|
|
||||||
|
await explorePlanet(db, planets[2].id) // third scan — completes
|
||||||
|
mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('DONE')
|
||||||
|
expect(mission.progress).toBe(3)
|
||||||
|
|
||||||
|
const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)!
|
||||||
|
expect(reward.kind).toBe('part')
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FIND completes when a haul contains its kind and ignores scans that do not', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const id = await insertMission(db, { type: 'FIND', description: 'Find at least one part while scanning', conditionKind: 'part', rewardKind: 'resource', rewardQty: 3 })
|
||||||
|
|
||||||
|
// Pick bodies by their deterministic per-planet rolls — one without parts first (no progress),
|
||||||
|
// then one with (completes).
|
||||||
|
const planets = await db.db.select().from(schema.planets)
|
||||||
|
const haulOf = (p: { id: number }) => rollExploration(explorationRng(save.seed, p.id))
|
||||||
|
const noPart = planets.find((p) => !haulOf(p).some((i) => i.kind === 'part'))!
|
||||||
|
expect(noPart).toBeDefined()
|
||||||
|
const withPart = planets.find((p) => haulOf(p).some((i) => i.kind === 'part'))!
|
||||||
|
expect(withPart).toBeDefined()
|
||||||
|
|
||||||
|
await acceptMission(db, id)
|
||||||
|
await explorePlanet(db, noPart.id) // haul has no parts — the mission does not advance
|
||||||
|
let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('ACTIVE')
|
||||||
|
expect(mission.progress).toBe(0)
|
||||||
|
|
||||||
|
await explorePlanet(db, withPart.id) // haul has parts — completes
|
||||||
|
mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('DONE')
|
||||||
|
|
||||||
|
const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)!
|
||||||
|
expect(reward.kind).toBe('resource')
|
||||||
|
expect(reward.qty).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELIVER completes on arrival at its target system', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const world = await getWorldState(db) // new game — start is current, six undiscovered neighbours
|
||||||
|
const [start] = await db.db.select().from(schema.solarSystems).where(eq(schema.solarSystems.isCurrent, true))
|
||||||
|
const neighbor = world.systems.find((s) => !s.discovered)!
|
||||||
|
|
||||||
|
await jumpToSystem(db, neighbor.id) // start is now discovered + non-current — a valid target
|
||||||
|
const id = await insertMission(db, { type: 'DELIVER', description: `Jump to ${start.name}`, targetSystemId: start.id, rewardKind: 'resource', rewardQty: 1 })
|
||||||
|
|
||||||
|
await acceptMission(db, id)
|
||||||
|
|
||||||
|
// Wrong destination: one of the ring neighbours generated around `neighbor` on the first jump.
|
||||||
|
const world2 = await getWorldState(db)
|
||||||
|
const wrongEdge = world2.edges.find(([a, b]) => (a === neighbor.id || b === neighbor.id) && a !== start.id && b !== start.id)!
|
||||||
|
const wrongId = wrongEdge[0] === neighbor.id ? wrongEdge[1] : wrongEdge[0]
|
||||||
|
await jumpToSystem(db, wrongId) // not the target — no completion
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE')
|
||||||
|
|
||||||
|
await jumpToSystem(db, neighbor.id) // back through `neighbor`…
|
||||||
|
const result = await jumpToSystem(db, start.id) // …to the target — completes
|
||||||
|
const mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('DONE')
|
||||||
|
expect(result.systems.find((s) => s.current)?.id).toBe(start.id)
|
||||||
|
|
||||||
|
const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)!
|
||||||
|
expect(reward.kind).toBe('resource')
|
||||||
|
expect(reward.qty).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewards of the same kind accumulate into one hold row across missions', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const a = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 2 })
|
||||||
|
const b = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 more bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 3 })
|
||||||
|
const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null)
|
||||||
|
|
||||||
|
await acceptMission(db, a)
|
||||||
|
await explorePlanet(db, planets[0].id)
|
||||||
|
await explorePlanet(db, planets[1].id) // completes A — 2× resource reward
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === a)!.state).toBe('DONE')
|
||||||
|
|
||||||
|
await acceptMission(db, b)
|
||||||
|
await explorePlanet(db, planets[2].id)
|
||||||
|
await explorePlanet(db, planets[3].id) // completes B — 3× more of the same kind+name
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === b)!.state).toBe('DONE')
|
||||||
|
|
||||||
|
const rewards = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.name === REWARD_ITEM_NAME)
|
||||||
|
expect(rewards).toHaveLength(1) // accumulated, not a second row
|
||||||
|
expect(rewards[0].qty).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('backfill v6', () => {
|
||||||
|
it('seeds the board on a pre-#10 save when it opens — deterministic and idempotent', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game at the current version
|
||||||
|
|
||||||
|
// Simulate a save created by pre-v6 code: no mission rows, stamped v5.
|
||||||
|
await db.db.delete(schema.missions)
|
||||||
|
await db.db.update(schema.saves).set({ schemaVersion: 5 })
|
||||||
|
|
||||||
|
await getWorldState(db) // ensureSave → backfill runs the v6 step
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows).toHaveLength(MIN_AVAILABLE)
|
||||||
|
for (const r of rows) expect(r.state).toBe('AVAILABLE')
|
||||||
|
const [save] = await db.db.select().from(schema.saves)
|
||||||
|
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
|
||||||
|
// Idempotent — a second pass changes nothing.
|
||||||
|
await getWorldState(db)
|
||||||
|
expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { persist, type GameDb } from '@/lib/db/client'
|
||||||
|
import { mulberry32, type FoundItem } from './world'
|
||||||
|
|
||||||
|
// Mission board (#10): contracts generated deterministically from the save seed. Three types —
|
||||||
|
// DELIVER (jump to a target system), EXPLORE (scan N bodies), FIND (haul in an item of one kind).
|
||||||
|
// One mission is ACTIVE at a time; the board always holds MIN_AVAILABLE AVAILABLE contracts.
|
||||||
|
// The pure helpers below are DB-free so generation/completion rules stay unit-testable alone.
|
||||||
|
|
||||||
|
export type MissionType = 'DELIVER' | 'EXPLORE' | 'FIND'
|
||||||
|
export type ItemKind = 'resource' | 'part'
|
||||||
|
export type MissionStateName = 'AVAILABLE' | 'ACTIVE' | 'DONE'
|
||||||
|
|
||||||
|
// The board never drops below this many AVAILABLE contracts (topped up after every action).
|
||||||
|
export const MIN_AVAILABLE = 3
|
||||||
|
|
||||||
|
// What generation produces and the DB stores — one row per mission.
|
||||||
|
export interface MissionDef {
|
||||||
|
type: MissionType
|
||||||
|
description: string
|
||||||
|
targetSystemId: number | null // DELIVER only
|
||||||
|
conditionKind: ItemKind | null // FIND only
|
||||||
|
progressTarget: number // EXPLORE: scans required; 1 for the others
|
||||||
|
rewardKind: ItemKind
|
||||||
|
rewardQty: number // 1–3, consistent with exploration hauls
|
||||||
|
}
|
||||||
|
|
||||||
|
// A system as far as generation cares about.
|
||||||
|
export interface MissionSystem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
discovered: boolean
|
||||||
|
isCurrent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts. Every
|
||||||
|
// action costs exactly one turn, so the stream never repeats for two different top-ups.
|
||||||
|
export function missionRng(seed: string, turn: number): () => number {
|
||||||
|
return mulberry32((Number(seed) + Math.imul(turn, 0x9e3779b1)) >>> 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which type to generate next: DELIVER when a target exists and the coin says so, else EXPLORE/FIND.
|
||||||
|
export function pickMissionType(rng: () => number, canDeliver: boolean): MissionType {
|
||||||
|
if (canDeliver && rng() < 0.5) return 'DELIVER'
|
||||||
|
return rng() < 0.5 ? 'EXPLORE' : 'FIND'
|
||||||
|
}
|
||||||
|
|
||||||
|
// One mission of the given type from the current world snapshot. DELIVER returns null when no
|
||||||
|
// discovered non-current system exists to target (e.g. a brand-new game).
|
||||||
|
export function generateMission(type: MissionType, rng: () => number, systems: MissionSystem[]): MissionDef | null {
|
||||||
|
const rewardKind: ItemKind = rng() < 0.5 ? 'resource' : 'part'
|
||||||
|
const rewardQty = 1 + Math.floor(rng() * 3) // 1–3
|
||||||
|
|
||||||
|
if (type === 'DELIVER') {
|
||||||
|
const candidates = systems.filter((s) => s.discovered && !s.isCurrent)
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
const target = candidates[Math.floor(rng() * candidates.length)]
|
||||||
|
return { type, description: `Jump to ${target.name}`, targetSystemId: target.id, conditionKind: null, progressTarget: 1, rewardKind, rewardQty }
|
||||||
|
}
|
||||||
|
if (type === 'EXPLORE') {
|
||||||
|
const scans = 2 + Math.floor(rng() * 3) // 2–4
|
||||||
|
return { type, description: `Scan ${scans} bodies`, targetSystemId: null, conditionKind: null, progressTarget: scans, rewardKind, rewardQty }
|
||||||
|
}
|
||||||
|
const kind: ItemKind = rng() < 0.5 ? 'resource' : 'part'
|
||||||
|
return { type, description: `Find at least one ${kind} while scanning`, targetSystemId: null, conditionKind: kind, progressTarget: 1, rewardKind, rewardQty }
|
||||||
|
}
|
||||||
|
|
||||||
|
// What an action did to the world — what completion checks run against.
|
||||||
|
export interface MissionAction {
|
||||||
|
jumpedTo?: number // system id reached by a jump
|
||||||
|
outcome?: FoundItem[] // haul from a scan
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far an action advances a mission: new progress, or null when the action doesn't touch it.
|
||||||
|
// DELIVER completes on arrival at its target; EXPLORE counts every scan (capped); FIND completes
|
||||||
|
// when a haul contains its kind.
|
||||||
|
export function advanceMission(
|
||||||
|
mission: { type: MissionType; targetSystemId: number | null; conditionKind: ItemKind | null; progressTarget: number; progress: number },
|
||||||
|
action: MissionAction,
|
||||||
|
): number | null {
|
||||||
|
if (action.jumpedTo !== undefined) {
|
||||||
|
return mission.type === 'DELIVER' && mission.targetSystemId === action.jumpedTo ? 1 : null
|
||||||
|
}
|
||||||
|
if (mission.type === 'EXPLORE') return Math.min(mission.progressTarget, mission.progress + 1)
|
||||||
|
if (mission.type === 'FIND' && mission.conditionKind !== null && (action.outcome ?? []).some((i) => i.kind === mission.conditionKind)) return 1
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DB layer -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MissionDto extends MissionDef {
|
||||||
|
id: number
|
||||||
|
progress: number
|
||||||
|
state: MissionStateName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||||
|
export class MissionError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMissions(db: GameDb): Promise<MissionDto[]> {
|
||||||
|
return (await db.db.select().from(schema.missions)).map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
type: m.type as MissionType,
|
||||||
|
description: m.description,
|
||||||
|
targetSystemId: m.targetSystemId,
|
||||||
|
conditionKind: m.conditionKind as ItemKind | null,
|
||||||
|
progressTarget: m.progressTarget,
|
||||||
|
progress: m.progress,
|
||||||
|
rewardKind: m.rewardKind as ItemKind,
|
||||||
|
rewardQty: m.rewardQty,
|
||||||
|
state: m.state as MissionStateName,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The board for the MISSIONS tab. Assumes a save exists (routes call ensureSave first) and tops
|
||||||
|
// the AVAILABLE list up before reading so the tab always has contracts to offer.
|
||||||
|
export async function getMissions(db: GameDb): Promise<MissionDto[]> {
|
||||||
|
await topUpMissionBoard(db)
|
||||||
|
return readMissions(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps at least MIN_AVAILABLE AVAILABLE missions on the board, generating deterministically from
|
||||||
|
// (seed, turn). Idempotent — a second call with a full list inserts nothing.
|
||||||
|
export async function topUpMissionBoard(db: GameDb): Promise<void> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
if (!save) return
|
||||||
|
const systems = (await db.db.select().from(schema.solarSystems)).map((s) => ({ id: s.id, name: s.name, discovered: s.discovered, isCurrent: s.isCurrent }))
|
||||||
|
|
||||||
|
let available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length
|
||||||
|
if (available >= MIN_AVAILABLE) return
|
||||||
|
|
||||||
|
const rng = missionRng(save.seed, save.turn)
|
||||||
|
while (available < MIN_AVAILABLE) {
|
||||||
|
const type = pickMissionType(rng, systems.some((s) => s.discovered && !s.isCurrent))
|
||||||
|
const def = generateMission(type, rng, systems)
|
||||||
|
if (!def) break // no DELIVER target — the other types always generate
|
||||||
|
await db.db.insert(schema.missions).values({ saveId: save.id, type: def.type, description: def.description, targetSystemId: def.targetSystemId, conditionKind: def.conditionKind, progressTarget: def.progressTarget, rewardKind: def.rewardKind, rewardQty: def.rewardQty })
|
||||||
|
available += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mission rewards are granted under one neutral name — spenders (ship actions, research) consume by kind only.
|
||||||
|
export const REWARD_ITEM_NAME = 'MISSION REWARD'
|
||||||
|
|
||||||
|
const kindLabel = (kind: ItemKind): string => (kind === 'resource' ? 'RESOURCES' : 'PARTS')
|
||||||
|
|
||||||
|
// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time), costs a turn like
|
||||||
|
// every other action and writes an event. Returns the fresh board.
|
||||||
|
export async function acceptMission(db: GameDb, missionId: number): Promise<MissionDto[]> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
if (!save) throw new MissionError(404, 'No save yet')
|
||||||
|
const rows = await readMissions(db)
|
||||||
|
const target = rows.find((m) => m.id === missionId)
|
||||||
|
if (!target) throw new MissionError(404, `Unknown mission ${missionId}`)
|
||||||
|
if (target.state !== 'AVAILABLE') throw new MissionError(409, `${target.description} is already ${target.state.toLowerCase()}`)
|
||||||
|
const active = rows.find((m) => m.state === 'ACTIVE')
|
||||||
|
if (active) throw new MissionError(409, `One mission at a time — finish "${active.description}" first`)
|
||||||
|
|
||||||
|
const turn = save.turn + 1
|
||||||
|
await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id))
|
||||||
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() })
|
||||||
|
await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
return readMissions(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs after a jump or scan: advances the ACTIVE mission (if any) and, on completion, marks it
|
||||||
|
// DONE, grants its reward into the cargo hold and writes an event. Called from save.ts inside the
|
||||||
|
// action that triggered it, before persist — so everything lands in one atomic write.
|
||||||
|
export async function checkMissionCompletion(db: GameDb, saveId: number, turn: number, action: MissionAction): Promise<void> {
|
||||||
|
const row = (await db.db.select().from(schema.missions)).find((m) => m.state === 'ACTIVE')
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
const active = { type: row.type as MissionType, targetSystemId: row.targetSystemId, conditionKind: row.conditionKind as ItemKind | null, progressTarget: row.progressTarget, progress: row.progress }
|
||||||
|
const newProgress = advanceMission(active, action)
|
||||||
|
if (newProgress === null) return
|
||||||
|
|
||||||
|
if (newProgress >= active.progressTarget) {
|
||||||
|
await db.db.update(schema.missions).set({ state: 'DONE', progress: newProgress }).where(eq(schema.missions.id, row.id))
|
||||||
|
// The reward lands in the hold like any haul — same kind+name accumulates into one row.
|
||||||
|
const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === row.rewardKind && i.name === REWARD_ITEM_NAME)
|
||||||
|
if (existing) {
|
||||||
|
await db.db.update(schema.inventoryItems).set({ qty: existing.qty + row.rewardQty }).where(eq(schema.inventoryItems.id, existing.id))
|
||||||
|
} else {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId, kind: row.rewardKind, name: REWARD_ITEM_NAME, qty: row.rewardQty })
|
||||||
|
}
|
||||||
|
await db.db.insert(schema.events).values({ saveId, turn, type: 'mission', message: `Mission complete — ${row.description} (+${row.rewardQty}× ${kindLabel(row.rewardKind as ItemKind)})`, createdAt: new Date().toISOString() })
|
||||||
|
} else {
|
||||||
|
await db.db.update(schema.missions).set({ progress: newProgress }).where(eq(schema.missions.id, row.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { BUILD_PART_COST, MAX_LEVEL, upgradeCost } from './ship-costs'
|
||||||
|
import { buildSystem, getShipState, upgradeSystem, type ShipState } from './ship'
|
||||||
|
import { TECHS } from './tech'
|
||||||
|
import { getTechState, researchTech } from './research'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-research-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
return { db: await openGameDb(file), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function addItem(db: GameDb, kind: 'resource' | 'part', name: string, qty: number): Promise<void> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind, name, qty })
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemOf = (state: ShipState, type: string): ShipState['systems'][number] => state.systems.find((s) => s.type === type)!
|
||||||
|
|
||||||
|
describe('getTechState', () => {
|
||||||
|
it('new game: every tech seeded — roots AVAILABLE, chained ones LOCKED naming the missing prereq', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const techs = await getTechState(db)
|
||||||
|
|
||||||
|
expect(techs).toHaveLength(TECHS.length)
|
||||||
|
for (const def of TECHS) {
|
||||||
|
const t = techs.find((x) => x.slug === def.slug)!
|
||||||
|
if (def.prereqs.length === 0) {
|
||||||
|
expect(t.state).toBe('AVAILABLE')
|
||||||
|
expect(t.missingPrereqs).toEqual([])
|
||||||
|
} else {
|
||||||
|
expect(t.state).toBe('LOCKED')
|
||||||
|
expect(t.missingPrereqs).toEqual(def.prereqs.map((slug) => TECHS.find((x) => x.slug === slug)!.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('researchTech', () => {
|
||||||
|
it('rejects unknown id with 404, locked techs with 409 naming the missing prereq, and re-research with 409', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const techs = await getTechState(db)
|
||||||
|
|
||||||
|
await expect(researchTech(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||||
|
|
||||||
|
const plasma = techs.find((t) => t.slug === 'PLASMA_CANNON')! // locked behind WEAPONS SYSTEMS
|
||||||
|
await expect(researchTech(db, plasma.id)).rejects.toMatchObject({ status: 409, message: /WEAPONS SYSTEMS/ })
|
||||||
|
|
||||||
|
const weapons = techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!
|
||||||
|
await addItem(db, 'part', 'SERVO ACTUATOR', weapons.costQty)
|
||||||
|
await researchTech(db, weapons.id)
|
||||||
|
await expect(researchTech(db, weapons.id)).rejects.toMatchObject({ status: 409 }) // already researched
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates the cost against the hold — 422 with no mutation when short', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const techs = await getTechState(db)
|
||||||
|
const nav = techs.find((t) => t.slug === 'NAV_AI')! // resource×2, root
|
||||||
|
const before = await getShipState(db)
|
||||||
|
|
||||||
|
await expect(researchTech(db, nav.id)).rejects.toMatchObject({ status: 422 })
|
||||||
|
|
||||||
|
expect((await getShipState(db)).turn).toBe(before.turn) // no turn spent on a failed research
|
||||||
|
expect((await db.db.select().from(schema.tech)).find((t) => t.slug === 'NAV_AI')!.researched).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('spends the cost from the oldest row first, persists researched state, costs a turn and writes an event', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getShipState(db)
|
||||||
|
await addItem(db, 'resource', 'IRON ORE', 1) // oldest — fully consumed by the cost of 2
|
||||||
|
await addItem(db, 'resource', 'CRYSTAL DUST', 5)
|
||||||
|
|
||||||
|
const techs = await getTechState(db)
|
||||||
|
const nav = techs.find((t) => t.slug === 'NAV_AI')!
|
||||||
|
const result = await researchTech(db, nav.id)
|
||||||
|
|
||||||
|
expect(result.find((t) => t.slug === 'NAV_AI')!.state).toBe('RESEARCHED')
|
||||||
|
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 4]]) // IRON ORE row deleted
|
||||||
|
expect((await getShipState(db)).turn).toBe(1)
|
||||||
|
|
||||||
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'research')!
|
||||||
|
expect(event.turn).toBe(1)
|
||||||
|
expect(event.message).toContain('NAVIGATION AI')
|
||||||
|
|
||||||
|
// Persisted: a fresh read still shows it researched.
|
||||||
|
expect((await getTechState(db)).find((t) => t.slug === 'NAV_AI')!.state).toBe('RESEARCHED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unlocks the next chain step once its prerequisite is researched', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getTechState(db) // ensureSave — the save must exist before items can be added
|
||||||
|
await addItem(db, 'part', 'SERVO ACTUATOR', 5) // WEAPONS SYSTEMS (2) + PLASMA CANNON (3)
|
||||||
|
let techs = await getTechState(db)
|
||||||
|
|
||||||
|
expect(techs.find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('LOCKED')
|
||||||
|
await researchTech(db, techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.id)
|
||||||
|
|
||||||
|
techs = await getTechState(db)
|
||||||
|
expect(techs.find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('AVAILABLE') // prereq met → researchable now
|
||||||
|
await researchTech(db, techs.find((t) => t.slug === 'PLASMA_CANNON')!.id)
|
||||||
|
expect((await getTechState(db)).find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('RESEARCHED')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gating of ship actions', () => {
|
||||||
|
it('WEAPONS build is 409 until the WEAPONS SYSTEMS tech is researched, then succeeds', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getShipState(db)
|
||||||
|
await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST + 2) // research (2) + build (3)
|
||||||
|
|
||||||
|
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409, message: /requires/ }) // gated — even with parts in the hold
|
||||||
|
const techs = await getTechState(db)
|
||||||
|
await researchTech(db, techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.id)
|
||||||
|
|
||||||
|
const state = await buildSystem(db, 'WEAPONS')
|
||||||
|
expect(systemOf(state, 'WEAPONS').built).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('raises the REACTOR upgrade cap once REACTOR OVERCLOCK is researched', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getShipState(db)
|
||||||
|
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
|
||||||
|
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) // at the base cap
|
||||||
|
|
||||||
|
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 })
|
||||||
|
|
||||||
|
await addItem(db, 'resource', 'IRON ORE', 3 + upgradeCost(MAX_LEVEL)) // research cost (3) + upgrade cost at level 5
|
||||||
|
const techs = await getTechState(db)
|
||||||
|
await researchTech(db, techs.find((t) => t.slug === 'REACTOR_OVERCLOCK')!.id)
|
||||||
|
|
||||||
|
const state = await upgradeSystem(db, reactorId) // the raised cap admits one more level
|
||||||
|
expect(systemOf(state, 'REACTOR').level).toBe(MAX_LEVEL + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { persist, type GameDb } from '@/lib/db/client'
|
||||||
|
import { EFFECTS, TECHS, techStatus, type TechDto } from './tech'
|
||||||
|
import { ensureSave } from './save'
|
||||||
|
import { ShipError, spendItems } from './ship'
|
||||||
|
|
||||||
|
// Technology research: the SKILL TREE tab's read + action. Researching a tech costs inventory
|
||||||
|
// items (oldest row first, like ship actions) plus a turn, and its unlock effect then gates or
|
||||||
|
// raises ship actions (enforced in lib/game/ship.ts via the same pure helpers).
|
||||||
|
|
||||||
|
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||||
|
export class ResearchError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function researchedSlugs(db: GameDb): Promise<Set<string>> {
|
||||||
|
return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full tech tree with per-save state — RESEARCHED / AVAILABLE / LOCKED (with the missing
|
||||||
|
// prerequisite names when locked). Auto-initializes like the other reads.
|
||||||
|
export async function getTechState(db: GameDb): Promise<TechDto[]> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const rows = await db.db.select().from(schema.tech)
|
||||||
|
const researched = new Set(rows.filter((t) => t.researched).map((t) => t.slug))
|
||||||
|
return TECHS.map((def) => {
|
||||||
|
const row = rows.find((r) => r.slug === def.slug)! // seeded on new game / backfill v5
|
||||||
|
const { state, missingPrereqs } = techStatus(def, researched)
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
slug: def.slug,
|
||||||
|
name: def.name,
|
||||||
|
costKind: def.costKind,
|
||||||
|
costQty: def.costQty,
|
||||||
|
prereqs: def.prereqs.map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug),
|
||||||
|
effect: EFFECTS[def.effect]?.description ?? def.effect,
|
||||||
|
state,
|
||||||
|
missingPrereqs,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Research one tech: validates prerequisites (409, naming what is missing) and the cost (422),
|
||||||
|
// spends it from the cargo hold, marks it researched, costs a turn and writes an event.
|
||||||
|
export async function researchTech(db: GameDb, techId: number): Promise<TechDto[]> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const rows = await db.db.select().from(schema.tech)
|
||||||
|
const target = rows.find((t) => t.id === techId)
|
||||||
|
if (!target) throw new ResearchError(404, `Unknown tech ${techId}`)
|
||||||
|
|
||||||
|
const def = TECHS.find((t) => t.slug === target.slug)!
|
||||||
|
const researched = await researchedSlugs(db)
|
||||||
|
if (researched.has(def.slug)) throw new ResearchError(409, `${def.name} is already researched`)
|
||||||
|
const missing = def.prereqs.filter((p) => !researched.has(p)).map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug)
|
||||||
|
if (missing.length > 0) throw new ResearchError(409, `${def.name} requires ${missing.join(' and ')} first`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spendItems(db, save.id, def.costKind, def.costQty)
|
||||||
|
} catch (err) {
|
||||||
|
// spendItems throws ShipError(422) on a short hold — re-throw as this domain's error.
|
||||||
|
if (err instanceof ShipError) throw new ResearchError(err.status, err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const turn = save.turn + 1
|
||||||
|
await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id))
|
||||||
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() })
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
return getTechState(db)
|
||||||
|
}
|
||||||
+26
-1
@@ -7,6 +7,7 @@ import { openGameDb, type GameDb } from '@/lib/db/client'
|
|||||||
import * as schema from '@/lib/db/schema'
|
import * as schema from '@/lib/db/schema'
|
||||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
|
import { TECHS } from './tech'
|
||||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
||||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||||
|
|
||||||
@@ -329,11 +330,15 @@ describe('backfill (old saves)', () => {
|
|||||||
const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) =>
|
const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) =>
|
||||||
rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on
|
rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on
|
||||||
const beforeShip = shipOf(await db.db.select().from(schema.shipSystems))
|
const beforeShip = shipOf(await db.db.select().from(schema.shipSystems))
|
||||||
|
const techShape = (rows: { id: number; slug: string; name: string; costKind: string; costQty: number; prerequisites: string; effect: string; researched: boolean }[]) =>
|
||||||
|
rows.map((t) => ({ slug: t.slug, name: t.name, costKind: t.costKind, costQty: t.costQty, prerequisites: t.prerequisites, effect: t.effect, researched: t.researched })).sort((a, b) => a.slug.localeCompare(b.slug))
|
||||||
|
const beforeTech = techShape(await db.db.select().from(schema.tech))
|
||||||
|
|
||||||
// Simulate a save created by pre-v0.4 code: no bodies, no crew, no ship, no version stamp (NULL).
|
// Simulate a save created by pre-v0.5 code: no bodies, no crew, no ship, no tech, no version stamp (NULL).
|
||||||
await db.db.delete(schema.planets)
|
await db.db.delete(schema.planets)
|
||||||
await db.db.delete(schema.crew)
|
await db.db.delete(schema.crew)
|
||||||
await db.db.delete(schema.shipSystems)
|
await db.db.delete(schema.shipSystems)
|
||||||
|
await db.db.delete(schema.tech)
|
||||||
await db.db.update(schema.saves).set({ schemaVersion: null })
|
await db.db.update(schema.saves).set({ schemaVersion: null })
|
||||||
|
|
||||||
const world = await getWorldState(db) // ensureSave → backfill runs
|
const world = await getWorldState(db) // ensureSave → backfill runs
|
||||||
@@ -345,6 +350,7 @@ describe('backfill (old saves)', () => {
|
|||||||
}
|
}
|
||||||
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
|
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
|
||||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||||
|
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
|
||||||
|
|
||||||
const [save] = await db.db.select().from(schema.saves)
|
const [save] = await db.db.select().from(schema.saves)
|
||||||
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
@@ -355,6 +361,7 @@ describe('backfill (old saves)', () => {
|
|||||||
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
|
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
|
||||||
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
|
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
|
||||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||||
|
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
|
||||||
for (const s of systems) {
|
for (const s of systems) {
|
||||||
const rows = await rowsFor(s.id)
|
const rows = await rowsFor(s.id)
|
||||||
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
|
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
|
||||||
@@ -387,4 +394,22 @@ describe('backfill (old saves)', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('seeds the tech tree on a new game: one row per known tech, none researched', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const rows = (await db.db.select().from(schema.tech)).sort((a, b) => a.slug.localeCompare(b.slug))
|
||||||
|
expect(rows).toHaveLength(TECHS.length)
|
||||||
|
for (const def of TECHS) {
|
||||||
|
const row = rows.find((r) => r.slug === def.slug)!
|
||||||
|
expect({ name: row.name, costKind: row.costKind, costQty: row.costQty, prerequisites: row.prerequisites, effect: row.effect }).toEqual({
|
||||||
|
name: def.name,
|
||||||
|
costKind: def.costKind,
|
||||||
|
costQty: def.costQty,
|
||||||
|
prerequisites: def.prereqs.join(','),
|
||||||
|
effect: def.effect,
|
||||||
|
})
|
||||||
|
expect(row.researched).toBe(false) // nothing is researched at the start
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+25
-3
@@ -4,6 +4,8 @@ import { persist } from '@/lib/db/client'
|
|||||||
import * as schema from '@/lib/db/schema'
|
import * as schema from '@/lib/db/schema'
|
||||||
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
||||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
|
import { checkMissionCompletion, topUpMissionBoard } from './missions'
|
||||||
|
import { TECHS } from './tech'
|
||||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
||||||
|
|
||||||
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
||||||
@@ -94,9 +96,8 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi
|
|||||||
|
|
||||||
// What each schema version added to a save. Saves created by older code lack the data newer
|
// What each schema version added to a save. Saves created by older code lack the data newer
|
||||||
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
|
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
|
||||||
// current — each step is idempotent (a no-op when its data already exists). When #9/#10 add
|
// current — each step is idempotent (a no-op when its data already exists).
|
||||||
// seeding, bump SCHEMA_VERSION and append a step here.
|
export const SCHEMA_VERSION = 6 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board
|
||||||
export const SCHEMA_VERSION = 4 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship
|
|
||||||
|
|
||||||
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
||||||
// unbuilt (level/capacity 0) until a BUILD action.
|
// unbuilt (level/capacity 0) until a BUILD action.
|
||||||
@@ -106,6 +107,13 @@ async function seedStartingShip(db: GameDb, saveId: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seeds the tech tree: one row per known tech (definitions from lib/game/tech.ts), none researched.
|
||||||
|
async function seedStartingTech(db: GameDb, saveId: number): Promise<void> {
|
||||||
|
for (const def of TECHS) {
|
||||||
|
await db.db.insert(schema.tech).values({ saveId, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
|
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
|
||||||
// Safe to call on every request (single player, one process: check-then-insert cannot race).
|
// Safe to call on every request (single player, one process: check-then-insert cannot race).
|
||||||
export async function ensureSave(db: GameDb): Promise<void> {
|
export async function ensureSave(db: GameDb): Promise<void> {
|
||||||
@@ -133,6 +141,8 @@ async function createNewGame(db: GameDb): Promise<void> {
|
|||||||
await insertRoutes(db, region.edges, resolve)
|
await insertRoutes(db, region.edges, resolve)
|
||||||
await seedStartingCrew(db, save.id, seed)
|
await seedStartingCrew(db, save.id, seed)
|
||||||
await seedStartingShip(db, save.id)
|
await seedStartingShip(db, save.id)
|
||||||
|
await seedStartingTech(db, save.id)
|
||||||
|
await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game
|
||||||
|
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
||||||
await persist(db)
|
await persist(db)
|
||||||
@@ -155,6 +165,14 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema
|
|||||||
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
|
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
|
||||||
version = 4
|
version = 4
|
||||||
}
|
}
|
||||||
|
if (version < 5) {
|
||||||
|
if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id)
|
||||||
|
version = 5
|
||||||
|
}
|
||||||
|
if (version < 6) {
|
||||||
|
await topUpMissionBoard(db) // idempotent — only inserts when fewer than MIN_AVAILABLE AVAILABLE contracts exist
|
||||||
|
version = 6
|
||||||
|
}
|
||||||
|
|
||||||
if (from !== SCHEMA_VERSION) {
|
if (from !== SCHEMA_VERSION) {
|
||||||
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
||||||
@@ -220,6 +238,8 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
|
|||||||
|
|
||||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
||||||
|
await checkMissionCompletion(db, save.id, turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
|
||||||
|
await topUpMissionBoard(db)
|
||||||
await persist(db)
|
await persist(db)
|
||||||
|
|
||||||
return getWorldState(db)
|
return getWorldState(db)
|
||||||
@@ -344,6 +364,8 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
|
|||||||
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
|
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
|
||||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
|
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
|
||||||
|
await checkMissionCompletion(db, save.id, turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
|
||||||
|
await topUpMissionBoard(db)
|
||||||
await persist(db)
|
await persist(db)
|
||||||
|
|
||||||
return { ...(await readSystemState(db)), outcome, crewFound }
|
return { ...(await readSystemState(db)), outcome, crewFound }
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ describe('expandSystem', () => {
|
|||||||
expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1)
|
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]])
|
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 db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
|
||||||
await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 })
|
await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 })
|
||||||
})
|
})
|
||||||
@@ -129,6 +133,10 @@ describe('buildSystem', () => {
|
|||||||
const { db } = await freshFile()
|
const { db } = await freshFile()
|
||||||
await getShipState(db)
|
await getShipState(db)
|
||||||
|
|
||||||
|
// WEAPONS is gated behind the WEAPONS SYSTEMS tech (issue #9) — research it directly so this
|
||||||
|
// test keeps covering the cost path; the gate itself is covered in research.test.ts.
|
||||||
|
await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS'))
|
||||||
|
|
||||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 422 }) // no parts in the hold yet
|
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 422 }) // no parts in the hold yet
|
||||||
|
|
||||||
await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST)
|
await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST)
|
||||||
@@ -140,6 +148,7 @@ describe('buildSystem', () => {
|
|||||||
expect(state.turn).toBe(1)
|
expect(state.turn).toBe(1)
|
||||||
|
|
||||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
|
||||||
|
expect(event.turn).toBe(1)
|
||||||
expect(event.message).toContain('WEAPONS')
|
expect(event.message).toContain('WEAPONS')
|
||||||
|
|
||||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
|
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
|
||||||
|
|||||||
+19
-7
@@ -1,7 +1,8 @@
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import * as schema from '@/lib/db/schema'
|
import * as schema from '@/lib/db/schema'
|
||||||
import { persist, type GameDb } from '@/lib/db/client'
|
import { persist, type GameDb } from '@/lib/db/client'
|
||||||
import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs'
|
import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs'
|
||||||
|
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from './tech'
|
||||||
import { ensureSave, type InventoryItemDto } from './save'
|
import { ensureSave, type InventoryItemDto } from './save'
|
||||||
|
|
||||||
export interface ShipSystemDto {
|
export interface ShipSystemDto {
|
||||||
@@ -51,7 +52,8 @@ export async function getShipState(db: GameDb): Promise<ShipState> {
|
|||||||
|
|
||||||
// Consume `amount` units of one kind from the oldest cargo rows first (id order); rows that hit
|
// Consume `amount` units of one kind from the oldest cargo rows first (id order); rows that hit
|
||||||
// zero are deleted. The total is checked up front, so an insufficient hold mutates nothing.
|
// zero are deleted. The total is checked up front, so an insufficient hold mutates nothing.
|
||||||
async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise<void> {
|
// Exported: research.ts spends tech costs through the same path.
|
||||||
|
export async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise<void> {
|
||||||
const rows = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.kind === kind).sort((a, b) => a.id - b.id)
|
const rows = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.kind === kind).sort((a, b) => a.id - b.id)
|
||||||
if (rows.reduce((sum, r) => sum + r.qty, 0) < amount) {
|
if (rows.reduce((sum, r) => sum + r.qty, 0) < amount) {
|
||||||
throw new ShipError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
|
throw new ShipError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
|
||||||
@@ -71,14 +73,20 @@ async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type:
|
|||||||
await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() })
|
await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() })
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPGRADE — level up a built system by one (capped at MAX_LEVEL). Cost: `level` units of any resource.
|
// Slugs of the researched techs on this save — drives the effective caps and build gates below.
|
||||||
|
async function researchedTechSlugs(db: GameDb): Promise<Set<string>> {
|
||||||
|
return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). Cost: `level` units of any resource.
|
||||||
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
||||||
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
||||||
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
||||||
if (target.level >= MAX_LEVEL) throw new ShipError(409, `${target.type} is at max level (${MAX_LEVEL})`)
|
const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db))
|
||||||
|
if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`)
|
||||||
|
|
||||||
const cost = upgradeCost(target.level)
|
const cost = upgradeCost(target.level)
|
||||||
await spendItems(db, save.id, 'resource', cost)
|
await spendItems(db, save.id, 'resource', cost)
|
||||||
@@ -89,14 +97,15 @@ export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipS
|
|||||||
return readShipState(db)
|
return readShipState(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EXPAND — raise a built system's capacity by one (capped at MAX_CAPACITY). Cost: `capacity` units of any resource.
|
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). Cost: `capacity` units of any resource.
|
||||||
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
||||||
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
||||||
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
||||||
if (target.capacity >= MAX_CAPACITY) throw new ShipError(409, `${target.type} is at max capacity (${MAX_CAPACITY})`)
|
const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db))
|
||||||
|
if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`)
|
||||||
|
|
||||||
const cost = expandCost(target.capacity)
|
const cost = expandCost(target.capacity)
|
||||||
await spendItems(db, save.id, 'resource', cost)
|
await spendItems(db, save.id, 'resource', cost)
|
||||||
@@ -107,13 +116,16 @@ export async function expandSystem(db: GameDb, systemId: number): Promise<ShipSt
|
|||||||
return readShipState(db)
|
return readShipState(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
|
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. Some types are
|
||||||
|
// gated behind a researched tech (see EFFECTS in lib/game/tech.ts).
|
||||||
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
|
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
|
||||||
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
|
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
|
||||||
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
|
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
|
||||||
|
const gate = buildGate(type, await researchedTechSlugs(db))
|
||||||
|
if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`)
|
||||||
|
|
||||||
await spendItems(db, save.id, 'part', BUILD_PART_COST)
|
await spendItems(db, save.id, 'part', BUILD_PART_COST)
|
||||||
const turn = save.turn + 1
|
const turn = save.turn + 1
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Pure technology model: the known techs, their costs/prerequisites and what each unlock does.
|
||||||
|
// No DB imports — both lib/game/research.ts + lib/game/ship.ts (server) and the frontend import
|
||||||
|
// from here so research/cap/gate logic can never drift from what the API enforces.
|
||||||
|
|
||||||
|
import { MAX_CAPACITY, MAX_LEVEL } from './ship-costs'
|
||||||
|
|
||||||
|
export interface TechDef {
|
||||||
|
slug: string // stable key used in DB + prerequisites
|
||||||
|
name: string // display name
|
||||||
|
costKind: 'resource' | 'part'
|
||||||
|
costQty: number
|
||||||
|
prereqs: string[] // slugs that must be researched first (short chains only)
|
||||||
|
effect: string // effect slug — see EFFECTS below
|
||||||
|
}
|
||||||
|
|
||||||
|
// The technology tree, in display order. Roots have no prerequisites; the two chains are
|
||||||
|
// WEAPONS SYSTEMS → PLASMA CANNON and REACTOR OVERCLOCK → FUSION CORE. Costs stay small so a
|
||||||
|
// handful of scans makes something affordable (the e2e scan loop relies on that).
|
||||||
|
export const TECHS: TechDef[] = [
|
||||||
|
{ slug: 'WEAPONS_SYSTEMS', name: 'WEAPONS SYSTEMS', costKind: 'part', costQty: 2, prereqs: [], effect: 'BUILD_WEAPONS' },
|
||||||
|
{ slug: 'REACTOR_OVERCLOCK', name: 'REACTOR OVERCLOCK', costKind: 'resource', costQty: 3, prereqs: [], effect: 'REACTOR_LEVEL_CAP' },
|
||||||
|
{ slug: 'HULL_PLATING', name: 'HULL PLATING', costKind: 'resource', costQty: 3, prereqs: [], effect: 'HULL_CAPACITY_CAP' },
|
||||||
|
{ slug: 'NAV_AI', name: 'NAVIGATION AI', costKind: 'resource', costQty: 2, prereqs: [], effect: 'NAV_LEVEL_CAP' },
|
||||||
|
{ slug: 'PLASMA_CANNON', name: 'PLASMA CANNON', costKind: 'part', costQty: 3, prereqs: ['WEAPONS_SYSTEMS'], effect: 'WEAPONS_LEVEL_CAP' },
|
||||||
|
{ slug: 'FUSION_CORE', name: 'FUSION CORE', costKind: 'resource', costQty: 4, prereqs: ['REACTOR_OVERCLOCK'], effect: 'REACTOR_CAPACITY_CAP' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// What each researched tech changes on the ship. `unlock_build` gates a BUILD action; the cap
|
||||||
|
// effects raise the UPGRADE/EXPAND ceiling for one system type (stacked over the base caps).
|
||||||
|
export interface TechEffect {
|
||||||
|
kind: 'unlock_build' | 'raise_level_cap' | 'raise_capacity_cap'
|
||||||
|
shipType: string
|
||||||
|
bonus?: number // cap effects only
|
||||||
|
}
|
||||||
|
|
||||||
|
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})` },
|
||||||
|
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})` },
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TechStateName = 'RESEARCHED' | 'AVAILABLE' | 'LOCKED'
|
||||||
|
|
||||||
|
// One tech as served to the UI (GET /api/tech) and consumed by both tabs.
|
||||||
|
export interface TechDto {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
costKind: 'resource' | 'part'
|
||||||
|
costQty: number
|
||||||
|
prereqs: string[] // prerequisite NAMES (for display), in TECHS order
|
||||||
|
effect: string // human-readable unlock description
|
||||||
|
state: TechStateName
|
||||||
|
missingPrereqs: string[] // unmet prerequisite names — non-empty only when LOCKED
|
||||||
|
}
|
||||||
|
|
||||||
|
export function techBySlug(slug: string): TechDef | undefined {
|
||||||
|
return TECHS.find((t) => t.slug === slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESEARCHED if done, AVAILABLE if all prerequisites are researched, LOCKED otherwise (with the
|
||||||
|
// missing prerequisite names so the UI can say which one is blocking it).
|
||||||
|
export function techStatus(def: TechDef, researchedSlugs: Set<string>): { state: TechStateName; missingPrereqs: string[] } {
|
||||||
|
if (researchedSlugs.has(def.slug)) return { state: 'RESEARCHED', missingPrereqs: [] }
|
||||||
|
const missing = def.prereqs.filter((p) => !researchedSlugs.has(p)).map((slug) => techBySlug(slug)?.name ?? slug)
|
||||||
|
return missing.length === 0 ? { state: 'AVAILABLE', missingPrereqs: [] } : { state: 'LOCKED', missingPrereqs: missing }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The UPGRADE ceiling for a system type given what is researched (base cap + bonuses).
|
||||||
|
export function effectiveLevelCap(shipType: string, researchedSlugs: Set<string>): number {
|
||||||
|
let cap = MAX_LEVEL
|
||||||
|
for (const def of TECHS) {
|
||||||
|
if (!researchedSlugs.has(def.slug)) continue
|
||||||
|
const effect = EFFECTS[def.effect]
|
||||||
|
if (effect?.kind === 'raise_level_cap' && effect.shipType === shipType) cap += effect.bonus ?? 0
|
||||||
|
}
|
||||||
|
return cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// The EXPAND ceiling for a system type given what is researched.
|
||||||
|
export function effectiveCapacityCap(shipType: string, researchedSlugs: Set<string>): number {
|
||||||
|
let cap = MAX_CAPACITY
|
||||||
|
for (const def of TECHS) {
|
||||||
|
if (!researchedSlugs.has(def.slug)) continue
|
||||||
|
const effect = EFFECTS[def.effect]
|
||||||
|
if (effect?.kind === 'raise_capacity_cap' && effect.shipType === shipType) cap += effect.bonus ?? 0
|
||||||
|
}
|
||||||
|
return cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// The name of the tech that must be researched before a system type can be BUILT, or null when
|
||||||
|
// building it is not gated (or the gate is already researched).
|
||||||
|
export function buildGate(shipType: string, researchedSlugs: Set<string>): string | null {
|
||||||
|
for (const def of TECHS) {
|
||||||
|
const effect = EFFECTS[def.effect]
|
||||||
|
if (effect?.kind === 'unlock_build' && effect.shipType === shipType && !researchedSlugs.has(def.slug)) return def.name
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -76,6 +76,19 @@ describe('generateRing (expansion)', () => {
|
|||||||
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
||||||
expect(taken.size).toBe(40)
|
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', () => {
|
describe('generatePlanets', () => {
|
||||||
|
|||||||
+15
-3
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
export interface WorldSystem {
|
export interface WorldSystem {
|
||||||
name: string
|
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
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,16 +36,28 @@ function title(word: string): string {
|
|||||||
return word.charAt(0) + word.slice(1).toLowerCase()
|
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 {
|
export function makeName(rng: () => number, taken: Set<string>): string {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
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)) {
|
if (!taken.has(name)) {
|
||||||
taken.add(name)
|
taken.add(name)
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(v: number): number {
|
function clamp(v: number): number {
|
||||||
return Math.min(96, Math.max(4, v))
|
return Math.min(96, Math.max(4, v))
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ describe('OpenAPI spec', () => {
|
|||||||
it('documents every public endpoint', () => {
|
it('documents every public endpoint', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||||
'/api/crew',
|
'/api/crew',
|
||||||
|
'/api/events',
|
||||||
'/api/explore',
|
'/api/explore',
|
||||||
'/api/jump',
|
'/api/jump',
|
||||||
|
'/api/missions',
|
||||||
|
'/api/missions/{id}/accept',
|
||||||
|
'/api/research',
|
||||||
'/api/ship',
|
'/api/ship',
|
||||||
'/api/ship/build',
|
'/api/ship/build',
|
||||||
'/api/ship/expand',
|
'/api/ship/expand',
|
||||||
'/api/ship/upgrade',
|
'/api/ship/upgrade',
|
||||||
'/api/system',
|
'/api/system',
|
||||||
|
'/api/tech',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
'/api/world',
|
'/api/world',
|
||||||
]);
|
]);
|
||||||
@@ -39,6 +44,56 @@ describe('OpenAPI spec', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('describes the tech endpoints: GET /api/tech → Tech[], POST /api/research with error responses', () => {
|
||||||
|
const tech = openApiSpec.paths['/api/tech'].get.responses[200].content['application/json'].schema;
|
||||||
|
expect(tech.type).toBe('array');
|
||||||
|
expect((tech.items as { $ref: string }).$ref).toBe('#/components/schemas/Tech');
|
||||||
|
|
||||||
|
const techProps = openApiSpec.components.schemas.Tech.properties;
|
||||||
|
expect(Object.keys(techProps)).toEqual(['id', 'slug', 'name', 'costKind', 'costQty', 'prereqs', 'effect', 'state', 'missingPrereqs']);
|
||||||
|
expect((techProps.state as { enum: string[] }).enum).toEqual(['RESEARCHED', 'AVAILABLE', 'LOCKED']);
|
||||||
|
|
||||||
|
const post = openApiSpec.paths['/api/research'].post;
|
||||||
|
expect(post.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ResearchRequest');
|
||||||
|
expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Tech');
|
||||||
|
for (const status of ['400', '404', '409', '422'] as const) {
|
||||||
|
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the mission endpoints: GET /api/missions → Mission[], POST accept with error responses', () => {
|
||||||
|
const missions = openApiSpec.paths['/api/missions'].get.responses[200].content['application/json'].schema;
|
||||||
|
expect(missions.type).toBe('array');
|
||||||
|
expect((missions.items as { $ref: string }).$ref).toBe('#/components/schemas/Mission');
|
||||||
|
|
||||||
|
const props = openApiSpec.components.schemas.Mission.properties;
|
||||||
|
expect(Object.keys(props)).toEqual(['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state']);
|
||||||
|
expect((props.type as { enum: string[] }).enum).toEqual(['DELIVER', 'EXPLORE', 'FIND']);
|
||||||
|
expect((props.state as { enum: string[] }).enum).toEqual(['AVAILABLE', 'ACTIVE', 'DONE']);
|
||||||
|
|
||||||
|
const post = openApiSpec.paths['/api/missions/{id}/accept'].post;
|
||||||
|
expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Mission');
|
||||||
|
for (const status of ['400', '404', '409'] as const) {
|
||||||
|
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
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;
|
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
||||||
expect(crew.type).toBe('array');
|
expect(crew.type).toBe('array');
|
||||||
|
|||||||
+236
@@ -378,6 +378,185 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/api/tech': {
|
||||||
|
get: {
|
||||||
|
summary: 'Tech tree state',
|
||||||
|
description:
|
||||||
|
"The full technology tree with per-save state — RESEARCHED if done, AVAILABLE if all prerequisites are researched, LOCKED otherwise (with the missing prerequisite names). Researching a tech gates or raises ship actions (e.g. building WEAPONS requires the WEAPONS SYSTEMS tech). Auto-initializes a new game when no save exists yet.",
|
||||||
|
operationId: 'getTech',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'The tech tree with per-save state',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/research': {
|
||||||
|
post: {
|
||||||
|
summary: 'Research a tech',
|
||||||
|
description:
|
||||||
|
"Validates the prerequisites (409, naming what is missing) and the cost against the cargo hold (422), spends the cost from the oldest row first, marks the tech researched, increments the turn counter and writes a research event. Returns the fresh tech tree state.",
|
||||||
|
operationId: 'researchTech',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/ResearchRequest' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Fresh tech tree state after the research',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Malformed body or non-integer techId',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown tech id',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: 'Tech is already researched or a prerequisite is not researched yet (message names the missing prereq)',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
422: {
|
||||||
|
description: 'Not enough of the required kind in the cargo hold for the cost',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/missions': {
|
||||||
|
get: {
|
||||||
|
summary: 'Mission board',
|
||||||
|
description:
|
||||||
|
"The save's mission contracts — generated deterministically from the seed, always topped up to at least three AVAILABLE. One mission is ACTIVE at a time; completion is checked after jumps and scans (DELIVER on arrival at its target system, EXPLORE after N scans, FIND when a haul contains its kind) and grants the reward into the cargo hold. Auto-initializes a new game when no save exists yet.",
|
||||||
|
operationId: 'getMissions',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'The mission board',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Mission' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/missions/{id}/accept': {
|
||||||
|
post: {
|
||||||
|
summary: 'Accept a mission',
|
||||||
|
description:
|
||||||
|
"Makes an AVAILABLE contract the single ACTIVE one (409 while another is active, or when it isn't AVAILABLE). Costs a turn and writes a mission event. Returns the fresh board.",
|
||||||
|
operationId: 'acceptMission',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
schema: { type: 'integer' },
|
||||||
|
description: 'missions.id of the contract to accept',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Fresh mission board after the acceptance',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Mission' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Non-integer id in the path',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown mission id',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: "Mission isn't AVAILABLE, or another mission is already ACTIVE (one at a time)",
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/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' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -491,6 +670,63 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['systems', 'inventory', 'turn'],
|
required: ['systems', 'inventory', 'turn'],
|
||||||
},
|
},
|
||||||
|
Tech: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
slug: { type: 'string', description: "Stable key, e.g. WEAPONS_SYSTEMS" },
|
||||||
|
name: { type: 'string', description: "Display name, e.g. 'WEAPONS SYSTEMS'" },
|
||||||
|
costKind: { type: 'string', enum: ['resource', 'part'] },
|
||||||
|
costQty: { type: 'integer' },
|
||||||
|
prereqs: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Prerequisite tech names (empty for roots)',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
effect: { type: 'string', description: "Human-readable unlock, e.g. 'Unlocks building the WEAPONS system'" },
|
||||||
|
state: { type: 'string', enum: ['RESEARCHED', 'AVAILABLE', 'LOCKED'] },
|
||||||
|
missingPrereqs: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Unmet prerequisite names — non-empty only when LOCKED',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id', 'slug', 'name', 'costKind', 'costQty', 'prereqs', 'effect', 'state', 'missingPrereqs'],
|
||||||
|
},
|
||||||
|
ResearchRequest: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
techId: { type: 'integer', description: 'tech.id of the tech to research' },
|
||||||
|
},
|
||||||
|
required: ['techId'],
|
||||||
|
},
|
||||||
|
Mission: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
type: { type: 'string', enum: ['DELIVER', 'EXPLORE', 'FIND'] },
|
||||||
|
description: { type: 'string', description: "Human-readable objective, e.g. 'Jump to Kepler Drift'" },
|
||||||
|
targetSystemId: { type: ['integer', 'null'], nullable: true, description: 'DELIVER only — destination system (NULL otherwise)' },
|
||||||
|
conditionKind: { type: ['string', 'null'], enum: ['resource', 'part', null], description: 'FIND only — item kind to haul in (NULL otherwise)' },
|
||||||
|
progressTarget: { type: 'integer', description: 'EXPLORE: scans required; 1 for the others' },
|
||||||
|
progress: { type: 'integer', description: 'Scans counted since acceptance; 0/1 for DELIVER/FIND' },
|
||||||
|
rewardKind: { type: 'string', enum: ['resource', 'part'] },
|
||||||
|
rewardQty: { type: 'integer', description: 'Reward quantity (1–3), granted into the cargo hold on completion' },
|
||||||
|
state: { type: 'string', enum: ['AVAILABLE', 'ACTIVE', 'DONE'] },
|
||||||
|
},
|
||||||
|
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: {
|
UpgradeRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.6.0",
|
"version": "0.9.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.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",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
@@ -25,11 +28,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.2.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/node": "^22.20.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/sql.js": "^1.4.11",
|
"@types/sql.js": "^1.4.11",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user