Compare commits

...
66 Commits
Author SHA1 Message Date
kamilkrzywda bf6d8714e6 Merge pull request 'v0.9.2 — WORLD MAP: click-to-jump on galactic chart, drop jump routes list (#19)' (#22) from world-map-click-jump into master
CI / build + unit + e2e (push) Successful in 2m19s
2026-09-29 10:06:05 +02:00
kamilkrzywda 32dda04e92 release v0.9.2
CI / build + unit + e2e (pull_request) Successful in 2m57s
2026-09-29 08:00:44 +00:00
kamilkrzywda ef1d282770 feat(ui): world map — jump by clicking chart fields (#19)
Remove the bottom JUMP ROUTES button list; systems adjacent to the current
one become clickable galactic-chart nodes (same POST /api/jump flow, same
state refreshes). Current + unreachable systems are dimmed and not
clickable. NodeGraph gains optional onNodeClick + per-node disabled
(backward-compatible); two smoke tests updated to click chart fields.
2026-09-29 08:00:44 +00:00
kamilkrzywda 18ca5eae6c Merge pull request 'v0.9.1 — layout pass: CARGO HOLD to right pane, wider panes, drop placeholders' (#20) from layout-tweaks into master
CI / build + unit + e2e (push) Successful in 2m27s
2026-09-29 09:12:54 +02:00
kamilkrzywda b05ac32da6 release v0.9.1
CI / build + unit + e2e (pull_request) Successful in 2m37s
2026-09-29 06:48:20 +00:00
kamilkrzywda 85627201f8 feat(ui): layout pass — CARGO HOLD to right column, wider panes, drop placeholders
CARGO HOLD // INVENTORY moves out of the LOCAL SYSTEM tab into a persistent
right-column panel; UPCOMING ENCOUNTERS removed; SHIP DATA folded into VESSEL
METRICS (SECTOR + TURN kept with real values, filler dropped). Grid goes
260px/1fr/240px -> 320px/1fr/360px. Cargo state uses the lifted-state pattern:
MissionControl owns /api/system and LocalSystemPanel reports each scan's
response up via onChanged (ShipSystemsPanel style) — no polling.
2026-09-29 06:48:20 +00:00
kamilkrzywda fb11a8ef81 Merge pull request 'v0.9.0 — EVENT LOG: real game events in the terminal (#11)' (#18) from event-log into master
CI / build + unit + e2e (push) Successful in 2m51s
2026-09-29 05:05:30 +02:00
kamilkrzywda 37b193ffb8 test(e2e): tab-switch test — assert real log lines, not the removed placeholder
CI / build + unit + e2e (pull_request) Successful in 2m36s
CI run 361: v0.9.0 replaced the placeholder feed with EventLogPanel, but this
pre-existing assertion still expected 'SHIP LOG // PLACEHOLDER FEED'. Assert a
real T<turn> // <MESSAGE> line instead — every save has the turn-0 new_game
event, so it holds on a fresh stack and across retries.
2026-09-29 03:02:04 +00:00
kamilkrzywda 760a45fc4b release v0.9.0
CI / build + unit + e2e (pull_request) Failing after 3m12s
2026-09-29 02:48:25 +00:00
kamilkrzywda 0b435a583b test(e2e): event log entries + restart persistence
smoke: jump (shared helper) + scan, then assert both events are in
GET /api/events with the newer one first, and that the EVENT LOG terminal
renders both T<turn> // <MESSAGE> lines. Idempotent across retries — asserts
on this run's own entries by turn/message, never absolute counts. state: the
latest five log lines come back identical (ids included) after the mid-run
container restart.
2026-09-29 02:47:36 +00:00
kamilkrzywda 29e8ac6909 feat(ui): EVENT LOG tab renders the real game log
Self-fetching EventLogPanel (MissionPanel pattern — refetches on each tab
open). One terminal line per event: T<turn> // <MESSAGE>, newest first, with
the event's local time as the timestamp; new_game lines render as system
lines. Placeholder feed + now() removed from mission-control.
2026-09-29 02:45:38 +00:00
kamilkrzywda 21abf8768f feat(api): GET /api/events endpoint + OpenAPI
Latest game events, newest first; auto-initializes like the other reads.
?limit= defaults to 50 (cap 200), non-integer/out-of-range → 400. OpenAPI
spec gains the path + Event schema (14 -> 15 paths).
2026-09-29 02:45:38 +00:00
kamilkrzywda 8d678d73ce test: assert events for the remaining action types
expand had no event assertion at all; build and mission-complete asserted
type/message but not the event's turn. Issue #11 requires every action type
to be covered with correct turn/type/message.
2026-09-29 02:45:38 +00:00
kamilkrzywda 0d5858695a feat(events): event log read — getEvents + limit parsing
lib/game/events.ts: the latest N events of the save, newest first (ORDER BY
id DESC), with parseEventLimit for the optional ?limit= param (default 50,
cap 200, 400 otherwise). Unit tests cover default/accept/reject and
newest-first ordering + slicing.
2026-09-29 02:45:38 +00:00
kamilkrzywda 6c828e40a6 Merge pull request 'v0.8.0 — MISSIONS: generation, accept & complete (#10)' (#17) from missions into master
CI / build + unit + e2e (push) Successful in 2m44s
2026-09-29 04:21:13 +02:00
kamilkrzywda b5a072c9aa test(e2e): mission test — match dotted ACTIVE badge, idempotent across retries
CI / build + unit + e2e (pull_request) Successful in 2m25s
CI run 358: the ACTIVE assertion used exact text but badge.tsx renders a
'●' dot prefix for ACTIVE variants (text is '●ACTIVE'), so it could never
match. And attempt 1's accept persisted an ACTIVE mission, leaving every
ACCEPT button disabled on retry. Drop exact matching and, when a leftover
active mission exists, assert the UI state and complete that one instead of
re-accepting.
2026-09-29 02:18:14 +00:00
kamilkrzywda 85eeeecc4a release v0.8.0
CI / build + unit + e2e (pull_request) Failing after 2m53s
2026-09-29 01:56:24 +00:00
kamilkrzywda 70f95145ae test(e2e): mission accept -> complete + board survives restart
smoke: pick an AVAILABLE contract (non-DELIVER preferred), accept it through
the UI (card flips ACTIVE, button disables), complete via API — scan loop for
EXPLORE/FIND, BFS jump path for DELIVER — assert DONE and the reward landed in
the hold. state: mission board deep-equal across the mid-run container restart.
2026-09-29 01:55:49 +00:00
kamilkrzywda 684a126a5b feat(ui): MISSIONS tab renders the live mission board
Self-fetching MissionPanel (world-map pattern — refetches on each tab open,
accept responses applied directly). One card per contract: type title + state
badge, CONTRACT // #id line, objective, reward line, EXPLORE progress line.
ACCEPT enabled only when AVAILABLE and nothing else is ACTIVE. Placeholder
encounter content removed.
2026-09-29 01:55:49 +00:00
kamilkrzywda 786a5ec001 feat(api): GET /api/missions + POST /api/missions/{id}/accept
Routes call ensureSave first (mission functions assume an initialized save),
map MissionError to 404/409 and non-integer path ids to 400. OpenAPI spec in
code gains both paths + the Mission schema (12 -> 14 paths); openapi.test.ts
covers the new shapes.
2026-09-29 01:55:41 +00:00
kamilkrzywda f356ffbe93 feat(missions): mission board — schema, deterministic generation, top-up + v6 backfill
New missions table (schema + DDL). Pure model in lib/game/missions.ts:
DELIVER / EXPLORE / FIND contracts generated from missionRng(seed, turn),
board always topped up to >=3 AVAILABLE after every action. save.ts wires
SCHEMA_VERSION 5 -> 6 with a v6 backfill step and completion hooks inside
jumpToSystem/explorePlanet (one atomic persist). Unit tests cover generation
determinism, top-up idempotency, backfill, accept validation and per-type
completion incl. reward accumulation.
2026-09-29 01:55:34 +00:00
kamilkrzywda 5657e8938a Merge pull request 'v0.7.0 — SKILL TREE: technology research unlocks (#9)' (#16) from skill-tree into master
CI / build + unit + e2e (push) Successful in 2m52s
2026-09-29 02:59:32 +02:00
kamilkrzywda 80885c8ad7 test: e2e jump helper prefers undiscovered systems — fixes exhausted-territory ping-pong
CI / build + unit + e2e (pull_request) Successful in 2m30s
2026-09-29 00:56:28 +00:00
kamilkrzywda a62ed2cf95 release v0.7.0
CI / build + unit + e2e (pull_request) Failing after 2m41s
2026-09-29 00:39:04 +00:00
kamilkrzywda 672e99bd96 e2e: research unlocks the gated WEAPONS build via UI + tech state survives restart 2026-09-29 00:38:09 +00:00
kamilkrzywda 94e9fc728c ui: real SKILL TREE tab (research) + SHIP SYSTEMS buttons gated by researched techs 2026-09-29 00:35:39 +00:00
kamilkrzywda 7b0227cde9 api+openapi: GET /api/tech + POST /api/research with 400/404/409/422 mapping 2026-09-29 00:30:10 +00:00
kamilkrzywda 8818f0928d game: tech research action + ship-action gating by researched techs (build gate, raised caps) 2026-09-29 00:24:25 +00:00
kamilkrzywda 4ef924edd6 db+game: tech table + starting tech list seeded on new game and v5 backfill step 2026-09-29 00:20:02 +00:00
kamilkrzywda bd16d3fc47 Merge pull request 'v0.6.0 — SHIP SYSTEMS: expand/upgrade/build + save backfill (#8)' (#15) from ship-systems into master
CI / build + unit + e2e (push) Successful in 2m48s
2026-09-29 01:55:06 +02:00
kamilkrzywda dcaeb4dded release v0.6.0
CI / build + unit + e2e (pull_request) Successful in 3m2s
2026-09-28 23:47:37 +00:00
kamilkrzywda 7e975566ab e2e: ship action via UI (bounded scan until affordable) + ship state survives container restart 2026-09-28 23:46:21 +00:00
kamilkrzywda de0cb45b09 ui: real SHIP SYSTEMS tab (upgrade/expand/build buttons) + left column mirrors live ship state 2026-09-28 23:43:03 +00:00
kamilkrzywda fce27ff6ca api+openapi: GET /api/ship + POST upgrade/expand/build with 400/404/409/422 mapping 2026-09-28 23:38:11 +00:00
kamilkrzywda 5fe306d64d game: ship actions — upgrade/expand/build with cost model, validation (404/409/422), turn + event per action 2026-09-28 23:35:28 +00:00
kamilkrzywda 0910891995 db+game: ship_systems table + starting ship seed (new game and v4 backfill step) 2026-09-28 23:32:04 +00:00
kamilkrzywda c7580d92bd db+game: schema_version on saves with ordered idempotent backfill (v2 bodies, v3 crew) — no more DB wipes 2026-09-28 23:25:06 +00:00
kamilkrzywda f5cba45b42 Merge pull request 'v0.5.0 — CREW: roster with good/bad traits (#7)' (#14) from crew-roster into master
CI / build + unit + e2e (push) Successful in 2m28s
2026-09-29 01:03:25 +02:00
kamilkrzywda 59ccf5674e release v0.5.0
CI / build + unit + e2e (pull_request) Successful in 2m20s
2026-09-28 22:56:25 +00:00
kamilkrzywda 60947d3cda e2e: CREW tab renders real roster cards with both traits; roster survives container restart 2026-09-28 22:51:26 +00:00
kamilkrzywda 08a15a09cf ui: CREW tab renders real roster cards with traits + origin (real data from /api/crew) 2026-09-28 22:50:01 +00:00
kamilkrzywda a2c8586618 api: GET /api/crew roster endpoint, OpenAPI spec updated in code 2026-09-28 22:47:18 +00:00
kamilkrzywda 7a9ff110f3 game: starting crew seeded on new game; exploration rolls for a joining crew member (same per-planet stream) 2026-09-28 22:45:55 +00:00
kamilkrzywda ccd156b7c6 db+game: crew table and pure seeded crew generation (starting crew + exploration roll) 2026-09-28 22:42:28 +00:00
kamilkrzywda a6a5e827a1 Merge pull request 'v0.4.0 — LOCAL SYSTEM: planets + exploration (#6)' (#13) from local-system-explore into master
CI / build + unit + e2e (push) Successful in 2m55s
2026-09-29 00:28:58 +02:00
kamilkrzywda 0ddba27333 release: v0.4.0 — LOCAL SYSTEM planet list + exploration to find things (issue #6)
CI / build + unit + e2e (pull_request) Successful in 3m6s
2026-09-28 22:17:56 +00:00
kamilkrzywda 1a50ffb2f9 e2e: local system smoke tests (staggered bodies, explore row state, inventory accumulation) + explored-state persistence across restart 2026-09-28 22:17:03 +00:00
kamilkrzywda 82d41b87be ui: LOCAL SYSTEM tab renders real bodies with explore buttons + cargo hold (real data from /api/system) 2026-09-28 22:13:19 +00:00
kamilkrzywda 8153007668 api: GET /api/system + POST /api/explore, OpenAPI spec updated in code 2026-09-28 22:11:01 +00:00
kamilkrzywda 3aa27ffd89 game: bodies generated with each system; explore action — deterministic haul, turn cost, inventory accumulation 2026-09-28 22:07:53 +00:00
kamilkrzywda cbab17047b game: seeded planet/moon generation per system + deterministic exploration roll 2026-09-28 22:04:23 +00:00
kamilkrzywda 172f167a15 db: planets (planets+moons via parent_id) and inventory_items tables 2026-09-28 22:01:41 +00:00
kamilkrzywda b79b77131f Merge pull request 'v0.3.0 — WORLD MAP: world generation + jump (#5)' (#12) from world-map-jump into master
CI / build + unit + e2e (push) Successful in 3m5s
2026-09-28 23:43:08 +02:00
kamilkrzywda f36820ddff release: v0.3.0 — world generation + jump between solar systems (issue #5)
CI / build + unit + e2e (pull_request) Successful in 3m7s
2026-09-28 21:36:37 +00:00
kamilkrzywda 6a623c738a e2e: world map smoke tests + jump persistence across container restart 2026-09-28 21:35:15 +00:00
kamilkrzywda 80b493746f test: openapi spec assertions for the world endpoints 2026-09-28 21:35:15 +00:00
kamilkrzywda 37c3800479 ui: WORLD MAP tab renders live world state with jump buttons (real data from /api/world) 2026-09-28 21:32:48 +00:00
kamilkrzywda cc6abe8f36 api: GET /api/world + POST /api/jump, OpenAPI spec updated in code 2026-09-28 21:29:23 +00:00
kamilkrzywda 58ac64af4f game: save lifecycle — auto-init, world state, jump with adjacency validation + chart growth 2026-09-28 21:28:14 +00:00
kamilkrzywda 1634c0602e game: seeded world generation (pure) — start region + expansion rings 2026-09-28 21:25:14 +00:00
kamilkrzywda 922a20707b db: game save schema — saves, solar_systems, jump_routes, events (single-player, one save) 2026-09-28 21:25:14 +00:00
kamilkrzywda aa3c5f5501 Merge pull request 'v0.2.2 — LOCAL SYSTEM list: stagger moons under parent planets' (#4) from local-system-moon-stagger into master
CI / build + unit + e2e (push) Successful in 2m55s
2026-09-28 23:07:05 +02:00
kamilkrzywda 7f48fd0e5e ui: LOCAL SYSTEM list staggers moons under their parent planet (v0.2.2)
CI / build + unit + e2e (pull_request) Successful in 2m42s
2026-09-28 21:03:14 +00:00
kamilkrzywda 0573e6d719 Merge pull request 'v0.2.1 — LOCAL SYSTEM tab: planet list only (drop node graph)' (#3) from local-system-list-only into master
CI / build + unit + e2e (push) Successful in 2m50s
2026-09-28 22:29:01 +02:00
kamilkrzywda 2651981044 ui: LOCAL SYSTEM tab shows only the explorable worlds list (v0.2.1)
CI / build + unit + e2e (pull_request) Successful in 3m48s
2026-09-28 20:23:31 +00:00
kamilkrzywda 16a7b468e3 Merge pull request 'v0.2.0 — 7-tab game interface template (visual only)' (#2) from ui-game-tabs into master
CI / build + unit + e2e (push) Successful in 2m36s
2026-09-28 21:25:39 +02:00
49 changed files with 5495 additions and 505 deletions
+80
View File
@@ -1,5 +1,85 @@
# Changelog
## 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
- SHIP SYSTEMS tab — expand / upgrade / build ship systems + save backfill plumbing (issue #8): the first spend-side game loop on top of issue #7's crew, plus a root-cause fix for saves created by older code lacking data newer code seeds. Single-player, no auth, by design.
- Backfill plumbing: `schema_version` INTEGER added to `saves` (NULL = v1). `ensureSave` now runs ordered IDEMPOTENT backfill steps for anything below current — v2: every system without planets gets its generated bodies (same `(seed, systemId)` derivation as fresh generation), v3: starting crew if no rows. New games get the current version directly. `lib/db/client.ts` open() carries an idempotent `ALTER TABLE ... ADD COLUMN` guard for files created before the column existed — old saves open fine and backfill on first read, so DB wipes are no longer needed when deploying new versions.
- New table (`lib/db/schema.ts` + DDL): `ship_systems` (id/save_id/type/level/capacity, UNIQUE(save_id,type)). `built` is derived, not stored — level > 0 means built; unbuilt rows sit at level/capacity 0. Starting ship seeded on new game AND as backfill step v4: core types (HULL, REACTOR, LIFE SUPPORT, NAV COMPUTER, COMMS ARRAY) built at base values, WEAPONS unbuilt until a build action.
- Cost model (`lib/game/ship-costs.ts`, pure — imported by both the API and the UI so affordability can't drift): UPGRADE costs `level` units of any resource (capped at MAX_LEVEL 5), EXPAND costs `capacity` units of any resource (capped at MAX_CAPACITY 10), BUILD costs a flat 3 parts. Consumption takes from the oldest cargo row of the required kind first, deleting rows that hit zero; an unaffordable action mutates nothing.
- Ship actions (`lib/game/ship.ts`): `upgradeSystem` / `expandSystem` / `buildSystem` — validate (404 unknown id/type, 409 not built / already built / at cap, 422 insufficient inventory), spend the cost, bump level/capacity or build at base values, cost one turn and write an event each.
- New endpoints: `GET /api/ship` → `{systems:[{id,type,level,capacity,built}],inventory,turn}`; `POST /api/ship/upgrade {systemId}`, `POST /api/ship/expand {systemId}`, `POST /api/ship/build {type}` — each returns the fresh ship state. OpenAPI spec in code updated (10 paths).
- SHIP SYSTEMS tab now renders REAL data: cards show type + level/capacity with UPGRADE / EXPAND buttons per built card and BUILD NEW for unbuilt types, disabled when unaffordable or capped; each action's response is applied directly (no polling). Left column mirrors live state too — the SHIP SYSTEMS panel lists every system with its status/level/capacity + an N ONLINE badge, and VESSEL METRICS bars are driven by real values (hull/reactor from system levels, crew roster vs a cap of 6). Placeholder constants for both removed; other tabs keep theirs until their issues land.
- Tests: unit — backfill upgrades a hand-built v1 save in place (deterministic vs fresh generation, idempotent), starting ship seeding, cost validation with no mutation on failure, exact oldest-first inventory deduction, level/capacity caps, build-once, turn + event per action; e2e — SHIP SYSTEMS tab performs one affordable UPGRADE/BUILD via the UI after a bounded scan loop until something is affordable (asserting card + left column reflect it), and ship state survives the mid-run container restart.
## v0.5.0 — 2026-09-28 22:51
- CREW tab — crew roster with good/bad traits (issue #7): the ship's people on top of issue #6's exploration loop. Single-player, no auth, by design. Display + arrival only — no dismiss/assign actions yet.
- New table (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `crew` (id/save_id/name/role/trait_good/trait_bad/origin). Origin is 'STARTING CREW' for the seeded crew or '<planet>, <system>' for members found while exploring.
- Pure seeded crew generation (`lib/game/crew.ts`, same pattern as world.ts): name pools (16 first × 22 last, unique per roster), role pool of 8, good/bad trait pools of 10 each. `generateStartingCrew` gives a new game 2–3 members with unique names and roles; `rollCrewMember` finds a joining member on ~18% of scans (`CREW_CHANCE = 0.18`).
- Starting crew is seeded in `ensureSave` from the save seed via `startingCrewRng(seed)` — same per-save derivation pattern as bodies/exploration, so a given seed always starts with the same crew.
- Exploration extended: the crew roll draws from the SAME per-planet rng stream as the resource/part haul (called after it in `explorePlanet`), so outcomes stay deterministic per save+planet and existing hauls are unchanged. Found members join the roster with origin = '<planet>, <system>'; each body can only be scanned once, so no duplicate joins.
- New endpoint: `GET /api/crew` → `[{id,name,role,trait_good,trait_bad,origin}]` in join order (auto-initializes like the others). `POST /api/explore` responses now also carry `crewFound` (the joining member or null). OpenAPI spec in code (`lib/openapi.ts`) updated for both.
- CREW tab now renders REAL data: fetches `/api/crew` on mount (Radix unmounts inactive tabs, so opening the tab always refetches — no polling), cards show name + role, the good (+) and bad (−) traits and an ORIGIN line. Placeholder crew constants removed for this tab only — other tabs keep theirs until their issues land.
## v0.4.0 — 2026-09-28 22:17
- LOCAL SYSTEM tab — planet list + exploration to find things (issue #6): the first in-system game loop on top of issue #5's save/world foundation. Single-player, no auth, by design.
- New tables (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `planets` (id/system_id/parent_id/name/type/hazard/explored — planets have parent_id NULL, moons point at their planet) and `inventory_items` (id/save_id/kind[name]/qty — the dumb-simple cargo hold SHIP SYSTEMS (#8) will spend from later).
- Planet generation hooks into world gen (`lib/game/world.ts`, pure + seeded): each system gets 2–5 planets, some with 1–3 moons, generated when the system is inserted via `bodyRng(seed, systemId)` — so a given system always has the same bodies regardless of order. Bodies are stored in display order (a moon follows its parent row).
- Exploration (`lib/game/save.ts`): `explorePlanet` rolls a deterministic haul from `explorationRng(seed, planetId)` — a resource and/or a part (qty), or nothing (~21% of scans) — marks the body explored (each once), costs a turn, accumulates finds into inventory (same kind+name bumps qty) and writes an explore event.
- New endpoints: `GET /api/system` → `{ systemId, systemName, planets[{id,name,type,hazard,explored,parent}], inventory, turn }`; `POST /api/explore {planetId}` → fresh system state + `outcome` (400 malformed body, 404 unknown planet, 409 already explored). OpenAPI spec in code (`lib/openapi.ts`) updated for both.
- LOCAL SYSTEM tab now renders REAL data: fetches `/api/system` on mount, draws the staggered body list (planets base level, moons indented + dimmed with their parent's name), badges reflect real state (UNEXPLORED / SCANNED / HAZARD) and an EXPLORE button per unexplored row; explore responses apply directly (no polling). A CARGO HOLD panel shows the live inventory. Placeholder constants removed for this tab only — other tabs keep theirs until their issues land.
## v0.3.0 — 2026-09-28 21:35
- World generation + jump between solar systems (issue #5): the game save, DB schema, turn counter and event writing that all later tabs build on. Single-player by design — one save row, no auth/accounts.
- New tables (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `saves` (id/seed/turn/created_at), `solar_systems` (name, x/y chart position, discovered, is_current), `jump_routes` (directed pairs, both directions stored for single-row adjacency lookups), `events` (append-only log: new-game + jump events so far).
- Seeded world generation (`lib/game/world.ts`, pure functions): a save's seed deterministically generates the starting system plus a ring of six undiscovered neighbours with jump routes; jumping into an undiscovered system grows the chart with four more neighbours around it, so there is always somewhere to jump and a route back.
- Save lifecycle (`lib/game/save.ts`): auto-initializes on first API hit when no save exists, exposes world state, validates jumps (adjacency required — non-adjacent/unknown rejected), sets current, increments the turn counter, writes events, persists durably.
- New endpoints: `GET /api/world` → `{ systems, edges, turn }`; `POST /api/jump { systemId }` → fresh world state (400 malformed body, 404 unknown system, 409 not adjacent). OpenAPI spec in code (`lib/openapi.ts`) updated for both.
- WORLD MAP tab now renders REAL data: fetches `/api/world` on mount, draws the live NodeGraph (current = ACTIVE/CURRENT, discovered = NEUTRAL/DISCOVERED, unknown = OFFLINE/UNKNOWN) with a jump button per adjacent system; placeholder map constants removed for this tab only — other tabs keep their placeholders until their own issues land.
## v0.2.2 — 2026-09-28 21:02
- LOCAL SYSTEM tab: explorable-worlds list now staggers moons under their parent planet (indented rows) — ASHFALL attached under BRIMSTONE, new PALEMOON moon under AURELLE; badges unchanged.
## v0.2.1 — 2026-09-28 20:22
- LOCAL SYSTEM tab: removed the system node graph (star/planets/moons) — the tab now shows only the explorable-worlds list at full panel width. WORLD MAP keeps its node graph; `components/ui/node-graph.tsx` unchanged and still in use there.
## v0.2.0 — 2026-09-28 19:19
- Game interface template (visual only, zero game logic): the operations panel in `components/mission-control.tsx` is now seven tabs — WORLD MAP (NodeGraph of 10 placeholder solar systems linked by jump routes), LOCAL SYSTEM (NodeGraph of star + planets/moons plus an explorable-worlds list), CREW (crew cards with name/role and good/bad trait placeholders), SHIP SYSTEMS (system cards with inert EXPAND / UPGRADE / BUILD NEW affordances), SKILL TREE (tech cards unlocking new system builds/upgrades), MISSIONS (previous NEXT MISSION content), EVENT LOG (ship log terminal). All data is local placeholder constants.
- Left column: VESSEL METRICS progress rings replaced with scificn Progress bracket bars; SHIP SYSTEMS status panel, header stat cards and right column unchanged.
+2 -2
View File
@@ -46,8 +46,8 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
## 7. App architecture quick map
- **Scaffolded (v0.1.0)** — stack proof only, no game systems yet; the rest of this section is what's built + what lands next.
- Next.js 16 App Router + React 19 + TypeScript strict, bun package manager (bun.lock), Node 22 in CI/Docker.
- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart, NodeGraph, Progress), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. As of v0.2.0 the operations panel is a 7-tab visual template (WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE, MISSIONS, EVENT LOG) built on NodeGraph + Card widgets; all data are local placeholder constants until real game systems land. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Schema is EMPTY at scaffold stage — first game tables land with the turn engine.
- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart, NodeGraph, Progress), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. As of v0.2.0 the operations panel is a 7-tab visual template (WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE, MISSIONS, EVENT LOG) built on NodeGraph + Card widgets. As of v0.3.0 the WORLD MAP tab renders live data from `GET /api/world` (seeded world generation in `lib/game/`, save/jump/events via Drizzle/sql.js — single player, one save, no auth); as of v0.4.0 the LOCAL SYSTEM tab also renders live data (`GET /api/system` + `POST /api/explore` — per-system planet/moon generation and deterministic exploration into inventory). The other tabs keep local placeholder constants until their own issues land. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Game tables (`saves`, `solar_systems`, `jump_routes`, `planets`, `inventory_items`, `events`) live since v0.3.0/v0.4.0 — single-player save with seeded world + per-system body generation, turn counter and exploration; DDL in `lib/db/client.ts` must stay in sync with `lib/db/schema.ts`.
- Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only.
- Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer).
- Plain React hooks for client state, no external store. **NO auth/accounts/multiplayer by design** (single player only) — there is no users/sessions table and no session headers; a "session" means the local single-player save slot in the DB, not an identity system. Do not copy Flight-Ops's lib/auth.ts / SignInScreen / admin patterns.
+8
View File
@@ -0,0 +1,8 @@
import { getGameDb } from '@/lib/db/client'
import { getCrewState } from '@/lib/game/save'
// The ship's crew roster: starting crew plus members found while exploring. Auto-initializes on first hit.
export async function GET() {
const db = await getGameDb()
return Response.json(await getCrewState(db))
}
+21
View File
@@ -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))
}
+28
View File
@@ -0,0 +1,28 @@
import { getGameDb } from '@/lib/db/client'
import { ExploreError, explorePlanet } from '@/lib/game/save'
// Scan one body of the current system. Body: { "planetId": number }. Returns the fresh
// system state plus what this scan found. Costs a turn; each body can only be scanned once.
export async function POST(req: Request) {
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const planetId = (body as { planetId?: unknown }).planetId
if (typeof planetId !== 'number' || !Number.isInteger(planetId)) {
return Response.json({ error: 'planetId must be an integer' }, { status: 400 })
}
try {
const db = await getGameDb()
return Response.json(await explorePlanet(db, planetId))
} catch (err) {
if (err instanceof ExploreError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err
}
}
+27
View File
@@ -0,0 +1,27 @@
import { getGameDb } from '@/lib/db/client'
import { JumpError, jumpToSystem } from '@/lib/game/save'
// Jump to an adjacent solar system. Body: { "systemId": number }. Returns the fresh world state.
export async function POST(req: Request) {
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const systemId = (body as { systemId?: unknown }).systemId
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
}
try {
const db = await getGameDb()
return Response.json(await jumpToSystem(db, systemId))
} catch (err) {
if (err instanceof JumpError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err
}
}
+24
View File
@@ -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
}
}
+10
View File
@@ -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))
}
+27
View File
@@ -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
}
}
+28
View File
@@ -0,0 +1,28 @@
import { getGameDb } from '@/lib/db/client'
import { ShipError, buildSystem } from '@/lib/game/ship'
// Build an unbuilt ship system type at base values. Body: { "type": string }.
// Costs inventory items + a turn; returns the fresh ship state.
export async function POST(req: Request) {
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const type = (body as { type?: unknown }).type
if (typeof type !== 'string' || type.length === 0) {
return Response.json({ error: 'type must be a non-empty string' }, { status: 400 })
}
try {
const db = await getGameDb()
return Response.json(await buildSystem(db, type))
} catch (err) {
if (err instanceof ShipError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err
}
}
+28
View File
@@ -0,0 +1,28 @@
import { getGameDb } from '@/lib/db/client'
import { ShipError, expandSystem } from '@/lib/game/ship'
// Raise a built ship system's capacity by one (capped at max capacity). Body: { "systemId": number }.
// Costs inventory items + a turn; returns the fresh ship state.
export async function POST(req: Request) {
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const systemId = (body as { systemId?: unknown }).systemId
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
}
try {
const db = await getGameDb()
return Response.json(await expandSystem(db, systemId))
} catch (err) {
if (err instanceof ShipError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err
}
}
+8
View File
@@ -0,0 +1,8 @@
import { getGameDb } from '@/lib/db/client'
import { getShipState } from '@/lib/game/ship'
// The ship's systems + cargo hold + turn; auto-initializes on first hit.
export async function GET() {
const db = await getGameDb()
return Response.json(await getShipState(db))
}
+28
View File
@@ -0,0 +1,28 @@
import { getGameDb } from '@/lib/db/client'
import { ShipError, upgradeSystem } from '@/lib/game/ship'
// Level up a built ship system by one (capped at max level). Body: { "systemId": number }.
// Costs inventory items + a turn; returns the fresh ship state.
export async function POST(req: Request) {
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const systemId = (body as { systemId?: unknown }).systemId
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
}
try {
const db = await getGameDb()
return Response.json(await upgradeSystem(db, systemId))
} catch (err) {
if (err instanceof ShipError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err
}
}
+8
View File
@@ -0,0 +1,8 @@
import { getGameDb } from '@/lib/db/client'
import { getSystemState } from '@/lib/game/save'
// The current system's bodies (planets + moons) and the save's inventory; auto-initializes on first hit.
export async function GET() {
const db = await getGameDb()
return Response.json(await getSystemState(db))
}
+8
View File
@@ -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))
}
+8
View File
@@ -0,0 +1,8 @@
import { getGameDb } from '@/lib/db/client'
import { getWorldState } from '@/lib/game/save'
// The full world state for the single active save; auto-initializes on first hit.
export async function GET() {
const db = await getGameDb()
return Response.json(await getWorldState(db))
}
+101
View File
@@ -0,0 +1,101 @@
'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { CrewDto } from '@/lib/game/save'
// CREW tab — the ship's roster: starting crew plus members found while exploring. Fetches
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet.
interface CrewPanelProps {
narrow?: boolean
}
export function CrewPanel({ narrow }: CrewPanelProps) {
const [roster, setRoster] = useState<CrewDto[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch('/api/crew')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as CrewDto[]
})
.then((r) => {
if (!cancelled) setRoster(r)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [])
if (error && !roster) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
CREW MANIFEST OFFLINE // {error.toUpperCase()}
</span>
</div>
)
}
if (!roster) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING CREW MANIFEST…</span>
</div>
)
}
return (
<div
style={{
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
gap: '0.75rem',
}}
>
{roster.map((member) => (
<Card key={member.id}>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{member.name}</CardTitle>
<Badge variant="ACTIVE" className="ml-auto">
ACTIVE
</Badge>
</div>
<p
style={{
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
{member.role}
</p>
</CardHeader>
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-green)' }}>+ </span>
<span style={{ color: 'var(--text-secondary)' }}>{member.traitGood}</span>
</div>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-red)' }}>- </span>
<span style={{ color: 'var(--text-secondary)' }}>{member.traitBad}</span>
</div>
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
ORIGIN // {member.origin.toUpperCase()}
</p>
</CardContent>
</Card>
))}
</div>
)
}
+59
View File
@@ -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%' }} />
)
}
+138
View File
@@ -0,0 +1,138 @@
'use client'
import { useCallback, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { ExploreResult, SystemState } from '@/lib/game/save'
import type { FoundItem } from '@/lib/game/world'
// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an
// EXPLORE button per unexplored row. The parent (MissionControl) owns the /api/system state so
// the right column's CARGO HOLD mirrors scans immediately; this panel reports each scan's
// response up via onChanged and keeps only its own UI bits locally (no polling). Turn-based:
// one scan costs a turn, each body once.
interface LocalSystemPanelProps {
system: SystemState | null
onChanged: (next: SystemState) => void
}
export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) {
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [exploringId, setExploringId] = useState<number | null>(null)
const explore = useCallback(
async (planetId: number) => {
setExploringId(planetId)
try {
const res = await fetch('/api/explore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ planetId }),
})
const body = (await res.json()) as ExploreResult & { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
onChanged(body) // fresh state straight from the response — parent mirrors it to CARGO HOLD
setLastOutcome(body.outcome)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setExploringId(null)
}
},
[onChanged]
)
if (!system) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING SYSTEM…</span>
</div>
)
}
const badgeFor = (explored: boolean, hazard: boolean) =>
explored ? { label: 'SCANNED', variant: 'ACTIVE' as const } : hazard ? { label: 'HAZARD', variant: 'WARNING' as const } : { label: 'UNEXPLORED', variant: 'SCANNING' as const }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
<div
style={{
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
EXPLORABLE WORLDS // {system.systemName.toUpperCase()} — TURN {system.turn}
</div>
{system.planets.map((row, i) => {
const depth = row.parent !== null ? 1 : 0
const badge = badgeFor(row.explored, row.hazard)
return (
<div
key={row.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem',
padding: depth ? '0.6rem 0.75rem 0.6rem 1.75rem' : '0.6rem 0.75rem',
borderBottom: i < system.planets.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<div style={{ overflow: 'hidden' }}>
<div
style={{
fontSize: '0.68rem',
color: depth ? 'var(--text-muted)' : 'var(--text-secondary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
{row.name}
</div>
<div style={{ fontSize: '0.57rem', color: 'var(--text-muted)', letterSpacing: '0.06em', marginTop: '2px' }}>
{depth ? `MOON OF ${system.planets.find((p) => p.id === row.parent)?.name ?? '?'} // ${row.type}` : row.type}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Badge variant={badge.variant}>{badge.label}</Badge>
{!row.explored && (
<Button
variant="OUTLINE"
size="SM"
disabled={exploringId !== null}
onClick={() => void explore(row.id)}
>
{exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // EXPLORE`}
</Button>
)}
</div>
</div>
)
})}
</div>
{lastOutcome !== null && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
SCAN RESULT //{' '}
{lastOutcome.length > 0 ? lastOutcome.map((i) => `${i.qty}× ${i.name.toUpperCase()}`).join(', ') : 'NOTHING FOUND'}
</span>
</div>
)}
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>SCAN FAILED // {error.toUpperCase()}</span>
</div>
)}
</div>
)
}
+159 -490
View File
@@ -2,20 +2,28 @@
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { NodeGraph, type NodeGraphEdge, type NodeGraphNode } from '@/components/ui/node-graph'
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
import { Progress } from '@/components/ui/progress'
import { StatCard } from '@/components/ui/stat-card'
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
import { CrewPanel } from '@/components/crew-panel'
import { EventLogPanel } from '@/components/event-log'
import { LocalSystemPanel } from '@/components/local-system'
import { MissionPanel } from '@/components/missions'
import { ShipSystemsPanel } from '@/components/ship-systems'
import { SkillTreePanel } from '@/components/skill-tree'
import { WorldMapPanel } from '@/components/world-map'
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
import type { CrewDto, SystemState } from '@/lib/game/save'
import type { ShipState } from '@/lib/game/ship'
import type { TechDto } from '@/lib/game/tech'
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui), with
// placeholder SpaceWanderer content — no game logic yet. Single player, no auth, by design.
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven
// tabs render live data (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions,
// /api/events); the right column's CARGO HOLD renders from the lifted /api/system state. Single
// player, no auth, by design.
function useNarrow(threshold = 900): boolean {
const [narrow, setNarrow] = useState(
@@ -29,125 +37,60 @@ function useNarrow(threshold = 900): boolean {
return narrow
}
function now(): string {
return new Date().toLocaleTimeString('en-GB', { hour12: false })
}
// ── Placeholder content (no game logic — real values land with the turn engine) ──
const shipSystems: SystemEntry[] = [
{ name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
{ name: 'LIFE SUPPORT', status: 'ACTIVE' },
{ name: 'NAV COMPUTER', status: 'SCANNING' },
{ name: 'WEAPONS', status: 'OFFLINE' },
{ name: 'COMMS', status: 'ACTIVE' },
]
// WORLD MAP — every node is a solar system, edges are jump routes.
const worldMapNodes: NodeGraphNode[] = [
{ id: 'kepler', label: 'Kepler Drift', x: 50, y: 48, status: 'ACTIVE', sublabel: 'CURRENT' },
{ id: 'vesper', label: 'Vesper Reach', x: 22, y: 26, status: 'NEUTRAL', sublabel: '3 WORLDS' },
{ id: 'hollow', label: 'Hollow Gate', x: 74, y: 20, status: 'NEUTRAL', sublabel: '5 WORLDS' },
{ id: 'cinder', label: 'Cinder Field', x: 86, y: 56, status: 'WARNING', sublabel: 'RADIATION' },
{ id: 'lantern', label: 'Lantern Void', x: 30, y: 74, status: 'NEUTRAL', sublabel: '2 WORLDS' },
{ id: 'sable', label: 'Sable Reach', x: 8, y: 46, status: 'OFFLINE', sublabel: 'UNKNOWN' },
{ id: 'orpheus', label: 'Orpheus Deep', x: 52, y: 10, status: 'NEUTRAL', sublabel: '7 WORLDS' },
{ id: 'mire', label: 'Mire Station', x: 68, y: 84, status: 'ACTIVE', sublabel: 'OUTPOST' },
{ id: 'thalassa', label: 'Thalassa Bend', x: 12, y: 86, status: 'NEUTRAL', sublabel: '4 WORLDS' },
{ id: 'kuiper', label: 'Kuiper Shard', x: 90, y: 30, status: 'CRITICAL', sublabel: 'WARZONE' },
]
const worldMapEdges: NodeGraphEdge[] = [
{ from: 'kepler', to: 'vesper', animated: true },
{ from: 'kepler', to: 'hollow', label: 'JUMP-2' },
{ from: 'kepler', to: 'lantern', animated: true },
{ from: 'kepler', to: 'mire' },
{ from: 'vesper', to: 'orpheus' },
{ from: 'hollow', to: 'kuiper', label: 'JUMP-1' },
{ from: 'cinder', to: 'kuiper' },
{ from: 'lantern', to: 'thalassa' },
{ from: 'sable', to: 'vesper' },
{ from: 'mire', to: 'cinder' },
]
// LOCAL SYSTEM — the current system (regenerated per save after each jump, later).
const localSystemNodes: NodeGraphNode[] = [
{ id: 'star', label: 'Kepler-442', x: 50, y: 50, status: 'ACTIVE', sublabel: 'G2 STAR' },
{ id: 'aurelle', label: 'Aurelle', x: 18, y: 30, status: 'ACTIVE', sublabel: 'TERRESTRIAL' },
{ id: 'brimstone', label: 'Brimstone', x: 76, y: 24, status: 'NEUTRAL', sublabel: 'GAS GIANT' },
{ id: 'ashfall', label: 'Ashfall', x: 90, y: 52, status: 'WARNING', sublabel: 'MOON' },
{ id: 'cinderrock', label: 'Cinderrock', x: 74, y: 80, status: 'NEUTRAL', sublabel: 'ROCKY' },
{ id: 'driftband', label: 'Driftband', x: 30, y: 78, status: 'WARNING', sublabel: 'ASTEROID RING' },
{ id: 'palemoon', label: 'Palemoon', x: 8, y: 54, status: 'NEUTRAL', sublabel: 'MOON' },
]
const localSystemEdges: NodeGraphEdge[] = [
{ from: 'star', to: 'aurelle' },
{ from: 'star', to: 'brimstone' },
{ from: 'brimstone', to: 'ashfall', label: 'MOON' },
{ from: 'star', to: 'cinderrock' },
{ from: 'star', to: 'driftband' },
{ from: 'aurelle', to: 'palemoon', label: 'MOON' },
]
const explorablePlanets = [
{ name: 'AURELLE', type: 'TERRESTRIAL // OCEAN CONTINENTS', state: 'UNEXPLORED' as const },
{ name: 'BRIMSTONE', type: 'GAS GIANT // CLOUD DECKS', state: 'SCANNED' as const },
{ name: 'ASHFALL (MOON)', type: 'VOLCANIC MOON // SULFUR VEINS', state: 'UNEXPLORED' as const },
{ name: 'CINDERROCK', type: 'ROCKY // IRON RICH', state: 'UNEXPLORED' as const },
{ name: 'DRIFTBAND', type: 'ASTEROID FIELD // HAZARD', state: 'HAZARD' as const },
]
const crew = [
{ name: 'CMDR. E. VANCE', role: 'COMMANDER', good: 'STEADY UNDER PRESSURE', bad: 'RIGID — SLOW TO ADAPT' },
{ name: 'LT. M. OKONKWO', role: 'PILOT', good: 'RECKLESS COURAGE', bad: 'SHORT FUSE' },
{ name: 'DR. I. SORENSEN', role: 'MEDIC', good: 'CALM IN A CRISIS', bad: 'OVERCAUTIOUS' },
{ name: 'R. "SPARK" DELACRUZ', role: 'ENGINEER', good: 'FIXES ANYTHING', bad: 'CARELESS WITH FUSES' },
]
const shipSystemCards = [
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] },
{ name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
]
const skillCards = [
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
{ 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() {
const narrow = useNarrow()
const [tick, setTick] = useState(0)
// Ship + system state is owned here (not in the tab panels): the SHIP SYSTEMS tab mirrors ship
// actions via setShip, and the right column's CARGO HOLD renders from the lifted inventory —
// the LOCAL SYSTEM tab reports each scan's response up via setSystem. Crew count feeds the
// roster bar — refetched on page load, like the other panels.
const [ship, setShip] = useState<ShipState | null>(null)
const [system, setSystem] = useState<SystemState | null>(null)
const [techs, setTechs] = useState<TechDto[] | null>(null)
const [crewCount, setCrewCount] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetch('/api/ship')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as ShipState
})
.then((s) => {
if (!cancelled) setShip(s)
})
.catch(() => {}) // left column + tab keep their loading state on failure
fetch('/api/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')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as CrewDto[]
})
.then((c) => {
if (!cancelled) setCrewCount(c.length)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000)
@@ -156,6 +99,10 @@ export function MissionControl() {
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 (
<div
style={{
@@ -296,7 +243,7 @@ export function MissionControl() {
style={{
flex: 1,
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
gap: '1px',
background: 'var(--border)',
}}
@@ -316,11 +263,25 @@ export function MissionControl() {
<PanelHeader>
<PanelTitle>SHIP SYSTEMS</PanelTitle>
<div style={{ marginLeft: 'auto' }}>
<Badge variant="ACTIVE">4 ONLINE</Badge>
<Badge variant={ship && ship.systems.some((s) => s.built) ? 'ACTIVE' : 'OFFLINE'}>
{ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
</Badge>
</div>
</PanelHeader>
<StatusGrid systems={shipSystems} />
{ship ? (
<StatusGrid
systems={ship.systems.map((s) => ({
name: s.type,
status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const),
detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined,
}))}
/>
) : (
<div style={{ padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
</div>
)}
</Panel>
<Panel notch="sm">
@@ -328,11 +289,40 @@ export function MissionControl() {
<PanelTitle>VESSEL METRICS</PanelTitle>
</PanelHeader>
<PanelContent>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
<Progress label="HULL INTEGRITY" value={87} />
<Progress label="REACTOR OUTPUT" value={94} />
<Progress label="CREW ROSTER / 6" value={50} />
</div>
{ship ? (
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
// crew roster vs the display cap; SECTOR/TURN rows folded in from the old SHIP DATA
// placeholder (the rest of it duplicated these bars or had no game equivalent).
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
<div style={{ display: 'flex', flexDirection: 'column' }}>
{[
{ label: 'SECTOR', value: (system?.systemName ?? '—').toUpperCase() },
{ label: 'TURN', value: String(ship.turn) },
].map((item, i) => (
<div
key={item.label}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.4rem 0',
borderBottom: i < 1 ? '1px solid var(--border)' : 'none',
}}
>
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>{item.label}</span>
<span style={{ fontSize: '0.78rem', color: 'var(--color-green)', textShadow: 'var(--text-glow-green)', letterSpacing: '0.04em', fontWeight: 600 }}>
{item.value}
</span>
</div>
))}
</div>
</div>
) : (
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
)}
</PanelContent>
</Panel>
</div>
@@ -374,291 +364,46 @@ export function MissionControl() {
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
</TabsList>
{/* WORLD MAP TAB */}
{/* WORLD MAP TAB — live data from /api/world */}
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
<NodeGraph
nodes={worldMapNodes}
edges={worldMapEdges}
variant="ACTIVE"
title="GALACTIC CHART // JUMP ROUTES (PLACEHOLDER)"
height={380}
style={{ width: '100%' }}
/>
<WorldMapPanel height={380} />
</TabsContent>
{/* LOCAL SYSTEM TAB */}
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<NodeGraph
nodes={localSystemNodes}
edges={localSystemEdges}
variant="ACTIVE"
title="KEPLER DRIFT // LOCAL SYSTEM (PLACEHOLDER)"
height={280}
style={{ width: '100%' }}
/>
<div
style={{
border: '1px solid var(--border)',
background: 'var(--surface)',
overflow: 'hidden',
}}
>
<div
style={{
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
EXPLORABLE WORLDS // FIND THINGS HERE (PLACEHOLDER)
</div>
{explorablePlanets.map((planet, i) => (
<div
key={planet.name}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem',
padding: '0.6rem 0.75rem',
borderBottom:
i < explorablePlanets.length - 1
? '1px solid var(--border)'
: 'none',
}}
>
<div style={{ overflow: 'hidden' }}>
<div
style={{
fontSize: '0.68rem',
color: 'var(--text-secondary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
{planet.name}
</div>
<div
style={{
fontSize: '0.57rem',
color: 'var(--text-muted)',
letterSpacing: '0.06em',
marginTop: '2px',
}}
>
{planet.type}
</div>
</div>
<Badge
variant={
planet.state === 'UNEXPLORED'
? 'SCANNING'
: planet.state === 'HAZARD'
? 'WARNING'
: 'ACTIVE'
}
>
{planet.state}
</Badge>
</div>
))}
</div>
</div>
<LocalSystemPanel system={system} onChanged={setSystem} />
</TabsContent>
{/* CREW TAB */}
{/* CREW TAB — live data from /api/crew */}
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
gap: '0.75rem',
}}
>
{crew.map((member) => (
<Card key={member.name}>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{member.name}</CardTitle>
<Badge variant="ACTIVE" className="ml-auto">
ACTIVE
</Badge>
</div>
<p
style={{
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
{member.role}
</p>
</CardHeader>
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-green)' }}>+ </span>
<span style={{ color: 'var(--text-secondary)' }}>{member.good}</span>
</div>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-red)' }}>- </span>
<span style={{ color: 'var(--text-secondary)' }}>{member.bad}</span>
</div>
</CardContent>
</Card>
))}
</div>
<CrewPanel narrow={narrow} />
</TabsContent>
{/* SHIP SYSTEMS TAB */}
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
gap: '0.75rem',
}}
>
{shipSystemCards.map((system) => (
<Card key={system.name}>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{system.name}</CardTitle>
<Badge variant={system.status} className="ml-auto">
{system.level}
</Badge>
</div>
</CardHeader>
<CardContent>
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
MODULE BAY // PLACEHOLDER READOUT
</p>
</CardContent>
<CardFooter className="gap-2">
{system.actions.map((action) => (
<Button key={action} variant="OUTLINE" size="SM">
{action}
</Button>
))}
</CardFooter>
</Card>
))}
</div>
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} researchedSlugs={researchedSlugs} />
</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' }}>
<div
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>
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
</TabsContent>
{/* MISSIONS TAB */}
{/* MISSIONS TAB — live data from /api/missions */}
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
<div
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>
<MissionPanel />
</TabsContent>
{/* EVENT LOG TAB */}
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
<Terminal
lines={logLines}
title="SHIP LOG // PLACEHOLDER FEED"
height="22rem"
style={{ width: '100%' }}
/>
<EventLogPanel />
</TabsContent>
</Tabs>
</PanelContent>
</Panel>
</div>
{/* ── RIGHT: UPCOMING ENCOUNTERS + SHIP DATA ── */}
{/* ── RIGHT: CARGO HOLD (persistent — inventory lifted from /api/system) ── */}
<div
style={{
background: 'var(--background)',
@@ -671,110 +416,34 @@ export function MissionControl() {
>
<Panel notch="sm">
<PanelHeader>
<PanelTitle>UPCOMING ENCOUNTERS</PanelTitle>
<div style={{ marginLeft: 'auto' }}>
<Badge variant="SCANNING">{encounters.length} QUEUED</Badge>
</div>
<PanelTitle>CARGO HOLD // INVENTORY</PanelTitle>
{system && (
<div style={{ marginLeft: 'auto' }}>
<Badge variant={system.inventory.length > 0 ? 'ACTIVE' : 'OFFLINE'}>
{system.inventory.reduce((n, i) => n + i.qty, 0)} UNITS
</Badge>
</div>
)}
</PanelHeader>
<PanelContent style={{ padding: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{encounters.map((enc, i) => {
const isNext = i === 0
const isLast = i === encounters.length - 1
return (
<div
key={enc.id}
style={{
padding: '0.6rem 0.75rem',
borderBottom: isLast ? 'none' : '1px solid var(--border)',
background: isNext ? 'var(--surface-raised)' : 'transparent',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '0.5rem',
}}
>
<div style={{ overflow: 'hidden', flex: 1 }}>
<div
style={{
fontSize: '0.68rem',
color: isNext ? 'var(--text-secondary)' : 'var(--text-muted)',
letterSpacing: '0.04em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textTransform: 'uppercase',
}}
>
{enc.name}
</div>
<div
style={{
fontSize: '0.57rem',
color: 'var(--text-muted)',
letterSpacing: '0.06em',
marginTop: '2px',
}}
>
{enc.when} // {enc.id}
</div>
</div>
<Badge variant={isNext ? 'WARNING' : 'SCANNING'}>
{isNext ? 'NEXT' : 'SCHED'}
</Badge>
</div>
</div>
)
})}
</div>
</PanelContent>
</Panel>
<Panel notch="sm">
<PanelHeader>
<PanelTitle>SHIP DATA</PanelTitle>
</PanelHeader>
<PanelContent>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{shipData.map((item, i) => (
<div
key={item.label}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.4rem 0',
borderBottom:
i < shipData.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<span
style={{
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.1em',
}}
>
{item.label}
</span>
<span
style={{
fontSize: '0.78rem',
color: 'var(--color-green)',
textShadow: 'var(--text-glow-green)',
letterSpacing: '0.04em',
fontWeight: 600,
}}
>
{item.value}
{!system ? (
<div style={{ padding: '0.6rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
</div>
) : system.inventory.length === 0 ? (
<div style={{ padding: '0.6rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY — EXPLORE WORLDS TO FIND THINGS</span>
</div>
) : (
system.inventory.map((item) => (
<div key={`${item.kind}-${item.name}`} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{item.name} <span style={{ color: 'var(--text-muted)' }}>// {item.kind}</span>
</span>
<Badge variant="ACTIVE">×{item.qty}</Badge>
</div>
))}
</div>
))
)}
</PanelContent>
</Panel>
</div>
+120
View File
@@ -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>
)
}
+134
View File
@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
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
// column mirrors actions immediately; this panel applies each action's response directly via
// onChanged (no polling). Buttons are disabled when unaffordable, capped or gated by an
// unresearched tech, using the same pure helpers the API enforces (lib/game/ship-costs.ts +
// lib/game/tech.ts).
interface ShipSystemsPanelProps {
ship: ShipState | null
narrow?: boolean
onChanged: (next: ShipState) => void
researchedSlugs: Set<string> // slugs of researched techs — raises caps, lifts build gates
}
export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: ShipSystemsPanelProps) {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
if (!ship) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING SHIP SYSTEMS…</span>
</div>
)
}
const resources = totalQty(ship.inventory, 'resource')
const parts = totalQty(ship.inventory, 'part')
async function act(url: string, body: unknown) {
setBusy(true)
setError(null)
try {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = (await res.json()) as ShipState & { error?: string }
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
onChanged(data) // response is the fresh ship state — apply directly
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
{ship.systems.map((system) => (
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} researchedSlugs={researchedSlugs} onAct={act} />
))}
</div>
</div>
)
}
function SystemCard({
system,
resources,
parts,
busy,
researchedSlugs,
onAct,
}: {
system: ShipSystemDto
resources: number
parts: number
busy: boolean
researchedSlugs: Set<string>
onAct: (url: string, body: unknown) => void
}) {
const maxLevel = effectiveLevelCap(system.type, researchedSlugs)
const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs)
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 (
<Card>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{system.type}</CardTitle>
<Badge variant={system.built ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
{system.built ? `LV ${system.level}` : 'OFFLINE'}
</Badge>
</div>
</CardHeader>
<CardContent>
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
{system.built ? `LEVEL ${system.level} · CAPACITY ${system.capacity}` : 'NOT INSTALLED'}
</p>
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
{system.built
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
: `BUILD ${BUILD_PART_COST}× PARTS`}
</p>
{!system.built && gate !== null && (
<p style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>REQUIRES // {gate}</p>
)}
</CardContent>
<CardFooter className="gap-2">
{system.built ? (
<>
<Button variant="OUTLINE" size="SM" disabled={upgradeDisabled} onClick={() => onAct('/api/ship/upgrade', { systemId: system.id })}>
UPGRADE
</Button>
<Button variant="OUTLINE" size="SM" disabled={expandDisabled} onClick={() => onAct('/api/ship/expand', { systemId: system.id })}>
EXPAND
</Button>
</>
) : (
<Button variant="OUTLINE" size="SM" disabled={buildDisabled} onClick={() => onAct('/api/ship/build', { type: system.type })}>
BUILD NEW
</Button>
)}
</CardFooter>
</Card>
)
}
+110
View File
@@ -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>
)
}
+12 -1
View File
@@ -13,6 +13,7 @@ export interface NodeGraphNode {
y: number
status?: NodeStatus
sublabel?: string
disabled?: boolean // not clickable even when onNodeClick is set (e.g. the current system)
}
export interface NodeGraphEdge {
@@ -29,6 +30,7 @@ export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string
height?: number
directed?: boolean
onNodeClick?: (id: string) => void // makes non-disabled nodes clickable buttons
}
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
@@ -59,6 +61,7 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
title,
height = 320,
directed = false,
onNodeClick,
style,
...props
},
@@ -219,13 +222,21 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
const cy = nodePy(node)
const isHovered = hovered === node.id
const statusColor = node.status ? STATUS_COLOR[node.status] : color
const clickable = !!onNodeClick && !node.disabled
return (
<g
key={node.id}
onMouseEnter={() => setHovered(node.id)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: 'default' }}
onClick={clickable ? () => onNodeClick(node.id) : undefined}
onKeyDown={clickable
? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNodeClick(node.id) } }
: undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
aria-label={clickable ? node.label.toUpperCase() : undefined}
style={{ cursor: clickable ? 'pointer' : 'default' }}
>
{/* Background */}
<rect
+113
View File
@@ -0,0 +1,113 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { NodeGraph } from '@/components/ui/node-graph'
import type { WorldState } from '@/lib/game/save'
// WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the
// current one on the galactic chart jumps there (same POST /api/jump flow as before); the
// current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount,
// refetch via the jump response — no polling.
interface WorldMapPanelProps {
height?: number
}
export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
const [world, setWorld] = useState<WorldState | null>(null)
const [error, setError] = useState<string | null>(null)
const [jumpingId, setJumpingId] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetch('/api/world')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as WorldState
})
.then((state) => {
if (!cancelled) setWorld(state)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [])
const jump = useCallback(async (systemId: number) => {
setJumpingId(systemId)
try {
const res = await fetch('/api/jump', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ systemId }),
})
const body = (await res.json()) as WorldState & { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
setWorld(body) // fresh state straight from the response — no refetch needed
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setJumpingId(null)
}
}, [])
if (error && !world) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
CHART OFFLINE // {error.toUpperCase()}
</span>
</div>
)
}
if (!world) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING CHART…</span>
</div>
)
}
const current = world.systems.find((s) => s.current)!
// Systems one jump away — the only clickable chart fields (the same set as the old JUMP ROUTES list).
const reachableIds = new Set(
world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<NodeGraph
nodes={world.systems.map((s) => {
const jumpable = !s.current && reachableIds.has(s.id)
return {
id: String(s.id),
label: s.name,
x: s.x,
y: s.y,
status: s.current ? 'ACTIVE' : jumpable ? 'NEUTRAL' : 'OFFLINE',
sublabel: s.current ? 'CURRENT' : jumpable ? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
disabled: !jumpable || jumpingId !== null, // dimmed + not clickable; all locked while a jump is in flight
}
})}
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%' }}
onNodeClick={(id) => void jump(Number(id))}
/>
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
JUMP FAILED // {error.toUpperCase()}
</span>
</div>
)}
</div>
)
}
+29
View File
@@ -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;
}
+385 -1
View File
@@ -1,4 +1,51 @@
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.
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
interface InventoryItemDto { kind: string; name: string; qty: number }
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number }
interface FoundItem { kind: string; name: string; qty: number }
interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown }
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
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);
// 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 }) => {
await page.goto('/');
@@ -18,7 +65,344 @@ test('game template tabs render (scificn-ui component layer wired in)', async ({
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
await page.goto('/');
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
// The placeholder feed was replaced by the real log (v0.9.0): at least one T<turn> // <MESSAGE> line
// must render — every save has the turn-0 new_game event, so this holds on a fresh stack and across retries.
await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible();
});
test('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('/');
// Live chart title carries the turn counter.
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
// The current system is marked, and every adjacent system is a clickable chart field (role=button).
const current = world.systems.find((s) => s.current)!;
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 }) => {
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('/');
// Click the target's chart field — clickable nodes are role=button with their name as accessible label.
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
expect(after.turn).toBe(before.turn + 1);
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
});
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
await page.goto('/');
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
// Header carries the real system name + turn counter.
await expect(page.getByText(`EXPLORABLE WORLDS // ${sys.systemName.toUpperCase()} — TURN ${sys.turn}`)).toBeVisible();
// One EXPLORE button per unexplored body (planets and moons alike).
const exploreButtons = page.getByRole('button', { name: /\/\/ EXPLORE$/ });
await expect(exploreButtons.first()).toBeVisible();
await expect(exploreButtons).toHaveCount(sys.planets.filter((p) => !p.explored).length);
});
test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => {
const before = (await (await page.request.get('/api/system')).json()) as SystemState;
const target = before.planets.find((p) => !p.explored)!;
await page.goto('/');
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click();
const after = (await (await page.request.get('/api/system')).json()) as SystemState;
expect(after.turn).toBe(before.turn + 1);
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true);
// UI reflects it: the row badge flipped to SCANNED and its EXPLORE button is gone.
await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0);
await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible();
});
test('exploration yields finds that accumulate in the inventory', async ({ page }) => {
// Scan bodies until one yields something (most scans do; jumping keeps it going if a whole
// system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible.
const found = new Map<string, number>(); // kind|name -> qty
let iterations = 0;
while (found.size === 0 && iterations < 60) {
iterations++;
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
const unexplored = sys.planets.filter((p) => !p.explored);
if (unexplored.length > 0) {
const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } });
expect(res.status()).toBe(200);
for (const item of ((await res.json()) as ExploreResult).outcome) {
found.set(`${item.kind}|${item.name}`, item.qty);
}
} else {
// Current system fully scanned — jump somewhere and keep scanning.
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = world.systems.find((s) => s.current)!.id;
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
}
}
expect(found.size).toBeGreaterThan(0);
// Every find landed in the inventory with at least that qty.
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
for (const [key, qty] of found) {
const [kind, name] = key.split('|');
expect(sys.inventory.find((i) => i.kind === kind && i.name === name)?.qty).toBeGreaterThanOrEqual(qty);
}
});
test('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => {
const roster = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
expect(roster.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
await page.goto('/');
await page.getByRole('tab', { name: 'CREW' }).click();
for (const member of roster) {
const heading = page.getByRole('heading', { name: member.name });
await expect(heading).toBeVisible();
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
const card = heading.locator('xpath=ancestor::div[3]');
await expect(card.getByText(member.role)).toBeVisible();
await expect(card.getByText(member.traitGood)).toBeVisible();
await expect(card.getByText(member.traitBad)).toBeVisible();
await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible();
}
});
test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => {
// Scan bodies until something is affordable: an upgrade costs `level` resources (at least one),
// a build costs three parts. Jumping keeps it going if a whole system is exhausted.
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level);
// 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;
while (!upgradeTarget() && !buildTarget() && iterations < 60) {
iterations++;
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
const unexplored = sys.planets.filter((p) => !p.explored);
if (unexplored.length > 0) {
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
} else {
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = world.systems.find((s) => s.current)!.id;
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
}
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
}
const upgrade = upgradeTarget();
const build = !upgrade ? buildTarget() : undefined;
expect(upgrade ?? build).toBeDefined(); // the bounded scan always finds something to spend on
await page.goto('/');
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
if (upgrade) {
const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]');
await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled();
await card.getByRole('button', { name: 'UPGRADE' }).click();
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
const row = after.systems.find((s) => s.id === upgrade.id)!;
expect(row.level).toBe(upgrade.level + 1);
expect(after.turn).toBe(ship.turn + 1);
// The card badge flipped to the new level and the left column mirrors it.
await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible();
await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible();
} else {
const target = build!;
const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]');
await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
await card.getByRole('button', { name: 'BUILD NEW' }).click();
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
const row = after.systems.find((s) => s.id === target.id)!;
expect(row.built).toBe(true);
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 });
// The card badge flipped OFFLINE → LV 1 and the left column's online count went up.
await expect(card.getByText('LV 1')).toBeVisible();
await expect(page.getByText(`${after.systems.filter((s) => s.built).length} ONLINE`)).toBeVisible();
}
});
test('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 }) => {
+78
View File
@@ -1,6 +1,7 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
import { expect, test } from '@playwright/test';
import { jumpToFreshSystem } from './helpers';
// 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
@@ -8,6 +9,16 @@ import { expect, test } from '@playwright/test';
const repoRoot = path.join(__dirname, '..');
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
// Mirrors the /api/system response shape (lib/game/save.ts) — kept local so the spec stays self-contained.
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] }
interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string }
interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string }
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
test.setTimeout(180_000);
@@ -17,6 +28,43 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
const before = (await resBefore.json()) as { version: string };
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
// Game-state baseline: jump once so there is real progress that must survive the restart.
await jumpToFreshSystem(page);
// Exploration baseline: scan one body so explored state + inventory must survive too.
// (Earlier specs may have exhausted the current system, so jump until an unscanned body shows up.)
let sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
for (let i = 0; !sysBefore.planets.some((p) => !p.explored) && i < 20; i++) {
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
}
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
const exploreRes = await page.request.post('/api/explore', { data: { planetId: targetPlanet.id } });
expect(exploreRes.status()).toBe(200);
const sysExplored = (await exploreRes.json()) as SystemState;
// Crew baseline: the roster (starting crew + any finds so far) must survive too.
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
// Ship baseline: the systems (levels/capacities) + cargo hold must survive too.
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship is always seeded
// 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.
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
@@ -37,6 +85,36 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
expect(after).toBeDefined();
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
// Game state survived: same turn counter and current system as before the restart.
const worldAfter = (await (await page.request.get('/api/world')).json()) as { turn: number; systems: { id: number; current: boolean }[] };
expect(worldAfter.turn).toBe(sysExplored.turn);
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId);
// Exploration state survived too: the scanned body is still explored.
const sysAfter = (await (await page.request.get('/api/system')).json()) as SystemState;
expect(sysAfter.turn).toBe(sysExplored.turn);
expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true);
// Crew roster survived too: same members, traits and origins (no re-seed on restart).
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
expect(crewAfter).toEqual(crewBefore);
// Ship state survived too: same systems, levels/capacities and cargo hold.
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
expect(shipAfter).toEqual(shipBefore);
// 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.
await page.goto('/');
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
+108 -4
View File
@@ -14,12 +14,108 @@ export interface GameDb {
file: string;
}
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. Game tables land with
// the turn engine; _meta is DB plumbing (not game state): a brand-new sql.js database exports
// as ZERO bytes, so without at least one real table there would be nothing valid to persist
// on the ./db volume from first boot.
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. _meta is DB plumbing
// (not game state): a brand-new sql.js database exports as ZERO bytes, so without at least
// one real table there would be nothing valid to persist on the ./db volume from first boot.
const DDL = `
CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS saves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seed TEXT NOT NULL,
turn INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
schema_version INTEGER -- which save-schema version this row was backfilled up to (NULL = v1)
);
CREATE TABLE IF NOT EXISTS solar_systems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
name TEXT NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
discovered INTEGER NOT NULL DEFAULT 0,
is_current INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS jump_routes (
from_system INTEGER NOT NULL REFERENCES solar_systems(id),
to_system INTEGER NOT NULL REFERENCES solar_systems(id),
PRIMARY KEY (from_system, to_system)
);
CREATE TABLE IF NOT EXISTS planets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
system_id INTEGER NOT NULL REFERENCES solar_systems(id),
parent_id INTEGER, -- NULL for a planet, set to the parent planet's id for a moon
name TEXT NOT NULL,
type TEXT NOT NULL,
hazard INTEGER NOT NULL DEFAULT 0,
explored INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS inventory_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
kind TEXT NOT NULL, -- 'resource' | 'part'
name TEXT NOT NULL,
qty INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS crew (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
name TEXT NOT NULL,
role TEXT NOT NULL,
trait_good TEXT NOT NULL,
trait_bad TEXT NOT NULL,
origin TEXT NOT NULL -- 'STARTING CREW' or '<planet>, <system>' for members found while exploring
);
CREATE TABLE IF NOT EXISTS ship_systems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
type TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0, -- 0 = not built yet ('built' is derived: level > 0)
capacity INTEGER NOT NULL DEFAULT 0,
UNIQUE (save_id, type)
);
CREATE TABLE IF NOT EXISTS 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
turn INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
);
`;
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
@@ -39,6 +135,14 @@ async function open(file: string): Promise<GameDb> {
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
raw.exec(DDL); // no-op for tables that already exist in an older file
// Idempotent evolution for files created by older code: CREATE TABLE IF NOT EXISTS above cannot
// add columns, so check and add any that are missing. This is what makes DB wipes unnecessary —
// old saves open fine and get backfilled by ensureSave (see lib/game/save.ts).
const saveCols = raw.exec('PRAGMA table_info(saves)')[0]?.values.map((v) => v[1]) ?? [];
if (!saveCols.includes('schema_version')) {
raw.exec('ALTER TABLE saves ADD COLUMN schema_version INTEGER');
}
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
// First run: stamp when this DB was created and land the file on disk so the ./db volume
+135 -3
View File
@@ -1,6 +1,138 @@
// Drizzle schema for game state. EMPTY on purpose at scaffold stage (v0.1.0) — no game
// tables yet; the first turn-engine/ship/crew tables land with the first game systems.
import { integer, primaryKey, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
// Drizzle schema for game state. Single-player by design: exactly ONE save row ever exists
// (no auth/accounts), so every table hangs off saves.id but only one save is in play.
// Column names/types here are the source of truth; matching CREATE TABLE statements live
// in lib/db/client.ts (DDL) and must stay in sync with this file.
export {};
export const saves = sqliteTable('saves', {
id: integer('id').primaryKey({ autoIncrement: true }),
// Seed for the deterministic world generator — same seed always regenerates the same world.
seed: text('seed').notNull(),
turn: integer('turn').notNull().default(0),
createdAt: text('created_at').notNull(),
// Which schema version this save was last brought up to (NULL on saves from before the
// column existed = v1). ensureSave runs ordered idempotent backfill steps for anything below.
schemaVersion: integer('schema_version'),
})
export const solarSystems = sqliteTable('solar_systems', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
name: text('name').notNull(),
// 0–100 coordinate space, rendered directly by the NodeGraph component.
x: real('x').notNull(),
y: real('y').notNull(),
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
isCurrent: integer('is_current', { mode: 'boolean' }).notNull().default(false),
})
// Jump routes, stored as directed pairs (both directions of each route) so adjacency and
// "reachable from current" are single-row lookups. Read side dedupes for display.
export const jumpRoutes = sqliteTable(
'jump_routes',
{
fromSystem: integer('from_system').notNull().references(() => solarSystems.id),
toSystem: integer('to_system').notNull().references(() => solarSystems.id),
},
(t) => [primaryKey({ columns: [t.fromSystem, t.toSystem] })],
)
// Bodies of a solar system: planets (parent_id NULL) and moons (parent_id = their planet's
// id). Generated when the system is generated, so every system has 2–5 planets, some with
// 1–3 moons. Insertion order is display order — a moon always follows its parent row.
export const planets = sqliteTable('planets', {
id: integer('id').primaryKey({ autoIncrement: true }),
systemId: integer('system_id').notNull().references(() => solarSystems.id),
parentId: integer('parent_id'), // NULL for a planet, set for a moon
name: text('name').notNull(),
type: text('type').notNull(),
hazard: integer('hazard', { mode: 'boolean' }).notNull().default(false),
explored: integer('explored', { mode: 'boolean' }).notNull().default(false),
})
// Dumb-simple cargo hold on the save: what exploration has found so far. SHIP SYSTEMS (#8)
// will spend parts from here later; nothing consumes it yet.
export const inventoryItems = sqliteTable('inventory_items', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
kind: text('kind').notNull(), // 'resource' | 'part'
name: text('name').notNull(),
qty: integer('qty').notNull().default(1),
})
// The ship's crew roster: the starting crew (seeded on new game) plus members found while
// exploring planets/moons. Display-only for now — no dismiss/assign actions yet (#7 scope).
export const crew = sqliteTable('crew', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
name: text('name').notNull(),
role: text('role').notNull(),
traitGood: text('trait_good').notNull(),
traitBad: text('trait_bad').notNull(),
// Where the member came from: 'STARTING CREW' or '<planet>, <system>' for found members.
origin: text('origin').notNull(),
})
// The ship's systems, one row per known type (see SHIP_TYPES in lib/game/ship-costs.ts).
// `built` is derived, not stored: level > 0 means built — unbuilt rows sit at level/capacity 0.
export const shipSystems = sqliteTable(
'ship_systems',
{
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
type: text('type').notNull(),
level: integer('level').notNull().default(0), // 0 = not built yet
capacity: integer('capacity').notNull().default(0),
},
(t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)],
)
// 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
// writers and a read endpoint.
export const events = sqliteTable('events', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
turn: integer('turn').notNull(),
type: text('type').notNull(),
message: text('message').notNull(),
createdAt: text('created_at').notNull(),
})
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { CREW_CHANCE, generateStartingCrew, makeCrewName, rollCrewMember, startingCrewRng } from './crew'
import { explorationRng } from './world'
describe('generateStartingCrew', () => {
it('gives a new game 2–3 members, each with exactly one good and one bad trait', () => {
for (const seed of [1, 7, 42, 999]) {
const crew = generateStartingCrew(startingCrewRng(String(seed)))
expect(crew.length).toBeGreaterThanOrEqual(2)
expect(crew.length).toBeLessThanOrEqual(3)
for (const member of crew) {
expect(member.name.trim().length).toBeGreaterThan(0)
expect(member.role.trim().length).toBeGreaterThan(0)
expect(member.traitGood.trim().length).toBeGreaterThan(0)
expect(member.traitBad.trim().length).toBeGreaterThan(0)
}
// Names are unique within the crew; roles too (drawn without replacement).
expect(new Set(crew.map((m) => m.name)).size).toBe(crew.length)
expect(new Set(crew.map((m) => m.role)).size).toBe(crew.length)
}
})
it('is deterministic under a fixed seed and differs across seeds', () => {
const a = generateStartingCrew(startingCrewRng('42'))
expect(generateStartingCrew(startingCrewRng('42'))).toEqual(a) // same save → same crew
const b = generateStartingCrew(startingCrewRng('43'))
expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different crew
})
})
describe('rollCrewMember', () => {
it('finds a member with the documented probability and never reuses a taken name', () => {
const rng = startingCrewRng('42')
let found = 0
for (let i = 0; i < 1000; i++) found += rollCrewMember(rng, new Set<string>()) ? 1 : 0
// 18% ± a comfortable band — catches an inverted or missing chance without flaking.
expect(found).toBeGreaterThan(90)
expect(found).toBeLessThan(270)
const taken = new Set(['Ava Vance'])
for (let i = 0; i < 50; i++) {
const member = rollCrewMember(startingCrewRng(String(i)), taken)
if (member) expect(member.name).not.toBe('Ava Vance') // never reuses a roster name
}
})
it('is deterministic per save+planet when drawn from the exploration stream', () => {
const first = rollCrewMember(explorationRng('42', 13), new Set<string>())
expect(rollCrewMember(explorationRng('42', 13), new Set<string>())).toEqual(first) // same save+planet → same result
// Across many planets both outcomes occur (someone is found, someone isn't).
let found = 0
for (let planetId = 1; planetId <= 200; planetId++) {
if (rollCrewMember(explorationRng('42', planetId), new Set<string>())) found++
}
expect(found).toBeGreaterThan(0)
expect(found).toBeLessThan(200)
})
it('keeps the crew chance documented and sane', () => {
expect(CREW_CHANCE).toBeGreaterThanOrEqual(0.15)
expect(CREW_CHANCE).toBeLessThanOrEqual(0.2)
})
})
describe('makeCrewName', () => {
it('never reuses a taken name and always terminates', () => {
const rng = startingCrewRng('7')
const taken = new Set<string>()
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
expect(taken.size).toBe(40)
})
})
+90
View File
@@ -0,0 +1,90 @@
// Pure, seeded crew generation — no DB, no Date, no Math.random. Same seed → same starting
// crew; the exploration crew roll draws from the caller's per-planet rng stream so a found
// member is deterministic per save+planet (same derivation as the resource/part haul).
import { mulberry32 } from './world'
export interface CrewMember {
name: string
role: string
traitGood: string
traitBad: string
}
const FIRST_NAMES = [
'AVA', 'BEN', 'CASS', 'DANA', 'ELI', 'FERN', 'GIDEON', 'HALYA',
'IVAR', 'JUNO', 'KAI', 'LORE', 'MARA', 'NICO', 'ODESSA', 'PETRA',
]
const LAST_NAMES = [
'VANCE', 'OKONKWO', 'SORENSEN', 'DELACRUZ', 'HALLOWAY', 'IBARRA', 'KOVAC', 'LINDQVIST',
'MERCER', 'NADEAU', 'ORLOV', 'QUINTANA', 'REYES', 'STALKE', 'TANAKA', 'UNDERWOOD',
'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL',
]
const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN']
const GOOD_TRAITS = [
'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING',
'SHARP SHOOTER', 'READS STARS BLIND', 'QUICK HANDS', 'IRON WILL',
'FINDS HIDDEN THINGS', 'TALKS TO ANYONE',
]
const BAD_TRAITS = [
'RIGID — SLOW TO ADAPT', 'SHORT FUSE', 'OVERCAUTIOUS', 'CARELESS WITH FUSES',
'SLOWS THE CREW DOWN', 'ARGUES WITH EVERYONE', 'FORGETS TO EAT', 'JINXED IN A DOGFIGHT',
'HOARDS RATIONS', 'DOUBTS THE CAPTAIN',
]
const STARTING_CREW_MIN = 2
const STARTING_CREW_SPREAD = 2 // 2..3 starting members
export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans)
function title(word: string): string {
return word.charAt(0) + word.slice(1).toLowerCase()
}
function pick<T>(rng: () => number, arr: T[]): T {
return arr[Math.floor(rng() * arr.length)]
}
// A name unique against `taken` (the current roster), claimed into the set on success.
// 16×22 = 352 combinations — a roster realistically never approaches that size.
export function makeCrewName(rng: () => number, taken: Set<string>): string {
for (;;) {
const name = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
if (!taken.has(name)) {
taken.add(name)
return name
}
}
}
// The crew a new game starts with: 2–3 members, unique names and roles (roles are drawn
// without replacement). Pure — the caller derives the rng from the save seed.
export function generateStartingCrew(rng: () => number): CrewMember[] {
const taken = new Set<string>()
const roles = [...ROLES]
const crew: CrewMember[] = []
for (let i = 0; i < STARTING_CREW_MIN + Math.floor(rng() * STARTING_CREW_SPREAD); i++) {
const role = roles.splice(Math.floor(rng() * roles.length), 1)[0]
crew.push({ name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) })
}
return crew
}
// The exploration crew roll: with CREW_CHANCE a member is found and joins the ship. Draws
// from the SAME per-planet rng stream as the resource/part haul (called after it in
// save.ts), so the outcome stays deterministic per save+planet. `taken` is the current
// roster's names, keeping every name on the ship unique.
export function rollCrewMember(rng: () => number, taken: Set<string>): CrewMember | null {
if (rng() >= CREW_CHANCE) return null
const role = pick(rng, ROLES)
return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) }
}
// Independent PRNG stream for the starting crew of one save (same pattern as bodyRng /
// explorationRng in world.ts — a fixed offset keeps it away from every other derivation).
export function startingCrewRng(seed: string): () => number {
return mulberry32((Number(seed) + 0x51ed270b) >>> 0)
}
+68
View File
@@ -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']])
})
})
+40
View File
@@ -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 }))
}
+295
View File
@@ -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)
})
})
+201
View File
@@ -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))
}
}
+147
View File
@@ -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)
})
})
+79
View File
@@ -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)
}
+415
View File
@@ -0,0 +1,415 @@
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 { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
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 { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
const dirs: string[] = []
async function freshFile(): Promise<{ db: GameDb; file: string }> {
const dir = await mkdtemp(path.join(tmpdir(), 'sw-save-test-'))
dirs.push(dir)
const file = path.join(dir, 'test.db')
return { db: await openGameDb(file), file }
}
afterAll(async () => {
for (const d of dirs) await rm(d, { recursive: true, force: true })
})
function currentOf(world: WorldState): WorldState['systems'][number] {
const current = world.systems.filter((s) => s.current)
expect(current).toHaveLength(1)
return current[0]
}
describe('ensureSave / getWorldState', () => {
it('auto-initializes a save on first read: start + 6 neighbours, turn 0, new_game event', async () => {
const { db } = await freshFile()
const world = await getWorldState(db)
expect(world.turn).toBe(0)
expect(world.systems).toHaveLength(7)
expect(currentOf(world).discovered).toBe(true)
expect(world.systems.filter((s) => !s.discovered)).toHaveLength(6)
expect(world.edges).toHaveLength(12) // start↔each neighbour + ring cycle
const ids = new Set(world.systems.map((s) => s.id))
for (const [a, b] of world.edges) {
expect(ids.has(a)).toBe(true)
expect(ids.has(b)).toBe(true)
}
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
expect(rows[0].values).toEqual([['new_game', 0]])
})
it('is idempotent — a second read does not regenerate the world', async () => {
const { db } = await freshFile()
const first = await getWorldState(db)
const second = await getWorldState(db)
expect(second).toEqual(first)
})
})
describe('jumpToSystem validation', () => {
it('rejects unknown systems with 404 and jumping to the current system with 409', async () => {
const { db } = await freshFile()
const world = await getWorldState(db)
const start = currentOf(world)
await expect(jumpToSystem(db, 999_999)).rejects.toMatchObject({ status: 404 })
await expect(jumpToSystem(db, start.id)).rejects.toMatchObject({ status: 409 })
const after = await getWorldState(db)
expect(after.turn).toBe(0) // rejected jumps mutate nothing
expect(currentOf(after).id).toBe(start.id)
})
it('rejects non-adjacent systems with 409 and leaves state untouched', async () => {
const { db } = await freshFile()
const w0 = await getWorldState(db)
const start = currentOf(w0)
// Every initial neighbour is adjacent to the start, so jump once first.
const n0 = w0.systems.find((s) => !s.current)!
await jumpToSystem(db, n0.id)
const w1 = await getWorldState(db)
expect(currentOf(w1).id).toBe(n0.id)
// A system with no route to the current one (ring members two steps away qualify).
const adjacent = new Set(
w1.edges.filter(([a, b]) => a === n0.id || b === n0.id).flatMap(([a, b]) => [a, b]),
)
const far = w1.systems.find((s) => !adjacent.has(s.id))!
await expect(jumpToSystem(db, far.id)).rejects.toMatchObject({ status: 409 })
const after = await getWorldState(db)
expect(after.turn).toBe(1)
expect(currentOf(after).id).toBe(n0.id)
})
})
describe('jumpToSystem success path', () => {
it('sets current, increments turn, discovers the target and grows the chart', async () => {
const { db } = await freshFile()
const w0 = await getWorldState(db)
const start = currentOf(w0)
const target = w0.systems.find((s) => !s.current)! // all initial neighbours are adjacent to the start
const after = await jumpToSystem(db, target.id)
expect(after.turn).toBe(1)
expect(currentOf(after).id).toBe(target.id)
expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true)
// Expansion: 4 new undiscovered neighbours around the target, each routed to it.
const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id))
expect(fresh).toHaveLength(4)
expect(fresh.every((s) => !s.discovered)).toBe(true)
for (const n of fresh) {
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true)
}
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
expect(rows[0].values).toEqual([['new_game', 0], ['jump', 1]])
})
it('persists the current system and turn across a DB reopen (container restart)', async () => {
const { db, file } = await freshFile()
const w0 = await getWorldState(db)
const target = w0.systems.find((s) => !s.current)!
const after = await jumpToSystem(db, target.id)
const reopened = await openGameDb(file) // a new process would see the same file
const again = await getWorldState(reopened)
expect(again.turn).toBe(after.turn)
expect(currentOf(again).id).toBe(target.id)
expect(again.systems).toHaveLength(after.systems.length)
})
})
describe('getSystemState', () => {
it('generates bodies for every system on new game; moons nest under a parent planet only', async () => {
const { db } = await freshFile()
const state = await getSystemState(db)
// Current (start) system: 2–5 planets, all unexplored yet.
const planetRows = state.planets.filter((p) => p.parent === null)
expect(planetRows.length).toBeGreaterThanOrEqual(2)
expect(planetRows.length).toBeLessThanOrEqual(5)
const planetIds = new Set(planetRows.map((p) => p.id))
for (const row of state.planets) {
if (row.parent !== null) expect(planetIds.has(row.parent)).toBe(true) // moons hang off a planet in the same system
expect(row.explored).toBe(false)
}
// Every one of the 7 fresh-save systems got bodies.
const counts = db.raw.exec('SELECT system_id, COUNT(*) FROM planets GROUP BY system_id')
expect(counts[0].values).toHaveLength(7)
for (const [, n] of counts[0].values) expect(Number(n)).toBeGreaterThanOrEqual(2)
expect(state.inventory).toEqual([]) // nothing found before any scan
expect(state.turn).toBe(0)
})
})
describe('explorePlanet', () => {
it('rejects unknown planets with 404 and already-explored ones with 409, mutating nothing on failure', async () => {
const { db } = await freshFile()
const state = await getSystemState(db)
const target = state.planets[0]
await expect(explorePlanet(db, 999_999)).rejects.toMatchObject({ status: 404 })
await explorePlanet(db, target.id)
await expect(explorePlanet(db, target.id)).rejects.toMatchObject({ status: 409 })
const after = await getSystemState(db)
expect(after.turn).toBe(1) // only the successful scan costs a turn
expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true)
})
it('marks explored, bumps the turn, lands finds in inventory and writes an event', async () => {
const { db } = await freshFile()
const state = await getSystemState(db)
const target = state.planets[0]
const result = await explorePlanet(db, target.id)
expect(result.turn).toBe(1)
// Fresh save: inventory is exactly this scan's haul.
for (const item of result.outcome) {
expect(result.inventory.find((i) => i.kind === item.kind && i.name === item.name)?.qty).toBe(item.qty)
}
expect(result.inventory.length).toBe(result.outcome.length)
const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id')
expect(rows[0].values).toEqual([['new_game', 0], ['explore', 1]])
})
it('is deterministic per save+planet and accumulates qty across scans (known seed)', async () => {
// Hand-built save with a known seed so every outcome is recomputable offline.
const { db } = await freshFile()
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: new Date().toISOString() }).returning()
const [sys] = await db.db
.insert(schema.solarSystems)
.values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true })
.returning({ id: schema.solarSystems.id })
const planetIds: number[] = []
for (const body of generatePlanets(bodyRng('42', sys.id))) {
const [p] = await db.db
.insert(schema.planets)
.values({ systemId: sys.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard })
.returning({ id: schema.planets.id })
planetIds.push(p.id)
}
const expected = planetIds.map((id) => rollExploration(explorationRng('42', id)))
for (let i = 0; i < planetIds.length; i++) {
const result = await explorePlanet(db, planetIds[i])
expect(result.outcome).toEqual(expected[i]) // same save+planet → same haul
}
// Inventory accumulates: qty per kind+name equals the sum over all scans.
const totals = new Map<string, number>()
for (const items of expected) {
for (const it of items) totals.set(`${it.kind}|${it.name}`, (totals.get(`${it.kind}|${it.name}`) ?? 0) + it.qty)
}
const state = await getSystemState(db)
expect(state.inventory.length).toBe(totals.size)
for (const item of state.inventory) {
expect(item.qty).toBe(totals.get(`${item.kind}|${item.name}`))
}
expect(state.turn).toBe(planetIds.length) // one turn per scan
})
})
describe('getCrewState', () => {
it('seeds a starting crew of 2–3 members derived from the save seed', async () => {
const { db } = await freshFile()
const crew = await getCrewState(db) // ensureSave runs inside, so this also seeds the crew
expect(crew.length).toBeGreaterThanOrEqual(2)
expect(crew.length).toBeLessThanOrEqual(3)
for (const member of crew) {
expect(member.origin).toBe('STARTING CREW')
expect(member.name.trim()).not.toBe('')
expect(member.role.trim()).not.toBe('')
expect(member.traitGood.trim()).not.toBe('')
expect(member.traitBad.trim()).not.toBe('')
}
// The roster is exactly the pure generation for this save's seed.
const [save] = await db.db.select().from(schema.saves)
const expected = generateStartingCrew(startingCrewRng(save.seed))
expect(crew.map((c) => c.name)).toEqual(expected.map((m) => m.name))
expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role))
expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad]))
})
})
describe('explorePlanet crew finds', () => {
it('lands a found member in the roster with the planet+system origin and never joins twice (known seed)', async () => {
// Hand-built save with a known seed so the crew roll is recomputable offline.
const { db } = await freshFile()
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: new Date().toISOString() }).returning()
const [sys] = await db.db
.insert(schema.solarSystems)
.values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true })
.returning({ id: schema.solarSystems.id })
// Find a small planet id whose exploration roll finds crew (deterministic — recomputed here,
// mirroring save.ts: the item haul draws first, then the crew roll on the same stream).
let crewPlanetId = -1
for (let id = 1; id <= 50 && crewPlanetId === -1; id++) {
const rng = explorationRng('42', id)
rollExploration(rng)
if (rollCrewMember(rng, new Set<string>())) crewPlanetId = id
}
expect(crewPlanetId).toBeGreaterThan(0)
const planetName = `P${crewPlanetId}`
for (let i = 1; i <= crewPlanetId; i++) {
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
}
const before = await getCrewState(db) // empty — this save was hand-built, no starting crew seeded
const result = await explorePlanet(db, crewPlanetId)
expect(result.crewFound).not.toBeNull()
const expectedRng = explorationRng('42', crewPlanetId)
rollExploration(expectedRng) // consume the item haul draws first, exactly as save.ts does
const expectedMember = rollCrewMember(expectedRng, new Set<string>())! // same save+planet → same member
expect(result.crewFound!.name).toBe(expectedMember.name)
expect(result.crewFound!.role).toBe(expectedMember.role)
expect([result.crewFound!.traitGood, result.crewFound!.traitBad]).toEqual([expectedMember.traitGood, expectedMember.traitBad])
expect(result.crewFound!.origin).toBe(`${planetName}, Test System`)
const after = await getCrewState(db)
expect(after.length).toBe(before.length + 1) // exactly one join
expect(after.find((c) => c.id === result.crewFound!.id)).toMatchObject({ name: expectedMember.name, origin: `${planetName}, Test System` })
// The body is already explored → a second scan is rejected and no duplicate joins.
await expect(explorePlanet(db, crewPlanetId)).rejects.toMatchObject({ status: 409 })
expect((await getCrewState(db)).length).toBe(before.length + 1)
})
})
describe('backfill (old saves)', () => {
// Planets/moons of one system in insertion order, nested by parent — ids are excluded so a
// regenerated set compares equal to the original even though autoincrement moved on.
function bodyShape(rows: { id: number; name: string; type: string; hazard: boolean; parentId: number | null }[]) {
return rows
.filter((r) => r.parentId === null)
.map((p) => ({ name: p.name, type: p.type, hazard: p.hazard, moons: rows.filter((m) => m.parentId === p.id).map((m) => [m.name, m.type, m.hazard]) }))
}
it('upgrades a v1 save (no bodies, no crew) in place — deterministic and idempotent', async () => {
const { db } = await freshFile()
await getWorldState(db) // new game: everything seeded at the current version
const systems = await db.db.select().from(schema.solarSystems)
const rowsFor = (systemId: number) => db.db.select().from(schema.planets).where(eq(schema.planets.systemId, systemId))
const beforeBodies: Record<number, unknown> = {}
for (const s of systems) {
const rows = await rowsFor(s.id)
beforeBodies[s.id] = bodyShape(rows.sort((a, b) => a.id - b.id))
}
const crewOf = (rows: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }[]) =>
rows.map((c) => ({ name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) // ids excluded — re-seed moves autoincrement on
const beforeCrew = crewOf(await db.db.select().from(schema.crew))
const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) =>
rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on
const beforeShip = shipOf(await db.db.select().from(schema.shipSystems))
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.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.crew)
await db.db.delete(schema.shipSystems)
await db.db.delete(schema.tech)
await db.db.update(schema.saves).set({ schemaVersion: null })
const world = await getWorldState(db) // ensureSave → backfill runs
expect(world.systems).toHaveLength(7)
for (const s of systems) {
const rows = await rowsFor(s.id)
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id]) // identical to fresh generation
}
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
const [save] = await db.db.select().from(schema.saves)
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
// Idempotent: a second pass changes nothing.
const planetCount = (await db.db.select().from(schema.planets)).length
await getWorldState(db)
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
for (const s of systems) {
const rows = await rowsFor(s.id)
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
}
})
it('leaves an up-to-date save untouched', async () => {
const { db } = await freshFile()
await getWorldState(db)
const planetCount = (await db.db.select().from(schema.planets)).length
const [save] = await db.db.select().from(schema.saves)
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
await getWorldState(db) // second read: no backfill work to do
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
})
it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
const { db } = await freshFile()
await getWorldState(db)
const rows = (await db.db.select().from(schema.shipSystems)).sort((a, b) => a.type.localeCompare(b.type))
expect(rows).toHaveLength(SHIP_TYPES.length)
for (const def of SHIP_TYPES) {
const row = rows.find((r) => r.type === def.type)!
if (def.builtAtStart) {
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
} else {
expect(row.level).toBe(0) // unbuilt until a BUILD action
expect(row.capacity).toBe(0)
}
}
})
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
}
})
})
+372
View File
@@ -0,0 +1,372 @@
import { eq } from 'drizzle-orm'
import type { GameDb } from '@/lib/db/client'
import { persist } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
import { 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'
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
// Single-player by design — exactly one save row ever exists, so every query below is
// unscoped (there is nothing to scope against).
export interface WorldSystemDto {
id: number
name: string
x: number
y: number
discovered: boolean
current: boolean
}
export interface WorldState {
systems: WorldSystemDto[]
edges: [number, number][] // undirected system-id pairs, deduped + sorted for stable output
turn: number
}
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
export class JumpError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
function newSeed(): string {
const buf = new Uint32Array(1)
crypto.getRandomValues(buf)
return String(buf[0])
}
// Deterministic per save+turn: the whole world of a seed is reproducible from (seed, turn).
function expansionRng(seed: string, turn: number): () => number {
return mulberry32((Number(seed) + turn * 1_000_003) >>> 0)
}
// The bodies of one system (planets + moons), derived from (seed, system id) via bodyRng —
// independent of generation order and every other system. Shared by new-game creation and the
// v2 backfill so both produce identical rows for a given save+system.
async function insertBodiesForSystem(db: GameDb, systemId: number, seed: string): Promise<void> {
for (const body of generatePlanets(bodyRng(seed, systemId))) {
const [planet] = await db.db.insert(schema.planets).values({ systemId, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id })
for (const moon of body.moons) {
await db.db.insert(schema.planets).values({ systemId, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
}
}
}
// Inserts one system together with its generated bodies, so a system is never left without
// worlds to explore.
async function insertSystemWithBodies(db: GameDb, saveId: number, ws: { name: string; x: number; y: number }, seed: string): Promise<number> {
const [row] = await db.db.insert(schema.solarSystems).values({ saveId, name: ws.name, x: ws.x, y: ws.y }).returning({ id: schema.solarSystems.id })
await insertBodiesForSystem(db, row.id, seed)
return row.id
}
// v2 backfill: every system without planets gets its generated bodies. Idempotent — systems
// that already have rows are skipped, so a second run changes nothing.
async function ensureBodiesForAllSystems(db: GameDb, saveId: number, seed: string): Promise<void> {
const systems = await db.db.select().from(schema.solarSystems)
for (const system of systems) {
const rows = await db.db.select({ id: schema.planets.id }).from(schema.planets).where(eq(schema.planets.systemId, system.id))
if (rows.length === 0) await insertBodiesForSystem(db, system.id, seed)
}
}
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
for (const [a, b] of edges) {
const from = resolve(a)
const to = resolve(b)
await db.db.insert(schema.jumpRoutes).values({ fromSystem: from, toSystem: to })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: to, toSystem: from }) // both directions → adjacency is a single-row lookup
}
}
// Seeds the starting crew for a fresh save: 2–3 members derived from the seed alone.
async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promise<void> {
for (const member of generateStartingCrew(startingCrewRng(seed))) {
await db.db.insert(schema.crew).values({ saveId, name: member.name, role: member.role, traitGood: member.traitGood, traitBad: member.traitBad, origin: 'STARTING CREW' })
}
}
// What each schema version added to a save. Saves created by older code lack the data newer
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
// current — each step is idempotent (a no-op when its data already exists).
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
// Seeds the starting ship: one row per known type — core types built at base values, the rest
// unbuilt (level/capacity 0) until a BUILD action.
async function seedStartingShip(db: GameDb, saveId: number): Promise<void> {
for (const def of SHIP_TYPES) {
await db.db.insert(schema.shipSystems).values({ saveId, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 })
}
}
// 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.
// Safe to call on every request (single player, one process: check-then-insert cannot race).
export async function ensureSave(db: GameDb): Promise<void> {
const existing = await db.db.select().from(schema.saves).limit(1)
if (existing.length === 0) {
await createNewGame(db)
return
}
await backfillSave(db, existing[0])
}
async function createNewGame(db: GameDb): Promise<void> {
const seed = newSeed()
const region = generateStartRegion(mulberry32(Number(seed)))
const nowIso = new Date().toISOString()
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso, schemaVersion: SCHEMA_VERSION }).returning()
// All inserted as undiscovered first; the start is then marked discovered + current.
const startId = await insertSystemWithBodies(db, save.id, region.start, seed)
const neighborIds: number[] = []
for (const n of region.neighbors) neighborIds.push(await insertSystemWithBodies(db, save.id, n, seed))
await db.db.update(schema.solarSystems).set({ discovered: true, isCurrent: true }).where(eq(schema.solarSystems.id, startId))
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
await insertRoutes(db, region.edges, resolve)
await seedStartingCrew(db, save.id, seed)
await 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 persist(db)
}
// Ordered idempotent backfill for a save created by older code. A NULL version means v1.
async function backfillSave(db: GameDb, save: { id: number; seed: string; schemaVersion: number | null }): Promise<void> {
const from = save.schemaVersion ?? 1
let version = from
if (version < 2) {
await ensureBodiesForAllSystems(db, save.id, save.seed) // bodies derive from (seed, system id) — identical to fresh generation
version = 2
}
if (version < 3) {
if ((await db.db.select().from(schema.crew)).length === 0) await seedStartingCrew(db, save.id, save.seed)
version = 3
}
if (version < 4) {
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
version = 4
}
if (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) {
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
await persist(db)
}
}
export async function getWorldState(db: GameDb): Promise<WorldState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const routes = await db.db.select().from(schema.jumpRoutes)
// Dedupe the stored directed pairs into sorted undirected edges.
const seen = new Set<string>()
const edges: [number, number][] = []
for (const r of routes) {
const a = Math.min(r.fromSystem, r.toSystem)
const b = Math.max(r.fromSystem, r.toSystem)
const key = `${a}-${b}`
if (!seen.has(key)) {
seen.add(key)
edges.push([a, b])
}
}
return {
systems: systems.map((s) => ({ id: s.id, name: s.name, x: s.x, y: s.y, discovered: s.discovered, current: s.isCurrent })),
edges,
turn: save.turn,
}
}
export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const target = systems.find((s) => s.id === systemId)
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
const current = systems.find((s) => s.isCurrent)!
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
const routes = await db.db.select().from(schema.jumpRoutes)
const adjacent = routes.some((r) => r.fromSystem === current.id && r.toSystem === target.id)
if (!adjacent) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
const turn = save.turn + 1
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
if (!target.discovered) {
// Growing the chart: a ring of new (undiscovered) neighbours around the newly reached
// system, so there is always somewhere to jump next and a route back.
const rng = expansionRng(save.seed, turn)
const takenNames = new Set(systems.map((s) => s.name))
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
const ringIds: number[] = []
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
await insertRoutes(db, ring.edges, resolve)
}
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
await topUpMissionBoard(db)
await persist(db)
return getWorldState(db)
}
// --- Local system + exploration ---------------------------------------------
export interface PlanetDto {
id: number
name: string
type: string
hazard: boolean
explored: boolean
parent: number | null // NULL for a planet, the parent planet's id for a moon
}
export interface InventoryItemDto {
kind: 'resource' | 'part'
name: string
qty: number
}
export interface SystemState {
systemId: number
systemName: string
planets: PlanetDto[] // insertion order = display order (a moon follows its parent row)
inventory: InventoryItemDto[]
turn: number
}
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
export class ExploreError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
async function readSystemState(db: GameDb): Promise<SystemState> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const current = systems.find((s) => s.isCurrent)!
const planets = (await db.db.select().from(schema.planets).where(eq(schema.planets.systemId, current.id))).map((p) => ({
id: p.id,
name: p.name,
type: p.type,
hazard: p.hazard,
explored: p.explored,
parent: p.parentId,
}))
const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty }))
return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn }
}
export async function getSystemState(db: GameDb): Promise<SystemState> {
await ensureSave(db)
return readSystemState(db)
}
// --- Crew roster --------------------------------------------------------------
export interface CrewDto {
id: number
name: string
role: string
traitGood: string
traitBad: string
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring
}
// The full roster in join order (starting crew first, then finds). Auto-initializes like the other reads.
export async function getCrewState(db: GameDb): Promise<CrewDto[]> {
await ensureSave(db)
return (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
}
export interface ExploreResult extends SystemState {
outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing)
crewFound: CrewDto | null // a member who joined the ship on this scan (null when none)
}
// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored,
// costs a turn, accumulates finds into inventory and writes an event. The same per-planet
// rng stream also rolls for a crew member (~18% of scans) who joins the roster. Any existing
// planet may be scanned (the UI only offers the current system's rows).
export async function explorePlanet(db: GameDb, planetId: number): Promise<ExploreResult> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const planets = await db.db.select().from(schema.planets)
const target = planets.find((p) => p.id === planetId)
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
const rng = explorationRng(save.seed, target.id)
const outcome = rollExploration(rng) // resource/part haul first…
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream
const turn = save.turn + 1
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
for (const item of outcome) {
// Accumulate: the same kind+name on this save bumps qty instead of adding a row.
const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === item.kind && i.name === item.name)
if (existing) {
await db.db.update(schema.inventoryItems).set({ qty: existing.qty + item.qty }).where(eq(schema.inventoryItems.id, existing.id))
} else {
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty })
}
}
let crewFound: CrewDto | null = null
if (foundMember) {
const systems = await db.db.select().from(schema.solarSystems)
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
}
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
await topUpMissionBoard(db)
await persist(db)
return { ...(await readSystemState(db)), outcome, crewFound }
}
+48
View File
@@ -0,0 +1,48 @@
// Pure ship-system model: the known system types, base values and the action cost model.
// No DB imports — both lib/game/ship.ts (server) and the frontend import from here so the
// UI's affordability/cap logic can never drift from what the API enforces.
export interface ShipTypeDef {
type: string
builtAtStart: boolean // part of the starting ship? (otherwise unbuilt until BUILD)
}
// The known system types — display order. Reuses the placeholder SHIP SYSTEMS tab card names
// for continuity, plus HULL so the vessel-metrics hull bar can be driven by a real level.
export const SHIP_TYPES: ShipTypeDef[] = [
{ type: 'HULL', builtAtStart: true },
{ type: 'REACTOR', builtAtStart: true },
{ type: 'LIFE SUPPORT', builtAtStart: true },
{ type: 'NAV COMPUTER', builtAtStart: true },
{ type: 'COMMS ARRAY', builtAtStart: true },
{ type: 'WEAPONS', builtAtStart: false },
]
export const BASE_LEVEL = 1 // what a system starts at when built (starting ship or BUILD)
export const BASE_CAPACITY = 2
export const MAX_LEVEL = 5 // UPGRADE cap
export const MAX_CAPACITY = 10 // EXPAND cap
// Display-only cap for the VESSEL METRICS crew roster bar (no gameplay effect yet).
export const CREW_CAPACITY = 6
// Cost model — all costs are in inventory items, consumed from the oldest cargo row of the
// required kind first (see spendItems in lib/game/ship.ts):
// UPGRADE {systemId} — `level` units of any resource (scales with current level)
// EXPAND {systemId} — `capacity` units of any resource (scales with current capacity)
// BUILD {type} — a flat amount of parts (parts are rarer than resources, so builds
// are the bigger investment)
export function upgradeCost(level: number): number {
return level
}
export function expandCost(capacity: number): number {
return capacity
}
export const BUILD_PART_COST = 3
// Total qty of one kind across the cargo hold — used for affordability checks.
export function totalQty(items: { kind: string; qty: number }[], kind: 'resource' | 'part'): number {
return items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0)
}
+157
View File
@@ -0,0 +1,157 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { BASE_CAPACITY, BASE_LEVEL, BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES } from './ship-costs'
import { buildSystem, expandSystem, getShipState, upgradeSystem, type ShipState } from './ship'
const dirs: string[] = []
async function freshFile(): Promise<{ db: GameDb; file: string }> {
const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-test-'))
dirs.push(dir)
const file = path.join(dir, 'test.db')
return { db: await openGameDb(file), file }
}
afterAll(async () => {
for (const d of dirs) await rm(d, { recursive: true, force: true })
})
async function addItem(db: GameDb, kind: 'resource' | 'part', name: string, qty: number): Promise<void> {
const [save] = await db.db.select().from(schema.saves).limit(1)
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind, name, qty })
}
const systemOf = (state: ShipState, type: string): ShipState['systems'][number] => state.systems.find((s) => s.type === type)!
describe('getShipState', () => {
it('new game: starting ship in display order — core types built at base values, WEAPONS unbuilt', async () => {
const { db } = await freshFile()
const state = await getShipState(db)
expect(state.systems.map((s) => s.type)).toEqual(SHIP_TYPES.map((t) => t.type)) // display order follows SHIP_TYPES
for (const def of SHIP_TYPES) {
const row = systemOf(state, def.type)
if (def.builtAtStart) {
expect(row.built).toBe(true)
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
} else {
expect(row.built).toBe(false) // unbuilt until a BUILD action
expect(row.level).toBe(0)
expect(row.capacity).toBe(0)
}
}
expect(state.inventory).toEqual([])
expect(state.turn).toBe(0)
})
})
describe('upgradeSystem', () => {
it('rejects with 422 and mutates nothing when the hold is empty', async () => {
const { db } = await freshFile()
const before = await getShipState(db)
const reactorId = systemOf(before, 'REACTOR').id
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 422 })
const after = await getShipState(db)
expect(systemOf(after, 'REACTOR').level).toBe(BASE_LEVEL) // no level change…
expect(after.turn).toBe(before.turn) // …and no turn spent on a failed action
})
it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => {
const { db } = await freshFile()
await getShipState(db)
await expect(upgradeSystem(db, 999)).rejects.toMatchObject({ status: 404 })
const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id
await expect(upgradeSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 })
})
it('consumes the cost from the oldest resource row first (deleting rows that hit zero), costs a turn and writes an event', async () => {
const { db } = await freshFile()
await getShipState(db)
await addItem(db, 'resource', 'IRON ORE', 1) // oldest — fully consumed by the cost of 1
await addItem(db, 'resource', 'CRYSTAL DUST', 5)
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
const state = await upgradeSystem(db, reactorId) // level 1 → cost 1 resource
expect(systemOf(state, 'REACTOR').level).toBe(BASE_LEVEL + 1)
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 5]]) // IRON ORE row deleted
expect(state.turn).toBe(1)
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'upgrade')!
expect(event.turn).toBe(1)
expect(event.message).toContain('REACTOR')
})
it('caps at MAX_LEVEL with 409', async () => {
const { db } = await freshFile()
await getShipState(db)
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) // cost path covered above
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 })
expect((await getShipState(db)).turn).toBe(0)
})
})
describe('expandSystem', () => {
it('raises capacity by one for `capacity` resources; caps at MAX_CAPACITY with 409', async () => {
const { db } = await freshFile()
await getShipState(db)
await addItem(db, 'resource', 'IRON ORE', BASE_CAPACITY + 1) // cost is the current capacity (2), one left over
const hullId = systemOf(await getShipState(db), 'HULL').id
const state = await expandSystem(db, hullId)
expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1)
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]])
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'expand')!
expect(event.turn).toBe(1)
expect(event.message).toContain('HULL')
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 })
})
it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => {
const { db } = await freshFile()
await getShipState(db)
await expect(expandSystem(db, 999)).rejects.toMatchObject({ status: 404 })
const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id
await expect(expandSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 })
})
})
describe('buildSystem', () => {
it('builds an unbuilt type at base values for a flat parts cost; a second build is 409 and unknown types are 404', async () => {
const { db } = await freshFile()
await getShipState(db)
// 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 addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST)
const state = await buildSystem(db, 'WEAPONS')
const weapons = systemOf(state, 'WEAPONS')
expect(weapons.built).toBe(true)
expect({ level: weapons.level, capacity: weapons.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // parts row deleted
expect(state.turn).toBe(1)
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
expect(event.turn).toBe(1)
expect(event.message).toContain('WEAPONS')
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type
})
})
+136
View File
@@ -0,0 +1,136 @@
import { eq } from 'drizzle-orm'
import * as schema from '@/lib/db/schema'
import { persist, type GameDb } from '@/lib/db/client'
import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs'
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from './tech'
import { ensureSave, type InventoryItemDto } from './save'
export interface ShipSystemDto {
id: number
type: string
level: number
capacity: number
built: boolean // derived — level > 0 (unbuilt rows sit at level/capacity 0)
}
// The ship's full state for the SHIP SYSTEMS tab and left column.
export interface ShipState {
systems: ShipSystemDto[] // display order follows SHIP_TYPES
inventory: InventoryItemDto[]
turn: number
}
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
export class ShipError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}
async function readShipState(db: GameDb): Promise<ShipState> {
const [save] = await db.db.select().from(schema.saves).limit(1)
const rows = await db.db.select().from(schema.shipSystems)
const orderOf = (type: string) => {
const i = SHIP_TYPES.findIndex((t) => t.type === type)
return i === -1 ? 99 : i // unknown types (shouldn't exist) sort last
}
const systems = [...rows]
.sort((a, b) => orderOf(a.type) - orderOf(b.type))
.map((r) => ({ id: r.id, type: r.type, level: r.level, capacity: r.capacity, built: r.level > 0 }))
const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty }))
return { systems, inventory, turn: save.turn }
}
// The ship's systems + cargo hold + turn. Auto-initializes like the other reads.
export async function getShipState(db: GameDb): Promise<ShipState> {
await ensureSave(db)
return readShipState(db)
}
// Consume `amount` units of one kind from the oldest cargo rows first (id order); rows that hit
// zero are deleted. The total is checked up front, so an insufficient hold mutates nothing.
// 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)
if (rows.reduce((sum, r) => sum + r.qty, 0) < amount) {
throw new ShipError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
}
let remaining = amount
for (const row of rows) {
if (remaining <= 0) break
const take = Math.min(row.qty, remaining)
remaining -= take
if (take >= row.qty) await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.id, row.id))
else await db.db.update(schema.inventoryItems).set({ qty: row.qty - take }).where(eq(schema.inventoryItems.id, row.id))
}
}
async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type: string, message: string): Promise<void> {
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, saveId))
await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() })
}
// 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> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
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)
await spendItems(db, save.id, 'resource', cost)
const turn = save.turn + 1
await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id))
await bumpTurnAndEvent(db, save.id, turn, 'upgrade', `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`)
await persist(db)
return readShipState(db)
}
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). Cost: `capacity` units of any resource.
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
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)
await spendItems(db, save.id, 'resource', cost)
const turn = save.turn + 1
await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id))
await bumpTurnAndEvent(db, save.id, turn, 'expand', `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`)
await persist(db)
return readShipState(db)
}
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. 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> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
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)
const turn = save.turn + 1
await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id))
await bumpTurnAndEvent(db, save.id, turn, 'build', `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`)
await persist(db)
return readShipState(db)
}
+102
View File
@@ -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
}
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world'
describe('generateStartRegion', () => {
const region = generateStartRegion(mulberry32(42))
it('produces a starting system plus six neighbours', () => {
expect(region.neighbors).toHaveLength(6)
expect(region.start.x).toBe(50)
expect(region.start.y).toBe(50)
})
it('gives every system a unique name and in-bounds coordinates', () => {
const all = [region.start, ...region.neighbors]
const names = all.map((s) => s.name)
expect(new Set(names).size).toBe(names.length)
for (const s of all) {
expect(s.x).toBeGreaterThanOrEqual(0)
expect(s.x).toBeLessThanOrEqual(100)
expect(s.y).toBeGreaterThanOrEqual(0)
expect(s.y).toBeLessThanOrEqual(100)
}
})
it('connects the start to every neighbour and closes the ring cycle', () => {
const n = region.neighbors.length
// anchor↔each member (n edges) + ring cycle (n edges)
expect(region.edges).toHaveLength(n * 2)
for (let i = 0; i < n; i++) {
expect(region.edges).toContainEqual([0, i + 1])
expect(region.edges).toContainEqual([i + 1, ((i + 1) % n) + 1])
}
// every edge endpoint is a valid index into [start, ...neighbors]
for (const [a, b] of region.edges) {
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThan(n + 1)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThan(n + 1)
}
})
it('is deterministic under a fixed seed and differs across seeds', () => {
const again = generateStartRegion(mulberry32(42))
expect(again).toEqual(region)
const other = generateStartRegion(mulberry32(43))
expect(other.start.name !== region.start.name || other.neighbors[0].name !== region.neighbors[0].name).toBe(true)
})
})
describe('generateRing (expansion)', () => {
it('adds four systems spaced away from the anchor and existing positions', () => {
const takenNames = new Set(['Kepler Drift'])
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
expect(ring.systems).toHaveLength(4)
const ringNames = ring.systems.map((s) => s.name)
for (const s of ring.systems) {
expect(Math.hypot(s.x - 50, s.y - 50)).toBeGreaterThanOrEqual(16)
expect(s.name).not.toBe('Kepler Drift') // never reuses a pre-taken name
}
expect(new Set(ringNames).size).toBe(4) // and makeName claimed them into the set
for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
expect(ring.edges).toHaveLength(8) // anchor↔each + cycle
for (const [a, b] of ring.edges) {
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThan(5)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThan(5)
}
})
it('never reuses a taken name', () => {
const rng = mulberry32(1)
const taken = new Set<string>()
for (let i = 0; i < 40; i++) makeName(rng, taken)
expect(taken.size).toBe(40)
})
})
describe('generatePlanets', () => {
it('gives every system 2–5 planets and keeps body names unique within the system', () => {
for (const seed of [1, 7, 42, 999]) {
const planets = generatePlanets(bodyRng(String(seed), seed))
expect(planets.length).toBeGreaterThanOrEqual(2)
expect(planets.length).toBeLessThanOrEqual(5)
const names: string[] = []
for (const p of planets) {
names.push(p.name, ...p.moons.map((m) => m.name))
expect(['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']).toContain(p.type)
for (const m of p.moons) {
expect(['MOON', 'ICE MOON', 'VOLCANIC MOON']).toContain(m.type)
}
}
expect(new Set(names).size).toBe(names.length) // planets + moons share one name pool per system
}
})
it('is deterministic per (seed, system id) and differs across systems', () => {
const a = generatePlanets(bodyRng('42', 7))
expect(generatePlanets(bodyRng('42', 7))).toEqual(a) // same save+system → same bodies
const b = generatePlanets(bodyRng('43', 7))
expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different world
})
})
describe('rollExploration', () => {
it('is deterministic for the same save+planet and yields valid items', () => {
const first = rollExploration(explorationRng('42', 13))
expect(rollExploration(explorationRng('42', 13))).toEqual(first)
for (const item of first) {
expect(['resource', 'part']).toContain(item.kind)
if (item.kind === 'resource') {
expect(item.qty).toBeGreaterThanOrEqual(1)
expect(item.qty).toBeLessThanOrEqual(4)
} else {
expect(item.qty).toBe(1)
}
}
})
it('across many planets produces every outcome shape: resource, part, both, or nothing', () => {
const shapes = new Set<string>()
for (let planetId = 1; planetId <= 200; planetId++) {
const found = rollExploration(explorationRng('42', planetId))
const hasResource = found.some((f) => f.kind === 'resource')
const hasPart = found.some((f) => f.kind === 'part')
shapes.add(`${hasResource ? 'r' : '-'}${hasPart ? 'p' : '-'}`)
}
expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--']))
})
})
+227
View File
@@ -0,0 +1,227 @@
// Pure, seeded world generation — no DB, no Date, no Math.random. The same seed always
// yields the same world, which is what makes unit tests (and save regeneration) deterministic.
export interface WorldSystem {
name: string
x: number // 0–100 coordinate space, rendered directly by NodeGraph
y: number
}
export const START_X = 50
export const START_Y = 50
const START_RING = 6 // neighbours around the starting system on a new game
const EXPAND_RING = 4 // new neighbours generated when jumping into an undiscovered system
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
// mulberry32 — small fast seeded PRNG; plenty for name/position picking.
export function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const FIRST = [
'KEPLER', 'VESPER', 'HOLLOW', 'CINDER', 'LANTERN', 'SABLE', 'ORPHEUS', 'MIRE',
'THALASSA', 'KUIPER', 'AURELLE', 'BRIMSTONE', 'EMBER', 'FROST', 'GALE', 'IRON',
]
const SECOND = ['DRIFT', 'REACH', 'GATE', 'FIELD', 'VOID', 'DEEP', 'BEND', 'SHARD']
function title(word: string): string {
return word.charAt(0) + word.slice(1).toLowerCase()
}
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one.
export function makeName(rng: () => number, taken: Set<string>): string {
for (;;) {
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
if (!taken.has(name)) {
taken.add(name)
return name
}
}
}
function clamp(v: number): number {
return Math.min(96, Math.max(4, v))
}
// Places `count` systems on a ring around (cx,cy), nudging each candidate until it is at
// least MIN_SPACING away from every known position. After 12 tries it accepts the last
// candidate anyway — ponytail: a slightly crowded node beats an infinite loop; with ≤6
// rings per region and spacing 16 in a 0–100 box this never triggers in practice.
function placeRing(
rng: () => number,
cx: number,
cy: number,
count: number,
takenNames: Set<string>,
takenPositions: WorldSystem[],
): WorldSystem[] {
const placed: WorldSystem[] = []
for (let i = 0; i < count; i++) {
const baseAngle = (i / count) * Math.PI * 2 + (rng() - 0.5) * 0.6
let x = cx
let y = cy
for (let attempt = 0; attempt < 12; attempt++) {
const angle = baseAngle + attempt * 0.45
const radius = 26 + rng() * 8
x = clamp(cx + Math.cos(angle) * radius)
y = clamp(cy + Math.sin(angle) * radius)
if ([...takenPositions, ...placed].every((p) => Math.hypot(p.x - x, p.y - y) >= MIN_SPACING)) break
}
placed.push({ name: makeName(rng, takenNames), x, y })
}
return placed
}
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
// cycle connecting consecutive members (so every system has ≥2 routes and you can always
// get back). Pairs are indices into [anchor, ...members].
function ringEdges(n: number): [number, number][] {
const edges: [number, number][] = []
for (let i = 0; i < n; i++) {
edges.push([0, i + 1])
edges.push([i + 1, ((i + 1) % n) + 1])
}
return edges
}
export interface StartRegion {
start: WorldSystem
neighbors: WorldSystem[]
edges: [number, number][] // indices into [start, ...neighbors]
}
// New game: the starting system at chart centre plus a ring of undiscovered neighbours.
export function generateStartRegion(rng: () => number): StartRegion {
const taken = new Set<string>()
const start: WorldSystem = { name: makeName(rng, taken), x: START_X, y: START_Y }
const neighbors = placeRing(rng, START_X, START_Y, START_RING, taken, [start])
return { start, neighbors, edges: ringEdges(neighbors.length) }
}
export interface RingRegion {
systems: WorldSystem[]
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
}
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
// neighbours around it. `takenNames`/`takenPositions` are every system already in the save.
export function generateRing(
rng: () => number,
anchorX: number,
anchorY: number,
takenNames: Set<string>,
takenPositions: WorldSystem[],
): RingRegion {
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
return { systems, edges: ringEdges(systems.length) }
}
// --- Bodies (planets + moons) and exploration -------------------------------
export interface WorldMoon {
name: string
type: string
hazard: boolean
}
export interface WorldPlanet {
name: string
type: string
hazard: boolean
moons: WorldMoon[]
}
// Derive an independent PRNG stream for one entity from the save seed, so a system's bodies
// and a planet's exploration haul are reproducible offline (tests recompute them directly).
export function bodyRng(seed: string, systemId: number): () => number {
return mulberry32((Number(seed) + Math.imul(systemId, 0x9e3779b1)) >>> 0)
}
export function explorationRng(seed: string, planetId: number): () => number {
return mulberry32((Number(seed) + Math.imul(planetId, 0x85ebca6b)) >>> 0)
}
const BODY_NAMES = [
'AURELLE', 'BRIMSTONE', 'CINDERROCK', 'DRIFTBAND', 'PALEMOON', 'ASHFALL', 'VESPERINE', 'KORR',
'MIRELLA', 'SABLET', 'EMBERLING', 'FROSTHOLD', 'GALEN', 'IRONVEIN', 'JUNO', 'KESTREL',
'LUMEN', 'NADIR', 'OPALINE', 'PYRE', 'QUILL', 'RUSTBARK', 'SIROCCO', 'TARN',
'UMBRA', 'VELVETINE', 'WYRM', 'ZEPHYR', 'HALCYON', 'MORROW', 'ONYX', 'THALASSA',
]
const PLANET_TYPES = ['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']
const MOON_TYPES = ['MOON', 'ICE MOON', 'VOLCANIC MOON']
const HAZARD_CHANCE = 0.25
const MOON_CHANCE = 0.6 // a planet has any moons at all
const PLANET_COUNT_MIN = 2
const PLANET_COUNT_SPREAD = 4 // 2..5 planets per system
// A body name unique within its system (planets and moons share one pool). Max bodies per
// system is 5 + 5*3 = 20 < BODY_NAMES.length, so the loop always terminates.
function makeBodyName(rng: () => number, taken: Set<string>): string {
for (;;) {
const name = title(BODY_NAMES[Math.floor(rng() * BODY_NAMES.length)])
if (!taken.has(name)) {
taken.add(name)
return name
}
}
}
function pick<T>(rng: () => number, arr: T[]): T {
return arr[Math.floor(rng() * arr.length)]
}
// The bodies of one system: 2–5 planets, some with 1–3 moons. Pure — the caller derives the
// rng from (save seed, system id) so a given system always gets the same bodies.
export function generatePlanets(rng: () => number): WorldPlanet[] {
const taken = new Set<string>()
const planets: WorldPlanet[] = []
for (let i = 0; i < PLANET_COUNT_MIN + Math.floor(rng() * PLANET_COUNT_SPREAD); i++) {
const name = makeBodyName(rng, taken)
const type = pick(rng, PLANET_TYPES)
const hazard = rng() < HAZARD_CHANCE
const moons: WorldMoon[] = []
if (rng() < MOON_CHANCE) {
for (let m = 0; m < 1 + Math.floor(rng() * 3); m++) {
moons.push({ name: makeBodyName(rng, taken), type: pick(rng, MOON_TYPES), hazard: rng() < HAZARD_CHANCE })
}
}
planets.push({ name, type, hazard, moons })
}
return planets
}
export interface FoundItem {
kind: 'resource' | 'part'
name: string
qty: number
}
const RESOURCE_NAMES = ['IRON ORE', 'CRYSTAL SHARDS', 'GAS CELLS', 'WATER ICE', 'RARE EARTH']
const PART_NAMES = ['NAV MODULE', 'HULL PLATE', 'REACTOR CORE', 'COMMS ARRAY', 'ENGINE TUNING']
const RESOURCE_CHANCE = 0.7
const PART_CHANCE = 0.3 // parts are rarer than resources
// The haul from scanning one body: a resource and/or a part, or nothing (~21% of scans).
// Pure — the caller derives the rng from (save seed, planet id), so the same save+planet
// always yields the same outcome.
export function rollExploration(rng: () => number): FoundItem[] {
const found: FoundItem[] = []
if (rng() < RESOURCE_CHANCE) {
found.push({ kind: 'resource', name: pick(rng, RESOURCE_NAMES), qty: 1 + Math.floor(rng() * 4) }) // 1..4
}
if (rng() < PART_CHANCE) {
found.push({ kind: 'part', name: pick(rng, PART_NAMES), qty: 1 })
}
return found
}
+129 -2
View File
@@ -3,8 +3,135 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the one public endpoint (scaffold stage)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/version']);
it('documents every public endpoint', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/crew',
'/api/events',
'/api/explore',
'/api/jump',
'/api/missions',
'/api/missions/{id}/accept',
'/api/research',
'/api/ship',
'/api/ship/build',
'/api/ship/expand',
'/api/ship/upgrade',
'/api/system',
'/api/tech',
'/api/version',
'/api/world',
]);
});
it('describes the ship endpoints as ShipState in and out, with error responses on the actions', () => {
const ship = openApiSpec.paths['/api/ship'].get.responses[200].content['application/json'].schema;
expect(ship.$ref).toBe('#/components/schemas/ShipState');
const system = openApiSpec.components.schemas.ShipSystem.properties;
expect(Object.keys(system)).toEqual(['id', 'type', 'level', 'capacity', 'built']);
for (const [path, request] of [
['/api/ship/upgrade', '#/components/schemas/UpgradeRequest'],
['/api/ship/expand', '#/components/schemas/ExpandRequest'],
['/api/ship/build', '#/components/schemas/BuildRequest'],
] as const) {
const post = openApiSpec.paths[path].post;
expect(post.requestBody.content['application/json'].schema.$ref).toBe(request);
expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ShipState');
for (const status of ['400', '404', '409', '422'] as const) {
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
}
}
});
it('describes 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', () => {
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
expect(crew.type).toBe('array');
expect((crew.items as { $ref: string }).$ref).toBe('#/components/schemas/CrewMember');
const member = openApiSpec.components.schemas.CrewMember.properties;
expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']);
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] };
const extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).properties;
expect(Object.keys(extra)).toEqual(['outcome', 'crewFound']);
});
it('describes the system endpoints as SystemState in and out', () => {
const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema;
expect(system.$ref).toBe('#/components/schemas/SystemState');
const explore = openApiSpec.paths['/api/explore'].post;
expect(explore.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreRequest');
expect(explore.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreResult');
// ExploreResult extends SystemState with the scan outcome.
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: { $ref?: string }[] };
expect(exploreResult.allOf[0].$ref).toBe('#/components/schemas/SystemState');
for (const status of ['400', '404', '409'] as const) {
expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
}
});
it('describes the world endpoints as WorldState in and out', () => {
const world = openApiSpec.paths['/api/world'].get.responses[200].content['application/json'].schema;
expect(world.$ref).toBe('#/components/schemas/WorldState');
const jump = openApiSpec.paths['/api/jump'].post;
expect(jump.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/JumpRequest');
expect(jump.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/WorldState');
for (const status of ['400', '404', '409'] as const) {
expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
}
});
it('keeps info.version in sync with package.json', () => {
+756 -1
View File
@@ -8,7 +8,7 @@ export const openApiSpec = {
info: {
title: 'SpaceWanderer API',
description:
'JSON API for SpaceWanderer — a single-player turn-based space adventure. Scaffold stage: app metadata only; game endpoints (turn actions, state snapshots) land with the first game systems.',
'JSON API for SpaceWanderer — a single-player turn-based space adventure (one save, no accounts). World endpoints: read the generated world state and jump between adjacent solar systems.',
// Sourced from package.json so it stays in sync with every version bump.
version: pkg.version,
},
@@ -32,6 +32,531 @@ export const openApiSpec = {
},
},
},
'/api/world': {
get: {
summary: 'World state',
description:
'The full world of the single active save — every known solar system, jump routes and the turn counter. Auto-initializes a new game (seeded world generation) when no save exists yet.',
operationId: 'getWorld',
responses: {
200: {
description: 'Current world state',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/WorldState' },
},
},
},
},
},
},
'/api/jump': {
post: {
summary: 'Jump to an adjacent solar system',
description:
'Validates that the target is a jump route away from the current system, sets it as current (discovering it if new and generating its neighbours), increments the turn counter and writes a jump event. Returns the fresh world state.',
operationId: 'jumpToSystem',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/JumpRequest' },
},
},
},
responses: {
200: {
description: 'Fresh world state after the jump',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/WorldState' },
},
},
},
400: {
description: 'Malformed body or non-integer systemId',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: 'Unknown system id',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'Target is not adjacent to the current system (or is the current system itself)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
'/api/system': {
get: {
summary: 'Current system state',
description:
'The bodies of the current solar system — planets and their moons (nested via parent) with hazard/explored flags — plus the save inventory and turn counter. Auto-initializes a new game when no save exists yet.',
operationId: 'getSystem',
responses: {
200: {
description: 'Current system state',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SystemState' },
},
},
},
},
},
},
'/api/explore': {
post: {
summary: 'Explore a planet or moon',
description:
'Scans one body of the save with a deterministic haul (a resource and/or a part, or nothing), marks it explored, increments the turn counter, accumulates finds into inventory and writes an explore event. Returns the fresh system state plus this scan outcome.',
operationId: 'explorePlanet',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ExploreRequest' },
},
},
},
responses: {
200: {
description: 'Fresh system state plus the scan outcome',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ExploreResult' },
},
},
},
400: {
description: 'Malformed body or non-integer planetId',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: 'Unknown planet id',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'Body was already explored (each can only be scanned once)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
'/api/crew': {
get: {
summary: 'Crew roster',
description:
'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring planets/moons. Each member has one good and one bad trait and an origin. Auto-initializes a new game when no save exists yet.',
operationId: 'getCrew',
responses: {
200: {
description: 'Crew roster',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/CrewMember' } },
},
},
},
},
},
},
'/api/ship': {
get: {
summary: 'Ship state',
description:
"The ship's systems (built flag derived from level), the cargo hold and the turn counter. The starting ship is seeded on new game — core types built at base values, the rest unbuilt until a build action. Auto-initializes a new game when no save exists yet.",
operationId: 'getShip',
responses: {
200: {
description: 'Current ship state',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ShipState' },
},
},
},
},
},
},
'/api/ship/upgrade': {
post: {
summary: 'Upgrade a ship system',
description:
"Level up a built ship system by one (capped at max level). Costs `level` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.",
operationId: 'upgradeShipSystem',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/UpgradeRequest' },
},
},
},
responses: {
200: {
description: 'Fresh ship state after the upgrade',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ShipState' },
},
},
},
400: {
description: 'Malformed body or non-integer systemId',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: 'Unknown ship system id',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'System is not built or already at max level',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
422: {
description: 'Not enough resources in the cargo hold for the cost',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
'/api/ship/expand': {
post: {
summary: 'Expand a ship system',
description:
"Raise a built ship system's capacity by one (capped at max capacity). Costs `capacity` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.",
operationId: 'expandShipSystem',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ExpandRequest' },
},
},
},
responses: {
200: {
description: 'Fresh ship state after the expansion',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ShipState' },
},
},
},
400: {
description: 'Malformed body or non-integer systemId',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: 'Unknown ship system id',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'System is not built or already at max capacity',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
422: {
description: 'Not enough resources in the cargo hold for the cost',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
'/api/ship/build': {
post: {
summary: 'Build a ship system',
description:
'Build an unbuilt ship system type at base values. Costs a flat amount of parts, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.',
operationId: 'buildShipSystem',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/BuildRequest' },
},
},
},
responses: {
200: {
description: 'Fresh ship state after the build',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ShipState' },
},
},
},
400: {
description: 'Malformed body or empty type',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
404: {
description: 'Unknown ship system type',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
409: {
description: 'System is already built',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
422: {
description: 'Not enough parts in the cargo hold for the cost',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
'/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: {
schemas: {
@@ -42,6 +567,236 @@ export const openApiSpec = {
},
required: ['version'],
},
WorldSystem: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
x: { type: 'number', description: '0–100 chart coordinate' },
y: { type: 'number', description: '0–100 chart coordinate' },
discovered: { type: 'boolean' },
current: { type: 'boolean', description: 'True for the system the ship is in now' },
},
required: ['id', 'name', 'x', 'y', 'discovered', 'current'],
},
WorldState: {
type: 'object',
properties: {
systems: { type: 'array', items: { $ref: '#/components/schemas/WorldSystem' } },
edges: {
type: 'array',
description: 'Undirected jump routes as [fromId, toId] pairs (sorted, deduped)',
items: {
type: 'array',
items: { type: 'integer' },
minItems: 2,
maxItems: 2,
},
},
turn: { type: 'integer', description: 'Turn counter — one per jump' },
},
required: ['systems', 'edges', 'turn'],
},
JumpRequest: {
type: 'object',
properties: {
systemId: { type: 'integer', description: 'solar_systems.id of the jump target' },
},
required: ['systemId'],
},
Planet: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
type: { type: 'string', description: 'Body class, e.g. TERRESTRIAL or GAS GIANT' },
hazard: { type: 'boolean' },
explored: { type: 'boolean', description: 'True once scanned — each body can only be explored once' },
parent: { type: ['integer', 'null'], nullable: true, description: 'NULL for a planet, the parent planet id for a moon' },
},
required: ['id', 'name', 'type', 'hazard', 'explored', 'parent'],
},
InventoryItem: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['resource', 'part'] },
name: { type: 'string' },
qty: { type: 'integer' },
},
required: ['kind', 'name', 'qty'],
},
FoundItem: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['resource', 'part'] },
name: { type: 'string' },
qty: { type: 'integer' },
},
required: ['kind', 'name', 'qty'],
},
CrewMember: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' },
trait_good: { type: 'string', description: 'The member good trait' },
trait_bad: { type: 'string', description: 'The member bad trait' },
origin: { type: 'string', description: "'STARTING CREW' or '<planet>, <system>' for members found while exploring" },
},
required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'],
},
ShipSystem: {
type: 'object',
properties: {
id: { type: 'integer' },
type: { type: 'string', description: "System type, e.g. REACTOR or WEAPONS" },
level: { type: 'integer', description: '0 when not built yet' },
capacity: { type: 'integer', description: '0 when not built yet' },
built: { type: 'boolean', description: 'Derived — true when level > 0' },
},
required: ['id', 'type', 'level', 'capacity', 'built'],
},
ShipState: {
type: 'object',
properties: {
systems: {
type: 'array',
description: "The ship's systems in display order",
items: { $ref: '#/components/schemas/ShipSystem' },
},
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
turn: { type: 'integer', description: 'Turn counter — one per jump, exploration or ship action' },
},
required: ['systems', 'inventory', 'turn'],
},
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: {
type: 'object',
properties: {
systemId: { type: 'integer', description: 'ship_systems.id of the built system to level up' },
},
required: ['systemId'],
},
ExpandRequest: {
type: 'object',
properties: {
systemId: { type: 'integer', description: 'ship_systems.id of the built system to expand' },
},
required: ['systemId'],
},
BuildRequest: {
type: 'object',
properties: {
type: { type: 'string', description: "Ship system type to build, e.g. WEAPONS (see ShipSystem.type)" },
},
required: ['type'],
},
SystemState: {
type: 'object',
properties: {
systemId: { type: 'integer' },
systemName: { type: 'string' },
planets: {
type: 'array',
description: 'Planets and moons in display order — a moon always follows its parent row',
items: { $ref: '#/components/schemas/Planet' },
},
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
turn: { type: 'integer', description: 'Turn counter — one per jump or exploration' },
},
required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'],
},
ExploreRequest: {
type: 'object',
properties: {
planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' },
},
required: ['planetId'],
},
ExploreResult: {
allOf: [
{ $ref: '#/components/schemas/SystemState' },
{
type: 'object',
properties: {
outcome: {
type: 'array',
description: 'What this scan found — may be empty (some scans find nothing)',
items: { $ref: '#/components/schemas/FoundItem' },
},
crewFound: {
description: 'A crew member who joined the ship on this scan (~18% of scans), null when none',
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
},
},
required: ['outcome', 'crewFound'],
},
],
},
Error: {
type: 'object',
properties: {
error: { type: 'string' },
},
required: ['error'],
},
},
},
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space-wanderer",
"version": "0.2.0",
"version": "0.9.2",
"private": true,
"scripts": {
"dev": "next dev -p 3000",