Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
939c9695da | ||
|
|
34e8fd8514 | ||
|
|
3ea2d1c38b | ||
|
|
ca590c7b53 | ||
|
|
bb44530649 | ||
|
|
311eb5c713 | ||
|
|
d20037762d | ||
|
|
ab22a7e68b | ||
|
|
90de8818f8 | ||
|
|
a593cd04cd | ||
|
|
352ef31625 | ||
|
|
9af7d21d93 | ||
|
|
7d5a561882 | ||
|
|
39920b3e05 | ||
|
|
92c2c26047 | ||
|
|
b8a998dcbb | ||
|
|
1a36160392 | ||
|
|
9d1a8e9d89 | ||
|
|
f3751b4747 | ||
|
|
5bc411cb76 | ||
|
|
7f2121a717 | ||
|
|
449d4b9895 | ||
|
|
678cd48a94 | ||
|
|
e792c8a06c | ||
|
|
8f018a6ee9 | ||
|
|
f1633eb518 | ||
|
|
1993623548 | ||
|
|
9020a773e1 | ||
|
|
bf5ac8c8a8 | ||
|
|
babae7c392 | ||
|
|
a0307e4ccd | ||
|
|
1ddb8ad469 | ||
|
|
b51bde3c25 | ||
|
|
53cf1bdacc | ||
|
|
f1b5645e9d | ||
|
|
1cabea7972 | ||
|
|
a199b64348 | ||
|
|
07c1508999 | ||
|
|
1084f4e099 | ||
|
|
84d1aa8122 | ||
|
|
178de6e4fb | ||
|
|
03b8b661f6 | ||
|
|
4e312d054f | ||
|
|
d7faf739a9 | ||
|
|
bbcd77c41b | ||
|
|
d4491e082e | ||
|
|
6c51c3cec8 | ||
|
|
f52a59c5fc | ||
|
|
2a8cb4da06 | ||
|
|
0d2370850f | ||
|
|
be11aac356 | ||
|
|
fdc5624b0c | ||
|
|
ff4ced9d71 | ||
|
|
d14ffdcf2f | ||
|
|
d083b5b708 | ||
|
|
c012b0fe56 | ||
|
|
6483d510ef | ||
|
|
bc6bc7dd07 | ||
|
|
3a786913df | ||
|
|
978a1d119d | ||
|
|
a57c590bae | ||
|
|
1710e50b63 | ||
|
|
1bb04d2a51 | ||
|
|
e27ff7015e | ||
|
|
e6c88b10c2 | ||
|
|
f4690bc1a2 | ||
|
|
4b030716f7 | ||
|
|
06f8a28e96 | ||
|
|
36ff412f0b | ||
|
|
c35edc60d0 | ||
|
|
f545792c36 | ||
|
|
593cca5dce | ||
|
|
d2fb252a98 | ||
|
|
bf6d8714e6 | ||
|
|
32dda04e92 | ||
|
|
ef1d282770 | ||
|
|
18ca5eae6c | ||
|
|
b05ac32da6 | ||
|
|
85627201f8 | ||
|
|
fb11a8ef81 | ||
|
|
37b193ffb8 | ||
|
|
760a45fc4b | ||
|
|
0b435a583b | ||
|
|
29e8ac6909 | ||
|
|
21abf8768f | ||
|
|
8d678d73ce | ||
|
|
0d5858695a | ||
|
|
6c828e40a6 | ||
|
|
b5a072c9aa | ||
|
|
85eeeecc4a | ||
|
|
70f95145ae | ||
|
|
684a126a5b | ||
|
|
786a5ec001 | ||
|
|
f356ffbe93 | ||
|
|
5657e8938a | ||
|
|
80885c8ad7 | ||
|
|
a62ed2cf95 | ||
|
|
672e99bd96 | ||
|
|
94e9fc728c | ||
|
|
7b0227cde9 | ||
|
|
8818f0928d | ||
|
|
4ef924edd6 | ||
|
|
bd16d3fc47 | ||
|
|
dcaeb4dded | ||
|
|
7e975566ab | ||
|
|
de0cb45b09 | ||
|
|
fce27ff6ca | ||
|
|
5fe306d64d | ||
|
|
0910891995 | ||
|
|
c7580d92bd | ||
|
|
f5cba45b42 | ||
|
|
59ccf5674e | ||
|
|
60947d3cda | ||
|
|
08a15a09cf | ||
|
|
a2c8586618 | ||
|
|
7a9ff110f3 | ||
|
|
ccd156b7c6 | ||
|
|
a6a5e827a1 | ||
|
|
0ddba27333 | ||
|
|
1a50ffb2f9 | ||
|
|
82d41b87be | ||
|
|
8153007668 | ||
|
|
3aa27ffd89 | ||
|
|
cbab17047b | ||
|
|
172f167a15 | ||
|
|
b79b77131f | ||
|
|
f36820ddff | ||
|
|
6a623c738a | ||
|
|
80b493746f | ||
|
|
37c3800479 | ||
|
|
cc6abe8f36 | ||
|
|
58ac64af4f | ||
|
|
1634c0602e | ||
|
|
922a20707b | ||
|
|
aa3c5f5501 | ||
|
|
7f48fd0e5e | ||
|
|
0573e6d719 | ||
|
|
2651981044 | ||
|
|
16a7b468e3 |
@@ -19,3 +19,13 @@
|
|||||||
|
|
||||||
## Other
|
## Other
|
||||||
1. For longer terminal tasks you should use pty_* tools.
|
1. For longer terminal tasks you should use pty_* tools.
|
||||||
|
|
||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
+160
@@ -1,5 +1,165 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.14.0 — 2026-10-06 12:50
|
||||||
|
- The NEXT TURN button now lives inside the right pane (full width of the pane, larger) instead of floating as a fixed bottom-right overlay — it can no longer be covered by the active-tasks strip (#52).
|
||||||
|
- The button grew from the SM to the MD size and spans the whole pane content width; it pins to the pane bottom when the content is short and scrolls with it when the cargo list is long. The `TURN <n>` counter, the EXEC styling and the in-flight behaviour are unchanged.
|
||||||
|
- E2E: new spec pins the button full-width in the right pane (≥ 280px, within ±8px of the CARGO HOLD panel) and asserts its bounding box does not intersect the active-tasks strip's while a task is queued.
|
||||||
|
|
||||||
|
## v0.13.0 — 2026-10-06 12:17
|
||||||
|
- Jump spread limit (#51): jumping into an undiscovered system now mints a seeded-random 1–4 new systems (was a fixed 4). Each leg can instead attach an already-known system (closing a loop) with probability 0.35, so the chart grows slower and more circular. A frontier guarantee ensures at least one new system is always minted.
|
||||||
|
- `ringEdges` small-n fix: n=1 no longer emits a `[1,1]` self-loop, n=2 no longer emits a duplicate edge. n≥3 output is byte-identical to the legacy wheel.
|
||||||
|
- `generateRing` gains `attachCandidates`, `existingEdgeKeys`, and `anchorId` parameters; `RingRegion` gains an `attaches` field. The `EXPAND_RING` constant is removed in favour of `EXPAND_MIN`/`EXPAND_MAX`.
|
||||||
|
|
||||||
|
## v0.12.0 — 2026-10-04 21:04
|
||||||
|
- FINDS HIDDEN THINGS now grants +1 exploration haul item — one extra roll on the save's seeded per-planet stream, appended to the scan's haul.
|
||||||
|
- TALKS TO ANYONE now raises the crew-recruit chance from 18% to 30% when a member with the trait is on the exploring team.
|
||||||
|
- Determinism preserved: both effects draw from the existing seeded stream (same save+planet+team → identical haul), unit-tested with and without each trait; the no-trait path is byte-identical to v0.11.0.
|
||||||
|
|
||||||
|
## v0.11.0 — 2026-10-04 03:47
|
||||||
|
- Skill progression: crew skills (NAVIGATION, SCANNING, ENGINEERING, RESEARCH) gain XP on every completed action, leveling through 5 bands; higher skill levels reduce task duration via power scaling.
|
||||||
|
- Role-based starting levels: PILOTs start NAVIGATION L2, SCIENTISTS start SCANNING+RESEARCH L2, etc. Recruits discovered during exploration get the same role-based starting profile.
|
||||||
|
- Level-up events: when a member's skill steps into the SENIOR (L3) or MASTER (L5) band, a named event is emitted (e.g. "A. OKAFOR is now a SENIOR SCIENTIST — SCANNING L3").
|
||||||
|
- CREW tab: each member now shows their four skill levels and a title (SENIOR/MASTER + role) based on their highest skill.
|
||||||
|
- Team-builder: the duration preview now accounts for crew skill power (same computation as the queue routes).
|
||||||
|
- Combat traits (SHARP SHOOTER, SHORT FUSE, JINXED IN A DOGFIGHT) now display a "STANDBY — encounter-ready" label, as promised in v0.10.0.
|
||||||
|
- OpenAPI: `CrewMember` gains `skills` (array of {skill, level}) and `title` fields; fixes pre-existing snake_case `trait_good`/`trait_bad` to camelCase to match the actual response.
|
||||||
|
|
||||||
|
## v0.10.1 — 2026-10-03 18:36
|
||||||
|
- Fix React 19 hydration mismatch on every production load — the banner's live clock was prerendered at build time in the static page and desynced at hydration (CI-verified: no hydration errors in the job logs). A real fix, but NOT the cause of the red e2e jump specs.
|
||||||
|
- Fix the world-map jump dispatch — the actual cause of the three deterministically red e2e jump specs: TeamBuilder's `blocked` term was `undefined`, not `null`, whenever no jump was queued (`Array.find` returns `undefined`), so the roster rendered (truthiness check) while the DISPATCH button's `blocked !== null` guard read it as blocked and stayed disabled — the JUMP panel's dispatch was permanently dead on an empty queue (the 300 s `waitForResponse` timeout; present since the builder shipped in v0.10.0, so the UI-jump specs were red on every run since, on a fresh DB; non-JUMP panels were unaffected, their branch yielding `null`).
|
||||||
|
|
||||||
|
## v0.10.0 — 2026-10-03 00:57
|
||||||
|
- Queue-based actions (#44): every game action (jump, explore, ship upgrade/expand/build, research) is now a queued task with an assigned team — a lead + optional helpers. Validation and cost deduction happen at queue time (unknown crew → 404, busy/committed crew → 409, in-transit or second jump → 409); effects apply when the task completes, mission accept is instant and free, and mission-completion checks run when the satisfying task resolves.
|
||||||
|
- Manual time: new `POST /api/turn` advances exactly one turn — each active task ticks down, completions resolve via per-type resolvers (reusing the existing jump/explore/ship/research logic), FIFO successors auto-start; an empty queue is a free skip. `save.turn` now only increments here, and the UI gets the NEXT TURN button (bottom-right) with the turn counter.
|
||||||
|
- Crew teaming with a live duration preview: action buttons open an inline team builder — pick a lead (radio), toggle helpers (checkboxes) — and show the computed duration as the team changes ("Lead AVA VANCE · PILOT L2 + 1 helper → 3 turns"); busy/committed members are disabled with a reason. A new active-tasks strip (bottom-left) lists active tasks first, pending FIFO tasks dimmed.
|
||||||
|
- Trait duration modifiers: all 15 non-combat traits now affect duration (7 good — cancel the role-mismatch penalty or −1 turn for a specific action type; 8 bad — +1 turn for a specific type or any action), applied to the lead only; the 3 combat traits are labelled STANDBY until the future encounters iteration (the 2 outcome-effect traits ship in #43).
|
||||||
|
- New `tasks` table (schema v7; idempotent backfill for existing saves — existing saves gain an empty table, turn/crew/inventory untouched) and a single balance module `lib/game/crew-costs.ts` (work points, power bands, trait modifiers); OpenAPI spec updated accordingly.
|
||||||
|
- Tests: unit matrix covers the duration formula, queue validation rules and tick resolution (completion effects, FIFO auto-start, in-transit blocking); the whole e2e suite was reworked from instant-resolution to the queue + NEXT TURN model (assign team → NEXT TURN ×N → assert the effect).
|
||||||
|
|
||||||
|
## v0.9.12 — 2026-09-29 21:40
|
||||||
|
- ADMIN button in the mission-control header (top right, next to the SHIP NOMINAL / SECTOR SCAN badges) opens a DB CRUD panel (#38): pick any of the ten game tables from `lib/db/schema.ts`, browse its rows in an HTML table, and add/edit/delete rows via a per-column form. Driven by new `/api/admin/` endpoints — `GET /api/admin/tables` (columns + row counts), `GET|POST|PATCH|DELETE /api/admin/tables/{name}/rows` — built off a name→table registry so there is no per-table code; unknown table names get 404, malformed payloads 400, constraint violations 409. Deliberately unauthenticated by explicit request (marked with a `ponytail:` comment).
|
||||||
|
- e2e: new read-only spec — click ADMIN, assert the panel lists every table the API reports, open `saves` and assert its rows render matching `/api/admin/tables/saves/rows` (no mutations in specs; CRUD is covered by unit tests in lib/db/admin.test.ts).
|
||||||
|
|
||||||
|
## v0.9.11 — 2026-09-29 14:49
|
||||||
|
- NAVIGATION AI now enables multi-system jumps (#35): once the existing root tech is researched, `/api/jump` accepts targets up to two hops away (BFS over jump routes) and a multi-hop jump costs one turn per hop — single-hop jumps keep their existing cost. On the WORLD MAP chart, two-hop systems become clickable JUMP fields with a distinct `// JUMP ×2` sublabel; without the tech the range stays adjacent-only as before.
|
||||||
|
- e2e: new spec researches NAVIGATION AI (scan loop + SKILL TREE UI) and jumps to a two-hop system in one action, asserting the world state and the +2 turn cost; the world-map button-count spec now derives its expected count from the save's tech state so it holds across retries on the shared CI DB.
|
||||||
|
|
||||||
|
## v0.9.10 — 2026-09-29 12:57
|
||||||
|
- The WORLD MAP now fills the entire available area of its tab (#36): in wide mode (pinned viewport, #21) the OPERATIONS Tabs are a full-height flex column and the world-map panel flexes to the bottom of the pane — the chart SVG sizes to the measured container instead of the old fixed 380px height, so there is no internal scrollbar on this tab at any window size. The scene keeps its viewBox and scales with the pane on resizes (pan/zoom stay accurate; re-fitting after a resize is intentionally not done). Narrow mode keeps the fixed fallback height since the page scrolls naturally there.
|
||||||
|
- Follow-up to that layout change (CI run 382): the OPERATIONS Tabs root uses `min-height: 100%` instead of `height: 100%`. With a capped height, a tall tab's content overflowed the flex column and shrank items down to their automatic minimum — the tablist's `overflow-x: auto` zeroes that minimum per CSS spec, so the bar collapsed to ~0px with its buttons clipped out of hit-testing (every subsequent tab click failed with "div intercepts pointer events"); a capped box also released the sticky pin on deep scrolls. With min-height the box grows to fit tall tabs — the bar keeps its natural height and stays pinned across the whole scroll range, while WORLD MAP still fills the pane exactly.
|
||||||
|
- e2e: the #31 spec no longer relies on WORLD MAP's overflow to force scrolling — it now switches to SHIP SYSTEMS, whose six always-present stacked system cards deterministically overflow the shrunk 1280×560 pane (waiting for the HULL card first), and asserts the same pinned tab bar.
|
||||||
|
|
||||||
|
## v0.9.9 — 2026-09-29 11:21
|
||||||
|
- The WORLD MAP chart is now a force-directed graph (d3-force) with pan + zoom (d3-zoom), replacing the hand-rolled static NodeGraph render (#28): nodes settle into a dynamic layout seeded from the API coordinates and visibly rearrange after every jump, then auto zoom-to-fit so the whole network stays framed in the chart box. Wheel/drag pans and zooms the scene; only systems adjacent to the current one remain clickable (same `role=button` + name contract as before), all nodes lock while a jump is in flight, and a real pan's trailing click never triggers an accidental jump. Jump flow (`POST /api/jump`, turn counter, `onJumped` refresh from #30) is unchanged; the now-orphaned hand-rolled `NodeGraph` component (world map was its only consumer) is deleted.
|
||||||
|
|
||||||
|
## v0.9.8 — 2026-09-29 10:20
|
||||||
|
- The OPERATIONS tab bar now stays pinned while its content scrolls (#31): the TabsList is `position: sticky; top: 0` with a z-index above the panel content, so it pins to the top of the OPERATIONS scroll area in wide mode (where only the center pane scrolls, #21) and to the viewport when the body scrolls in narrow mode. Its existing opaque background (`--surface-raised`) keeps scrolled content from showing through; no layout restructure needed — #21 behavior is untouched.
|
||||||
|
- e2e: new smoke spec — shrink the viewport so WORLD MAP's fixed-height chart overflows the pane, scroll the panel to the bottom (asserting a real offset), and check the tablist's bounding box hasn't moved.
|
||||||
|
|
||||||
|
## v0.9.7 — 2026-09-29 09:59
|
||||||
|
- LOCAL SYSTEM no longer shows the previous system after a jump (#30): WorldMapPanel owns the world state and applied the `/api/jump` response locally, so MissionControl's lifted `/api/system` state (which drives the LOCAL SYSTEM tab, CARGO HOLD and the SECTOR row) went stale until a page refresh. The panel now takes an `onJumped` callback that MissionControl wires to a `/api/system` refetch after every successful jump — same failure policy as the mount fetch (keep old state on error).
|
||||||
|
- e2e: new smoke spec — click-to-jump on WORLD MAP, wait for the chart's turn counter to update, switch to LOCAL SYSTEM and assert its header shows the NEW current system from `/api/world` (no `page.reload()` workaround; one jump per run/retry, same pattern as the existing jump test).
|
||||||
|
|
||||||
|
## v0.9.6 — 2026-09-29 09:41
|
||||||
|
- The active operations tab now survives a page refresh (#27): the selected tab ID is persisted to `localStorage` (`sw-active-tab`) on every change and restored on mount when it matches one of the seven valid tab IDs (unknown/stale values fall back to WORLD MAP). The Radix Tabs are controlled for this; SSR and first client render still agree on the default, so there is no hydration mismatch.
|
||||||
|
- e2e: new smoke spec — click CREW, reload, assert `aria-selected=true` again (fresh context per run/retry, no API state touched, idempotent).
|
||||||
|
|
||||||
|
## v0.9.5 — 2026-09-29 09:13
|
||||||
|
- Fixed the production freeze reported after the v0.9.3 (#21) deploy ("entire UI system is now fucked"). Root cause was NOT the #21 layout change (verified healthy across all 7 tabs and multiple viewports — no clipping, middle pane scrolls internally, side panes fixed): it was world name generation. `makeName` draws from a finite pool of 16×8=128 system names with an unbounded rejection loop, so once a save grew past ~128 systems (turns of play / e2e jump loops), any jump into an undiscovered system spun forever on Node's single thread — sql.js runs synchronously, so the whole process blocked and EVERY API route hung, leaving all panels stuck at LOADING.
|
||||||
|
- `makeName` (lib/game/world.ts) now falls back to numbered variants (`Kepler Drift 2`) once the pool is exhausted, so it always terminates; `makeCrewName` (lib/game/crew.ts) gets the same guard — the roster has no cap and its 16×22=352 pool would eventually exhaust too.
|
||||||
|
- Tests: unit — both name generators terminate with unique numbered names after full pool exhaustion (80 tests green, was 78); e2e unchanged — no spec depends on the old behavior and a fresh CI stack never approaches 128 systems.
|
||||||
|
|
||||||
|
## v0.9.4 — 2026-09-29 08:29
|
||||||
|
- Top bar (#23): the `◄ SHIP` label in the mission control header is replaced with the app version number (e.g. `v0.9.4`). The header reads it from `package.json` via the same `@/package.json` import that `/api/version` and the OpenAPI spec use, so there is a single source of truth — no second hardcoded copy.
|
||||||
|
- e2e: no spec changes needed — no spec asserted on the old `◄ SHIP` label (the landing-page test only checks the SpaceWanderer heading). All 15 specs compile unchanged.
|
||||||
|
|
||||||
|
## v0.9.3 — 2026-09-29 08:20
|
||||||
|
- Layout (#21): only the middle column scrolls now — the app is pinned to the viewport (`height: 100vh` + `overflow: hidden` on the root in wide mode) so body-level scrolling no longer moves the side panes. Left/right columns keep their existing `overflow-y: auto`, so any overflowing content (e.g. a long planet list) scrolls inside its own track instead.
|
||||||
|
- Added `min-height: 0` to the BODY wrapper and the 3-column grid — flex/grid items default to a content-based minimum size, which is what let the page grow past the viewport before.
|
||||||
|
- The middle OPERATIONS panel became a flex column with its PanelContent as the scroll area (`flex: 1; min-height: 0; overflow-y: auto`): long tab content scrolls inside the panel while the "OPERATIONS // SHIP COMMAND" header stays pinned — without this, the Panel root's default `overflow: hidden` would have clipped it.
|
||||||
|
- Narrow layout (<900px) is unchanged: stacked columns with natural body scroll (the viewport pin applies to wide mode only).
|
||||||
|
- e2e: no spec changes needed — none use mouse.wheel/scrollTo, and Playwright scrolls click targets into view within any scroll container. All 15 specs compile unchanged.
|
||||||
|
|
||||||
|
## v0.9.2 — 2026-09-29 08:00
|
||||||
|
- WORLD MAP (#19): the bottom JUMP ROUTES button list is gone — jumps now happen by clicking galactic chart fields. Systems adjacent to the current one (the same set as before, undiscovered included) are clickable nodes with a pointer cursor and a `// JUMP` sublabel; the current system stays bright green with CURRENT, unreachable systems are dimmed (OFFLINE) and not clickable.
|
||||||
|
- NodeGraph (`components/ui/node-graph.tsx`) gained two optional, backward-compatible additions: an `onNodeClick(id)` prop that turns non-disabled nodes into buttons (click + Enter/Space, role=button, aria-label, focusable), and a per-node `disabled` flag — while a jump is in flight every node locks, mirroring the old disabled-button behaviour.
|
||||||
|
- The jump flow itself is unchanged: same POST /api/jump call, world state refreshed from the response (turn counter, current system, new routes), event log entry written server-side as before.
|
||||||
|
- e2e: two smoke tests touched the removed buttons and were updated — "world map shows…" now asserts the adjacent systems render as clickable chart fields (role=button count matches API adjacency) and "jumping to an adjacent system" clicks the target node by its accessible name. All 15 specs compile; `jumpToFreshSystem` is API-driven and needed no change.
|
||||||
|
|
||||||
|
## v0.9.1 — 2026-09-29 06:47
|
||||||
|
- UI layout pass (visual only — no game logic or API changes): CARGO HOLD // INVENTORY moved out of the LOCAL SYSTEM tab into a persistent panel in the right column; the UPCOMING ENCOUNTERS placeholder was removed entirely; the SHIP DATA placeholder was merged into VESSEL METRICS.
|
||||||
|
- Grid: `260px / 1fr / 240px` → `320px / 1fr / 360px` (left pane ~+23%, right pane ~50% wider).
|
||||||
|
- Cargo state wiring follows the existing lifted-state pattern: MissionControl fetches `/api/system` on mount and owns the SystemState; LocalSystemPanel receives it as a prop, keeps its planet list + scan result + explore action, and reports each scan's response up via `onChanged` (same as ShipSystemsPanel) — so CARGO HOLD mirrors scans live with no polling.
|
||||||
|
- VESSEL METRICS now carries the two SHIP DATA rows that had real values: SECTOR (current system name from the lifted state) and TURN (`ship.turn`). The rest was dropped — HULL INTEGRITY / REACTOR OUTPUT / CREW ACTIVE duplicate the existing progress bars, TOTAL MODULES duplicates the left column's "N ONLINE" badge, DRIFT VELOCITY has no game equivalent.
|
||||||
|
- e2e: no assertion changes needed — the LOCAL SYSTEM tests only assert panel internals (header, EXPLORE buttons, SCAN RESULT) and API state; CARGO HOLD / UPCOMING ENCOUNTERS / SHIP DATA were never referenced by any spec. All 15 specs compile unchanged.
|
||||||
|
|
||||||
|
## v0.9.0 — 2026-09-29 02:47
|
||||||
|
- EVENT LOG tab — the last of the seven tabs to go live (issue #11): the terminal now renders the save's real game events instead of a placeholder feed. Every action already appends an event row (new game, jump, scan, research, ship upgrade/expand/build, mission accept + completion); this issue adds the read side and the rendering.
|
||||||
|
- New endpoint: `GET /api/events` → latest N events, newest first (`{id, turn, type, message, createdAt}`), auto-initializes like all other reads. Optional `?limit=` (default 50, cap 200; non-integers or out-of-range values → 400). OpenAPI spec in code updated (14 → 15 paths) with the Event schema enumerating the eight event types.
|
||||||
|
- Read model (`lib/game/events.ts`): `getEvents(db, limit)` — ORDER BY id DESC LIMIT N over the existing append-only events table; `parseEventLimit` for the query param. No schema change — the events table and all its writers already existed.
|
||||||
|
- EVENT LOG tab: self-fetching EventLogPanel (MissionPanel pattern — refetches on each tab open). One terminal line per event: `T<turn> // <MESSAGE>` uppercased, with the event's local time as the timestamp; new_game lines render as system lines. Placeholder feed + now() removed from mission-control.tsx.
|
||||||
|
- Tests: unit — parseEventLimit default/accept/reject (400) and getEvents newest-first ordering + slicing; writer coverage gaps closed so every action type is asserted with correct turn/type/message (expand had no event assertion at all; build and mission-complete lacked the turn check); e2e — after a jump + scan, both events appear in GET /api/events with the newer one first and the terminal renders both lines (idempotent across retries: asserts on this run's own entries by turn/message, never absolute counts), plus the latest five log lines come back identical (ids included) after the mid-run container restart.
|
||||||
|
|
||||||
|
## v0.8.0 — 2026-09-29 01:56
|
||||||
|
- MISSIONS tab — contract generation, acceptance and completion (issue #10): the first objective loop on top of issue #9's research. The board is generated deterministically from the save seed; one mission is ACTIVE at a time; completion is checked after every jump and scan and grants its reward into the cargo hold.
|
||||||
|
- New table (`lib/db/schema.ts` + DDL): `missions` (id/save_id/type/description/target_system_id/condition_kind/progress_target/progress/reward_kind/reward_qty/state). Seeded on new game AND as backfill step v6 — same pattern as tech; saves created by older code get their board on first read. `SCHEMA_VERSION` 5 → 6.
|
||||||
|
- 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
|
## 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.
|
- 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.
|
- 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
@@ -46,8 +46,8 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
|
|||||||
## 7. App architecture quick map
|
## 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.
|
- **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.
|
- 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.
|
- **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. Schema is EMPTY at scaffold stage — first game tables land with the turn engine.
|
- 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.
|
- 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).
|
- 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.
|
- Plain React hooks for client state, no external store. **NO auth/accounts/multiplayer by design** (single player only) — there is no users/sessions table and no session headers; a "session" means the local single-player save slot in the DB, not an identity system. Do not copy Flight-Ops's lib/auth.ts / SignInScreen / admin patterns.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { AdminError, deleteAdminRow, insertAdminRow, readAdminRows, updateAdminRow } from '@/lib/db/admin'
|
||||||
|
|
||||||
|
// #38 — rows of one admin table: GET (capped list), POST (insert), PATCH ({key, values}), DELETE ({key}).
|
||||||
|
async function jsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await req.json()
|
||||||
|
} catch {
|
||||||
|
throw new AdminError(400, 'Invalid JSON body')
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) throw new AdminError(400, 'Body must be a JSON object')
|
||||||
|
return body as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(fn: () => Promise<T>): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return Response.json(await fn())
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AdminError) return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
const { name } = await ctx.params
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const limitRaw = url.searchParams.get('limit')
|
||||||
|
const offsetRaw = url.searchParams.get('offset')
|
||||||
|
const limit = limitRaw === null ? 50 : Number(limitRaw)
|
||||||
|
const offset = offsetRaw === null ? 0 : Number(offsetRaw)
|
||||||
|
return run(() => readAdminRows(db, name, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
const { name } = await ctx.params
|
||||||
|
return run(async () => insertAdminRow(db, name, await jsonBody(req)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
const { name } = await ctx.params
|
||||||
|
return run(async () => {
|
||||||
|
const body = await jsonBody(req)
|
||||||
|
return updateAdminRow(db, name, body.key, body.values)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
const { name } = await ctx.params
|
||||||
|
return run(async () => deleteAdminRow(db, name, (await jsonBody(req)).key))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { listAdminTables } from '@/lib/db/admin'
|
||||||
|
|
||||||
|
// #38 — admin DB CRUD, no auth by explicit request (ponytail: add auth before exposing beyond the dev host).
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
return Response.json(await listAdminTables(db))
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MAX_TEAM_SIZE } from '@/lib/game/crew'
|
||||||
|
|
||||||
|
// The crew-team half of a queueing-action body (leadCrewId + helperCrewIds) plus the route-layer
|
||||||
|
// team rules: one task team, distinct members, the size cap. queueTask (lib) deliberately does
|
||||||
|
// NOT validate teams, so this shared parse is the single place the team 400s live — extracted
|
||||||
|
// verbatim from the 5a jump/explore crew-parse blocks so the six queueing routes can't drift.
|
||||||
|
// Returns a 400-shaped error object instead of throwing, so the caller maps it to a response.
|
||||||
|
|
||||||
|
export type CrewTeam =
|
||||||
|
| { leadCrewId: number; helperCrewIds: number[] }
|
||||||
|
| { error: string; status: number }
|
||||||
|
|
||||||
|
export function parseCrewTeam(body: unknown): CrewTeam {
|
||||||
|
const leadCrewId = (body as { leadCrewId?: unknown }).leadCrewId
|
||||||
|
if (typeof leadCrewId !== 'number' || !Number.isInteger(leadCrewId)) {
|
||||||
|
return { error: 'leadCrewId must be an integer', status: 400 }
|
||||||
|
}
|
||||||
|
let helperCrewIds: number[] = []
|
||||||
|
const rawHelpers = (body as { helperCrewIds?: unknown }).helperCrewIds
|
||||||
|
if (rawHelpers !== undefined) {
|
||||||
|
if (!Array.isArray(rawHelpers) || !rawHelpers.every((h) => typeof h === 'number' && Number.isInteger(h))) {
|
||||||
|
return { error: 'helperCrewIds must be an array of integers', status: 400 }
|
||||||
|
}
|
||||||
|
helperCrewIds = rawHelpers
|
||||||
|
}
|
||||||
|
// Team rules (route layer, not queueTask's): one task team, distinct members, size cap.
|
||||||
|
const team = [leadCrewId, ...helperCrewIds]
|
||||||
|
if (new Set(team).size !== team.length) {
|
||||||
|
return { error: 'crew members must be unique', status: 400 }
|
||||||
|
}
|
||||||
|
if (team.length > MAX_TEAM_SIZE) {
|
||||||
|
return { error: `a task team holds at most ${MAX_TEAM_SIZE} crew members`, status: 400 }
|
||||||
|
}
|
||||||
|
return { leadCrewId, helperCrewIds }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { getCrewState } from '@/lib/game/save'
|
||||||
|
|
||||||
|
// The ship's crew roster wrapped with the snapshot task queue: { crew, tasks } — the roster
|
||||||
|
// (starting crew plus members found while exploring) and the ACTIVE + PENDING queue. Auto-initializes on first hit.
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
return Response.json(await getCrewState(db))
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { ensureSave } from '@/lib/game/save'
|
||||||
|
import { EventError, getEvents, parseEventLimit } from '@/lib/game/events'
|
||||||
|
|
||||||
|
// The latest game events, newest first; auto-initializes on first hit.
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await ensureSave(db) // getEvents assumes a save exists
|
||||||
|
|
||||||
|
let limit: number
|
||||||
|
try {
|
||||||
|
limit = parseEventLimit(new URL(req.url).searchParams.get('limit'))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof EventError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(await getEvents(db, limit))
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Handler-level tests for the queueing explore route (Task 5a): POST /api/explore with a crew
|
||||||
|
// team queues an EXPLORE row instead of resolving instantly — same system snapshot (findings key
|
||||||
|
// kept, empty), same pre-check codes, no turn spent at queue time.
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
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 { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getSystemState, type SystemState } from '@/lib/game/save'
|
||||||
|
import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-explore-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/explore', { method: 'POST', body: JSON.stringify(body) }))
|
||||||
|
|
||||||
|
describe('POST /api/explore (5a)', () => {
|
||||||
|
it('queues an EXPLORE: row with type/payload, turn unchanged, planet not yet scanned, no haul', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
const invBefore = (await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)
|
||||||
|
|
||||||
|
const res = await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
const sys = await getSystemState(db)
|
||||||
|
for (const k of Object.keys(sys) as (keyof SystemState)[]) expect(body[k]).toEqual(sys[k]) // today's system snapshot part unchanged; the findings key stays, empty (JC-2)
|
||||||
|
const [row] = await db.db.select().from(schema.tasks)
|
||||||
|
expect(row).toMatchObject({ type: 'EXPLORE', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column
|
||||||
|
expect(JSON.parse(row.payload)).toEqual({ planetId: f.p0 })
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||||
|
expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(false) // the effect waits for the tick
|
||||||
|
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.kind, i.name, i.qty] as const)).toEqual(invBefore) // no haul yet
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown crew → 404 (queueTask)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
expect((await post({ planetId: f.p0, leadCrewId: 999 })).status).toBe(404)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('busy helper → 409, nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
expect((await post({ planetId: f.p0, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('already scanned → 409 (existing pre-check, existing code)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, f.p0))
|
||||||
|
expect((await post({ planetId: f.p0, leadCrewId: c1 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
|
import { checkExploreTarget, ExploreError, getSystemState, makePowerOfFor, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||||
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
|
// Scan one body of the current system: assign the crew team (lead + helpers) and queue the
|
||||||
|
// EXPLORE task — the haul, the scanned flag, the crew roll and the event land when the task
|
||||||
|
// completes on the turn tick (runTurn). Body: { planetId, leadCrewId, helperCrewIds? }.
|
||||||
|
// Queueing spends no turn; returns the same system snapshot as the instant scan, with the
|
||||||
|
// findings key empty until the scan resolves (JC-2).
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
const team = parseCrewTeam(body)
|
||||||
|
if ('error' in team) {
|
||||||
|
return Response.json({ error: team.error }, { status: team.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'EXPLORE'))
|
||||||
|
await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write
|
||||||
|
return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ExploreError || err instanceof TaskError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Handler-level tests for the queueing jump route (Task 5a): POST /api/jump with a crew
|
||||||
|
// team queues a JUMP row instead of resolving instantly — same snapshots, same pre-check
|
||||||
|
// codes, no turn spent at queue time.
|
||||||
|
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 { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getWorldState } from '@/lib/game/save'
|
||||||
|
import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-jump-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/jump', { method: 'POST', body: JSON.stringify(body) }))
|
||||||
|
|
||||||
|
describe('POST /api/jump (5a)', () => {
|
||||||
|
it('queues a JUMP: row with type/payload/status, turn unchanged, snapshot preserved, ship not moved', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
|
||||||
|
const res = await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(await getWorldState(db)) // the same fresh snapshot as today — queueing changes nothing else
|
||||||
|
const [row] = await db.db.select().from(schema.tasks)
|
||||||
|
expect(row).toMatchObject({ type: 'JUMP', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
expect(JSON.parse(row.helpers)).toEqual([c2]) // helpers is a JSON text column
|
||||||
|
expect(JSON.parse(row.payload)).toEqual({ systemId: f.sys1, hops: 1 }) // route-computed via the existing reachability logic
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
|
||||||
|
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the effect waits for the tick
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown crew → 404 (queueTask)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 999 })).status).toBe(404)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('busy helper → 409, nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: c1, helperCrewIds: [c2] })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a jump in transit → 409 (blocks any new task)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 3, remaining: 3, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: c1 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a jump already pending → second jump 409', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'ACTIVE', queuedAtTurn: 1 })
|
||||||
|
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 1 }) // lead chains behind its own ACTIVE
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: c2 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unreachable target → 409 (existing pre-check, existing code)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
expect((await post({ systemId: f.farSystem, leadCrewId: c1 })).status).toBe(409) // JC-4: a beyond-reach system in the fixture; add it to buildWorldFixture if absent
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('malformed crew fields → 400, nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
expect((await post({ systemId: f.sys1 })).status).toBe(400) // leadCrewId is required
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 'x' })).status).toBe(400)
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 1.5 })).status).toBe(400)
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: 'nope' })).status).toBe(400)
|
||||||
|
expect((await post({ systemId: f.sys1, leadCrewId: 1, helperCrewIds: [1, 'x'] })).status).toBe(400)
|
||||||
|
expect((await post('not json at all' as unknown)).status).toBe(400)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
|
import { checkJumpTarget, getWorldState, JumpError, makePowerOfFor, queueStateFromSave, saveTaskRow } from '@/lib/game/save'
|
||||||
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
|
// Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP
|
||||||
|
// task — the move lands when the task completes on the turn tick (runTurn). Body:
|
||||||
|
// { systemId, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh
|
||||||
|
// world state as the instant jump did.
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
const team = parseCrewTeam(body)
|
||||||
|
if ('error' in team) {
|
||||||
|
return Response.json({ error: team.error }, { status: team.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'JUMP'))
|
||||||
|
await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write
|
||||||
|
return Response.json(await getWorldState(db))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof JumpError || err instanceof TaskError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Handler-level tests for mission accept (Task 5a(ii)): instant and free per the approved
|
||||||
|
// spec (JC-19) — the AVAILABLE → ACTIVE flip and the event land at the current turn, no
|
||||||
|
// turn is spent, no crew params; the board response shape is unchanged.
|
||||||
|
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 { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getMissions } from '@/lib/game/missions'
|
||||||
|
import { buildWorldFixture } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-mission-accept-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (id: string) => POST(new Request(`http://localhost/api/missions/${id}/accept`, { method: 'POST' }), { params: Promise.resolve({ id }) })
|
||||||
|
|
||||||
|
// The fixture suite mints no mission rows (JC-6) — insert exactly what the test needs (same
|
||||||
|
// shape as the insertMission helper in lib/game/missions.test.ts, plus a state override).
|
||||||
|
async function insertMission(
|
||||||
|
db: GameDb,
|
||||||
|
fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number; state?: string },
|
||||||
|
): 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, state: fields.state ?? 'AVAILABLE' })
|
||||||
|
.returning()
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/missions/[id]/accept (5a(ii))', () => {
|
||||||
|
it('happy: mission goes active, board returned, turn unchanged (free)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const id = await insertMission(db, { type: 'DELIVER', description: 'Jump to Outpost', targetSystemId: 2 })
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
|
||||||
|
const res = await post(String(id))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(await getMissions(db)) // same board shape as today
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE') // AVAILABLE → active
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // free — no turn cost
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-integer id → 400, nothing changes', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
expect((await post('abc')).status).toBe(400)
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown id → 404, nothing changes', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
expect((await post('999')).status).toBe(404)
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('not AVAILABLE → 409 (existing code), nothing changes', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, state: 'ACTIVE' })
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
expect((await post(String(id))).status).toBe(409)
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a mission already active → 409 (existing code), the target stays AVAILABLE', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
await insertMission(db, { type: 'FIND', description: 'Find a part while scanning', conditionKind: 'part', state: 'ACTIVE' })
|
||||||
|
const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2 })
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
expect((await post(String(id))).status).toBe(409)
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn)
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('AVAILABLE')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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. Instant and free (no turn,
|
||||||
|
// no body); returns the fresh board.
|
||||||
|
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await ensureSave(db) // acceptMission assumes a save exists
|
||||||
|
|
||||||
|
const { id } = await ctx.params
|
||||||
|
const missionId = Number(id)
|
||||||
|
if (!Number.isInteger(missionId)) {
|
||||||
|
return Response.json({ error: 'mission id must be an integer' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Response.json(await acceptMission(db, missionId))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MissionError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { ensureSave } from '@/lib/game/save'
|
||||||
|
import { getMissions } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// The full mission board for the single active save; auto-initializes on first hit.
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await ensureSave(db) // top-up inside getMissions assumes a save exists
|
||||||
|
return Response.json(await getMissions(db))
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Handler-level tests for the queueing research route (Task 5a(ii)): POST /api/research with
|
||||||
|
// a crew team queues a RESEARCH row instead of resolving instantly — the tech stays
|
||||||
|
// unresearched until the task completes on the turn tick, the cost comes out of the cargo
|
||||||
|
// hold at queue time, same snapshot shape, same pre-check codes, no turn spent at queue time.
|
||||||
|
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 { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getTechState } from '@/lib/game/research'
|
||||||
|
import { TECHS, type TechDef } from '@/lib/game/tech'
|
||||||
|
import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-research-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/research', { method: 'POST', body: JSON.stringify(body) }))
|
||||||
|
|
||||||
|
// The fixture suite mints no tech rows (JC-6) — insert exactly what the test needs from the
|
||||||
|
// pure TECHS definitions (WEAPONS_SYSTEMS: part cost, no prerequisites). The happy path seeds
|
||||||
|
// every row, like the new-game backfill: the snapshot read (getTechState) maps every TECHS def.
|
||||||
|
async function insertTech(db: GameDb, def: TechDef, over: { researched?: boolean } = {}) {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db
|
||||||
|
.insert(schema.tech)
|
||||||
|
.values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: over.researched ?? false })
|
||||||
|
.returning()
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixture suite mints no cargo hold (JC-6) — seed rows directly.
|
||||||
|
async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
for (const r of rows) {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/research (5a(ii))', () => {
|
||||||
|
it('happy: row with type/payload, turn unchanged, cost deducted, tech not researched yet', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const def = TECHS[0] // WEAPONS_SYSTEMS — the tech row's cost comes from its TECHS def
|
||||||
|
for (const d of TECHS) await insertTech(db, d) // every row, like the new-game backfill
|
||||||
|
const tech = (await db.db.select().from(schema.tech)).find((t) => t.slug === def.slug)!
|
||||||
|
await seedHold(db, [{ kind: def.costKind, name: def.costKind === 'resource' ? 'resource' : 'part', qty: def.costQty }])
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
|
||||||
|
const res = await post({ techId: tech.id, leadCrewId: c1 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(await getTechState(db)) // the same snapshot shape the instant action returned
|
||||||
|
const [row] = await db.db.select().from(schema.tasks)
|
||||||
|
expect(row).toMatchObject({ type: 'RESEARCH', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column
|
||||||
|
expect(JSON.parse(row.payload)).toEqual({ techId: tech.id })
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
|
||||||
|
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted at queue time
|
||||||
|
expect((await db.db.select().from(schema.tech)).find((t) => t.id === tech.id)!.researched).toBe(false) // the effect waits for the tick
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unaffordable → 422 (existing spendItems message), nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const def = TECHS[0]
|
||||||
|
const tech = await insertTech(db, def)
|
||||||
|
await seedHold(db, [{ kind: def.costKind, name: def.costKind === 'resource' ? 'resource' : 'part', qty: def.costQty - 1 }])
|
||||||
|
const res = await post({ techId: tech.id, leadCrewId: c1 })
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('already researched → 409 (existing pre-check, existing code), nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const def = TECHS[0]
|
||||||
|
const tech = await insertTech(db, def, { researched: true })
|
||||||
|
expect((await post({ techId: tech.id, leadCrewId: c1 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
|
import { getTechState, checkResearchPreconditions, ResearchError } from '@/lib/game/research'
|
||||||
|
import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||||
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
|
// Research one tech: assign the crew team (lead + helpers) and queue the RESEARCH task — the
|
||||||
|
// tech cost is deducted at queue time and the tech is marked researched (unlock effect
|
||||||
|
// included) when the task completes on the turn tick (runTurn). Body:
|
||||||
|
// { techId, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh
|
||||||
|
// tech tree state the instant action returned.
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
const team = parseCrewTeam(body)
|
||||||
|
if ('error' in team) {
|
||||||
|
return Response.json({ error: team.error }, { status: team.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'RESEARCH'))
|
||||||
|
await saveTaskRow(db, task)
|
||||||
|
await saveInventory(db, inventory) // the cost was deducted at queue time
|
||||||
|
return Response.json(await getTechState(db))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ResearchError || err instanceof TaskError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Handler-level tests for the queueing ship-build route (Task 5a(ii)): POST /api/ship/build
|
||||||
|
// with a crew team queues a SHIP_BUILD row instead of resolving instantly — the build at
|
||||||
|
// base values waits for the turn tick, the flat parts cost comes out of the cargo hold at
|
||||||
|
// queue time, same snapshot shape, same pre-check codes, no turn spent at queue time.
|
||||||
|
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 { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getShipState } from '@/lib/game/ship'
|
||||||
|
import { BUILD_PART_COST } from '@/lib/game/ship-costs'
|
||||||
|
import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-build-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/ship/build', { method: 'POST', body: JSON.stringify(body) }))
|
||||||
|
|
||||||
|
// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs. HULL is
|
||||||
|
// the only type that is unbuilt by default AND has no build gate (WEAPONS is gated behind
|
||||||
|
// the WEAPONS SYSTEMS tech, which the fixture leaves unresearched).
|
||||||
|
async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).returning()
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixture suite mints no cargo hold (JC-6) — seed rows directly.
|
||||||
|
async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
for (const r of rows) {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/ship/build (5a(ii))', () => {
|
||||||
|
it('happy: row with type/payload, turn unchanged, parts deducted, not built yet', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const sys = await insertSystem(db, { type: 'HULL', level: 0, capacity: 0 }) // unbuilt, no gate
|
||||||
|
await seedHold(db, [{ kind: 'part', name: 'part', qty: BUILD_PART_COST }]) // exactly the existing flat cost
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
|
||||||
|
const res = await post({ type: sys.type, leadCrewId: c1 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned
|
||||||
|
const [row] = await db.db.select().from(schema.tasks)
|
||||||
|
expect(row).toMatchObject({ type: 'SHIP_BUILD', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column
|
||||||
|
expect(JSON.parse(row.payload)).toEqual({ type: sys.type })
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
|
||||||
|
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted at queue time
|
||||||
|
expect((await db.db.select().from(schema.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(0) // the effect waits for the tick
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unaffordable → 422 (existing spendItems message), nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
await insertSystem(db, { type: 'HULL', level: 0, capacity: 0 })
|
||||||
|
await seedHold(db, [{ kind: 'part', name: 'part', qty: BUILD_PART_COST - 1 }])
|
||||||
|
const res = await post({ type: 'HULL', leadCrewId: c1 })
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('already built → 409 (existing pre-check, existing code), nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const sys = await insertSystem(db, { type: 'HULL', level: 1, capacity: 2 }) // built at base values
|
||||||
|
expect((await post({ type: sys.type, leadCrewId: c1 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
|
import { checkBuildPreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||||
|
import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||||
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
|
// Build an unbuilt ship system type at base values: assign the crew team (lead + helpers)
|
||||||
|
// and queue the SHIP_BUILD task — the flat parts cost is deducted at queue time and the
|
||||||
|
// build lands when the task completes on the turn tick (runTurn). Body:
|
||||||
|
// { type, leadCrewId, helperCrewIds? }. Queueing spends no turn; returns the same fresh
|
||||||
|
// ship state the instant action returned.
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
const team = parseCrewTeam(body)
|
||||||
|
if ('error' in team) {
|
||||||
|
return Response.json({ error: team.error }, { status: team.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_BUILD'))
|
||||||
|
await saveTaskRow(db, task)
|
||||||
|
await saveInventory(db, inventory) // the cost was deducted at queue time
|
||||||
|
return Response.json(await getShipState(db))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ShipError || err instanceof TaskError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Handler-level tests for the queueing ship-expand route (Task 5a(ii)): POST /api/ship/expand
|
||||||
|
// with a crew team queues a SHIP_EXPAND row instead of resolving instantly — the capacity
|
||||||
|
// bump waits for the turn tick, the cost comes out of the cargo hold at queue time, same
|
||||||
|
// snapshot shape, same pre-check codes, no turn spent at queue time.
|
||||||
|
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 { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getShipState } from '@/lib/game/ship'
|
||||||
|
import { expandCost } from '@/lib/game/ship-costs'
|
||||||
|
import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-expand-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/ship/expand', { method: 'POST', body: JSON.stringify(body) }))
|
||||||
|
|
||||||
|
// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs.
|
||||||
|
async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).returning()
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixture suite mints no cargo hold (JC-6) — seed rows directly.
|
||||||
|
async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
for (const r of rows) {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/ship/expand (5a(ii))', () => {
|
||||||
|
it('happy: row with type/payload, turn unchanged, cost deducted, no capacity change yet', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 2 }) // built, not maxed (base cap 10)
|
||||||
|
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: expandCost(sys.capacity) }]) // exactly the existing cost logic
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
|
||||||
|
const res = await post({ systemId: sys.id, leadCrewId: c1 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned
|
||||||
|
const [row] = await db.db.select().from(schema.tasks)
|
||||||
|
expect(row).toMatchObject({ type: 'SHIP_EXPAND', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column
|
||||||
|
expect(JSON.parse(row.payload)).toEqual({ systemId: sys.id })
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
|
||||||
|
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted at queue time
|
||||||
|
expect((await db.db.select().from(schema.shipSystems)).find((s) => s.id === sys.id)!.capacity).toBe(sys.capacity) // the effect waits for the tick
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unaffordable → 422 (existing spendItems message), nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 2 })
|
||||||
|
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: expandCost(sys.capacity) - 1 }])
|
||||||
|
const res = await post({ systemId: sys.id, leadCrewId: c1 })
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('max capacity → 409 (existing pre-check, existing code), nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const sys = await insertSystem(db, { type: 'REACTOR', level: 1, capacity: 99 }) // above any max
|
||||||
|
expect((await post({ systemId: sys.id, leadCrewId: c1 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
|
import { checkExpandPreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||||
|
import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||||
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
|
// Raise a built ship system's capacity by one (capped at max capacity): assign the crew team
|
||||||
|
// (lead + helpers) and queue the SHIP_EXPAND task — the cost (`capacity` units of any
|
||||||
|
// resource) is deducted at queue time and the capacity bump lands when the task completes on
|
||||||
|
// the turn tick (runTurn). Body: { systemId, leadCrewId, helperCrewIds? }. Queueing spends no
|
||||||
|
// turn; returns the same fresh ship state the instant action returned.
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
const team = parseCrewTeam(body)
|
||||||
|
if ('error' in team) {
|
||||||
|
return Response.json({ error: team.error }, { status: team.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_EXPAND'))
|
||||||
|
await saveTaskRow(db, task)
|
||||||
|
await saveInventory(db, inventory) // the cost was deducted at queue time
|
||||||
|
return Response.json(await getShipState(db))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ShipError || err instanceof TaskError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { getShipState } from '@/lib/game/ship'
|
||||||
|
|
||||||
|
// The ship's systems + cargo hold + turn; auto-initializes on first hit.
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
return Response.json(await getShipState(db))
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Handler-level tests for the queueing ship-upgrade route (Task 5a(ii)): POST /api/ship/upgrade
|
||||||
|
// with a crew team queues a SHIP_UPGRADE row instead of resolving instantly — the level-up
|
||||||
|
// waits for the turn tick, the cost comes out of the cargo hold at queue time, same snapshot
|
||||||
|
// shape, same pre-check codes, no turn spent at queue time.
|
||||||
|
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 { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getShipState } from '@/lib/game/ship'
|
||||||
|
import { upgradeCost } from '@/lib/game/ship-costs'
|
||||||
|
import { addCrew, buildWorldFixture } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-upgrade-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = (body: unknown) => POST(new Request('http://localhost/api/ship/upgrade', { method: 'POST', body: JSON.stringify(body) }))
|
||||||
|
|
||||||
|
// The fixture suite mints no ship rows (JC-6) — insert exactly what the test needs.
|
||||||
|
async function insertSystem(db: GameDb, over: { type: string; level: number; capacity: number }) {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db.insert(schema.shipSystems).values({ saveId: save.id, ...over }).returning()
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixture suite mints no cargo hold (JC-6) — seed rows directly.
|
||||||
|
async function seedHold(db: GameDb, rows: { kind: 'resource' | 'part'; name: string; qty: number }[]) {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
for (const r of rows) {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, ...r })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/ship/upgrade (5a(ii))', () => {
|
||||||
|
it('happy: row with type/payload, turn unchanged, cost deducted, no level change yet', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const sys = await insertSystem(db, { type: 'REACTOR', level: 2, capacity: 2 }) // built, not maxed (base cap 5)
|
||||||
|
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: upgradeCost(sys.level) }]) // exactly the existing cost logic
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
|
||||||
|
const res = await post({ systemId: sys.id, leadCrewId: c1 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(await getShipState(db)) // the same snapshot shape the instant action returned
|
||||||
|
const [row] = await db.db.select().from(schema.tasks)
|
||||||
|
expect(row).toMatchObject({ type: 'SHIP_UPGRADE', leadCrewId: c1, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
expect(JSON.parse(row.helpers)).toEqual([]) // helpers is a JSON text column
|
||||||
|
expect(JSON.parse(row.payload)).toEqual({ systemId: sys.id })
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(before.turn) // no turn increment
|
||||||
|
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // whole cost deducted at queue time
|
||||||
|
expect((await db.db.select().from(schema.shipSystems)).find((s) => s.id === sys.id)!.level).toBe(sys.level) // the effect waits for the tick
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unaffordable → 422 (existing spendItems message), nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const sys = await insertSystem(db, { type: 'REACTOR', level: 2, capacity: 2 })
|
||||||
|
await seedHold(db, [{ kind: 'resource', name: 'resource', qty: upgradeCost(sys.level) - 1 }])
|
||||||
|
const res = await post({ systemId: sys.id, leadCrewId: c1 })
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('max level → 409 (existing pre-check, existing code), nothing queued', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const sys = await insertSystem(db, { type: 'REACTOR', level: 99, capacity: 2 }) // above any max
|
||||||
|
expect((await post({ systemId: sys.id, leadCrewId: c1 })).status).toBe(409)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { parseCrewTeam } from '@/app/api/crew-team'
|
||||||
|
import { checkUpgradePreconditions, getShipState, ShipError } from '@/lib/game/ship'
|
||||||
|
import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save'
|
||||||
|
import { queueTask, TaskError } from '@/lib/game/tasks'
|
||||||
|
|
||||||
|
// Level up a built ship system by one (capped at max level): assign the crew team (lead +
|
||||||
|
// helpers) and queue the SHIP_UPGRADE task — the cost (`level` units of any resource) is
|
||||||
|
// deducted at queue time and the level-up lands when the task completes on the turn tick
|
||||||
|
// (runTurn). Body: { systemId, leadCrewId, helperCrewIds? }. Queueing spends no turn;
|
||||||
|
// returns the same fresh ship state the instant action returned.
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
const team = parseCrewTeam(body)
|
||||||
|
if ('error' in team) {
|
||||||
|
return Response.json({ error: team.error }, { status: team.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_UPGRADE'))
|
||||||
|
await saveTaskRow(db, task)
|
||||||
|
await saveInventory(db, inventory) // the cost was deducted at queue time
|
||||||
|
return Response.json(await getShipState(db))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ShipError || err instanceof TaskError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { getSystemState } from '@/lib/game/save'
|
||||||
|
|
||||||
|
// The current system's bodies (planets + moons) and the save's inventory; auto-initializes on first hit.
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
return Response.json(await getSystemState(db))
|
||||||
|
}
|
||||||
@@ -0,0 +1,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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Handler-level tests for the manual tick route (Task 5b): POST /api/turn (no body) advances
|
||||||
|
// exactly one turn — an empty queue is a FREE SKIP (200, turn +1, nothing else changes, the
|
||||||
|
// route never rejects), and a mid-countdown ACTIVE task decrements by exactly one in both the
|
||||||
|
// returned snapshot and the persisted row (no resolution, no mission hook — it never hits 0).
|
||||||
|
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 { getGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { addCrew, buildWorldFixture, insertTaskRow } from '@/lib/game/test-fixtures'
|
||||||
|
import { POST } from './route'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
// The route talks to the app-process singleton (getGameDb), so each test points it at a
|
||||||
|
// fresh temp file and uses that very instance for fixtures and assertions — two in-memory
|
||||||
|
// sql.js copies of one file would never see each other's writes.
|
||||||
|
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> }
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-turn-route-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
process.env.SPACE_WANDERER_DB_PATH = file
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
return { db: await getGameDb(), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.SPACE_WANDERER_DB_PATH
|
||||||
|
delete g.__spaceWandererGameDb
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// No request body — the handler never reads one.
|
||||||
|
const post = () => POST()
|
||||||
|
|
||||||
|
describe('POST /api/turn', () => {
|
||||||
|
it('empty queue → free skip: 200, turn +1, nothing else changes', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildWorldFixture(db)
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
|
||||||
|
const res = await post()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const snap = await res.json()
|
||||||
|
expect(snap.turn).toBe(before.turn + 1)
|
||||||
|
expect(await db.db.select().from(schema.tasks)).toHaveLength(0) // the tick creates nothing
|
||||||
|
expect(snap.tasks).toEqual([]) // the composed queue array is empty
|
||||||
|
})
|
||||||
|
|
||||||
|
it('one ACTIVE task → 200, remaining decrements by exactly 1 in the returned snapshot', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const [before] = await db.db.select().from(schema.saves)
|
||||||
|
// Fixture task: one ACTIVE row, mid-countdown — a direct insert on the queue table
|
||||||
|
// (the 4b save.test pattern: it works with the queue rows directly); it never reaches 0
|
||||||
|
// this tick, so no resolver and no mission hook runs
|
||||||
|
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 5, remaining: 3, status: 'ACTIVE', queuedAtTurn: before.turn })
|
||||||
|
|
||||||
|
const res = await post()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const snap = await res.json()
|
||||||
|
expect(snap.turn).toBe(before.turn + 1)
|
||||||
|
expect(snap.tasks).toHaveLength(1)
|
||||||
|
expect(snap.tasks[0].remaining).toBe(2) // 3 − 1: exactly one decrement
|
||||||
|
expect((await db.db.select().from(schema.tasks))[0].remaining).toBe(2) // the persisted row matches the snapshot
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { getWorldState, runTurn } from '@/lib/game/save'
|
||||||
|
|
||||||
|
// The manual tick (spec §3): advance exactly one turn — every ACTIVE task ages by one, tasks
|
||||||
|
// that reach 0 resolve (the 4a resolvers + their mission hooks, 4b), and PENDING successors
|
||||||
|
// auto-start in FIFO order. An empty queue is a FREE SKIP: turn +1 with nothing else changed —
|
||||||
|
// the route never rejects a tick, so the UI's NEXT TURN button is never disabled. No request
|
||||||
|
// body. Returns the same world snapshot GET /api/world returns, composed with the tick's
|
||||||
|
// task array (the snapshot readers do not select the tasks table, so it comes from the TickResult).
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const db = await getGameDb()
|
||||||
|
const tick = await runTurn(db)
|
||||||
|
return Response.json({ ...(await getWorldState(db)), tasks: tick.tasks })
|
||||||
|
} catch (err) {
|
||||||
|
// No domain errors escape runTurn (4b fails the task instead) — anything reaching here is
|
||||||
|
// a non-domain rethrow, i.e. a real bug: the error-mapping pattern, 500 when no status.
|
||||||
|
const e = err as { status?: number; message: string }
|
||||||
|
return Response.json({ error: e.message }, { status: e.status ?? 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -160,6 +160,12 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Radix Tabs hides inactive content via the `hidden` attribute, but inline display styles (the
|
||||||
|
#36 pane-fill flex chain on the WORLD MAP tab) outrank the UA stylesheet — restore semantics. */
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
@@ -20,6 +23,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.2.0",
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
|
"@types/d3-selection": "^3.0.12",
|
||||||
|
"@types/d3-zoom": "^3.0.9",
|
||||||
"@types/node": "^22.20.3",
|
"@types/node": "^22.20.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
@@ -221,6 +227,16 @@
|
|||||||
|
|
||||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
|
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||||
|
|
||||||
|
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
|
||||||
|
|
||||||
|
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||||
|
|
||||||
|
"@types/d3-selection": ["@types/d3-selection@3.0.12", "", {}, "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw=="],
|
||||||
|
|
||||||
|
"@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="],
|
||||||
|
|
||||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
|
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
|
||||||
@@ -295,6 +311,28 @@
|
|||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||||
|
|
||||||
|
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||||
|
|
||||||
|
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
|
||||||
|
|
||||||
|
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||||
|
|
||||||
|
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||||
|
|
||||||
|
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
|
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||||
|
|
||||||
|
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
|
||||||
|
|
||||||
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
|
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
|
||||||
|
|
||||||
|
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
|
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||||
|
|
||||||
|
// #38 — direct DB CRUD for the admin. ponytail: deliberately unauthenticated (explicit request);
|
||||||
|
// add auth before this is reachable beyond the dev host.
|
||||||
|
interface AdminColumnInfo { name: string; type: string; pk: number }
|
||||||
|
interface AdminTableInfo { name: string; columns: AdminColumnInfo[]; count: number }
|
||||||
|
interface AdminRows { rows: Record<string, unknown>[]; total: number }
|
||||||
|
|
||||||
|
// Form state is all strings (one input per column); parsed on save against the column type.
|
||||||
|
function parseValue(raw: string, col: AdminColumnInfo): number | string | null {
|
||||||
|
const v = raw.trim()
|
||||||
|
if (v === '' || v.toLowerCase() === 'null') return null
|
||||||
|
if (/INT|REAL|FLOA|DOUB/.test(col.type)) {
|
||||||
|
const n = Number(v)
|
||||||
|
if (!Number.isFinite(n)) throw new Error(`'${col.name}' must be a number, got '${v}'`)
|
||||||
|
return col.type.includes('INT') ? Math.trunc(n) : n
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(value: unknown): string {
|
||||||
|
return value === null || value === undefined ? '—' : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPanel({ onClose }: { onClose: () => void }) {
|
||||||
|
const [tables, setTables] = useState<AdminTableInfo[] | null>(null)
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
const [rows, setRows] = useState<AdminRows | null>(null)
|
||||||
|
// null = form closed; { key: null } = add row; { key: {...} } = edit that row.
|
||||||
|
const [editing, setEditing] = useState<{ key: Record<string, number> | null } | null>(null)
|
||||||
|
const [form, setForm] = useState<Record<string, string>>({})
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const table = tables?.find((t) => t.name === selected) ?? null
|
||||||
|
|
||||||
|
const refreshTables = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/tables')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
setTables((await res.json()) as AdminTableInfo[])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshRows = useCallback(async (name: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
setRows((await res.json()) as AdminRows)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshTables()
|
||||||
|
}, [refreshTables])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) return
|
||||||
|
setRows(null)
|
||||||
|
void refreshRows(selected)
|
||||||
|
}, [selected, refreshRows])
|
||||||
|
|
||||||
|
const pkColumns = (t: AdminTableInfo): AdminColumnInfo[] => t.columns.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
|
||||||
|
|
||||||
|
const rowKey = (t: AdminTableInfo, row: Record<string, unknown>): Record<string, number> =>
|
||||||
|
Object.fromEntries(pkColumns(t).map((c) => [c.name, Number(row[c.name])]))
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
if (!table) return
|
||||||
|
setEditing({ key: null })
|
||||||
|
setForm(Object.fromEntries(table.columns.map((c) => [c.name, ''])))
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (row: Record<string, unknown>) => {
|
||||||
|
if (!table) return
|
||||||
|
setEditing({ key: rowKey(table, row) })
|
||||||
|
setForm(Object.fromEntries(table.columns.map((c) => [c.name, row[c.name] == null ? '' : String(row[c.name])])))
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!table || !editing) return
|
||||||
|
try {
|
||||||
|
const payload: Record<string, number | string | null> = {}
|
||||||
|
for (const col of table.columns) payload[col.name] = parseValue(form[col.name] ?? '', col)
|
||||||
|
const url = `/api/admin/tables/${encodeURIComponent(table.name)}/rows`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: editing.key === null ? 'POST' : 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(editing.key === null ? payload : { key: editing.key, values: payload }),
|
||||||
|
})
|
||||||
|
const body = (await res.json()) as { error?: string } & Record<string, unknown>
|
||||||
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
|
setEditing(null)
|
||||||
|
setError(null)
|
||||||
|
await Promise.all([refreshRows(table.name), refreshTables()])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (row: Record<string, unknown>) => {
|
||||||
|
if (!table) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/tables/${encodeURIComponent(table.name)}/rows`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: rowKey(table, row) }),
|
||||||
|
})
|
||||||
|
const body = (await res.json()) as { error?: string }
|
||||||
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
|
await Promise.all([refreshRows(table.name), refreshTables()])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
padding: '0.3rem 0.4rem',
|
||||||
|
outline: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
background: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Panel notch="md" style={{ width: 'min(980px, 100%)', height: 'min(640px, 100%)', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<PanelHeader>
|
||||||
|
<PanelTitle>ADMIN // DB CRUD</PanelTitle>
|
||||||
|
<Badge variant="WARNING">NO AUTH</Badge>
|
||||||
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
|
<Button size="SM" onClick={onClose}>CLOSE</Button>
|
||||||
|
</div>
|
||||||
|
</PanelHeader>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '200px 1fr', gap: '1px', background: 'var(--border)', minHeight: 0 }}>
|
||||||
|
{/* ── TABLE LIST ── */}
|
||||||
|
<div style={{ background: 'var(--surface)', overflowY: 'auto', padding: '0.5rem' }}>
|
||||||
|
{!tables ? (
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
|
) : (
|
||||||
|
tables.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.name}
|
||||||
|
onClick={() => setSelected(t.name)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.4rem 0.5rem',
|
||||||
|
marginBottom: '2px',
|
||||||
|
background: selected === t.name ? 'var(--surface-raised)' : 'transparent',
|
||||||
|
border: `1px solid ${selected === t.name ? 'var(--color-green)' : 'var(--border)'}`,
|
||||||
|
color: selected === t.name ? 'var(--color-green)' : 'var(--text-secondary)',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{t.name}</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>{t.count}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── ROWS / EDITOR ── */}
|
||||||
|
<div style={{ background: 'var(--surface)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
{!table ? (
|
||||||
|
<PanelContent>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SELECT A TABLE</span>
|
||||||
|
</PanelContent>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.4rem 0.75rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>{table.name}</span>
|
||||||
|
{rows && (
|
||||||
|
<Badge variant="ACTIVE">
|
||||||
|
{rows.rows.length} / {rows.total}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.4rem' }}>
|
||||||
|
{!editing && <Button size="SM" variant="EXEC" onClick={openAdd}>ADD ROW</Button>}
|
||||||
|
{error && (
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', alignSelf: 'center' }}>{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.4rem' }}>
|
||||||
|
{table.columns.map((c) => (
|
||||||
|
<label key={c.name} style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
||||||
|
<span style={{ fontSize: '0.58rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>
|
||||||
|
{c.name.toUpperCase()}
|
||||||
|
{c.pk > 0 ? ` (PK${c.pk})` : ''} · {c.type}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={form[c.name] ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, [c.name]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.5rem' }}>
|
||||||
|
<Button size="SM" variant="EXEC" onClick={() => void save()}>SAVE</Button>
|
||||||
|
<Button size="SM" onClick={() => setEditing(null)}>CANCEL</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{!rows ? (
|
||||||
|
<PanelContent>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
|
</PanelContent>
|
||||||
|
) : rows.rows.length === 0 ? (
|
||||||
|
<PanelContent>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY TABLE</span>
|
||||||
|
</PanelContent>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.65rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{table.columns.map((c) => (
|
||||||
|
<th
|
||||||
|
key={c.name}
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '0.35rem 0.5rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
{c.pk > 0 ? ` (PK${c.pk})` : ''}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th style={{ position: 'sticky', top: 0, background: 'var(--surface-raised)', padding: '0.35rem 0.5rem', borderBottom: '1px solid var(--border)' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.rows.map((row) => (
|
||||||
|
<tr key={JSON.stringify(rowKey(table, row))}>
|
||||||
|
{table.columns.map((c) => (
|
||||||
|
<td
|
||||||
|
key={c.name}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.5rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
color: row[c.name] === null ? 'var(--text-muted)' : 'var(--text-secondary)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '220px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cellText(row[c.name])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td style={{ padding: '0.3rem 0.5rem', borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', gap: '0.3rem' }}>
|
||||||
|
<Button size="SM" onClick={() => openEdit(row)}>EDIT</Button>
|
||||||
|
<Button size="SM" variant="ABORT" onClick={() => void remove(row)}>DEL</Button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'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, CrewState } 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.
|
||||||
|
|
||||||
|
// Spec §8: the three combat traits that carry no duration effect yet — their effect lands
|
||||||
|
// with the encounters iteration, so the trait line labels them encounter-ready (STANDBY).
|
||||||
|
// The label is additive — the original name always appears in the text unchanged.
|
||||||
|
const STANDBY_TRAITS = new Set(['SHARP SHOOTER', 'SHORT FUSE', 'JINXED IN A DOGFIGHT'])
|
||||||
|
const standbyLabel = (trait: string) => (STANDBY_TRAITS.has(trait) ? `${trait} (STANDBY — encounter-ready)` : trait)
|
||||||
|
|
||||||
|
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 CrewState // { crew, tasks } — the endpoint wraps the roster (JC-7)
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setRoster(r.crew)
|
||||||
|
})
|
||||||
|
.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>
|
||||||
|
{/* The member's four skill levels, fixed order (JC-27) — one `·`-separated line. */}
|
||||||
|
<p style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
|
||||||
|
{member.skills.map((s) => `${s.skill} L${s.level}`).join(' · ')}
|
||||||
|
</p>
|
||||||
|
{/* The title only exists at L3+ (SENIOR) / L5 (MASTER) — titleFor returns null below. */}
|
||||||
|
{member.title !== null && (
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>
|
||||||
|
{member.title}
|
||||||
|
</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)' }}>{standbyLabel(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)' }}>{standbyLabel(member.traitBad)}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
ORIGIN // {member.origin.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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%' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { TeamBuilder } from '@/components/team-builder'
|
||||||
|
import type { CrewState, 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. A button click ARMS the planet (#44): the crew team
|
||||||
|
// builder mounts at the top of the column and its DISPATCH POSTs /api/explore with the team.
|
||||||
|
// 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)
|
||||||
|
// #44 — the armed planet (the last-clicked unexplored row); the builder above the list picks
|
||||||
|
// the team and its DISPATCH performs the scan.
|
||||||
|
const [armedPlanetId, setArmedPlanetId] = useState<number | null>(null)
|
||||||
|
const [crew, setCrew] = useState<CrewState | null>(null)
|
||||||
|
|
||||||
|
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
|
||||||
|
// successful dispatch (the scan response updates the system state, not the roster).
|
||||||
|
// Keep-old-on-failure, the house .catch(() => {}) pattern.
|
||||||
|
const fetchCrew = useCallback(() => {
|
||||||
|
fetch('/api/crew')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as CrewState
|
||||||
|
})
|
||||||
|
.then((c) => setCrew(c))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCrew()
|
||||||
|
}, [fetchCrew])
|
||||||
|
|
||||||
|
const explore = useCallback(
|
||||||
|
async (planetId: number, leadCrewId: number, helperCrewIds: number[]) => {
|
||||||
|
setExploringId(planetId)
|
||||||
|
setArmedPlanetId(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/explore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ planetId, leadCrewId, helperCrewIds }),
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setExploringId(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChanged, fetchCrew]
|
||||||
|
)
|
||||||
|
|
||||||
|
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' }}>
|
||||||
|
{/* #44 — the armed planet's crew picker (JC-13: mounted only while a planet is armed);
|
||||||
|
its DISPATCH performs the scan with the picked team. */}
|
||||||
|
{armedPlanetId !== null && crew !== null && (
|
||||||
|
<TeamBuilder
|
||||||
|
type="EXPLORE"
|
||||||
|
roster={crew.crew}
|
||||||
|
tasks={crew.tasks}
|
||||||
|
onQueue={(leadCrewId, helperCrewIds) => void explore(armedPlanetId, leadCrewId, helperCrewIds)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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={() => setArmedPlanetId(armedPlanetId === row.id ? null : row.id)} // #44 — arms/disarms; the builder dispatches
|
||||||
|
>
|
||||||
|
{exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // ${armedPlanetId === row.id ? 'PICK TEAM' : '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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+270
-503
@@ -1,21 +1,34 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { AdminPanel } from '@/components/admin-panel'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { Button } from '@/components/ui/button'
|
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 { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { StatCard } from '@/components/ui/stat-card'
|
import { StatCard } from '@/components/ui/stat-card'
|
||||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
import { CrewPanel } from '@/components/crew-panel'
|
||||||
|
import { EventLogPanel } from '@/components/event-log'
|
||||||
|
import { LocalSystemPanel } from '@/components/local-system'
|
||||||
|
import { MissionPanel } from '@/components/missions'
|
||||||
|
import { NextTurnButton } from '@/components/next-turn-button'
|
||||||
|
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||||
|
import { SkillTreePanel } from '@/components/skill-tree'
|
||||||
|
import { TaskStrip } from '@/components/task-strip'
|
||||||
|
import { WorldMapPanel } from '@/components/world-map'
|
||||||
|
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
||||||
|
import type { CrewState, SystemState } from '@/lib/game/save'
|
||||||
|
import type { ShipState } from '@/lib/game/ship'
|
||||||
|
import type { TechDto } from '@/lib/game/tech'
|
||||||
|
import pkg from '@/package.json'
|
||||||
|
|
||||||
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
// 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
|
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven
|
||||||
// placeholder SpaceWanderer content — no game logic yet. Single player, no auth, by design.
|
// 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 {
|
function useNarrow(threshold = 900): boolean {
|
||||||
const [narrow, setNarrow] = useState(
|
const [narrow, setNarrow] = useState(
|
||||||
@@ -29,137 +42,126 @@ function useNarrow(threshold = 900): boolean {
|
|||||||
return narrow
|
return narrow
|
||||||
}
|
}
|
||||||
|
|
||||||
function now(): string {
|
// Must match the TabsTrigger values in the render below — used to validate the tab persisted for
|
||||||
return new Date().toLocaleTimeString('en-GB', { hour12: false })
|
// #27 (an unknown stored value falls back to the default).
|
||||||
}
|
const TAB_IDS = ['world-map', 'local-system', 'crew', 'ship-systems', 'skill-tree', 'missions', 'event-log']
|
||||||
|
const ACTIVE_TAB_KEY = 'sw-active-tab'
|
||||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
|
||||||
|
|
||||||
const 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() {
|
export function MissionControl() {
|
||||||
const narrow = useNarrow()
|
const narrow = useNarrow()
|
||||||
const [tick, setTick] = useState(0)
|
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)
|
||||||
|
// #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until
|
||||||
|
// then so SSR and the first client render agree on the default tab (no hydration mismatch).
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||||
|
// #38 — admin DB CRUD overlay, opened from the header.
|
||||||
|
const [adminOpen, setAdminOpen] = useState(false)
|
||||||
|
// #44 (v0.10.1) — true only after hydration: the banner clock must render '' in both SSR and the
|
||||||
|
// first client render, otherwise the prerendered (build-time) string desyncs at hydration.
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
|
||||||
|
// #44 — the four mount fetches, extracted into a callback so the NEXT TURN button (5b) can
|
||||||
|
// re-run them after /api/turn: a tick can change all four snapshots (the turn always; inventory
|
||||||
|
// on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research
|
||||||
|
// completion) — a strict superset of refreshSystem, which stays for the jump callback. Same
|
||||||
|
// failure policy as the mount fetch — keep the old state on error.
|
||||||
|
const refreshAll = useCallback(() => {
|
||||||
|
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 CrewState // { crew, tasks } — the endpoint wraps the roster (JC-7)
|
||||||
|
})
|
||||||
|
.then((c) => {
|
||||||
|
if (!cancelled) setCrewCount(c.crew.length)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// The effect returns refreshAll's cleanup, so unmount still cancels the in-flight setStates.
|
||||||
|
useEffect(() => refreshAll(), [refreshAll])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
// #44 (v0.10.1) — flip after hydration so the banner clock can start (SSR/first render both '').
|
||||||
|
useEffect(() => setHydrated(true), [])
|
||||||
|
|
||||||
|
// #27 — restore the persisted tab once on mount (validated against TAB_IDS).
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem(ACTIVE_TAB_KEY)
|
||||||
|
if (stored && TAB_IDS.includes(stored)) setActiveTab(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// #30 — re-fetch /api/system after a jump: WorldMapPanel owns the world state, so without this
|
||||||
|
// the lifted system state stays on the previous system until a page refresh. Same failure
|
||||||
|
// policy as the mount fetch — keep the old state on error.
|
||||||
|
const refreshSystem = useCallback(() => {
|
||||||
|
fetch('/api/system')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as SystemState
|
||||||
|
})
|
||||||
|
.then((s) => setSystem(s))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// #44 (v0.10.1) — hydration-safe: '' until hydrated (SSR and first client render agree), then the
|
||||||
|
// live time on the 1s tick — no more build-time clock baked into the prerendered HTML.
|
||||||
|
const timeStr = hydrated ? new Date().toLocaleTimeString('en-GB', { hour12: false }) : ''
|
||||||
|
|
||||||
|
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
|
||||||
|
// tab via the same pure helpers the API enforces (lib/game/tech.ts).
|
||||||
|
const researchedSlugs = new Set((techs ?? []).filter((t) => t.state === 'RESEARCHED').map((t) => t.slug))
|
||||||
|
const navAi = researchedSlugs.has('NAV_AI') // #35 — two-hop systems become jumpable chart fields
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
|
// Wide layout is pinned to the viewport so only the columns scroll internally (#21);
|
||||||
|
// narrow keeps natural flow with body scroll.
|
||||||
|
height: narrow ? undefined : '100vh',
|
||||||
|
overflow: narrow ? undefined : 'hidden',
|
||||||
background: 'var(--background)',
|
background: 'var(--background)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -190,7 +192,7 @@ export function MissionControl() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
◄ SHIP
|
v{pkg.version}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
||||||
@@ -230,6 +232,9 @@ export function MissionControl() {
|
|||||||
|
|
||||||
<Badge variant="SCANNING">SECTOR SCAN</Badge>
|
<Badge variant="SCANNING">SECTOR SCAN</Badge>
|
||||||
|
|
||||||
|
{/* #38 — admin DB CRUD, no auth by design (see components/admin-panel.tsx) */}
|
||||||
|
<Button size="SM" onClick={() => setAdminOpen(true)}>ADMIN</Button>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--color-green)',
|
color: 'var(--color-green)',
|
||||||
@@ -245,10 +250,13 @@ export function MissionControl() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{adminOpen && <AdminPanel onClose={() => setAdminOpen(false)} />}
|
||||||
|
|
||||||
{/* ── BODY ── */}
|
{/* ── BODY ── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: 0, // let the grid below be constrained to the viewport instead of growing it
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '1px',
|
gap: '1px',
|
||||||
@@ -295,8 +303,9 @@ export function MissionControl() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: 0, // same — the columns' overflowY:auto then scrolls inside their tracks
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
|
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
|
||||||
gap: '1px',
|
gap: '1px',
|
||||||
background: 'var(--border)',
|
background: 'var(--border)',
|
||||||
}}
|
}}
|
||||||
@@ -316,11 +325,25 @@ export function MissionControl() {
|
|||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
<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>
|
</div>
|
||||||
</PanelHeader>
|
</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>
|
||||||
|
|
||||||
<Panel notch="sm">
|
<Panel notch="sm">
|
||||||
@@ -328,11 +351,40 @@ export function MissionControl() {
|
|||||||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<PanelContent>
|
<PanelContent>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
{ship ? (
|
||||||
<Progress label="HULL INTEGRITY" value={87} />
|
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
||||||
<Progress label="REACTOR OUTPUT" value={94} />
|
// crew roster vs the display cap; SECTOR/TURN rows folded in from the old SHIP DATA
|
||||||
<Progress label="CREW ROSTER / 6" value={50} />
|
// placeholder (the rest of it duplicated these bars or had no game equivalent).
|
||||||
</div>
|
<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>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,7 +400,9 @@ export function MissionControl() {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Panel notch="md" style={{ flex: 1 }}>
|
{/* flex column + minHeight:0 so the tab content scrolls inside the panel (#21) —
|
||||||
|
the Panel root's default overflow:hidden would otherwise clip it */}
|
||||||
|
<Panel notch="md" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||||
@@ -356,15 +410,29 @@ export function MissionControl() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<PanelContent style={{ padding: 0 }}>
|
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<Tabs defaultValue="world-map">
|
{/* Flex column at least pane-tall so a tab can fill it (#36). min-height, not height:
|
||||||
<TabsList
|
tall tabs grow the box to their content, which keeps the sticky tab bar's containing
|
||||||
style={{
|
block spanning the whole scroll range (a capped height releases the pin on deep
|
||||||
padding: '0 1rem',
|
scrolls) and means items never overflow the box, so nothing gets shrink-squashed. */}
|
||||||
background: 'var(--surface-raised)',
|
<Tabs
|
||||||
overflowX: 'auto',
|
value={activeTab ?? 'world-map'}
|
||||||
}}
|
onValueChange={(v) => {
|
||||||
>
|
setActiveTab(v)
|
||||||
|
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}
|
||||||
|
>
|
||||||
|
<TabsList
|
||||||
|
style={{
|
||||||
|
padding: '0 1rem',
|
||||||
|
background: 'var(--surface-raised)', // opaque — content scrolls under it (#31)
|
||||||
|
overflowX: 'auto',
|
||||||
|
position: 'sticky', // #31 — pinned in both modes: to the top of the OPERATIONS
|
||||||
|
top: 0, // scroll area (wide) and to the viewport when the body scrolls (narrow)
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
|
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
|
||||||
<TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger>
|
<TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger>
|
||||||
<TabsTrigger value="crew">CREW</TabsTrigger>
|
<TabsTrigger value="crew">CREW</TabsTrigger>
|
||||||
@@ -374,291 +442,51 @@ export function MissionControl() {
|
|||||||
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* WORLD MAP TAB */}
|
{/* WORLD MAP TAB — live data from /api/world; fills the whole pane (#36). Wide mode:
|
||||||
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
flex column so the panel can fill it with pure flex sizing (a min-height-only
|
||||||
<NodeGraph
|
container breaks percentage resolution, so no height:'100%' anywhere in this chain). */}
|
||||||
nodes={worldMapNodes}
|
<TabsContent
|
||||||
edges={worldMapEdges}
|
value="world-map"
|
||||||
variant="ACTIVE"
|
style={{ margin: 0, padding: '1rem', ...(!narrow ? { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : {}) }}
|
||||||
title="GALACTIC CHART // JUMP ROUTES (PLACEHOLDER)"
|
>
|
||||||
height={380}
|
<WorldMapPanel height={380} narrow={narrow} navAi={navAi} onJumped={refreshSystem} />
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</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' }}>
|
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<LocalSystemPanel system={system} onChanged={setSystem} />
|
||||||
<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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* CREW TAB */}
|
{/* CREW TAB — live data from /api/crew */}
|
||||||
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<CrewPanel narrow={narrow} />
|
||||||
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>
|
|
||||||
</TabsContent>
|
</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' }}>
|
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} researchedSlugs={researchedSlugs} />
|
||||||
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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* SKILL TREE TAB */}
|
{/* SKILL TREE TAB — live data from /api/tech (state owned by MissionControl) */}
|
||||||
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{skillCards.map((skill) => (
|
|
||||||
<Card key={skill.name}>
|
|
||||||
<CardHeader>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<CardTitle>{skill.name}</CardTitle>
|
|
||||||
<Badge variant={skill.state === 'AVAILABLE' ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
|
|
||||||
{skill.state}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
|
||||||
UNLOCKS: {skill.unlocks}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button variant="OUTLINE" size="SM">
|
|
||||||
RESEARCH
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* MISSIONS TAB */}
|
{/* MISSIONS TAB — live data from /api/missions */}
|
||||||
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<MissionPanel />
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
|
||||||
>
|
|
||||||
<Alert variant="STATUS">
|
|
||||||
<AlertTitle>ENCOUNTER #E-102 — DERELICT FREIGHTER</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Placeholder briefing. Real encounter resolution lands with the turn engine.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: narrow ? '1fr' : '1fr 1fr',
|
|
||||||
gap: '0.625rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: 'DESTINATION', value: 'SECTOR 7G // DRIFT LANE' },
|
|
||||||
{ label: 'ETA', value: 'T-07:14 (PLACEHOLDER)' },
|
|
||||||
{ label: 'THREAT LEVEL', value: 'LOW — UNMANNED' },
|
|
||||||
{ label: 'CARGO MANIFEST', value: 'UNKNOWN' },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.55rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
marginBottom: '0.35rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* EVENT LOG TAB */}
|
{/* EVENT LOG TAB */}
|
||||||
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<Terminal
|
<EventLogPanel />
|
||||||
lines={logLines}
|
|
||||||
title="SHIP LOG // PLACEHOLDER FEED"
|
|
||||||
height="22rem"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── RIGHT: UPCOMING ENCOUNTERS + SHIP DATA ── */}
|
{/* ── RIGHT: CARGO HOLD (persistent — inventory lifted from /api/system) ── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--background)',
|
background: 'var(--background)',
|
||||||
@@ -671,112 +499,44 @@ export function MissionControl() {
|
|||||||
>
|
>
|
||||||
<Panel notch="sm">
|
<Panel notch="sm">
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<PanelTitle>UPCOMING ENCOUNTERS</PanelTitle>
|
<PanelTitle>CARGO HOLD // INVENTORY</PanelTitle>
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
{system && (
|
||||||
<Badge variant="SCANNING">{encounters.length} QUEUED</Badge>
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
</div>
|
<Badge variant={system.inventory.length > 0 ? 'ACTIVE' : 'OFFLINE'}>
|
||||||
|
{system.inventory.reduce((n, i) => n + i.qty, 0)} UNITS
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<PanelContent style={{ padding: 0 }}>
|
<PanelContent style={{ padding: 0 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
{!system ? (
|
||||||
{encounters.map((enc, i) => {
|
<div style={{ padding: '0.6rem 0.75rem' }}>
|
||||||
const isNext = i === 0
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
const isLast = i === encounters.length - 1
|
</div>
|
||||||
return (
|
) : system.inventory.length === 0 ? (
|
||||||
<div
|
<div style={{ padding: '0.6rem 0.75rem' }}>
|
||||||
key={enc.id}
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY — EXPLORE WORLDS TO FIND THINGS</span>
|
||||||
style={{
|
</div>
|
||||||
padding: '0.6rem 0.75rem',
|
) : (
|
||||||
borderBottom: isLast ? 'none' : '1px solid var(--border)',
|
system.inventory.map((item) => (
|
||||||
background: isNext ? 'var(--surface-raised)' : 'transparent',
|
<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>
|
||||||
<div
|
</span>
|
||||||
style={{
|
<Badge variant="ACTIVE">×{item.qty}</Badge>
|
||||||
display: 'flex',
|
</div>
|
||||||
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>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel notch="sm">
|
{/* #52 — the manual tick control (5b), moved in from the fixed bottom-right overlay:
|
||||||
<PanelHeader>
|
last child of the pane's flex column — full width, size MD, pinned to the pane
|
||||||
<PanelTitle>SHIP DATA</PanelTitle>
|
bottom when the content is short (the wrapper's marginTop:auto) and scrolling
|
||||||
</PanelHeader>
|
with the pane when the cargo list is long. In normal flow, the active-tasks strip
|
||||||
<PanelContent>
|
can no longer paint over it; the pane (unlike the tab content) is outside Radix's
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
unmount, so it stays visible on every tab. */}
|
||||||
{shipData.map((item, i) => (
|
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
|
||||||
<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}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PanelContent>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -814,6 +574,13 @@ export function MissionControl() {
|
|||||||
|
|
||||||
<Badge variant="ACTIVE">SYS ONLINE</Badge>
|
<Badge variant="ACTIVE">SYS ONLINE</Badge>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{/* #44 — the active-tasks strip: fixed bottom-left, outside the tab grid so it stays
|
||||||
|
visible on every tab. (#52 moved the NEXT TURN button out of this fixed-overlay area
|
||||||
|
and into the right pane, so the two can no longer overlap.) Data: the shell's system
|
||||||
|
snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on
|
||||||
|
every shell refresh with no fetch of its own. */}
|
||||||
|
<TaskStrip tasks={system?.tasks ?? []} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import type { MissionDto } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// MISSIONS tab — live data. Fetches /api/missions on mount (Radix Tabs unmounts inactive tabs, so it
|
||||||
|
// refetches whenever the tab is opened); accept responses are applied directly (no polling). ACCEPT is
|
||||||
|
// enabled only when the contract is AVAILABLE and no other mission is ACTIVE — mirroring what
|
||||||
|
// POST /api/missions/{id}/accept enforces.
|
||||||
|
|
||||||
|
export function MissionPanel() {
|
||||||
|
const [missions, setMissions] = useState<MissionDto[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null) // one action at a time — turn-based game
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/missions')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as MissionDto[]
|
||||||
|
})
|
||||||
|
.then((m) => {
|
||||||
|
if (!cancelled) setMissions(m)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const accept = useCallback(async (mission: MissionDto) => {
|
||||||
|
setBusyId(mission.id)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/missions/${mission.id}/accept`, { method: 'POST' })
|
||||||
|
const data = (await res.json()) as MissionDto[] & { error?: string }
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||||
|
setMissions(data) // response is the fresh board — apply directly
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!missions && !error) {
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING MISSIONS…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = (missions ?? []).find((m) => m.state === 'ACTIVE')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{error && (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem' }}>
|
||||||
|
{(missions ?? []).map((mission) => (
|
||||||
|
<MissionCard key={mission.id} mission={mission} busy={busyId === mission.id} blockedByActive={!!active && active.id !== mission.id} onAccept={accept} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissionCard({
|
||||||
|
mission,
|
||||||
|
busy,
|
||||||
|
blockedByActive,
|
||||||
|
onAccept,
|
||||||
|
}: {
|
||||||
|
mission: MissionDto
|
||||||
|
busy: boolean
|
||||||
|
blockedByActive: boolean
|
||||||
|
onAccept: (mission: MissionDto) => void
|
||||||
|
}) {
|
||||||
|
const acceptDisabled = busy || mission.state !== 'AVAILABLE' || blockedByActive
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{mission.type}</CardTitle>
|
||||||
|
<Badge variant={mission.state === 'ACTIVE' ? 'ACTIVE' : mission.state === 'AVAILABLE' ? 'SCANNING' : 'OFFLINE'} className="ml-auto">
|
||||||
|
{mission.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>CONTRACT // #{mission.id}</p>
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>{mission.description.toUpperCase()}</p>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.08em' }}>
|
||||||
|
REWARD {mission.rewardQty}× {mission.rewardKind === 'resource' ? 'RESOURCES' : 'PARTS'}
|
||||||
|
</p>
|
||||||
|
{mission.type === 'EXPLORE' && (
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
PROGRESS // {mission.progress}/{mission.progressTarget}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="OUTLINE" size="SM" disabled={acceptDisabled} onClick={() => onAccept(mission)}>
|
||||||
|
ACCEPT
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
// NEXT TURN — the manual tick control (5b): a normal-flow block in the right pane (#52) carrying
|
||||||
|
// the current turn from the ship snapshot the shell holds. One click = POST /api/turn (no body);
|
||||||
|
// on success the shell re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by
|
||||||
|
// game state — an empty queue is a FREE SKIP (the route always returns 200) — and the only
|
||||||
|
// transient is an in-flight request, which the handler ignores. `disabled` is never set, so the
|
||||||
|
// cva disabled:opacity-40 styling never applies either.
|
||||||
|
// #52 — the fixed bottom-right overlay became the right pane's last flex child (mission-control):
|
||||||
|
// full width, size MD, pinned to the pane bottom when the content is short (marginTop:auto) and
|
||||||
|
// scrolling with the pane when the cargo list is long. No position/zIndex any more — so the
|
||||||
|
// active-tasks strip can no longer paint over it.
|
||||||
|
|
||||||
|
export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
|
||||||
|
const [inFlight, setInFlight] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (inFlight) return // the only guard — a transient, never a game-state disable
|
||||||
|
setInFlight(true)
|
||||||
|
setError(null)
|
||||||
|
fetch('/api/turn', { method: 'POST' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
onTicked() // the same app-wide snapshot refresh the shell uses after every other action
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setInFlight(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
marginTop: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
|
||||||
|
TURN {turn ?? '—'}
|
||||||
|
</span>
|
||||||
|
<Button variant="EXEC" size="MD" className="w-full" onClick={tick}>
|
||||||
|
NEXT TURN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
'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 { TeamBuilder } from '@/components/team-builder'
|
||||||
|
import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
|
||||||
|
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
|
||||||
|
import type { CrewState } from '@/lib/game/save'
|
||||||
|
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from '@/lib/game/tech'
|
||||||
|
import type { TaskType } from '@/lib/game/tasks'
|
||||||
|
|
||||||
|
// 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). A card's UPGRADE / EXPAND / BUILD button ARMS the action (#44): the
|
||||||
|
// crew team builder mounts at the top of the column and its DISPATCH POSTs the ship route with
|
||||||
|
// the team. 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
|
||||||
|
// #44 — the armed ship action (which card + which of UPGRADE/EXPAND/BUILD); the builder above
|
||||||
|
// the grid picks the team and its DISPATCH performs it.
|
||||||
|
const [armed, setArmed] = useState<{ type: TaskType; url: string; body: Record<string, unknown>; systemId: number } | null>(null)
|
||||||
|
const [crew, setCrew] = useState<CrewState | null>(null)
|
||||||
|
|
||||||
|
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
|
||||||
|
// successful dispatch (the action response updates the ship state, not the roster).
|
||||||
|
// Keep-old-on-failure, the house .catch(() => {}) pattern.
|
||||||
|
const fetchCrew = useCallback(() => {
|
||||||
|
fetch('/api/crew')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as CrewState
|
||||||
|
})
|
||||||
|
.then((c) => setCrew(c))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCrew()
|
||||||
|
}, [fetchCrew])
|
||||||
|
|
||||||
|
// #44 — arm an action (toggle: clicking the armed action's button again disarms it).
|
||||||
|
const arm = useCallback((type: TaskType, url: string, body: Record<string, unknown>, systemId: number) => {
|
||||||
|
setArmed((a) => (a && a.systemId === systemId && a.type === type ? null : { type, url, body, systemId }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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)
|
||||||
|
setArmed(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
|
||||||
|
fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{/* #44 — the armed ship action's crew picker (JC-13: mounted only while an action is
|
||||||
|
armed); its DISPATCH performs it with the picked team. */}
|
||||||
|
{armed !== null && crew !== null && (
|
||||||
|
<TeamBuilder
|
||||||
|
type={armed.type}
|
||||||
|
roster={crew.crew}
|
||||||
|
tasks={crew.tasks}
|
||||||
|
onQueue={(leadCrewId, helperCrewIds) => void act(armed.url, { ...armed.body, leadCrewId, helperCrewIds })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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}
|
||||||
|
armedType={armed !== null && armed.systemId === system.id ? armed.type : null}
|
||||||
|
onArm={arm}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemCard({
|
||||||
|
system,
|
||||||
|
resources,
|
||||||
|
parts,
|
||||||
|
busy,
|
||||||
|
researchedSlugs,
|
||||||
|
armedType,
|
||||||
|
onArm,
|
||||||
|
}: {
|
||||||
|
system: ShipSystemDto
|
||||||
|
resources: number
|
||||||
|
parts: number
|
||||||
|
busy: boolean
|
||||||
|
researchedSlugs: Set<string>
|
||||||
|
armedType: TaskType | null // #44 — which of this card's actions is the armed target
|
||||||
|
onArm: (type: TaskType, url: string, body: Record<string, unknown>, systemId: number) => 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={() => onArm('SHIP_UPGRADE', '/api/ship/upgrade', { systemId: system.id }, system.id)}
|
||||||
|
>
|
||||||
|
{armedType === 'SHIP_UPGRADE' ? 'PICK TEAM' : 'UPGRADE'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="OUTLINE"
|
||||||
|
size="SM"
|
||||||
|
disabled={expandDisabled}
|
||||||
|
onClick={() => onArm('SHIP_EXPAND', '/api/ship/expand', { systemId: system.id }, system.id)}
|
||||||
|
>
|
||||||
|
{armedType === 'SHIP_EXPAND' ? 'PICK TEAM' : 'EXPAND'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="OUTLINE"
|
||||||
|
size="SM"
|
||||||
|
disabled={buildDisabled}
|
||||||
|
onClick={() => onArm('SHIP_BUILD', '/api/ship/build', { type: system.type }, system.id)}
|
||||||
|
>
|
||||||
|
{armedType === 'SHIP_BUILD' ? 'PICK TEAM' : 'BUILD NEW'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
'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 { TeamBuilder } from '@/components/team-builder'
|
||||||
|
import { totalQty } from '@/lib/game/ship-costs'
|
||||||
|
import type { ShipState } from '@/lib/game/ship'
|
||||||
|
import type { CrewState } from '@/lib/game/save'
|
||||||
|
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). A card's RESEARCH button ARMS
|
||||||
|
// the tech (#44): the crew team builder mounts at the top of the column and its DISPATCH POSTs
|
||||||
|
// /api/research with the team. The button 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
|
||||||
|
// #44 — the armed tech (last-clicked AVAILABLE card); the builder above the grid picks the
|
||||||
|
// team and its DISPATCH performs the research.
|
||||||
|
const [armedTechId, setArmedTechId] = useState<number | null>(null)
|
||||||
|
const [crew, setCrew] = useState<CrewState | null>(null)
|
||||||
|
|
||||||
|
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
|
||||||
|
// successful dispatch (the research response updates the tech state, not the roster).
|
||||||
|
// Keep-old-on-failure, the house .catch(() => {}) pattern.
|
||||||
|
const fetchCrew = useCallback(() => {
|
||||||
|
fetch('/api/crew')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as CrewState
|
||||||
|
})
|
||||||
|
.then((c) => setCrew(c))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCrew()
|
||||||
|
}, [fetchCrew])
|
||||||
|
|
||||||
|
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, leadCrewId: number, helperCrewIds: number[]) {
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setArmedTechId(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id, leadCrewId, helperCrewIds }) })
|
||||||
|
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
|
||||||
|
fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{/* #44 — the armed tech's crew picker (JC-13: mounted only while a tech is armed);
|
||||||
|
its DISPATCH performs the research with the picked team. */}
|
||||||
|
{armedTechId !== null && crew !== null && (
|
||||||
|
<TeamBuilder
|
||||||
|
type="RESEARCH"
|
||||||
|
roster={crew.crew}
|
||||||
|
tasks={crew.tasks}
|
||||||
|
onQueue={(leadCrewId, helperCrewIds) => {
|
||||||
|
const tech = techs.find((t) => t.id === armedTechId)
|
||||||
|
if (tech) void research(tech, leadCrewId, helperCrewIds)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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} armed={armedTechId === tech.id} onArm={(t) => setArmedTechId(armedTechId === t.id ? null : t.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechCard({
|
||||||
|
tech,
|
||||||
|
resources,
|
||||||
|
parts,
|
||||||
|
busy,
|
||||||
|
armed,
|
||||||
|
onArm,
|
||||||
|
}: {
|
||||||
|
tech: TechDto
|
||||||
|
resources: number
|
||||||
|
parts: number
|
||||||
|
busy: boolean
|
||||||
|
armed: boolean // #44 — this card's tech is the armed target (the builder is open for it)
|
||||||
|
onArm: (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={() => onArm(tech)}>
|
||||||
|
{armed ? 'PICK TEAM' : 'RESEARCH'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { QueueDto } from '@/lib/game/save'
|
||||||
|
|
||||||
|
// TASK STRIP — the save's task queue, fixed bottom-left (#44); the NEXT TURN button moved into
|
||||||
|
// the right pane in #52, so the two can no longer overlap.
|
||||||
|
// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING
|
||||||
|
// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows,
|
||||||
|
// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on
|
||||||
|
// every refreshAll (Task 7) — a pure display of the array it is handed.
|
||||||
|
|
||||||
|
export function TaskStrip({ tasks }: { tasks: QueueDto[] }) {
|
||||||
|
if (tasks.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: '1rem',
|
||||||
|
bottom: '1rem',
|
||||||
|
zIndex: 60, // above the header/footer's 50
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
maxWidth: '26rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'block', fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.12em', marginBottom: '0.3rem' }}>
|
||||||
|
ACTIVE TASKS
|
||||||
|
</span>
|
||||||
|
{tasks.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '0.2rem 0',
|
||||||
|
opacity: t.status === 'PENDING' ? 0.45 : 1, // dim, don't reorder — the array is the order
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', letterSpacing: '0.06em' }}>
|
||||||
|
{t.type} // {t.targetLabel}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.04em', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.leadName}
|
||||||
|
{t.helperNames.length > 0 ? ` +${t.helperNames.join(' +')}` : ''}
|
||||||
|
{' — '}
|
||||||
|
{t.status === 'ACTIVE' ? `${t.remaining} turn(s) left` : 'queued'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { CrewDto, QueueDto } from '@/lib/game/save'
|
||||||
|
import { computeDuration, type TaskType } from '@/lib/game/tasks'
|
||||||
|
import { makePowerOf, type SkillRow } from '@/lib/game/skills'
|
||||||
|
|
||||||
|
// Shared crew picker for the four action panels — PR A: an action is queued as a lead +
|
||||||
|
// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState
|
||||||
|
// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel
|
||||||
|
// performs its own fetch with the team fields in the body (Task 5a). The live preview is
|
||||||
|
// Task 1's computeDuration (pure, no DB import) with the skill-band powerOf — Task 14's
|
||||||
|
// makePowerOf over the roster's { skill, level } rows (R-P3: the same computation the
|
||||||
|
// queueing routes make, so the preview equals the stored duration); each member row shows
|
||||||
|
// that member's power contribution for the armed action's skill. The rows mirror Task 3's
|
||||||
|
// queueTask so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
|
||||||
|
export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
|
||||||
|
type: TaskType
|
||||||
|
hopCount?: number
|
||||||
|
roster: CrewDto[]
|
||||||
|
tasks: QueueDto[]
|
||||||
|
onQueue: (leadCrewId: number, helperCrewIds: number[]) => void
|
||||||
|
}) {
|
||||||
|
const [leadId, setLeadId] = useState<number | null>(null)
|
||||||
|
const [helperIds, setHelperIds] = useState<number[]>([])
|
||||||
|
|
||||||
|
// Whole-builder blocks: an ACTIVE jump is in transit (all types); in the jump panel any
|
||||||
|
// queued jump is — the second-jump 409 (JC-12). The JUMP branch coalesces find()'s
|
||||||
|
// undefined to null so blocked is strictly QueueDto | null: the roster ternary below tests
|
||||||
|
// truthiness, but the button guard compares to null — undefined slipped past it and
|
||||||
|
// disabled every empty-queue JUMP dispatch (v0.10.1 e2e jump regression).
|
||||||
|
const inTransit = tasks.find((t) => t.type === 'JUMP' && t.status === 'ACTIVE')
|
||||||
|
const queuedJump = type === 'JUMP' ? (tasks.find((t) => t.type === 'JUMP') ?? null) : null
|
||||||
|
const blocked = inTransit ?? queuedJump
|
||||||
|
|
||||||
|
// R-P3: the preview's powerOf is the same makePowerOf the queueing routes call (Task 14) —
|
||||||
|
// built from the roster's { skill, level } rows (JC-26), so the estimate equals the stored
|
||||||
|
// duration instead of assuming every member is power 1.
|
||||||
|
const skillRows: SkillRow[] = roster.flatMap((c) => c.skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))
|
||||||
|
const powerOf = makePowerOf(roster, skillRows, type)
|
||||||
|
|
||||||
|
const lead = roster.find((c) => c.id === leadId) ?? null
|
||||||
|
const helpers = roster.filter((c) => helperIds.includes(c.id))
|
||||||
|
const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers, powerOf) : null // CrewDto ⊇ CrewMember
|
||||||
|
|
||||||
|
const leadReason = (c: CrewDto) => {
|
||||||
|
const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
|
||||||
|
if (leads) return `${leads.type} ALREADY QUEUED`
|
||||||
|
const helps = tasks.find((x) => x.helperCrewIds.includes(c.id) && x.status === 'PENDING')
|
||||||
|
return helps ? `COMMITTED: ${helps.type}` : null
|
||||||
|
}
|
||||||
|
const helperReason = (c: CrewDto) => {
|
||||||
|
const t = tasks.find((x) => (x.leadCrewId === c.id || x.helperCrewIds.includes(c.id)) && (x.status === 'ACTIVE' || x.status === 'PENDING'))
|
||||||
|
return t ? `BUSY: ${t.type}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>PICK TEAM // {type}</span>
|
||||||
|
{blocked ? (
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||||
|
{inTransit ? `IN TRANSIT // ${inTransit.targetLabel}` : 'A JUMP IS ALREADY QUEUED'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
roster.map((c) => (
|
||||||
|
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="lead"
|
||||||
|
checked={leadId === c.id}
|
||||||
|
disabled={leadReason(c) !== null}
|
||||||
|
onChange={() => {
|
||||||
|
setLeadId(c.id)
|
||||||
|
setHelperIds((h) => h.filter((x) => x !== c.id)) // a lead is never also a helper of the same task
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input type="checkbox" checked={helperIds.includes(c.id)} disabled={leadId === c.id || helperReason(c) !== null} onChange={() => setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} />
|
||||||
|
<span style={{ fontSize: '0.65rem', color: leadId === c.id ? 'var(--text-secondary)' : 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
{c.name} (power {powerOf(c)})
|
||||||
|
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
{duration !== null ? `WILL TAKE ${duration} TURN(S)` : 'PICK A LEAD'}
|
||||||
|
</span>
|
||||||
|
<Button variant="EXEC" size="SM" className="ml-auto" disabled={!lead || blocked !== null || leadReason(lead) !== null} onClick={() => lead && onQueue(lead.id, helperIds)}>
|
||||||
|
DISPATCH
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export type NodeGraphVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
|
||||||
export type NodeStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'NEUTRAL'
|
|
||||||
|
|
||||||
export interface NodeGraphNode {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
x: number // 0–100 coordinate space
|
|
||||||
y: number
|
|
||||||
status?: NodeStatus
|
|
||||||
sublabel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeGraphEdge {
|
|
||||||
from: string
|
|
||||||
to: string
|
|
||||||
label?: string
|
|
||||||
animated?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
nodes: NodeGraphNode[]
|
|
||||||
edges: NodeGraphEdge[]
|
|
||||||
variant?: NodeGraphVariant
|
|
||||||
title?: string
|
|
||||||
height?: number
|
|
||||||
directed?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
|
||||||
DEFAULT: 'var(--text-secondary)',
|
|
||||||
ACTIVE: 'var(--color-green)',
|
|
||||||
WARNING: 'var(--color-amber)',
|
|
||||||
CRITICAL: 'var(--color-red)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<NodeStatus, string> = {
|
|
||||||
ACTIVE: 'var(--color-green)',
|
|
||||||
OFFLINE: 'var(--border)',
|
|
||||||
WARNING: 'var(--color-amber)',
|
|
||||||
CRITICAL: 'var(--color-red)',
|
|
||||||
NEUTRAL: 'var(--text-muted)',
|
|
||||||
}
|
|
||||||
|
|
||||||
const NODE_W = 100
|
|
||||||
const NODE_H = 36
|
|
||||||
|
|
||||||
const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
className,
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
variant = 'ACTIVE',
|
|
||||||
title,
|
|
||||||
height = 320,
|
|
||||||
directed = false,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
||||||
const [svgW, setSvgW] = React.useState(600)
|
|
||||||
const [hovered, setHovered] = React.useState<string | null>(null)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!containerRef.current) return
|
|
||||||
const ro = new ResizeObserver(entries => {
|
|
||||||
setSvgW(entries[0].contentRect.width)
|
|
||||||
})
|
|
||||||
ro.observe(containerRef.current)
|
|
||||||
return () => ro.disconnect()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const color = VARIANT_COLORS[variant]
|
|
||||||
const markerId = `arrow-${variant}`
|
|
||||||
|
|
||||||
function nodePx(node: NodeGraphNode) {
|
|
||||||
return (node.x / 100) * svgW
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodePy(node: NodeGraphNode) {
|
|
||||||
return (node.y / 100) * height
|
|
||||||
}
|
|
||||||
|
|
||||||
function edgeEndpoints(fromNode: NodeGraphNode, toNode: NodeGraphNode) {
|
|
||||||
const fx = nodePx(fromNode)
|
|
||||||
const fy = nodePy(fromNode)
|
|
||||||
const tx = nodePx(toNode)
|
|
||||||
const ty = nodePy(toNode)
|
|
||||||
const dx = tx - fx
|
|
||||||
const dy = ty - fy
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
||||||
if (dist < 1) return { x1: fx, y1: fy, x2: tx, y2: ty }
|
|
||||||
|
|
||||||
const ux = dx / dist
|
|
||||||
const uy = dy / dist
|
|
||||||
const hw = NODE_W / 2
|
|
||||||
const hh = NODE_H / 2
|
|
||||||
|
|
||||||
const tW = Math.abs(ux) > 0.001 ? hw / Math.abs(ux) : Infinity
|
|
||||||
const tH = Math.abs(uy) > 0.001 ? hh / Math.abs(uy) : Infinity
|
|
||||||
const t = Math.min(tW, tH)
|
|
||||||
|
|
||||||
return {
|
|
||||||
x1: fx + ux * t,
|
|
||||||
y1: fy + uy * t,
|
|
||||||
x2: tx - ux * t,
|
|
||||||
y2: ty - uy * t,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
|
||||||
const hasAnimatedEdge = edges.some(e => e.animated)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(className)}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{title && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '0.4rem 0.75rem',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
background: 'var(--surface-raised)',
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
|
|
||||||
<svg width={svgW} height={height} style={{ display: 'block' }}>
|
|
||||||
<defs>
|
|
||||||
{directed && (
|
|
||||||
<marker
|
|
||||||
id={markerId}
|
|
||||||
markerWidth="6"
|
|
||||||
markerHeight="6"
|
|
||||||
refX="6"
|
|
||||||
refY="3"
|
|
||||||
orient="auto"
|
|
||||||
>
|
|
||||||
<path d="M 0 0 L 6 3 L 0 6 Z" fill={color} />
|
|
||||||
</marker>
|
|
||||||
)}
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{hasAnimatedEdge && (
|
|
||||||
<style>{`
|
|
||||||
@keyframes nodeGraphDash { to { stroke-dashoffset: -8; } }
|
|
||||||
`}</style>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edges */}
|
|
||||||
{edges.map((edge, ei) => {
|
|
||||||
const fromNode = nodeMap.get(edge.from)
|
|
||||||
const toNode = nodeMap.get(edge.to)
|
|
||||||
if (!fromNode || !toNode) return null
|
|
||||||
|
|
||||||
const { x1, y1, x2, y2 } = edgeEndpoints(fromNode, toNode)
|
|
||||||
const mx = (x1 + x2) / 2
|
|
||||||
const my = (y1 + y2) / 2
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={ei}>
|
|
||||||
<line
|
|
||||||
x1={x1.toFixed(1)}
|
|
||||||
y1={y1.toFixed(1)}
|
|
||||||
x2={x2.toFixed(1)}
|
|
||||||
y2={y2.toFixed(1)}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={1}
|
|
||||||
opacity={0.5}
|
|
||||||
strokeDasharray={edge.animated ? '4 4' : undefined}
|
|
||||||
markerEnd={directed ? `url(#${markerId})` : undefined}
|
|
||||||
style={edge.animated
|
|
||||||
? { animation: 'nodeGraphDash 0.5s linear infinite' }
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
{edge.label && (
|
|
||||||
<text
|
|
||||||
x={mx.toFixed(1)}
|
|
||||||
y={my.toFixed(1)}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize={8}
|
|
||||||
fill="var(--text-muted)"
|
|
||||||
fontFamily="var(--font-mono)"
|
|
||||||
>
|
|
||||||
{edge.label}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Nodes */}
|
|
||||||
{nodes.map(node => {
|
|
||||||
const cx = nodePx(node)
|
|
||||||
const cy = nodePy(node)
|
|
||||||
const isHovered = hovered === node.id
|
|
||||||
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g
|
|
||||||
key={node.id}
|
|
||||||
onMouseEnter={() => setHovered(node.id)}
|
|
||||||
onMouseLeave={() => setHovered(null)}
|
|
||||||
style={{ cursor: 'default' }}
|
|
||||||
>
|
|
||||||
{/* Background */}
|
|
||||||
<rect
|
|
||||||
x={(cx - NODE_W / 2).toFixed(1)}
|
|
||||||
y={(cy - NODE_H / 2).toFixed(1)}
|
|
||||||
width={NODE_W}
|
|
||||||
height={NODE_H}
|
|
||||||
fill="var(--surface-raised)"
|
|
||||||
/>
|
|
||||||
{/* Hover tint */}
|
|
||||||
{isHovered && (
|
|
||||||
<rect
|
|
||||||
x={(cx - NODE_W / 2).toFixed(1)}
|
|
||||||
y={(cy - NODE_H / 2).toFixed(1)}
|
|
||||||
width={NODE_W}
|
|
||||||
height={NODE_H}
|
|
||||||
fill={statusColor}
|
|
||||||
fillOpacity={0.12}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Border */}
|
|
||||||
<rect
|
|
||||||
x={(cx - NODE_W / 2).toFixed(1)}
|
|
||||||
y={(cy - NODE_H / 2).toFixed(1)}
|
|
||||||
width={NODE_W}
|
|
||||||
height={NODE_H}
|
|
||||||
fill="none"
|
|
||||||
stroke={statusColor}
|
|
||||||
strokeWidth={isHovered ? 1.5 : 1}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<text
|
|
||||||
x={cx.toFixed(1)}
|
|
||||||
y={(node.sublabel ? cy - 5 : cy).toFixed(1)}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize={10}
|
|
||||||
fill={statusColor}
|
|
||||||
fontFamily="var(--font-mono)"
|
|
||||||
fontWeight="600"
|
|
||||||
>
|
|
||||||
{node.label.toUpperCase()}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* Sublabel */}
|
|
||||||
{node.sublabel && (
|
|
||||||
<text
|
|
||||||
x={cx.toFixed(1)}
|
|
||||||
y={(cy + 8).toFixed(1)}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize={8}
|
|
||||||
fill="var(--text-muted)"
|
|
||||||
fontFamily="var(--font-mono)"
|
|
||||||
>
|
|
||||||
{node.sublabel}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
NodeGraph.displayName = 'NodeGraph'
|
|
||||||
|
|
||||||
export { NodeGraph }
|
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from 'd3-force'
|
||||||
|
import type { SimulationNodeDatum } from 'd3-force'
|
||||||
|
import { select } from 'd3-selection'
|
||||||
|
import { zoom, zoomIdentity } from 'd3-zoom'
|
||||||
|
import { TeamBuilder } from '@/components/team-builder'
|
||||||
|
import type { CrewState, WorldState } from '@/lib/game/save'
|
||||||
|
import { reachableWithin } from '@/lib/game/world'
|
||||||
|
|
||||||
|
// WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with
|
||||||
|
// pan + zoom (d3-zoom), per #28. Clicking a system within jump range of the current one ARMS
|
||||||
|
// it (#44): the crew team builder mounts as a floating card over the chart, and its DISPATCH
|
||||||
|
// POSTs /api/jump with the team in the body (two hops away once NAVIGATION AI is researched,
|
||||||
|
// #35); the current and unreachable systems are dimmed and not
|
||||||
|
// clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount,
|
||||||
|
// refetch via the jump response — no polling. In wide mode (#36) the chart fills the whole tab
|
||||||
|
// pane (no internal scrollbar); narrow keeps a fixed fallback height since the page scrolls naturally there.
|
||||||
|
|
||||||
|
interface WorldMapPanelProps {
|
||||||
|
height?: number // fallback chart height when the pane gives no definite space (narrow mode)
|
||||||
|
narrow?: boolean
|
||||||
|
navAi?: boolean // #35 — NAVIGATION AI researched: two-hop systems are jumpable fields too
|
||||||
|
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeStatus = 'current' | 'jumpable' | 'offline'
|
||||||
|
|
||||||
|
// d3-force mutates these in place; the whole scene is rebuilt from scratch on every world change.
|
||||||
|
interface ChartNode extends SimulationNodeDatum {
|
||||||
|
id: number
|
||||||
|
name: string // already uppercased — doubles as the accessible label
|
||||||
|
status: NodeStatus
|
||||||
|
sublabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceLink replaces the numeric ids with node references once initialized.
|
||||||
|
interface ChartLink {
|
||||||
|
source: number | ChartNode
|
||||||
|
target: number | ChartNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_W = 100
|
||||||
|
const NODE_H = 36
|
||||||
|
|
||||||
|
// Palette carried over from the old hand-rolled chart.
|
||||||
|
const STATUS_COLOR: Record<NodeStatus, string> = {
|
||||||
|
current: 'var(--color-green)',
|
||||||
|
jumpable: 'var(--text-muted)',
|
||||||
|
offline: 'var(--border)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJumped }: WorldMapPanelProps) {
|
||||||
|
// Wide mode: the flex chain (Tabs → TabsContent → this panel) provides a definite pane size to fill (#36).
|
||||||
|
const filling = !narrow
|
||||||
|
const [world, setWorld] = useState<WorldState | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||||
|
// #44 — the team builder: the clicked node is the armed target; its DISPATCH performs the jump.
|
||||||
|
const [crew, setCrew] = useState<CrewState | null>(null)
|
||||||
|
const [armedId, setArmedId] = useState<number | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
|
||||||
|
// successful dispatch (the jump response updates the world state, not the roster).
|
||||||
|
// Keep-old-on-failure, the house .catch(() => {}) pattern.
|
||||||
|
const fetchCrew = useCallback(() => {
|
||||||
|
fetch('/api/crew')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as CrewState
|
||||||
|
})
|
||||||
|
.then((c) => setCrew(c))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCrew()
|
||||||
|
}, [fetchCrew])
|
||||||
|
|
||||||
|
// #35 — the jump-reach map: one hop away, or two once NAVIGATION AI is researched. The chart
|
||||||
|
// marks its fields clickable from it, and the team builder previews the armed target's
|
||||||
|
// duration from its hop count (the same logic /api/jump recomputes).
|
||||||
|
const hopMap = useMemo(() => {
|
||||||
|
if (!world) return null
|
||||||
|
const current = world.systems.find((s) => s.current)
|
||||||
|
return current ? reachableWithin(world.edges, current.id, navAi ? 2 : 1) : null
|
||||||
|
}, [world, navAi])
|
||||||
|
|
||||||
|
// Ref guard so a double-click can't start two jumps before the re-render locks the nodes.
|
||||||
|
const jumpingRef = useRef(false)
|
||||||
|
|
||||||
|
// #44 — a node click ARMS the target instead of jumping: the builder over the chart picks the
|
||||||
|
// team, and its DISPATCH performs the fetch (below). Clicking another jumpable node retargets.
|
||||||
|
const arm = useCallback((systemId: number) => {
|
||||||
|
if (jumpingRef.current) return
|
||||||
|
setArmedId(systemId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const jump = useCallback(async (systemId: number, leadCrewId: number, helperCrewIds: number[]) => {
|
||||||
|
if (jumpingRef.current) return
|
||||||
|
jumpingRef.current = true
|
||||||
|
setJumpingId(systemId)
|
||||||
|
setArmedId(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/jump', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ systemId, leadCrewId, helperCrewIds }),
|
||||||
|
})
|
||||||
|
const body = (await res.json()) as WorldState & { error?: string }
|
||||||
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
|
setWorld(body) // fresh state straight from the response — no refetch needed
|
||||||
|
onJumped?.() // #30 — parent re-fetches /api/system (LOCAL SYSTEM + CARGO HOLD mirror it)
|
||||||
|
fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
jumpingRef.current = false
|
||||||
|
setJumpingId(null)
|
||||||
|
}
|
||||||
|
}, [onJumped, fetchCrew])
|
||||||
|
|
||||||
|
// Rebuild the chart on every world/lock change — the force layout re-settles from the API's
|
||||||
|
// coordinates, so nodes visibly rearrange after each jump (#28).
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container || !world || !hopMap) return
|
||||||
|
|
||||||
|
// Measured pane size (#36): the flex chain gives a definite box in wide mode; `height` is the
|
||||||
|
// fallback when the container has no resolved size yet (narrow flow, or tab hidden on load).
|
||||||
|
const W = container.clientWidth || 800
|
||||||
|
const H = container.clientHeight || height
|
||||||
|
|
||||||
|
const nodes: ChartNode[] = world.systems.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name.toUpperCase(),
|
||||||
|
status: s.current ? 'current' : hopMap.has(s.id) ? 'jumpable' : 'offline',
|
||||||
|
sublabel: s.current
|
||||||
|
? 'CURRENT'
|
||||||
|
: hopMap.has(s.id)
|
||||||
|
? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hopMap.get(s.id) ?? 1) > 1 ? ' ×2' : ''}`
|
||||||
|
: s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
||||||
|
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
|
||||||
|
y: (s.y / 100) * H,
|
||||||
|
}))
|
||||||
|
const links: ChartLink[] = world.edges.map(([a, b]) => ({ source: a, target: b }))
|
||||||
|
|
||||||
|
// width/height 100% + fixed viewBox: the scene scales with the pane on window resizes without
|
||||||
|
// rebuilding (pan/zoom stay accurate — d3 maps pointers through the SVG transform matrix).
|
||||||
|
const svg = select(container)
|
||||||
|
.append('svg')
|
||||||
|
.attr('width', '100%')
|
||||||
|
.attr('height', '100%')
|
||||||
|
.attr('viewBox', `0 0 ${W} ${H}`)
|
||||||
|
.style('display', 'block')
|
||||||
|
|
||||||
|
const root = svg.append('g')
|
||||||
|
const edgeLayer = root.append('g')
|
||||||
|
const nodeLayer = root.append('g')
|
||||||
|
|
||||||
|
// Pan + zoom (#28): wheel/drag on the chart, transform applied to the scene root. d3-zoom does
|
||||||
|
// NOT swallow the trailing click after a drag, so track real pans and ignore node clicks that
|
||||||
|
// end one (mousedown on a node, tiny drag, release over the same node).
|
||||||
|
let panned = false
|
||||||
|
const zoomBehavior = zoom<SVGSVGElement, unknown>()
|
||||||
|
.scaleExtent([0.15, 5])
|
||||||
|
.on('start', () => {
|
||||||
|
panned = false
|
||||||
|
})
|
||||||
|
.on('zoom', (event) => {
|
||||||
|
root.attr('transform', event.transform.toString())
|
||||||
|
// Any user gesture (drag or trackpad pan); a clean click's own mousedown re-fires
|
||||||
|
// 'start' and resets this before its click lands. Programmatic fits have no sourceEvent.
|
||||||
|
if (event.sourceEvent) panned = true
|
||||||
|
})
|
||||||
|
svg.call(zoomBehavior)
|
||||||
|
|
||||||
|
// The settled force layout is bigger than the chart box for large saves, so once the
|
||||||
|
// simulation stops, zoom-to-fit: frame every node inside the container with padding.
|
||||||
|
const fitToNodes = () => {
|
||||||
|
let minX = Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
let maxX = -Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
for (const n of nodes) {
|
||||||
|
minX = Math.min(minX, (n.x ?? 0) - NODE_W / 2)
|
||||||
|
minY = Math.min(minY, (n.y ?? 0) - NODE_H / 2)
|
||||||
|
maxX = Math.max(maxX, (n.x ?? 0) + NODE_W / 2)
|
||||||
|
maxY = Math.max(maxY, (n.y ?? 0) + NODE_H / 2)
|
||||||
|
}
|
||||||
|
const pad = 16
|
||||||
|
const scale = Math.min(W / (maxX - minX + pad * 2), H / (maxY - minY + pad * 2))
|
||||||
|
const tx = W / 2 - scale * ((minX + maxX) / 2)
|
||||||
|
const ty = H / 2 - scale * ((minY + maxY) / 2)
|
||||||
|
svg.call(zoomBehavior.transform, zoomIdentity.translate(tx, ty).scale(scale))
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeSel = edgeLayer.selectAll('line').data(links).enter().append('line')
|
||||||
|
.attr('stroke', 'var(--color-green)')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
.attr('opacity', 0.5)
|
||||||
|
|
||||||
|
const nodeSel = nodeLayer.selectAll<SVGGElement, ChartNode>('g').data(nodes).enter().append('g')
|
||||||
|
|
||||||
|
// Same accessible contract as the old chart: only jumpable nodes are role=button with their
|
||||||
|
// name as the accessible label (e2e clicks them by role/name); all locked while a jump is in flight.
|
||||||
|
// A click ARMS the node (#44) — the team builder over the chart performs the jump.
|
||||||
|
nodeSel.filter((d) => d.status === 'jumpable' && jumpingId === null)
|
||||||
|
.attr('role', 'button')
|
||||||
|
.attr('tabindex', 0)
|
||||||
|
.attr('aria-label', (d) => d.name)
|
||||||
|
.style('cursor', 'pointer')
|
||||||
|
.on('click', (event: MouseEvent, d) => {
|
||||||
|
if (!panned) arm(d.id) // a real pan ends with a click too — don't arm on that one
|
||||||
|
})
|
||||||
|
.on('keydown', (event: KeyboardEvent, d) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
arm(d.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nodeSel.each(function (d) {
|
||||||
|
const g = select(this)
|
||||||
|
const color = STATUS_COLOR[d.status]
|
||||||
|
g.append('rect')
|
||||||
|
.attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
|
||||||
|
.attr('width', NODE_W).attr('height', NODE_H)
|
||||||
|
.attr('fill', 'var(--surface-raised)')
|
||||||
|
g.append('rect')
|
||||||
|
.attr('x', -NODE_W / 2).attr('y', -NODE_H / 2)
|
||||||
|
.attr('width', NODE_W).attr('height', NODE_H)
|
||||||
|
.attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1)
|
||||||
|
g.append('text')
|
||||||
|
.attr('x', 0).attr('y', -5)
|
||||||
|
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
|
||||||
|
.attr('font-size', 10).attr('fill', color)
|
||||||
|
.style('font-family', 'var(--font-mono)').style('font-weight', '600')
|
||||||
|
.text(d.name)
|
||||||
|
g.append('text')
|
||||||
|
.attr('x', 0).attr('y', 8)
|
||||||
|
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
|
||||||
|
.attr('font-size', 8).attr('fill', 'var(--text-muted)')
|
||||||
|
.style('font-family', 'var(--font-mono)')
|
||||||
|
.text(d.sublabel)
|
||||||
|
})
|
||||||
|
|
||||||
|
const simulation = forceSimulation(nodes)
|
||||||
|
.force('link', forceLink<ChartNode, ChartLink>(links).id((d) => d.id).distance(130).strength(0.25))
|
||||||
|
.force('charge', forceManyBody().strength(-420))
|
||||||
|
.force('center', forceCenter(W / 2, H / 2))
|
||||||
|
.force('collide', forceCollide(60))
|
||||||
|
.on('end', fitToNodes)
|
||||||
|
.on('tick', () => {
|
||||||
|
edgeSel
|
||||||
|
.attr('x1', (d) => (d.source as ChartNode).x ?? 0)
|
||||||
|
.attr('y1', (d) => (d.source as ChartNode).y ?? 0)
|
||||||
|
.attr('x2', (d) => (d.target as ChartNode).x ?? 0)
|
||||||
|
.attr('y2', (d) => (d.target as ChartNode).y ?? 0)
|
||||||
|
nodeSel.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
simulation.stop()
|
||||||
|
svg.remove()
|
||||||
|
}
|
||||||
|
}, [world, jumpingId, arm, height, navAi, hopMap])
|
||||||
|
|
||||||
|
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.08em' }}>SCANNING CHART…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flex (not height:'100%'): the pane chain is sized by min-height, where percentages don't resolve
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', ...(filling ? { flex: 1, minHeight: 0 } : {}) }}>
|
||||||
|
{/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns the
|
||||||
|
chart container inside it; the armed target's team builder floats over the chart (#44). */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
...(filling ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
|
||||||
|
</div>
|
||||||
|
<div ref={containerRef} style={{ width: '100%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
|
||||||
|
|
||||||
|
{/* #44 — the armed target's crew picker, floating over the chart (JC-13: mounted only
|
||||||
|
while a target is armed); its DISPATCH performs the jump with the picked team. */}
|
||||||
|
{armedId !== null && crew !== null && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 'min(24rem, calc(100% - 1.5rem))',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TeamBuilder
|
||||||
|
type="JUMP"
|
||||||
|
hopCount={hopMap?.get(armedId) ?? 0}
|
||||||
|
roster={crew.crew}
|
||||||
|
tasks={crew.tasks}
|
||||||
|
onQueue={(leadCrewId, helperCrewIds) => void jump(armedId, leadCrewId, helperCrewIds)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||||
|
JUMP FAILED // {error.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
|||||||
|
# Skill Progression Implementation Plan (PR B — Gitea #45, v0.11.0)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Crews gain skills by doing jobs; power bands make experienced teams faster; role names unchanged.
|
||||||
|
|
||||||
|
**Architecture:** A pure `lib/game/skills.ts` plus the `crew_skills` table (schema v8) feed power bands into the existing queue/tick engine defined by the PR A plan.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 App Router, React 19, TypeScript strict, bun, SQLite via Drizzle + sql.js (WASM), Vitest unit tests, Playwright e2e (CI-only)
|
||||||
|
|
||||||
|
**Spec:** docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md (§6)
|
||||||
|
|
||||||
|
**Upstream:** PR A plan: docs/superpowers/plans/2026-10-01-crew-driven-actions.md — Tasks 3/4a/4b define `queueTask` / `runTurn` / `computeDuration` / `Task`; this plan assumes the post-v0.10.0 merge and starts a fresh feature branch.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- AGENTS.md change process per PR: feature branch off master, no direct pushes to master; version bump in package.json + CHANGELOG entry at top (`## v<version> — YYYY-MM-DD HH:MM`); local verify ONLY `npx tsc --noEmit`, `npx vitest run`, `npx playwright test --list` (e2e is CI-only, never run locally); push → CI auto-merge on green (verify merged_at) → tag v<version> on master → manual deploy `docker compose up -d --build` + verify /api/version at https://space-wanderer.dev.kkhost.pl.
|
||||||
|
- DDL in lib/db/client.ts must stay in sync with lib/db/schema.ts; schema changes only via idempotent backfill steps in save.ts ensureSave (SCHEMA_VERSION 6→7→8); no DB wipes ever.
|
||||||
|
- OpenAPI spec in lib/openapi.ts updated whenever an endpoint changes (and lib/openapi.test.ts pins, which must be updated with it).
|
||||||
|
- Single-player by design: NO auth, accounts, or multiplayer, ever.
|
||||||
|
- Determinism: every game outcome reproducible from seed + player actions; no wall-clock randomness.
|
||||||
|
- New pure modules (crew-costs.ts, tasks.ts, skills.ts) must not import lib/db/* — shared server+client like the tech.ts precedent (required for live duration preview in the UI).
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Existing save loaded after deploy has no tasks/crew_skills tables → backfill must create them without touching turn, crew, or inventory state, and be idempotent across restarts.
|
||||||
|
|
||||||
|
---
|
||||||
|
Task numbering continues from the PR A plan (1–11).
|
||||||
|
|
||||||
|
|
||||||
|
### Task 12: crew_skills table, schema v8 (PR B — Gitea issue #45)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/db/client.ts` (add `crew_skills` to the `DDL` template, after `tasks`), `lib/db/schema.ts` (drizzle table `crewSkills` + unique index), `lib/game/save.ts` (`SCHEMA_VERSION` 7→8; idempotent `seedCrewSkills` helper; the v8 step in `backfillSave`; a `createNewGame` hook — JC-21), `lib/db/admin.ts` (register `crew_skills` in `ADMIN_TABLES`)
|
||||||
|
- Test: extend the `backfill (old saves)` describe in `lib/game/save.test.ts` (Task 2's v7-fixture pattern: `freshFile()` + `getWorldState` + stub `schemaVersion` + reopen via `openGameDb(file)`); `lib/db/admin.test.ts`'s registry test picks up the table automatically (it derives its expected list from `schema.ts`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `SkillName` + `STARTING_LEVELS` (`Record<string, Record<SkillName, number>>`) from Task 1's `lib/game/crew-costs.ts`; `schema.crew`/`schema.saves`; `backfillSave`/`ensureSave` in `lib/game/save.ts` (now `SCHEMA_VERSION = 7` after Task 2)
|
||||||
|
- Produces (for Task 13+): the `crew_skills` table — `saveId` (FK saves), `crewId` (FK crew), `skill` (`SkillName`), `level` (0–5), `xp` — seeded to **exactly 4 rows per member** (one per skill) with `level = STARTING_LEVELS[role][skill]`, `xp = 0`, for every save (fresh + upgraded). Task 13 (XP-on-completion, level-ups, power bands, UI) reads/writes this table; level→power stays in `crew-costs.ts` (`POWER_BANDS`)
|
||||||
|
|
||||||
|
- [ ] **Create the branch** (PR B, off `master` post-v0.10.0 merge): `git checkout -b feat/skill-progression master`. Every commit in this PR references `(#45)`.
|
||||||
|
|
||||||
|
- [ ] **Write the failing test** — extend the `backfill (old saves)` describe in `lib/game/save.test.ts`; add `import { STARTING_LEVELS } from './crew-costs'` at the top. Pins **Review Focus 1 (v8)**: v7→v8 seeds 4 rows per member without touching turn/crew/inventory, idempotent across restart:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Insert one member directly (skill seeding depends only on role — traits can be dummies).
|
||||||
|
const pinCrew = (db: GameDb, saveId: number, name: string, role: string) =>
|
||||||
|
db.db.insert(schema.crew).values({ saveId, name, role, traitGood: 'STEADY UNDER PRESSURE', traitBad: 'IMPULSIVE', origin: 'STARTING CREW' }).returning().then((r) => r[0])
|
||||||
|
// A member's 4 skill rows as { [skill]: { level, xp } }; the toHaveLength(4) asserts one row per skill.
|
||||||
|
const skillMap = async (db: GameDb, crewId: number) => {
|
||||||
|
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, crewId))
|
||||||
|
expect(rows).toHaveLength(4)
|
||||||
|
return Object.fromEntries(rows.map((r) => [r.skill, { level: r.level, xp: r.xp }]))
|
||||||
|
}
|
||||||
|
it('v8: a v7-stamped save (crew present, no skills) seeds 4 role-based rows per member, xp 0, state untouched; idempotent across restart', async () => {
|
||||||
|
const { db, file } = await freshFile()
|
||||||
|
await getWorldState(db) // fresh v8 save (crew_skills already seeded by createNewGame — JC-21)
|
||||||
|
const save = (await db.db.select().from(schema.saves))[0]
|
||||||
|
await db.db.delete(schema.crewSkills) // simulate a v0.10.0 save: crew present, no skills yet
|
||||||
|
await db.db.update(schema.saves).set({ schemaVersion: 7 })
|
||||||
|
const pilot = await pinCrew(db, save.id, 'P. VANCE', 'PILOT')
|
||||||
|
const scientist = await pinCrew(db, save.id, 'S. OKAFOR', 'SCIENTIST')
|
||||||
|
const gunner = await pinCrew(db, save.id, 'G. REYES', 'GUNNER')
|
||||||
|
const crewBefore = await db.db.select().from(schema.crew)
|
||||||
|
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||||||
|
const turnBefore = (await db.db.select().from(schema.saves))[0].turn
|
||||||
|
await getWorldState(db) // ensureSave → backfill v7 → v8 → seedCrewSkills
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(8)
|
||||||
|
expect(await skillMap(db, pilot.id)).toEqual({ NAVIGATION: { level: 2, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // PILOT
|
||||||
|
expect(await skillMap(db, scientist.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 2, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 2, xp: 0 } }) // SCIENTIST
|
||||||
|
expect(await skillMap(db, gunner.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // GUNNER (all L0)
|
||||||
|
for (const c of crewBefore) { // full invariant: EVERY member (incl. the random starting crew) matches its role map, xp 0
|
||||||
|
const expected = Object.fromEntries(Object.keys(STARTING_LEVELS[c.role]).map((s) => [s, { level: STARTING_LEVELS[c.role][s], xp: 0 }]))
|
||||||
|
expect(await skillMap(db, c.id)).toEqual(expected)
|
||||||
|
}
|
||||||
|
expect((await db.db.select().from(schema.saves))[0].turn).toBe(turnBefore) // Review Focus 1 (v8): turn / crew / inventory
|
||||||
|
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore) // byte-identical
|
||||||
|
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) // byte-identical
|
||||||
|
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||||||
|
await getWorldState(reopened)
|
||||||
|
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(8)
|
||||||
|
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM crew_skills')[0]?.values[0][0] ?? 0)).toBe(4 * (await reopened.db.select().from(schema.crew)).length) // still 4 rows per member
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Run — expect red**: `npx vitest run lib/game/save.test.ts` → red: `schema.crewSkills` is not exported (the test can't compile) and `SCHEMA_VERSION` is still 7. The other describes stay green (they don't reference `crewSkills`).
|
||||||
|
|
||||||
|
- [ ] **Implement**, each in the existing style of its file:
|
||||||
|
|
||||||
|
`lib/db/client.ts` — add to the `DDL` template after the `tasks` table (keep in sync with `schema.ts`):
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS crew_skills (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
crew_id INTEGER NOT NULL REFERENCES crew(id),
|
||||||
|
skill TEXT NOT NULL, -- 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' (SkillName)
|
||||||
|
level INTEGER NOT NULL DEFAULT 0, -- 0–5
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0, -- cumulative completions in this skill (Task 13 grants)
|
||||||
|
UNIQUE (crew_id, skill)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
`lib/db/schema.ts` — append after `tasks` (mirrors the DDL; `uniqueIndex` is already imported):
|
||||||
|
```ts
|
||||||
|
// Per-member skill progression (PR B, spec §6): one row per (crew member, skill); seeded to
|
||||||
|
// STARTING_LEVELS[role][skill] by createNewGame / the v8 backfill; Task 13 adds xp + level-ups.
|
||||||
|
export const crewSkills = sqliteTable('crew_skills', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
crewId: integer('crew_id').notNull().references(() => crew.id),
|
||||||
|
skill: text('skill').notNull(), // SkillName
|
||||||
|
level: integer('level').notNull().default(0), // 0–5
|
||||||
|
xp: integer('xp').notNull().default(0),
|
||||||
|
}, (t) => [uniqueIndex('crew_skills_crew_skill_unique').on(t.crewId, t.skill)])
|
||||||
|
```
|
||||||
|
`lib/game/save.ts` — add `import { STARTING_LEVELS, type SkillName } from './crew-costs'`; bump the constant; add the idempotent seeder (called by `createNewGame` and the v8 backfill):
|
||||||
|
```ts
|
||||||
|
export const SCHEMA_VERSION = 8 // v1: save + start · v2: bodies · v3: crew · v4: ship · v5: tech · v6: missions · v7: tasks · v8: crew_skills (role-based starting levels)
|
||||||
|
// Idempotent: for each crew member with no crew_skills rows yet, seed one row per skill
|
||||||
|
// (level = STARTING_LEVELS[role][skill], xp = 0). Called by createNewGame and the v8 backfill.
|
||||||
|
async function seedCrewSkills(db: GameDb, saveId: number) {
|
||||||
|
const crew = await db.db.select().from(schema.crew).where(eq(schema.crew.saveId, saveId))
|
||||||
|
const have = new Set((await db.db.select({ crewId: schema.crewSkills.crewId }).from(schema.crewSkills)).map((r) => r.crewId))
|
||||||
|
for (const m of crew) {
|
||||||
|
if (have.has(m.id)) continue // already seeded — idempotent
|
||||||
|
for (const skill of Object.keys(STARTING_LEVELS[m.role]) as SkillName[]) {
|
||||||
|
await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: STARTING_LEVELS[m.role][skill], xp: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `createNewGame`, add after `await seedStartingCrew(db, save.id, seed)`: `await seedCrewSkills(db, save.id)`. In `backfillSave`, add after the v7 step:
|
||||||
|
```ts
|
||||||
|
if (version < 8) {
|
||||||
|
await seedCrewSkills(db, save.id) // DDL creates the table at open; seed each member's 4 rows
|
||||||
|
version = 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`lib/db/admin.ts` — register in `ADMIN_TABLES` after `tasks`: `crew_skills: schema.crewSkills,`
|
||||||
|
|
||||||
|
- [ ] **Run — expect green**: `npx vitest run lib/game/save.test.ts lib/db/admin.test.ts` → green (new v8 test passes; the admin registry test picks up `crew_skills` automatically); `npx tsc --noEmit` clean.
|
||||||
|
|
||||||
|
- [ ] **Commit** (PR B): `git add -A && git commit -m "feat(db): crew_skills table + role-based starting levels — schema v8 (#45)"`.
|
||||||
|
|
||||||
|
**Closeout (PR B):** version bump **v0.11.0** + `CHANGELOG.md` entry + open/auto-merge PR + tag **v0.11.0** + manual deploy + close Gitea issue #45 follow the standard per-PR loop (mirroring the v0.10.0 / #44 closeout at this plan's tail). Gitea numbering here is the plan's convention (spec §10's pre-fix numbering lists this work as #44; the actual Gitea issue is #45 (the table is corrected in PR A's Task 11 before the spec is committed)).
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
- JC-21 — `ensureSave` runs `backfillSave` only for pre-existing saves; a *fresh* save is built by `createNewGame`, which never backfills. So the v8 seeding is also called from `createNewGame` (the same idempotent `seedCrewSkills`) — otherwise fresh v8 saves would have no `crew_skills` rows at all. Mirrors how `seedStartingCrew`/`seedStartingShip`/`seedStartingTech` are invoked from both `createNewGame` and their backfill steps.
|
||||||
|
|
||||||
|
### Task 13: skills.ts — XP, levels, power bands, titles (PR B)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/game/skills.ts` (pure — no `lib/db` imports; operates on plain `SkillRow` data from Task 12's `crew_skills` table; the frontend imports it for the CREW-tab skill display + titles, same server+client precedent as `crew-costs.ts`) and test `lib/game/skills.test.ts` (colocated, pure — no DB)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: from `./crew-costs` — `SkillName`, `XP_THRESHOLDS` (level thresholds [5,10,15,20,25]), `POWER_BANDS` ([1,1,1,2,2,3]); the `crew_skills` rows (Task 12) as plain `{ saveId, crewId, skill, level, xp }`
|
||||||
|
- Produces (for Task 14+): `SkillRow` interface, `grantXp(skillRows, crewIds, skill, members) → { rows: SkillRow[]; events: string[] }`, `levelFromXp(xp) → number`, `powerForLevel(level) → number`, `titleFor(member, skillRows) → string | null`
|
||||||
|
|
||||||
|
- [ ] Steps: TDD — write failing tests FIRST in `lib/game/skills.test.ts` using the spec's EXACT values (run `npx vitest run lib/game/skills.test.ts` → red, the module does not exist yet):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { SkillName } from './crew-costs'
|
||||||
|
import { grantXp, levelFromXp, powerForLevel, titleFor, type SkillRow } from './skills'
|
||||||
|
const pilot = { name: 'BEN OKONKWO', role: 'PILOT' }
|
||||||
|
const row = (crewId: string, skill: SkillName, level: number, xp: number): SkillRow =>
|
||||||
|
({ saveId: 's1', crewId, skill, level, xp })
|
||||||
|
const XP: [number, number][] = [[4, 0], [5, 1], [10, 2], [15, 3], [25, 5], [100, 5]]
|
||||||
|
describe('levelFromXp (thresholds [5,10,15,20,25], cap 5)', () => {
|
||||||
|
it('maps cumulative xp → highest level reached', () => {
|
||||||
|
for (const [xp, lvl] of XP) expect(levelFromXp(xp), `xp ${xp}`).toBe(lvl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const POWER: [number, number][] = [[0, 1], [2, 1], [3, 2], [4, 2], [5, 3]]
|
||||||
|
describe('powerForLevel (bands [1,1,1,2,2,3])', () => {
|
||||||
|
it('L0–2 → 1 · L3–4 → 2 · L5 → 3', () => {
|
||||||
|
for (const [lvl, p] of POWER) expect(powerForLevel(lvl), `L${lvl}`).toBe(p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('grantXp — +1 XP to every listed member; event only on a titled-band crossing', () => {
|
||||||
|
const pilotRows = (): SkillRow[] => [
|
||||||
|
row('c1', 'NAVIGATION', 2, 14), row('c1', 'SCANNING', 0, 0),
|
||||||
|
row('c1', 'ENGINEERING', 0, 0), row('c1', 'RESEARCH', 0, 0),
|
||||||
|
]
|
||||||
|
it('L0 → L1: xp 4 +1 → 5 (L1), no event (no event for plain L1/L2)', () => {
|
||||||
|
const { rows, events } = grantXp([row('c1', 'NAVIGATION', 0, 4)], ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(rows[0]).toEqual({ saveId: 's1', crewId: 'c1', skill: 'NAVIGATION', level: 1, xp: 5 })
|
||||||
|
expect(events).toEqual([])
|
||||||
|
})
|
||||||
|
it('L2 → L3: xp 14 +1 → 15, SENIOR event in the exact spec §6 text', () => {
|
||||||
|
const { rows, events } = grantXp(pilotRows(), ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(rows.find((r) => r.skill === 'NAVIGATION')!.level).toBe(3)
|
||||||
|
expect(events).toEqual(['BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3'])
|
||||||
|
})
|
||||||
|
it('L4 → L5: xp 24 +1 → 25, MASTER event', () => {
|
||||||
|
const { events } = grantXp([row('c1', 'NAVIGATION', 4, 24), row('c1', 'SCANNING', 0, 0)], ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(events).toEqual(['BEN OKONKWO is now a MASTER PILOT — NAVIGATION L5'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('titleFor — from the member's HIGHEST skill level', () => {
|
||||||
|
it('SCIENTIST with SCANNING L3 + RESEARCH L5 → MASTER SCIENTIST (highest wins)', () => {
|
||||||
|
const scientist = { name: 'AVA VANCE', role: 'SCIENTIST' }
|
||||||
|
const rows = [row('c2', 'SCANNING', 3, 15), row('c2', 'RESEARCH', 5, 25), row('c2', 'NAVIGATION', 0, 0), row('c2', 'ENGINEERING', 0, 0)]
|
||||||
|
expect(titleFor(scientist, rows)).toBe('MASTER SCIENTIST')
|
||||||
|
})
|
||||||
|
it('GUNNER with all L0 → null', () => {
|
||||||
|
const gunner = { name: 'RIG HOLLOWAY', role: 'GUNNER' }
|
||||||
|
const rows = [row('c3', 'NAVIGATION', 0, 0), row('c3', 'SCANNING', 0, 0), row('c3', 'ENGINEERING', 0, 0), row('c3', 'RESEARCH', 0, 0)]
|
||||||
|
expect(titleFor(gunner, rows)).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Implement `lib/game/skills.ts` (pure — no `lib/db` imports; frontend imports it for the CREW-tab skill display + titles, same precedent as `crew-costs.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Pure skill-progression module (spec §6): XP accrual, level→power, titles. No lib/db imports — operates on plain SkillRow data (Task 12's crew_skills table); the frontend imports it for the CREW-tab display.
|
||||||
|
import type { SkillName } from './crew-costs'
|
||||||
|
import { POWER_BANDS, XP_THRESHOLDS } from './crew-costs'
|
||||||
|
export interface SkillRow {
|
||||||
|
saveId: string
|
||||||
|
crewId: string
|
||||||
|
skill: SkillName
|
||||||
|
level: number
|
||||||
|
xp: number
|
||||||
|
}
|
||||||
|
export type CrewIdentity = { name: string; role: string }
|
||||||
|
// Highest level 0–5 with xp ≥ XP_THRESHOLDS[level−1]; thresholds [5,10,15,20,25], cap 5.
|
||||||
|
export function levelFromXp(xp: number): number {
|
||||||
|
let level = 0
|
||||||
|
for (let l = 1; l <= XP_THRESHOLDS.length; l++) if (xp >= XP_THRESHOLDS[l - 1]) level = l
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
// L0–2 → 1 · L3–4 → 2 · L5 → 3 (direct lookup into crew-costs POWER_BANDS).
|
||||||
|
export function powerForLevel(level: number): number {
|
||||||
|
return POWER_BANDS[level]
|
||||||
|
}
|
||||||
|
// From the member's HIGHEST skill level: ≥5 → "MASTER <ROLE>", ≥3 → "SENIOR <ROLE>", below → null.
|
||||||
|
export function titleFor(member: CrewIdentity, skillRows: SkillRow[]): string | null {
|
||||||
|
const highest = skillRows.reduce((max, r) => Math.max(max, r.level), 0)
|
||||||
|
if (highest >= 5) return `MASTER ${member.role}`
|
||||||
|
if (highest >= 3) return `SENIOR ${member.role}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// +1 XP to every listed member's row for `skill`; returns updated rows + any level-up events. An event
|
||||||
|
// fires when the gained skill steps into a titled band (3 → SENIOR, 5 → MASTER), e.g. "BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3" (title from the member's HIGHEST skill).
|
||||||
|
export function grantXp(
|
||||||
|
skillRows: SkillRow[],
|
||||||
|
crewIds: string[],
|
||||||
|
skill: SkillName,
|
||||||
|
members: Record<string, CrewIdentity>,
|
||||||
|
): { rows: SkillRow[]; events: string[] } {
|
||||||
|
const rows = skillRows.map((r) =>
|
||||||
|
r.skill === skill && crewIds.includes(r.crewId)
|
||||||
|
? { ...r, xp: r.xp + 1, level: levelFromXp(r.xp + 1) }
|
||||||
|
: r,
|
||||||
|
)
|
||||||
|
const events: string[] = []
|
||||||
|
for (const crewId of crewIds) {
|
||||||
|
const member = members[crewId]
|
||||||
|
if (!member) continue
|
||||||
|
const newLevel = rows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
|
||||||
|
if (newLevel === 3 || newLevel === 5) {
|
||||||
|
const title = titleFor(member, rows.filter((r) => r.crewId === crewId))
|
||||||
|
if (title) events.push(`${member.name} is now a ${title} — ${skill} L${newLevel}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rows, events }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Run — expect green**: `npx vitest run lib/game/skills.test.ts` → green (both levelFromXp/powerForLevel tables, the three grantXp crossings, the two titleFor cases); `npx tsc --noEmit` clean.
|
||||||
|
|
||||||
|
- [ ] **Commit** (PR B, same branch as Task 12): `git add -A && git commit -m "feat(game): skills.ts — XP, levels, power bands, titles (#45)"`.
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
- JC-22 — spec §6 shows one level-up event (the SENIOR PILOT example) and none for plain L1/L2/L4; so `grantXp` emits an event only when the gained skill steps into a titled band (new level 3 → SENIOR, 5 → MASTER). The event's title is computed from the member's HIGHEST skill level (via `titleFor` over all their rows); if a member is already MASTER from one skill and a second then crosses L3, this would emit a MASTER-titled event naming the L3 skill — a rare edge left as-is (per-member dedup can be added in Task 14's orchestrator if wanted).
|
||||||
|
|
||||||
|
### Task 14: Wire skill XP into the tick + power bands into duration (PR B)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/game/skills.ts` (add `makePowerOf` — the power-band closure); `lib/game/save.ts` (`runTurn`: grant XP on each resolved task, fold the level-up events into the tick's event batch, persist `crew_skills` in the final write; new `makePowerOfFor` route helper; imports `grantXp`/`makePowerOf`/`type SkillRow`/`ACTION_SKILL`); `lib/game/tasks.ts` (`queueTask` gains an optional `powerOf` param, forwarded to `computeDuration`); and the five queue paths — `app/api/jump/route.ts`, `app/api/explore/route.ts`, `app/api/ship/{upgrade,expand,build}/route.ts`, `app/api/research/route.ts` (each passes `makePowerOfFor(db, save, type)` into `queueTask`)
|
||||||
|
- Test: extend `lib/game/save.test.ts` (the `runTurn (tick on the DB)` describe) and `lib/game/tasks.test.ts` (the `queueTask` block). No new files.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 4b's `runTurn(db)` orchestrator in `save.ts` — reads `saves`/`crew`/`tasks`/`inventory`/`shipSystems`/`tech` into a `QueueState`, runs `tickTurn`, writes `saves.turn` before resolving, dispatches `result.completed` through `TASK_RESOLVERS`, and ends in one whole-DB `persist(db)`; Task 13's `grantXp(skillRows, crewIds, skill, members) → { rows, events }`, `powerForLevel(level)`, `levelFromXp(xp)`, `titleFor(member, rows)`, `SkillRow`, `CrewIdentity`; Task 1's `ACTION_SKILL: Record<TaskType, SkillName>`, the unchanged `computeDuration(type, hopCount, lead, helpers, powerOf?)`, and `CrewMember`; Task 12's `schema.crewSkills` table; Task 3's `queueTask(state, req)` / `QueueState`.
|
||||||
|
- Produces: `makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType) → (m: CrewMember) => number` in `skills.ts` — the exact `powerOf` closure Task 15's live UI preview calls (Task 15's crew endpoint must expose each member's skill rows for the UI to build it); and `makePowerOfFor(db, save, type): Promise<(m: CrewMember) => number>` in `save.ts` — the route-side builder the five queue paths call.
|
||||||
|
|
||||||
|
- [ ] **Write the failing tests** — extend the two existing describes (no new files):
|
||||||
|
- `lib/game/tasks.test.ts` (`queueTask` block) — a power-band duration case: a solo member with a `powerOf` returning 2 → `queueTask(state, { type: 'EXPLORE', ... }, (m) => 2)` stores `remaining 2` (`ceil(4/2)`), while the no-`powerOf` call (default flat 1) still stores 4 — pins the new param AND its backward-compatible default.
|
||||||
|
- `lib/game/save.test.ts` (`runTurn` describe) — three cases over the 4a `buildWorldFixture`/`freshFile` pattern (patch a member's `crew_skills` row directly; a fresh save already has 4 rows per member from Task 12's seed):
|
||||||
|
1. **XP accrual** — a 3-person EXPLORE (lead + 2 helpers) completes in one tick → all three members' `SCANNING` rows each gain exactly +1 XP (lead and helpers equally).
|
||||||
|
2. **Level-up event** — a SCIENTIST whose `SCANNING` row is at xp 14 (L2) completes an EXPLORE → the tick writes the §6 text into `schema.events`: `AVA VANCE is now a SENIOR SCIENTIST — SCANNING L3` (xp 15 → L3 → SENIOR band).
|
||||||
|
3. **§6 duration-shift** — a member at `SCANNING` L0/xp 0 queues an EXPLORE solo → stored `duration 4`; after 15 completed EXPLOREs (xp 15 → L3 → power 2) the next queued EXPLORE → stored `duration 2` (`ceil(4/2)`). The lead is a SCIENTIST (the preferred EXPLORE role) so the §6 "4 → 2" holds without a mismatch penalty — see JC-23.
|
||||||
|
- [ ] **Run — expect red**: `npx vitest run lib/game/tasks.test.ts lib/game/save.test.ts` → the new `it`s red (`queueTask` has no `powerOf` param; `runTurn` doesn't read `crew_skills`, so no XP accrual, no level-up event, and the queued duration stays 4 at L3); every pre-existing test (Task 3's `queueTask` block, 4b's `runTurn` block, the 4a A/B + guard blocks) stays green.
|
||||||
|
- [ ] **Implement** — one action per file:
|
||||||
|
1. `lib/game/skills.ts` — add `import { ACTION_SKILL } from './crew-costs'` and `import type { CrewDto } from './save'` (type-only, so no runtime cycle — the `tasks.ts` precedent), then `makePowerOf`: closes over `Map<name, crewId>` (names are unique per single-player save) + `Map<crewId, level-in-ACTION_SKILL[type]>`; returns `(m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0)` — an unknown member / no row falls back to L0 → power 1 (Task 1's default).
|
||||||
|
2. `lib/game/tasks.ts` — `queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1)`; the one line `const duration = computeDuration(req.type, hops, lead, helpers)` becomes `computeDuration(req.type, hops, lead, helpers, powerOf)`. `computeDuration` itself is unchanged.
|
||||||
|
3. `lib/game/save.ts` — import `grantXp`, `makePowerOf`, `type SkillRow` from `./skills` and `ACTION_SKILL` from `./crew-costs`. In `runTurn`: add `crewSkills` to the top `Promise.all` read; map the rows to `SkillRow[]` (stringify `saveId`/`crewId`) and build `members: Record<string, CrewIdentity>` keyed by `String(crew.id)`; in the `result.completed` loop, after each `TASK_RESOLVERS[task.type](db, task)`, call `grantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members)` and reassign `skillRows` (so overlapping crew across several completions in one tick accumulate), pushing its `events` into a tick-level array; after the loop, insert the level-up events as one `schema.events` batch at `result.turn` and update every changed `crew_skills` row — both inside the existing final `persist(db)`. Add `makePowerOfFor(db, save, type)` (reads `crew` + `crew_skills`, returns `makePowerOf(roster, rows, type)`).
|
||||||
|
4. The five queue-path routes — pass `await makePowerOfFor(db, save, type)` as the third `queueTask` arg so the stored duration uses power bands.
|
||||||
|
- [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (the three new `runTurn` cases, the new `queueTask` power-band case, and every pre-existing block); `npx tsc --noEmit` clean.
|
||||||
|
- [ ] **Commit** (PR B, same branch as Tasks 12–13): `git add -A && git commit -m "feat(game): skill XP on task completion + power bands in duration (#45)"`.
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
- JC-23 — the locked design's "e.g. a COMMANDER" for the §6 duration-shift would in fact incur EXPLORE's mismatch penalty (COMMANDER ≠ the preferred SCIENTIST → +1 → 5, not 4), contradicting the cited "duration 4 → 2 (`ceil(4/2)`)". The test therefore leads the EXPLORE with a SCIENTIST (the preferred role, no penalty) seeded at `SCANNING` L0 so the exact §6 "rookie 4 → 2" numbers hold; the power-band effect (the point of the test) is identical either way.
|
||||||
|
- JC-24 — `SkillRow.crewId`/`saveId` are strings (Task 13) but `crew_skills.crew_id`/`save_id` and `tasks.leadCrewId`/`helpers` are numeric; `runTurn` stringifies `crew.id`/`save.id` when mapping rows to `SkillRow[]` and when building `members` and the `grantXp` `crewIds`, keeping one string-id domain end to end so `grantXp`'s `crewIds.includes(r.crewId)` matches.
|
||||||
|
- JC-25 — 4a's resolvers each insert their own resolution event and `persist`; to keep the level-up events "in the same event batch" and the `crew_skills` updates "in the same single write" as the resolution, `runTurn` writes the level-up events as one batch insert and the `crew_skills` updates inside its existing final `persist(db)` (same `result.turn`, one atomic commit) rather than refactoring every 4a resolver to return its event.
|
||||||
|
|
||||||
|
### Task 15: CREW tab skill levels + titles, team-builder contributions (PR B)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/game/save.ts` — the crew-state reader `getCrewState` (post-PR-A it returns the `CrewState { crew, tasks }` wrapper; this is the only lib change in the task) attaches per-member skill data to each `CrewDto`: `skills` (the member's four `crew_skills` rows as `{ skill, level }` in the fixed order NAVIGATION → SCANNING → ENGINEERING → RESEARCH) and `title` (Task 13's `titleFor({ name, role }, memberRows)` — `null` when the member's highest skill is below L3); the `CrewDto` interface gains those two fields; imports `titleFor`/`type SkillRow` from `./skills` and `type SkillName` from `./crew-costs` (type-only, no runtime cycle); the `crew_skills` select joins the existing crew read in the same `Promise.all`.
|
||||||
|
- Modify: `components/crew-panel.tsx` — per member, render the four skill levels on one `·`-separated line (e.g. `NAVIGATION L3 · SCANNING L0 · ENGINEERING L0 · RESEARCH L2`) from `member.skills`, and, only when `member.title` is non-null, a title line (e.g. `SENIOR PILOT`). Display-only — no new interaction. Also label the three STANDBY traits (SHARP SHOOTER, SHORT FUSE, JINXED IN A DOGFIGHT) as "STANDBY — encounter-ready" in each member's trait line (spec §8).
|
||||||
|
- Modify: `components/team-builder.tsx` (PR A's component) — extend each member row to show that member's power contribution for the armed action's skill (e.g. `BEN OKONKWO (power 2)`), computed with Task 14's `makePowerOf`; the existing live duration preview and the dispatch control stay unchanged.
|
||||||
|
- Test: extend `lib/game/save.test.ts` — one new `it` in the `getCrewState` snapshot block for per-member `skills` + `title`. No component tests (the repo has no jsdom/@testing-library — the unit suite is pure-TS only), so the rendered UI is covered by e2e (CI-only), per the standard skip note.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the post-PR-A crew-state shape — `getCrewState(db): Promise<CrewState>` with `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` from `GET /api/crew` (PR A Task 6 / JC-7); Task 13's `titleFor(member: CrewIdentity, rows: SkillRow[]) → string | null` and `SkillRow`; Task 14's `makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType) → (m: CrewMember) => number`; Task 12's `crew_skills` table; Task 1's `ACTION_SKILL` / `SkillName`.
|
||||||
|
- Produces: `CrewDto.skills: { skill: SkillName; level: number }[]` (the four rows, fixed order) and `CrewDto.title: string | null` on the `GET /api/crew` response — asserted by the new unit test and by e2e (the CREW-tab skill/title lines and the team-builder `(power N)` rows). No new endpoint, no schema/table change.
|
||||||
|
|
||||||
|
- [ ] **Write the failing test** — in `lib/game/save.test.ts`'s `getCrewState` snapshot block, over a fresh save (which Task 12 already seeds with four `crew_skills` rows per member): pin one member (e.g. a PILOT) to `NAVIGATION` L3 (xp 15) and its other three skills L0, and leave a second member all-L0. Assert the pinned member's `getCrewState(db).crew` entry carries `skills` = `[{ NAVIGATION, 3 }, { SCANNING, 0 }, { ENGINEERING, 0 }, { RESEARCH, 0 }]` (all four, fixed order) and `title` = `"SENIOR PILOT"`, and the all-L0 member's `title` = `null`.
|
||||||
|
- [ ] **Run — expect red**: `npx vitest run lib/game/save.test.ts` → the new `it` is red (`getCrewState` still returns `CrewDto` entries without `skills`/`title`); every pre-existing block (4b, Task 14's XP/event/duration, the PR-A snapshot-queue block) stays green.
|
||||||
|
- [ ] **Implement** — one action per file:
|
||||||
|
1. `lib/game/save.ts` — in `getCrewState`, add `crew_skills` to the read and group rows by `crewId`; for each `CrewDto` set `skills` to the four fixed-order `{ skill, level }` rows and `title` to `titleFor({ name, role }, memberRows)`; add the two fields to the `CrewDto` interface and the type-only imports.
|
||||||
|
2. `components/crew-panel.tsx` — under each member's role line, render the `·`-separated skill-level line from `member.skills` and, only when set, a `member.title` line; purely presentational.
|
||||||
|
3. `components/team-builder.tsx` — import `makePowerOf` (and `type SkillRow`) from `@/lib/game/skills`; build `skillRows: SkillRow[]` once from the roster (`roster.flatMap((c) => c.skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))`); `const powerOf = makePowerOf(roster, skillRows, type)`; append `(power N)` to each member row's name for that member's contribution in the armed action's skill. Leave the live `duration` preview and dispatch as-is.
|
||||||
|
- [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (the new `getCrewState` skills/title `it` plus every pre-existing block); `npx tsc --noEmit` clean. The rendered CREW-tab lines and team-builder `(power N)` rows are e2e-covered (CI-only, per AGENTS.md).
|
||||||
|
- [ ] **Commit** (PR B, same branch as Tasks 12–14): `git add -A && git commit -m "feat(ui): skill levels + titles on CREW tab and team-builder (#45)"`.
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
- JC-26 — `getCrewState` returns the lightweight `{ skill, level }` per member (not the full `SkillRow` with `xp`/`saveId`) to keep the `GET /api/crew` payload display-oriented; the team builder reconstructs just enough of a `SkillRow[]` (`crewId` + `skill` + `level`) to feed `makePowerOf`, which reads only those fields, so the full rows are not shipped to the client twice.
|
||||||
|
- JC-27 — skill levels render in the fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (the spec §6 skill list) rather than DB row order, so the CREW-tab line and any e2e text assertion are stable; the same order is pinned in the new unit test.
|
||||||
|
- JC-28 — the `GET /api/crew` response object gains two additive display-only fields, but per the locked design this task keeps the OpenAPI `CrewMember` schema (and its `openapi.test.ts` key pin) at its six documented keys, leaving the spec slightly under-documented; a trivial follow-up may mirror `skills`/`title` into the schema + pin later. CI stays green as-is because the pin is unchanged.
|
||||||
|
|
||||||
|
### Task 16: Ship v0.11.0 (PR B — Gitea issue #45)
|
||||||
|
|
||||||
|
**Files touched (this ship step only — no app code, no failing-test step):**
|
||||||
|
- `docs/superpowers/plans/2026-10-01-skill-progression.md` — this plan (untracked, committed here; the spec doc was already committed + approved in PR A, so no spec edits in this PR).
|
||||||
|
- `package.json` (version bump) and `CHANGELOG.md` (new top entry).
|
||||||
|
|
||||||
|
This is a process task that ships the PR B already built by Tasks 12–15 (all code + tests already live on the same branch). It has no failing-test step and ends in a verification gate.
|
||||||
|
|
||||||
|
1. - [ ] **Commit the PR B plan document on the PR B branch** (the branch created in Task 12, `feat/skill-progression`) — the plan doc is untracked: `git add docs/superpowers/plans/2026-10-01-skill-progression.md && git commit -m "docs: skill-progression implementation plan (#45)"`. The spec doc was already committed in PR A — no spec edits in this PR.
|
||||||
|
|
||||||
|
2. - [ ] **Version bump** — `package.json` → `"version": "0.11.0"` (from the current `0.10.0`). `CHANGELOG.md`: new entry at the very top in the repo's format `## v0.11.0 — YYYY-MM-DD HH:MM` (fill the date/time at execution) with a bullet summary of the PR: the `crew_skills` table (schema v8) with role-based starting specialisation; +1 XP per completed task for all assigned crew; power bands in task duration (L0–2→1, L3–4→2, L5→3); SENIOR/MASTER titles; the CREW tab shows skill levels + title and the team-builder shows per-member power contribution.
|
||||||
|
|
||||||
|
3. - [ ] **Local verify — ONLY these three (e2e is CI-only, never run locally):** `npx tsc --noEmit` (clean), `npx vitest run` (all pass), `npx playwright test --list` (every spec compiles + lists). All three must pass. Do NOT run the e2e suite (`npx playwright test`) — CI-only per AGENTS.md.
|
||||||
|
|
||||||
|
4. - [ ] **Push + open the PR to master** — `git push -u origin feat/skill-progression`, then open a pull request to `master` on Gitea (repo `OpenCode/SpaceWanderer` at git.kkhost.pl). Gitea API access per Task 11's JC-20: prefer the pre-configured **gitea MCP tools** to create the PR; if you must use raw REST/curl, `source` the repo-root `.env` (`GITEA_TOKEN`, gitignored).
|
||||||
|
|
||||||
|
5. - [ ] **Auto-merge on green** — trigger Gitea's auto-merge: `POST /repos/OpenCode/SpaceWanderer/pulls/<n>/merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`. The PR merges automatically once the required status check `CI / build + unit + e2e (pull request)` passes (branch protection; CI runs the full e2e suite in an isolated dind stack, ~7 min). Poll the PR until `merged_at` is set (max ~15 min; poll the API every ~30s). If CI is red, **STOP and report the failure** — never force-merge.
|
||||||
|
|
||||||
|
6. - [ ] **Tag on master** — after pulling the merge on `master`: `git tag v0.11.0 && git push origin v0.11.0`.
|
||||||
|
|
||||||
|
7. - [ ] **Manual deploy** (CI never deploys) — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` returns the 0.11.0 payload.
|
||||||
|
|
||||||
|
8. - [ ] **Close the issue** — Gitea API: close issue #45 with a comment summarizing the shipped state (version 0.11.0, tag v0.11.0, live deploy verified).
|
||||||
|
|
||||||
|
**Verification gate (whole task):** `/api/version` on the live URL (`https://space-wanderer.dev.kkhost.pl`) reports 0.11.0 **AND** Gitea issue #45 is closed — only then is this PR done.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Trait Outcome Effects Implementation Plan (PR C — Gitea #43, v0.12.0)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** The two remaining good traits (FINDS HIDDEN THINGS, TALKS TO ANYONE) get deterministic mechanical effects in the explore resolver.
|
||||||
|
|
||||||
|
**Architecture:** A small change to the explore resolver's seeded stream (PR A plan Task 4a) plus the recruit-chance constant; no new tables.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 App Router, React 19, TypeScript strict, bun, SQLite via Drizzle + sql.js (WASM), Vitest unit tests, Playwright e2e (CI-only)
|
||||||
|
|
||||||
|
**Spec:** docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md (§5)
|
||||||
|
|
||||||
|
**Upstream:** Builds on the post-v0.11.0 state; the explore resolver it modifies is defined in the PR A plan (`resolveExploreTask`, Task 4a); task numbering continues from the PR B plan (12–16).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- AGENTS.md change process per PR: feature branch off master, no direct pushes to master; version bump in package.json + CHANGELOG entry at top (`## v<version> — YYYY-MM-DD HH:MM`); local verify ONLY `npx tsc --noEmit`, `npx vitest run`, `npx playwright test --list` (e2e is CI-only, never run locally); push → CI auto-merge on green (verify merged_at) → tag v<version> on master → manual deploy `docker compose up -d --build` + verify /api/version at https://space-wanderer.dev.kkhost.pl.
|
||||||
|
- DDL in lib/db/client.ts must stay in sync with lib/db/schema.ts; schema changes only via idempotent backfill steps in save.ts ensureSave (SCHEMA_VERSION 6→7→8); no DB wipes ever.
|
||||||
|
- OpenAPI spec in lib/openapi.ts updated whenever an endpoint changes (and lib/openapi.test.ts pins, which must be updated with it).
|
||||||
|
- Single-player by design: NO auth, accounts, or multiplayer, ever.
|
||||||
|
- Determinism: every game outcome reproducible from seed + player actions; no wall-clock randomness.
|
||||||
|
- New pure modules (crew-costs.ts, tasks.ts, skills.ts) must not import lib/db/* — shared server+client like the tech.ts precedent (required for live duration preview in the UI).
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Existing save loaded after deploy has no tasks/crew_skills tables → backfill must create them without touching turn, crew, or inventory state, and be idempotent across restarts.
|
||||||
|
5. Seeded-stream draw order must be fixed regardless of traits: an extra haul roll for FINDS HIDDEN THINGS shifts later draws on that per-planet stream — pin the exploration outcome as a pure function of (seed, planetId, assigned crew) with determinism tests both with and without the trait.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 17: Trait Outcome Effects — extra haul item + recruit chance (PR C — Gitea issue #43)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `lib/game/save.ts` — modify `resolveExploreTask` (defined in PR A, Task 4a): insert the FINDS HIDDEN THINGS extra haul-item roll between the regular haul and the recruit roll on the per-planet seeded stream; switch the recruit chance from the hardcoded 0.18 to the `RECRUIT_CHANCE_TALKER` / `RECRUIT_CHANCE_BASE` constants from `crew-costs.ts`
|
||||||
|
- `lib/game/save.test.ts` — extend the existing `describe('resolveExploreTask (A/B against explorePlanet)')` block with three new `it` cases
|
||||||
|
- `lib/game/crew-costs.ts` (created in PR A, Task 1) — consume `RECRUIT_CHANCE_BASE` (0.18) and `RECRUIT_CHANCE_TALKER` (0.30); no new constants added
|
||||||
|
- `lib/game/crew.ts` — add an optional `chance?: number` parameter to `rollCrewMember` (JC-30); default `CREW_CHANCE` so the no-trait path is bit-identical
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- **Consumes:** `explorationRng(seed, planetId)`, `rollExploration(rng)`, `rollCrewMember(rng, takenNames)` — all already in `save.ts` from PR A; `RECRUIT_CHANCE_BASE` / `RECRUIT_CHANCE_TALKER` from `./crew-costs`; the `Task` row's `leadCrewId` and `helpers` (assigned crew); crew trait columns (`traitGood` / `traitBad`) from the `crew` table
|
||||||
|
- **Produces:** `resolveExploreTask(db, task) → Promise<{outcome: FoundItem[]; crewFound: CrewDto | null}>` — same signature and return shape; the trait-modified draw order is internal and invisible to callers (`explorePlanet`, the tick resolver)
|
||||||
|
|
||||||
|
1. - [ ] **Write tests** — extend the `resolveExploreTask` describe block in `lib/game/save.test.ts`; add `import { RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER } from './crew-costs'` to the import. Three new `it` cases:
|
||||||
|
- **Regression pin** — an explore with NO trait-bearing crew assigned produces a byte-identical haul + recruit outcome to the pre-change (PR A) behavior for the same seed and planet.
|
||||||
|
- **Review Focus 5 pin** — same seed + same planet; compare assignment [A] (no traits) vs [A, B] where B has FINDS HIDDEN THINGS: the first N haul items are identical between the two, the FHT run has exactly one extra item, and the whole outcome (haul + recruit) is reproducible across repeated runs of the same save.
|
||||||
|
- **TALKS TO ANYONE constant pin** — with a TALKS TO ANYONE member assigned the recruit roll uses 0.30: assert `RECRUIT_CHANCE_BASE === 0.18` and `RECRUIT_CHANCE_TALKER === 0.30`, and (via a seeded case or by asserting the exact constant is consumed by `rollCrewMember`) that the TALKS TO ANYONE path selects 0.30.
|
||||||
|
2. - [ ] **Run — expect failure**: `npx vitest run lib/game/save.test.ts` → the three new tests fail: the current code has no extra haul roll and uses the hardcoded 0.18 recruit chance.
|
||||||
|
3. - [ ] **Implement** — in `resolveExploreTask` (`lib/game/save.ts`): read assigned crew ids from `task.leadCrewId` + `task.helpers`; load their trait columns; set `hasFHT` / `hasTalker` booleans; if `hasFHT`, call `rollExploration(rng)` one additional time on the same seeded stream and append the result to `outcome`; pass `hasTalker ? RECRUIT_CHANCE_TALKER : RECRUIT_CHANCE_BASE` as the recruit chance to `rollCrewMember` (add an optional `chance` param defaulting to `RECRUIT_CHANCE_BASE` if not already present — see JC-30).
|
||||||
|
4. - [ ] **Run — expect green**: `npx vitest run lib/game/save.test.ts` → all tests pass (the three new ones plus all pre-existing PR A tests).
|
||||||
|
5. - [ ] **Commit**: `git commit -m "feat(game): trait outcome effects — extra haul item + recruit chance (#43)"`
|
||||||
|
|
||||||
|
**Judgment calls**
|
||||||
|
|
||||||
|
- JC-29 — Trait-name matching uses the literal strings `'FINDS HIDDEN THINGS'` and `'TALKS TO ANYONE'` from the design spec §5, checking both `traitGood` and `traitBad` columns (defensive; in practice both are good traits). If PR A introduced trait-name constants in `crew-costs.ts` or elsewhere, use those instead — verify at implementation time.
|
||||||
|
- JC-30 — `rollCrewMember`'s current signature (PR A code is the source of truth) may or may not accept a chance parameter. If 0.18 is hardcoded inside the function, add an optional `chance?: number` param defaulting to `RECRUIT_CHANCE_BASE` so the no-trait path is bit-identical; if it already takes one, just pass the correct value.
|
||||||
|
|
||||||
|
### Task 18: Ship v0.12.0 (PR C — Gitea issue #43)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `docs/superpowers/plans/2026-10-01-trait-outcome-effects.md` — this plan (commit on the PR C branch; the spec doc is unchanged — already committed in PR A)
|
||||||
|
- `package.json` (version bump 0.11.0 → 0.12.0)
|
||||||
|
- `CHANGELOG.md` (new top entry)
|
||||||
|
|
||||||
|
This is a process task that ships the PR C already built by Task 17. No failing-test step; ends in a verification gate.
|
||||||
|
|
||||||
|
1. - [ ] **Commit the PR C plan doc on the PR C branch** (`feat/trait-outcome-effects`, created in Task 17) — only this plan file needs committing (spec unchanged): `git add docs/superpowers/plans/2026-10-01-trait-outcome-effects.md && git commit -m "docs: trait outcome effects implementation plan (#43)"`.
|
||||||
|
2. - [ ] **Version bump** — `package.json` → `"version": "0.12.0"` (from 0.11.0). `CHANGELOG.md`: new top entry `## v0.12.0 — YYYY-MM-DD HH:MM` (fill date/time at execution) with bullets: FINDS HIDDEN THINGS grants +1 exploration haul item; TALKS TO ANYONE raises crew-recruit chance 18% → 30%.
|
||||||
|
3. - [ ] **Local verify — ONLY these three (e2e is CI-only, never run locally):** `npx tsc --noEmit` (clean), `npx vitest run` (all pass), `npx playwright test --list` (every spec compiles + lists). Do NOT run `npx playwright test`.
|
||||||
|
4. - [ ] **Push + open the PR to master** — `git push -u origin feat/trait-outcome-effects`, then open a PR to `master` on Gitea (repo `OpenCode/SpaceWanderer`). Credentials per JC-20 (gitea MCP preferred, else `GITEA_TOKEN` from repo-root `.env`).
|
||||||
|
5. - [ ] **Auto-merge on green** — `POST /repos/OpenCode/SpaceWanderer/pulls/<n>/merge` body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`. Wait for CI (~7 min); poll until `merged_at` is set. If CI is red, **STOP and report the failure** — do not fix it in this step.
|
||||||
|
6. - [ ] **Tag on master** — `git checkout master && git pull && git tag v0.12.0 && git push origin v0.12.0`.
|
||||||
|
7. - [ ] **Manual deploy** — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` reports 0.12.0.
|
||||||
|
8. - [ ] **Close Gitea issue #43** — close #43 with a comment summarizing the shipped state (v0.12.0, tag v0.12.0, live deploy verified).
|
||||||
|
|
||||||
|
**Verification gate (whole task):** `/api/version` = 0.12.0 AND Gitea issue #43 is closed.
|
||||||
|
|
||||||
|
**Judgment calls** — none (process task mirroring the PR A plan's Task 11; no new judgment calls arise here).
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Crew-Driven Actions & Skill Progression — Design Spec
|
||||||
|
|
||||||
|
- Date: 2026-10-01
|
||||||
|
- Status: approved
|
||||||
|
- Tracking: Gitea issues #43–#45 (created after spec approval)
|
||||||
|
- Repo rules: AGENTS.md change process applies to every issue below
|
||||||
|
|
||||||
|
## 1. Background
|
||||||
|
|
||||||
|
SpaceWanderer is a single-player, turn-based space adventure (dungeon-crawler flavor; reference: Out There). As of v0.9.12 the loop is jump → scan planets → collect hauls → spend on ship upgrades & research → take missions for generic rewards. Every action resolves instantly and costs exactly 1 turn (jumps cost hop count, 1–2 with NAV AI tech). Crew members carry a role and two traits but change nothing mechanically — ORCHESTRATOR.md's "manage crew of flawed humans" is unimplemented.
|
||||||
|
|
||||||
|
This iteration makes **crew the central resource**: every action requires an assigned team, takes variable duration based on that team's roles/skills/traits, and time advances only when the player presses NEXT TURN. Doing jobs builds skill levels, so crews specialise over a playthrough.
|
||||||
|
|
||||||
|
## 2. Goals / Non-goals
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
1. Every game action (jump, explore, ship work, research) becomes a queued task with an assigned team (lead + optional helpers).
|
||||||
|
2. Duration is computed from work points ÷ team power, modified by role mismatch and the lead's traits — min 1 turn.
|
||||||
|
3. Manual time: NEXT TURN button (bottom-right) advances exactly 1 turn; empty queue = free skip.
|
||||||
|
4. Skill progression: 4 skills keyed by action type; XP from completed tasks; levels 0–5 with power bands; role-based starting specialisation; titles at L3/L5.
|
||||||
|
5. All 20 traits get mechanical meaning (duration modifiers now; two outcome effects in #43; three combat traits marked STANDBY for the future encounters iteration).
|
||||||
|
|
||||||
|
Non-goals (explicitly out of scope):
|
||||||
|
- Random encounters, combat, damage, death (next roadmap item per ORCHESTRATOR.md §7)
|
||||||
|
- Real-time mode — but the work-point model is time-independent by design to ease that migration later
|
||||||
|
- Crew injury/departure, supplies/hunger systems, task cancellation
|
||||||
|
|
||||||
|
## 3. Core Model: Queued Tasks + Turn Tick
|
||||||
|
|
||||||
|
- New `tasks` table (schema v7): one row per queued/active task. Fields: id, saveId, type (`JUMP|EXPLORE|SHIP_UPGRADE|SHIP_EXPAND|SHIP_BUILD|RESEARCH`), payload (JSON — targetSystemId / planetId / systemType+action / techSlug), leadCrewId, helpers (JSON array of crew ids), duration, remaining, status (`ACTIVE|PENDING|DONE`).
|
||||||
|
- **One active task per crew member** (as lead or helper). A crew member may hold a chain of PENDING tasks (FIFO pre-queueing); when their current task completes on a turn tick, the next pending auto-starts. Helper slots are fixed at queue time; a committed helper is busy for that task's duration and cannot be added elsewhere meanwhile.
|
||||||
|
- **Costs deduct at queue time** (ship work & research consume resources/parts immediately — prevents mid-turn affordability races); effects apply on completion. Jumps/explores have no resource cost.
|
||||||
|
- `save.turn` increments only via `POST /api/turn`. The tick: for every ACTIVE task, remaining−=1; tasks reaching 0 resolve (per-type resolvers reusing existing logic from save.ts / ship / research), grant XP (§6), write events, free the team, auto-start any FIFO successor, then run mission-completion checks for what just resolved.
|
||||||
|
- **In-transit rule:** while any JUMP task is ACTIVE, no new tasks can be queued (409). Pending jumps do not block; already-active/pending non-jump work continues through the jump and resolves against its target regardless of current system location. Only one JUMP may exist at a time (active or pending) — prevents two arrivals on one tick.
|
||||||
|
- **Mission accept becomes instant/free** (paperwork: no crew, no turn cost). Completion checks run when the satisfying task completes (jump arrival → DELIVER; scan → EXPLORE/FIND), same as today's hooks moved into tick resolution.
|
||||||
|
|
||||||
|
## 4. Duration Model: Work Points & Team Power
|
||||||
|
|
||||||
|
`duration = max(1, ceil(work(action) / totalPower) + mismatchPenalty + traitModifierSum)`
|
||||||
|
|
||||||
|
- `work(action)` — work points (balance knobs, §11): EXPLORE=4 · JUMP=hops×3 (single 3 / two-hop 6) · SHIP work=6 · RESEARCH=9. Durations are deliberately much longer than today; teams and skill levels claw back the time.
|
||||||
|
- `totalPower` — sum over lead + helpers of each member's power band in that action type's skill: L0–L2 → 1, L3–L4 → 2, L5 → 3. (Until #45 lands there is no skill table, so every crew member contributes power 1 — teamwork already works; progression layers on top.)
|
||||||
|
- `mismatchPenalty` — +1 turn if the LEAD's role is not a preferred role for the action type (preferred: JUMP → PILOT/NAVIGATOR · EXPLORE → SCIENTIST · SHIP work → ENGINEER/TECHNICIAN · RESEARCH → SCIENTIST). Helpers are never penalised. STEADY UNDER PRESSURE cancels it.
|
||||||
|
- `traitModifierSum` — sum of the LEAD's trait modifiers for this action type (§5 table). Helper traits do not apply.
|
||||||
|
|
||||||
|
Solo durations (power 1, no mismatch/traits): EXPLORE 4 · JUMP single 3 / two-hop 6 · SHIP work 6 · RESEARCH 9. A maxed specialist + helpers converges back to ~1–2 turns (§6 examples).
|
||||||
|
|
||||||
|
## 5. Trait Mechanics — all 20 traits
|
||||||
|
|
||||||
|
Duration modifiers (lead only, additive in §4 formula):
|
||||||
|
|
||||||
|
| Good trait | Effect |
|
||||||
|
|---|---|
|
||||||
|
| STEADY UNDER PRESSURE | Cancels role-mismatch penalty on any action |
|
||||||
|
| RECKLESS COURAGE | JUMP −1 turn |
|
||||||
|
| CALM IN A CRISIS | EXPLORE −1 turn |
|
||||||
|
| FIXES ANYTHING | SHIP work −1 turn |
|
||||||
|
| READS STARS BLIND | JUMP −1 turn |
|
||||||
|
| QUICK HANDS | Any action −1 turn |
|
||||||
|
| IRON WILL | RESEARCH −1 turn |
|
||||||
|
|
||||||
|
| Bad trait | Effect |
|
||||||
|
|---|---|
|
||||||
|
| RIGID — SLOW TO ADAPT | RESEARCH +1 turn |
|
||||||
|
| OVERCAUTIOUS | Any action +1 turn |
|
||||||
|
| SLOWS THE CREW DOWN | Any action +1 turn |
|
||||||
|
| CARELESS WITH FUSES | SHIP work +1 turn |
|
||||||
|
| HOARDS RATIONS | SHIP work +1 turn |
|
||||||
|
| FORGETS TO EAT | EXPLORE +1 turn |
|
||||||
|
| ARGUES WITH EVERYONE | RESEARCH +1 turn |
|
||||||
|
| DOUBTS THE CAPTAIN | JUMP +1 turn |
|
||||||
|
|
||||||
|
Outcome effects (#43): FINDS HIDDEN THINGS → exploration hauls gain +1 item (extra roll on the same seeded per-planet stream) · TALKS TO ANYONE → crew-recruit chance 18% → 30%.
|
||||||
|
Encounter-ready STANDBY (activate when random encounters land; shown as "STANDBY" in UI): SHARP SHOOTER, SHORT FUSE, JINXED IN A DOGFIGHT.
|
||||||
|
|
||||||
|
## 6. Skill Progression & Specialisation
|
||||||
|
|
||||||
|
- **Skills** — one per action type: NAVIGATION (jumps), SCANNING (exploration), ENGINEERING (ship work), RESEARCH. Stored in a new `crew_skills` table (schema v8): saveId, crewId, skill, level, xp — extensible for future COMBAT/MEDICAL skills.
|
||||||
|
- **Roles keep their names** — the existing 8 roles are unchanged; skills are a separate progression axis. A role matters only for: starting levels and the lead mismatch penalty.
|
||||||
|
- **Starting specialisation (role → L2 in its skill(s)):** PILOT/NAVIGATOR → NAVIGATION · SCIENTIST → SCANNING + RESEARCH · ENGINEER/TECHNICIAN → ENGINEERING · COMMANDER/MEDIC/GUNNER → all skills L0 (generalists; MEDIC/GUNNER become relevant with encounters).
|
||||||
|
- **Buildup by doing the job:** on task completion, every assigned crew member (lead and helpers) gains +1 XP in that action type's skill. Level thresholds (cumulative completions): L1=5 · L2=10 · L3=15 · L4=20 · L5=25 (cap).
|
||||||
|
- **Power bands:** L0–L2 → 1 · L3–L4 → 2 · L5 → 3 (§4).
|
||||||
|
- **Titles** (display only, role name preserved): highest skill level ≥5 → "MASTER <ROLE>", ≥3 → "SENIOR <ROLE>". Level-ups write an event ("BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3").
|
||||||
|
|
||||||
|
Duration examples (work values §4): EXPLORE solo rookie 4 turns → solo master 2 → master+rookie helper 1 · JUMP two-hop solo rookie 6 → solo master 2 → two masters 1 · SHIP work solo rookie 6 → solo master 2 · RESEARCH solo rookie 9 → solo master 3 (stays the long pole by design).
|
||||||
|
|
||||||
|
## 7. API Changes
|
||||||
|
|
||||||
|
- All action endpoints become queueing operations and gain crew params: `POST /api/jump {targetSystemId, leadCrewId, helperCrewIds[]}` · `POST /api/explore {planetId, ...}` · `POST /api/ship/{upgrade|expand|build} {...}` · `POST /api/research {techSlug, ...}`. Validation at queue time: unknown crew → 404; busy/committed crew → 409; in-transit or second jump → 409; unreachable target / unaffordable cost / build gate → existing error codes. No turn increment on queue.
|
||||||
|
- New `POST /api/turn` — advances exactly one turn (§3 tick), returns full snapshot.
|
||||||
|
- Snapshots (world/system/crew responses) gain an active+pending `tasks` array: {id, type, targetLabel, leadName, helperNames[], remaining} so the UI can render the task strip and disable busy crew. The admin CRUD panel picks up new tables automatically.
|
||||||
|
- OpenAPI spec in lib/openapi.ts updated accordingly; DDL in lib/db/client.ts kept in sync with lib/db/schema.ts (repo rule).
|
||||||
|
|
||||||
|
## 8. UI
|
||||||
|
|
||||||
|
- **NEXT TURN button fixed bottom-right** with current turn counter ("TURN 12") — the game's heartbeat; pressing it with an empty queue is a free skip.
|
||||||
|
- **Crew picker:** action buttons open an inline team builder — pick lead (radio), toggle helpers (checkboxes); live-computed duration updates as the team changes ("Lead AVA VANCE · PILOT L2 + 1 helper → 3 turns"); busy/committed members disabled with reason.
|
||||||
|
- **Active-tasks strip** (bottom-left): each task's type, target, team, remaining turns; pending FIFO tasks shown dimmed.
|
||||||
|
- **CREW tab:** per member — role (+title), traits, and the 4 skill levels; STANDBY traits labelled as encounter-ready.
|
||||||
|
- **Event log:** task-completion and level-up events.
|
||||||
|
|
||||||
|
## 9. Determinism & Testing
|
||||||
|
|
||||||
|
- Duration is a pure function `(actionType, hopCount, lead, helpers) → number` — unit-tested across work values × power bands × mismatch × trait matrix.
|
||||||
|
- XP/level-ups are deterministic (no rng). Trait outcome effects (#43) draw from the existing seeded per-planet streams (same save+planet → same haul), keeping everything reproducible.
|
||||||
|
- Unit tests (colocated *.test.ts): duration matrix; queue validation rules (busy 409, in-transit 409, second jump 409); tick resolution (completion effects, XP grant, level-up event, FIFO auto-start, mission checks); cost deduction at queue time.
|
||||||
|
- E2E (CI-only per AGENTS.md): assign team → NEXT TURN ×N → effect lands; parallel tasks complete independently; pre-queued FIFO starts after predecessor completes; in-transit queuing blocked; empty-queue skip works.
|
||||||
|
|
||||||
|
## 10. Delivery Plan — Gitea issues as TODO list
|
||||||
|
|
||||||
|
One issue = one feature branch/PR/version bump, per AGENTS.md loop (branch off master → version bump + CHANGELOG entry → local verify `tsc --noEmit` / `vitest run` / `playwright test --list` → push, CI auto-merge on green → tag → manual deploy → close issue with comment).
|
||||||
|
|
||||||
|
| Issue | Version | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| #44 Crew-driven actions + NEXT TURN engine | v0.10.0 | tasks table (schema v7; backfill creates empty table for existing saves), POST /api/turn tick, all action endpoints → queueing with lead+helpers, duration model (§4; power 1 for everyone until #45), trait duration modifiers (§5), UI: NEXT TURN button bottom-right + turn counter, crew picker with live duration, task strip. OpenAPI update. Rework existing unit/e2e assertions from instant-resolution to the queue+tick model |
|
||||||
|
| #45 Skill progression & specialisation | v0.11.0 | crew_skills table (schema v8; idempotent backfill seeds rows per role mapping for existing saves), XP on completion for all assigned crew, level-up events, power bands active (§6), UI: skill display in CREW tab + titles, picker shows per-member contribution |
|
||||||
|
| #43 Trait outcome effects | v0.12.0 | FINDS HIDDEN THINGS → hauls +1 item; TALKS TO ANYONE → recruit chance 30% |
|
||||||
|
|
||||||
|
## 11. Balance Knobs (single tuning surface)
|
||||||
|
|
||||||
|
All constants live in one new module `lib/game/crew-costs.ts` (alongside ship-costs.ts): WORK_POINTS per action type, POWER_BANDS, XP_THRESHOLDS, STARTING_LEVELS per role, recruit chance values. Tuning a number = editing one file; the duration formula and resolvers read from it.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// #38 — ADMIN DB CRUD panel. READ-ONLY on purpose: specs share the CI DB across retries, so no
|
||||||
|
// insert/update/delete here (the mutation endpoints are covered by unit tests in lib/db/admin.test.ts).
|
||||||
|
|
||||||
|
interface AdminColumnDto { name: string; type: string; pk: number }
|
||||||
|
interface AdminTableDto { name: string; columns: AdminColumnDto[]; count: number }
|
||||||
|
interface AdminRowsDto { rows: Record<string, unknown>[]; total: number }
|
||||||
|
|
||||||
|
test('ADMIN button opens the panel and lists every table (#38)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// The ADMIN button sits in the header next to the SECTOR SCAN badge.
|
||||||
|
await page.getByRole('button', { name: 'ADMIN' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('ADMIN // DB CRUD')).toBeVisible();
|
||||||
|
// Cross-check against the API: every table it lists must render in the panel (and vice versa).
|
||||||
|
const tables = (await (await page.request.get('/api/admin/tables')).json()) as AdminTableDto[];
|
||||||
|
expect(tables.length).toBeGreaterThanOrEqual(10);
|
||||||
|
for (const t of tables) {
|
||||||
|
await expect(page.getByRole('button', { name: new RegExp(`^${t.name}\\b`) })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening a table renders its rows from the API (#38)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'ADMIN' }).click();
|
||||||
|
|
||||||
|
// saves always has exactly one row (single-player by design) — deterministic across retries.
|
||||||
|
const api = (await (await page.request.get('/api/admin/tables/saves/rows')).json()) as AdminRowsDto;
|
||||||
|
expect(api.total).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^saves\b/ }).click();
|
||||||
|
// Column headers come from the API's column list.
|
||||||
|
for (const col of api.rows[0] ? Object.keys(api.rows[0]) : []) {
|
||||||
|
await expect(page.locator('table th', { hasText: col })).toBeVisible();
|
||||||
|
}
|
||||||
|
// Row count matches what the API reports, and a cell shows real data from it.
|
||||||
|
const body = page.locator('table tbody tr');
|
||||||
|
await expect(body).toHaveCount(api.rows.length);
|
||||||
|
await expect(page.locator('table tbody').getByText(String(api.rows[0].seed))).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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 }
|
||||||
|
|
||||||
|
// Minimal crew member — GET /api/crew is the {crew, tasks} object (Task 6 / JC-7); the full
|
||||||
|
// member shape lives in e2e/state.spec.ts.
|
||||||
|
interface CrewDto { id: number; role: string }
|
||||||
|
|
||||||
|
// The real TaskType strings (lib/game/tasks.ts) keyed by the action POST path — there is no
|
||||||
|
// bare SHIP type, ship actions split into UPGRADE / EXPAND / BUILD.
|
||||||
|
const TASK_TYPE: Record<string, string> = {
|
||||||
|
'/api/jump': 'JUMP',
|
||||||
|
'/api/explore': 'EXPLORE',
|
||||||
|
'/api/research': 'RESEARCH',
|
||||||
|
'/api/ship/upgrade': 'SHIP_UPGRADE',
|
||||||
|
'/api/ship/expand': 'SHIP_EXPAND',
|
||||||
|
'/api/ship/build': 'SHIP_BUILD',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The GET endpoint that serves each action's final snapshot (the action paths are POST-only).
|
||||||
|
const FINAL_SNAPSHOT: Record<string, string> = {
|
||||||
|
'/api/jump': '/api/world',
|
||||||
|
'/api/explore': '/api/system',
|
||||||
|
'/api/research': '/api/tech',
|
||||||
|
'/api/ship/upgrade': '/api/ship',
|
||||||
|
'/api/ship/expand': '/api/ship',
|
||||||
|
'/api/ship/build': '/api/ship',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Queue an action POST with a default one-person crew, then tick until that task resolves.
|
||||||
|
// The POST only queues (200, no turn bump, effect not yet visible); the effect appears once the
|
||||||
|
// task hits 0 on a tick — a task that starts ACTIVE with remaining N resolves on the Nth tick
|
||||||
|
// (lib/game/tasks.ts tickTurn) — so N (the full duration) is read from the queue response's
|
||||||
|
// tasks array and POST /api/turn is sent exactly N times. Returns the final snapshot for the
|
||||||
|
// path. A 409 on the queue POST is a busy / in-transit rejection (Task 3), not a tick-count bug.
|
||||||
|
export async function resolveAction(page: Page, path: string, data: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const roster = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[] } | CrewDto[];
|
||||||
|
const members: CrewDto[] = Array.isArray(roster) ? roster : (roster.crew ?? []); // {crew, tasks} object (JC-7); the array guard keeps the helper shape-tolerant
|
||||||
|
expect(members.length).toBeGreaterThan(0); // starting crew is always seeded
|
||||||
|
const res = await page.request.post(path, { data: { ...data, leadCrewId: members[0].id, helperCrewIds: [] } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const snap = (await res.json()) as { tasks?: { type: string; remaining: number }[] };
|
||||||
|
const n = snap.tasks?.find((t) => t.type === TASK_TYPE[path])?.remaining ?? 1;
|
||||||
|
for (let i = 0; i < n; i++) expect((await page.request.post('/api/turn')).status()).toBe(200);
|
||||||
|
return (await page.request.get(FINAL_SNAPSHOT[path])).json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
|
||||||
|
// undiscovered system mints a seeded-random 1–4 new systems (at least one always minted —
|
||||||
|
// lib/game/world.ts #51), 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. The jump is queued with a default
|
||||||
|
// one-person crew and ticked to completion (resolveAction) before this returns, so the caller
|
||||||
|
// is already standing in the target system, not merely en route to it.
|
||||||
|
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 after = (await resolveAction(page, '/api/jump', { systemId: target.id })) as WorldState;
|
||||||
|
expect(after.systems.find((s) => s.current)?.id).toBe(target.id); // landed, not just queued
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick the save's task queue until it is empty (free skips are legal 200s; every tick decrements
|
||||||
|
// every ACTIVE row and PENDING rows auto-start, so a non-empty queue always shrinks — the bound
|
||||||
|
// only fails a pathological state). Every spec that queues actions starts from this on retry:
|
||||||
|
// the CI DB persists across attempts, and a failed one may have left a row behind — an in-transit
|
||||||
|
// JUMP would 409 every new queue (lib/game/tasks.ts's in-transit rule).
|
||||||
|
export async function drainQueue(page: Page): Promise<void> {
|
||||||
|
for (let ticks = 0; ; ticks++) {
|
||||||
|
const body = (await (await page.request.get('/api/crew')).json()) as { crew?: CrewDto[]; tasks?: { id: number }[] } | CrewDto[];
|
||||||
|
const tasks = Array.isArray(body) ? [] : (body.tasks ?? []);
|
||||||
|
if (tasks.length === 0) return;
|
||||||
|
expect(ticks).toBeLessThan(60);
|
||||||
|
expect((await page.request.post('/api/turn')).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1064
-2
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { drainQueue, jumpToFreshSystem, resolveAction } from './helpers';
|
||||||
|
|
||||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
||||||
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
||||||
@@ -8,15 +9,73 @@ import { expect, test } from '@playwright/test';
|
|||||||
const repoRoot = path.join(__dirname, '..');
|
const repoRoot = path.join(__dirname, '..');
|
||||||
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
||||||
|
|
||||||
|
// Mirrors the /api/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 }
|
||||||
|
// Mirrors the QueueDto of lib/game/save.ts — GET /api/crew is the {crew, tasks} object (Task 6 /
|
||||||
|
// JC-7): the queue's display shape (resolved names, no raw payload), ACTIVE block first, then
|
||||||
|
// PENDING, DONE filtered.
|
||||||
|
interface TaskDto { id: number; type: string; targetLabel: string; leadName: string; helperNames: string[]; remaining: number; status: string; leadCrewId: number; helperCrewIds: number[] }
|
||||||
|
interface CrewState { crew: CrewDto[]; tasks: TaskDto[] }
|
||||||
|
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('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
// Retry-safety: a failed earlier attempt may have left a row queued — an in-transit JUMP would
|
||||||
|
// 409 every new queue (lib/game/tasks.ts's in-transit rule), so drain before the baselines.
|
||||||
|
await drainQueue(page);
|
||||||
|
|
||||||
// Baseline: the app serves and reports its version.
|
// Baseline: the app serves and reports its version.
|
||||||
const resBefore = await page.request.get('/api/version');
|
const resBefore = await page.request.get('/api/version');
|
||||||
expect(resBefore.status()).toBe(200);
|
expect(resBefore.status()).toBe(200);
|
||||||
const before = (await resBefore.json()) as { version: string };
|
const before = (await resBefore.json()) as { version: string };
|
||||||
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
|
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
|
|
||||||
|
// Game-state baseline: jump once so there is real progress that must survive the restart.
|
||||||
|
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)!;
|
||||||
|
// The explore POST only queues — no turn bump, explored not yet flipped — so tick it to
|
||||||
|
// completion and re-derive the baseline from the post-tick /api/system snapshot: the turn it
|
||||||
|
// pins (compared after the restart) must be the resolved one, not the queue's.
|
||||||
|
const sysExplored = (await resolveAction(page, '/api/explore', { planetId: targetPlanet.id })) as SystemState;
|
||||||
|
expect(sysExplored.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true); // the scan resolved before the baseline is pinned
|
||||||
|
|
||||||
|
// Crew baseline: the roster (starting crew + any finds so far) must survive too.
|
||||||
|
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewState;
|
||||||
|
expect(crewBefore.crew.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.
|
// Restart the app container mid-run — the named DB volume must survive it.
|
||||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
|
||||||
@@ -37,6 +96,37 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
expect(after).toBeDefined();
|
expect(after).toBeDefined();
|
||||||
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
||||||
|
|
||||||
|
// Game state survived: same turn counter and current system as before the restart.
|
||||||
|
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). The
|
||||||
|
// {crew, tasks} object shape (JC-7) means the still-queued task rows survive with it too.
|
||||||
|
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewState;
|
||||||
|
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.
|
// The page itself serves again with the landing title.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { getTableName } from 'drizzle-orm'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { openGameDb, type GameDb } from './client'
|
||||||
|
import * as schema from './schema'
|
||||||
|
import { AdminError, deleteAdminRow, insertAdminRow, isAdminTableName, listAdminTables, readAdminRows, updateAdminRow } from './admin'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<GameDb> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-admin-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
return openGameDb(path.join(dir, 'test.db'))
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function expectAdminError(promise: Promise<unknown>, status: number): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => {
|
||||||
|
throw new Error(`expected AdminError ${status}, resolved instead`)
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
expect(err).toBeInstanceOf(AdminError)
|
||||||
|
expect((err as AdminError).status).toBe(status)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin table registry', () => {
|
||||||
|
it('exposes every game table from schema.ts, keyed by SQL name', async () => {
|
||||||
|
const db = await freshFile()
|
||||||
|
const tables = await listAdminTables(db)
|
||||||
|
expect(tables.map((t) => t.name).sort()).toEqual(
|
||||||
|
Object.values(schema)
|
||||||
|
.map((s) => getTableName(s as Parameters<typeof getTableName>[0]))
|
||||||
|
.sort(),
|
||||||
|
)
|
||||||
|
expect(tables.every((t) => isAdminTableName(t.name))).toBe(true)
|
||||||
|
for (const bad of ['_meta', 'nope', 'Saves']) expect(isAdminTableName(bad)).toBe(false)
|
||||||
|
|
||||||
|
const saves = tables.find((t) => t.name === 'saves')!
|
||||||
|
expect(saves.columns.map((c) => c.name)).toEqual(['id', 'seed', 'turn', 'created_at', 'schema_version'])
|
||||||
|
expect(saves.count).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown tables with 404 on every operation', async () => {
|
||||||
|
const db = await freshFile()
|
||||||
|
await expectAdminError(readAdminRows(db, 'nope'), 404)
|
||||||
|
await expectAdminError(insertAdminRow(db, 'nope', {}), 404)
|
||||||
|
await expectAdminError(updateAdminRow(db, 'nope', { id: 1 }, {}), 404)
|
||||||
|
await expectAdminError(deleteAdminRow(db, 'nope', { id: 1 }), 404)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('admin row CRUD (single-PK tables)', () => {
|
||||||
|
it('inserts, reads back and pages rows with limit/offset validation', async () => {
|
||||||
|
const db = await freshFile()
|
||||||
|
const row = await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: '2026-01-01T00:00:00Z' })
|
||||||
|
expect(row.id).toBe(1)
|
||||||
|
expect(row.seed).toBe('abc')
|
||||||
|
|
||||||
|
const all = await readAdminRows(db, 'saves')
|
||||||
|
expect(all.total).toBe(1)
|
||||||
|
expect(all.rows[0]).toMatchObject({ id: 1, seed: 'abc', turn: 0, schema_version: null })
|
||||||
|
|
||||||
|
for (const bad of [0, -1, 201]) await expectAdminError(readAdminRows(db, 'saves', bad), 400)
|
||||||
|
await expectAdminError(readAdminRows(db, 'saves', 50, -1), 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown columns, missing required columns and wrong value types with 400', async () => {
|
||||||
|
const db = await freshFile()
|
||||||
|
await expectAdminError(insertAdminRow(db, 'saves', { bogus: 1 }), 400)
|
||||||
|
await expectAdminError(insertAdminRow(db, 'saves', { seed: 'x' }), 400) // created_at missing
|
||||||
|
await expectAdminError(insertAdminRow(db, 'saves', { seed: 'x', turn: 'nope', created_at: 't' }), 400)
|
||||||
|
await expectAdminError(insertAdminRow(db, 'saves', ['not', 'an', 'object']), 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates by key and returns the fresh row; unknown keys are 404', async () => {
|
||||||
|
const db = await freshFile()
|
||||||
|
await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: 't' })
|
||||||
|
|
||||||
|
const updated = await updateAdminRow(db, 'saves', { id: 1 }, { turn: 5 })
|
||||||
|
expect(updated).toMatchObject({ id: 1, seed: 'abc', turn: 5 })
|
||||||
|
|
||||||
|
await expectAdminError(updateAdminRow(db, 'saves', { id: 99 }, { turn: 1 }), 404)
|
||||||
|
await expectAdminError(updateAdminRow(db, 'saves', {}, { turn: 1 }), 400) // missing pk column in key
|
||||||
|
await expectAdminError(updateAdminRow(db, 'saves', { id: 1 }, {}), 400) // nothing to set
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes by key and returns the removed row; second delete is 404', async () => {
|
||||||
|
const db = await freshFile()
|
||||||
|
await insertAdminRow(db, 'saves', { seed: 'abc', turn: 0, created_at: 't' })
|
||||||
|
|
||||||
|
const deleted = await deleteAdminRow(db, 'saves', { id: 1 })
|
||||||
|
expect(deleted).toMatchObject({ id: 1, seed: 'abc' })
|
||||||
|
expect((await readAdminRows(db, 'saves')).total).toBe(0)
|
||||||
|
await expectAdminError(deleteAdminRow(db, 'saves', { id: 1 }), 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps SQLite constraint violations to 409', async () => {
|
||||||
|
const db = await freshFile()
|
||||||
|
await insertAdminRow(db, 'crew', { save_id: 1, name: 'A', role: 'PILOT', trait_good: 'g', trait_bad: 'b', origin: 'STARTING CREW' })
|
||||||
|
// ship_systems has UNIQUE(save_id, type) — a duplicate is a constraint failure, not a crash.
|
||||||
|
await insertAdminRow(db, 'ship_systems', { save_id: 1, type: 'HULL', level: 0, capacity: 0 })
|
||||||
|
await expectAdminError(insertAdminRow(db, 'ship_systems', { save_id: 1, type: 'HULL', level: 1, capacity: 0 }), 409)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('admin row CRUD (composite-PK tables)', () => {
|
||||||
|
it('requires every PK column in the key for jump_routes', async () => {
|
||||||
|
const db = await freshFile()
|
||||||
|
const row = await insertAdminRow(db, 'jump_routes', { from_system: 1, to_system: 2 })
|
||||||
|
expect(row).toMatchObject({ from_system: 1, to_system: 2 })
|
||||||
|
|
||||||
|
// PK columns are NOT NULL without defaults — omitting one is a 400, not a SQLite error.
|
||||||
|
await expectAdminError(insertAdminRow(db, 'jump_routes', { from_system: 1 }), 400)
|
||||||
|
await expectAdminError(updateAdminRow(db, 'jump_routes', { from_system: 1 }, {}), 400)
|
||||||
|
|
||||||
|
const deleted = await deleteAdminRow(db, 'jump_routes', { from_system: 1, to_system: 2 })
|
||||||
|
expect(deleted).toMatchObject({ from_system: 1, to_system: 2 })
|
||||||
|
await expectAdminError(deleteAdminRow(db, 'jump_routes', { from_system: 1, to_system: 3 }), 404)
|
||||||
|
})
|
||||||
|
})
|
||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
import { getTableName } from 'drizzle-orm'
|
||||||
|
import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
|
||||||
|
import * as schema from './schema'
|
||||||
|
import { persist, type GameDb } from './client'
|
||||||
|
|
||||||
|
// #38 — admin CRUD over the raw DB. Every game table is exposed through this registry; the SQL
|
||||||
|
// names come from the drizzle tables themselves (getTableName) so the list can't drift from
|
||||||
|
// lib/db/schema.ts. All queries run on the raw sql.js handle driven by PRAGMA table_info, so there
|
||||||
|
// is no per-table code — adding a table to schema.ts + this map is all it takes.
|
||||||
|
export const ADMIN_TABLES = {
|
||||||
|
saves: schema.saves,
|
||||||
|
solar_systems: schema.solarSystems,
|
||||||
|
jump_routes: schema.jumpRoutes,
|
||||||
|
planets: schema.planets,
|
||||||
|
inventory_items: schema.inventoryItems,
|
||||||
|
crew: schema.crew,
|
||||||
|
ship_systems: schema.shipSystems,
|
||||||
|
tech: schema.tech,
|
||||||
|
missions: schema.missions,
|
||||||
|
events: schema.events,
|
||||||
|
tasks: schema.tasks,
|
||||||
|
crew_skills: schema.crewSkills,
|
||||||
|
} as const satisfies Record<string, SQLiteTable>
|
||||||
|
|
||||||
|
const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t)))
|
||||||
|
|
||||||
|
export function isAdminTableName(name: string): boolean {
|
||||||
|
return TABLE_NAMES.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thrown by the helpers below; routes map it to a JSON error response with its status.
|
||||||
|
export class AdminError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTable(name: string): void {
|
||||||
|
if (!TABLE_NAMES.has(name)) throw new AdminError(404, `Unknown table '${name}'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SqlParam = string | number | null
|
||||||
|
|
||||||
|
// Column metadata straight from SQLite. pk is the position within the primary key (0 = not a PK),
|
||||||
|
// so composite keys like jump_routes(from_system, to_system) come out ordered for free.
|
||||||
|
export interface AdminColumn {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
notNull: boolean
|
||||||
|
hasDefault: boolean
|
||||||
|
pk: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableColumns(db: GameDb, name: string): AdminColumn[] {
|
||||||
|
const res = db.raw.exec(`PRAGMA table_info(${name})`) // name validated by assertTable — no injection path
|
||||||
|
return (res[0]?.values ?? []).map((v) => ({
|
||||||
|
name: String(v[1]),
|
||||||
|
type: String(v[2]).toUpperCase(),
|
||||||
|
notNull: v[3] === 1,
|
||||||
|
hasDefault: v[4] !== null && v[4] !== undefined,
|
||||||
|
pk: Number(v[5]) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowCount(db: GameDb, name: string): number {
|
||||||
|
return Number(db.raw.exec(`SELECT COUNT(*) FROM ${name}`)[0]?.values[0][0] ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTableInfo {
|
||||||
|
name: string
|
||||||
|
// pk mirrors PRAGMA table_info: position within the primary key (0 = not one), so the UI can
|
||||||
|
// build row keys for any table, composite included.
|
||||||
|
columns: { name: string; type: string; pk: number }[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminTables(db: GameDb): Promise<AdminTableInfo[]> {
|
||||||
|
const out: AdminTableInfo[] = []
|
||||||
|
for (const table of Object.values(ADMIN_TABLES)) {
|
||||||
|
const name = getTableName(table)
|
||||||
|
const cols = tableColumns(db, name)
|
||||||
|
out.push({ name, columns: cols.map((c) => ({ name: c.name, type: c.type, pk: c.pk })), count: rowCount(db, name) })
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRows {
|
||||||
|
rows: Record<string, unknown>[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ROWS = 200
|
||||||
|
|
||||||
|
export async function readAdminRows(db: GameDb, name: string, limit = 50, offset = 0): Promise<AdminRows> {
|
||||||
|
assertTable(name)
|
||||||
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_ROWS) throw new AdminError(400, `limit must be an integer in [1, ${MAX_ROWS}]`)
|
||||||
|
if (!Number.isInteger(offset) || offset < 0) throw new AdminError(400, 'offset must be a non-negative integer')
|
||||||
|
|
||||||
|
const res = db.raw.exec(`SELECT * FROM ${name} LIMIT ? OFFSET ?`, [limit, offset])
|
||||||
|
return {
|
||||||
|
rows: (res[0]?.values ?? []).map((v) => Object.fromEntries(res[0].columns.map((c, i) => [c, v[i]]))),
|
||||||
|
total: rowCount(db, name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sql.js throws a plain Error on constraint violations (UNIQUE/FK/NOT NULL) — map those to 409.
|
||||||
|
function runWrite(db: GameDb, sql: string, params: SqlParam[]): void {
|
||||||
|
try {
|
||||||
|
db.raw.run(sql, params)
|
||||||
|
} catch (err) {
|
||||||
|
throw new AdminError(409, err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON scalar → SQLite value for the column's declared type. Booleans fold into INTEGER columns
|
||||||
|
// (the schema stores flags as 0/1); null is only allowed where the column allows it.
|
||||||
|
function coerce(value: unknown, col: AdminColumn): SqlParam {
|
||||||
|
if (value === null) {
|
||||||
|
if (col.notNull && !col.hasDefault) throw new AdminError(400, `column '${col.name}' cannot be NULL`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const isInt = col.type.includes('INT')
|
||||||
|
const isReal = /REAL|FLOA|DOUB|NUM/.test(col.type)
|
||||||
|
if (isInt || isReal) {
|
||||||
|
const n = typeof value === 'boolean' ? (value ? 1 : 0) : typeof value === 'number' ? value : NaN
|
||||||
|
if (!Number.isFinite(n)) throw new AdminError(400, `column '${col.name}' must be a number`)
|
||||||
|
return isInt && !isReal ? Math.trunc(n) : n
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') throw new AdminError(400, `column '${col.name}' must be a string or null`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkKey(name: string, cols: AdminColumn[], key: unknown): Record<string, number> {
|
||||||
|
const pk = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
|
||||||
|
if (typeof key !== 'object' || key === null || Array.isArray(key)) throw new AdminError(400, `key must be an object with the primary key columns (${pk.map((c) => c.name).join(', ')})`)
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const col of pk) {
|
||||||
|
const v = (key as Record<string, unknown>)[col.name]
|
||||||
|
if (typeof v !== 'number' || !Number.isInteger(v)) throw new AdminError(400, `key column '${col.name}' must be an integer`)
|
||||||
|
out[col.name] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function pkWhere(cols: AdminColumn[], key: Record<string, number>): { sql: string; params: number[] } {
|
||||||
|
const pks = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
|
||||||
|
return {
|
||||||
|
sql: pks.map((c) => `${c.name} = ?`).join(' AND '),
|
||||||
|
params: pks.map((c) => key[c.name]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchRow(db: GameDb, name: string, whereSql: string, params: SqlParam[]): Record<string, unknown> | null {
|
||||||
|
const res = db.raw.exec(`SELECT * FROM ${name} WHERE ${whereSql}`, params)
|
||||||
|
const v = res[0]?.values[0]
|
||||||
|
return v ? Object.fromEntries(res[0].columns.map((c, i) => [c, v[i]])) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertAdminRow(db: GameDb, name: string, values: unknown): Promise<Record<string, unknown>> {
|
||||||
|
assertTable(name)
|
||||||
|
if (typeof values !== 'object' || values === null || Array.isArray(values)) throw new AdminError(400, 'body must be a JSON object of column → value')
|
||||||
|
const cols = tableColumns(db, name)
|
||||||
|
const byName = new Map(cols.map((c) => [c.name, c]))
|
||||||
|
|
||||||
|
const entries: [string, SqlParam][] = []
|
||||||
|
for (const [k, v] of Object.entries(values as Record<string, unknown>)) {
|
||||||
|
const col = byName.get(k)
|
||||||
|
if (!col) throw new AdminError(400, `Unknown column '${k}' in table '${name}'`)
|
||||||
|
entries.push([k, coerce(v, col)])
|
||||||
|
}
|
||||||
|
for (const col of cols) {
|
||||||
|
// Autoincrement single-PK ids are optional; everything else NOT NULL without a default is required.
|
||||||
|
const pks = cols.filter((c) => c.pk > 0)
|
||||||
|
if (!entries.some(([k]) => k === col.name) && col.notNull && !col.hasDefault && !(col.pk > 0 && pks.length === 1)) {
|
||||||
|
throw new AdminError(400, `Missing required column '${col.name}'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = entries.map(([k]) => k)
|
||||||
|
runWrite(db, `INSERT INTO ${name} (${names.join(', ')}) VALUES (${entries.map(() => '?').join(', ')})`, entries.map(([, v]) => v))
|
||||||
|
// Read last_insert_rowid() BEFORE persist(): sql.js's export() resets the per-connection value.
|
||||||
|
const lastId = Number(db.raw.exec('SELECT last_insert_rowid()')[0]?.values[0][0])
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
// Read the row back: explicit PK values win, otherwise fall back to last_insert_rowid().
|
||||||
|
const pks = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk)
|
||||||
|
if (pks.length === 1 && !entries.some(([k]) => k === pks[0].name)) {
|
||||||
|
return fetchRow(db, name, `${pks[0].name} = ?`, [lastId])!
|
||||||
|
}
|
||||||
|
const key: Record<string, number> = {}
|
||||||
|
for (const c of pks) {
|
||||||
|
const e = entries.find(([k]) => k === c.name)
|
||||||
|
if (!e) throw new AdminError(400, `Missing primary key column '${c.name}'`)
|
||||||
|
key[c.name] = Number(e[1])
|
||||||
|
}
|
||||||
|
const where = pkWhere(cols, key)
|
||||||
|
return fetchRow(db, name, where.sql, where.params)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminRow(db: GameDb, name: string, key: unknown, values: unknown): Promise<Record<string, unknown>> {
|
||||||
|
assertTable(name)
|
||||||
|
const cols = tableColumns(db, name)
|
||||||
|
const byName = new Map(cols.map((c) => [c.name, c]))
|
||||||
|
const pkKey = checkKey(name, cols, key)
|
||||||
|
|
||||||
|
if (typeof values !== 'object' || values === null || Array.isArray(values)) throw new AdminError(400, 'values must be a JSON object of column → value')
|
||||||
|
const entries: [string, SqlParam][] = []
|
||||||
|
for (const [k, v] of Object.entries(values as Record<string, unknown>)) {
|
||||||
|
const col = byName.get(k)
|
||||||
|
if (!col) throw new AdminError(400, `Unknown column '${k}' in table '${name}'`)
|
||||||
|
entries.push([k, coerce(v, col)])
|
||||||
|
}
|
||||||
|
if (entries.length === 0) throw new AdminError(400, 'values must contain at least one column')
|
||||||
|
|
||||||
|
const where = pkWhere(cols, pkKey)
|
||||||
|
runWrite(db, `UPDATE ${name} SET ${entries.map(([k]) => `${k} = ?`).join(', ')} WHERE ${where.sql}`, [...entries.map(([, v]) => v), ...where.params])
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
const row = fetchRow(db, name, where.sql, where.params)
|
||||||
|
if (!row) throw new AdminError(404, `No row in '${name}' with that key`)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminRow(db: GameDb, name: string, key: unknown): Promise<Record<string, unknown>> {
|
||||||
|
assertTable(name)
|
||||||
|
const cols = tableColumns(db, name)
|
||||||
|
const pkKey = checkKey(name, cols, key)
|
||||||
|
const where = pkWhere(cols, pkKey)
|
||||||
|
|
||||||
|
const row = fetchRow(db, name, where.sql, where.params)
|
||||||
|
if (!row) throw new AdminError(404, `No row in '${name}' with that key`)
|
||||||
|
runWrite(db, `DELETE FROM ${name} WHERE ${where.sql}`, where.params)
|
||||||
|
await persist(db)
|
||||||
|
return row
|
||||||
|
}
|
||||||
+131
-4
@@ -14,12 +14,131 @@ export interface GameDb {
|
|||||||
file: string;
|
file: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. Game tables land with
|
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. _meta is DB plumbing
|
||||||
// the turn engine; _meta is DB plumbing (not game state): a brand-new sql.js database exports
|
// (not game state): a brand-new sql.js database exports as ZERO bytes, so without at least
|
||||||
// as ZERO bytes, so without at least one real table there would be nothing valid to persist
|
// one real table there would be nothing valid to persist on the ./db volume from first boot.
|
||||||
// on the ./db volume from first boot.
|
|
||||||
const DDL = `
|
const DDL = `
|
||||||
CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT);
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
type TEXT NOT NULL, -- 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH'
|
||||||
|
payload TEXT NOT NULL, -- JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId}
|
||||||
|
lead_crew_id INTEGER NOT NULL REFERENCES crew(id),
|
||||||
|
helpers TEXT NOT NULL DEFAULT '[]', -- JSON array of crew ids
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
remaining INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE'
|
||||||
|
queued_at_turn INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS crew_skills (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
crew_id INTEGER NOT NULL REFERENCES crew(id),
|
||||||
|
skill TEXT NOT NULL, -- 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' (SkillName)
|
||||||
|
level INTEGER NOT NULL DEFAULT 0, -- 0–5
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0, -- cumulative completions in this skill (Task 13 grants)
|
||||||
|
UNIQUE (crew_id, skill)
|
||||||
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
|
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
|
||||||
@@ -39,6 +158,14 @@ async function open(file: string): Promise<GameDb> {
|
|||||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
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
|
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 };
|
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
|
// First run: stamp when this DB was created and land the file on disk so the ./db volume
|
||||||
|
|||||||
+163
-3
@@ -1,6 +1,166 @@
|
|||||||
// Drizzle schema for game state. EMPTY on purpose at scaffold stage (v0.1.0) — no game
|
import { integer, primaryKey, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||||
// tables yet; the first turn-engine/ship/crew tables land with the first game systems.
|
|
||||||
|
// 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
|
// 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.
|
// 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; seeds the force-directed WORLD MAP layout (components/world-map.tsx).
|
||||||
|
x: real('x').notNull(),
|
||||||
|
y: real('y').notNull(),
|
||||||
|
discovered: integer('discovered', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Queued actions (PR A, spec §3): one row per task assigned to a team. `payload` is a JSON
|
||||||
|
// string whose shape depends on `type` (see the Task interface in lib/game/tasks.ts);
|
||||||
|
// `helpers` is a JSON array of crew ids. Upgraded saves start with an empty queue — the v7
|
||||||
|
// backfill stamps the version, the DDL in client.ts creates the table.
|
||||||
|
export const tasks = sqliteTable('tasks', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
type: text('type').notNull(), // TaskType
|
||||||
|
payload: text('payload').notNull(),
|
||||||
|
leadCrewId: integer('lead_crew_id').notNull().references(() => crew.id),
|
||||||
|
helpers: text('helpers').notNull().default('[]'), // JSON array of crew ids
|
||||||
|
duration: integer('duration').notNull(),
|
||||||
|
remaining: integer('remaining').notNull(),
|
||||||
|
status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE'
|
||||||
|
queuedAtTurn: integer('queued_at_turn').notNull().default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Per-member skill progression (PR B, spec §6): one row per (crew member, skill); seeded to
|
||||||
|
// STARTING_LEVELS[role][skill] by createNewGame / the v8 backfill; Task 13 adds xp + level-ups.
|
||||||
|
export const crewSkills = sqliteTable('crew_skills', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
crewId: integer('crew_id').notNull().references(() => crew.id),
|
||||||
|
skill: text('skill').notNull(), // SkillName
|
||||||
|
level: integer('level').notNull().default(0), // 0–5
|
||||||
|
xp: integer('xp').notNull().default(0),
|
||||||
|
}, (t) => [uniqueIndex('crew_skills_crew_skill_unique').on(t.crewId, t.skill)])
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Pure crew-balance module (spec §11): every tuning knob for the duration model and skill
|
||||||
|
// progression lives here, so tuning a number means editing one file. No lib/db imports —
|
||||||
|
// the frontend imports this for the live duration preview in the crew picker (same precedent
|
||||||
|
// as ship-costs.ts).
|
||||||
|
import type { TaskType } from './tasks'
|
||||||
|
|
||||||
|
export type SkillName = 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH'
|
||||||
|
|
||||||
|
export const WORK_POINTS = { EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 } as const
|
||||||
|
|
||||||
|
export const ACTION_SKILL: Record<TaskType, SkillName> = {
|
||||||
|
JUMP: 'NAVIGATION',
|
||||||
|
EXPLORE: 'SCANNING',
|
||||||
|
SHIP_UPGRADE: 'ENGINEERING',
|
||||||
|
SHIP_EXPAND: 'ENGINEERING',
|
||||||
|
SHIP_BUILD: 'ENGINEERING',
|
||||||
|
RESEARCH: 'RESEARCH',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PREFERRED_ROLES: Record<TaskType, string[]> = {
|
||||||
|
JUMP: ['PILOT', 'NAVIGATOR'],
|
||||||
|
EXPLORE: ['SCIENTIST'],
|
||||||
|
SHIP_UPGRADE: ['ENGINEER', 'TECHNICIAN'],
|
||||||
|
SHIP_EXPAND: ['ENGINEER', 'TECHNICIAN'],
|
||||||
|
SHIP_BUILD: ['ENGINEER', 'TECHNICIAN'],
|
||||||
|
RESEARCH: ['SCIENTIST'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POWER_BANDS = [1, 1, 1, 2, 2, 3] // skill level 0..5 → power (L0–L2 → 1 · L3–L4 → 2 · L5 → 3)
|
||||||
|
export const XP_THRESHOLDS = [5, 10, 15, 20, 25] // cumulative task completions for L1..L5 (cap 5)
|
||||||
|
|
||||||
|
// Role → starting skill levels (spec §6): specialists start at L2, everyone else at L0.
|
||||||
|
export const STARTING_LEVELS: Record<string, Record<SkillName, number>> = {
|
||||||
|
COMMANDER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
|
||||||
|
PILOT: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
|
||||||
|
ENGINEER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 },
|
||||||
|
MEDIC: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
|
||||||
|
NAVIGATOR: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
|
||||||
|
SCIENTIST: { NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 },
|
||||||
|
GUNNER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 },
|
||||||
|
TECHNICIAN: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lead's good trait that cancels the role-mismatch penalty (spec §5).
|
||||||
|
export const MISMATCH_CANCEL_TRAIT = 'STEADY UNDER PRESSURE'
|
||||||
|
|
||||||
|
// All 15 duration traits of spec §5, keyed by the EXACT trait strings from lib/game/crew.ts.
|
||||||
|
// Value = additive turn modifier keyed by action type or 'ANY'; empty object = no additive
|
||||||
|
// modifier (the trait's effect is the mismatch cancel, see MISMATCH_CANCEL_TRAIT). Traits
|
||||||
|
// with no entry (the three STANDBY combat traits and #45's outcome traits) contribute 0.
|
||||||
|
// "SHIP work" covers all three SHIP types.
|
||||||
|
export const TRAIT_DURATION_MODIFIERS: Record<string, Partial<Record<TaskType | 'ANY', number>>> = {
|
||||||
|
'STEADY UNDER PRESSURE': {},
|
||||||
|
'RECKLESS COURAGE': { JUMP: -1 },
|
||||||
|
'CALM IN A CRISIS': { EXPLORE: -1 },
|
||||||
|
'FIXES ANYTHING': { SHIP_UPGRADE: -1, SHIP_EXPAND: -1, SHIP_BUILD: -1 },
|
||||||
|
'READS STARS BLIND': { JUMP: -1 },
|
||||||
|
'QUICK HANDS': { ANY: -1 },
|
||||||
|
'IRON WILL': { RESEARCH: -1 },
|
||||||
|
'RIGID — SLOW TO ADAPT': { RESEARCH: 1 },
|
||||||
|
'OVERCAUTIOUS': { ANY: 1 },
|
||||||
|
'SLOWS THE CREW DOWN': { ANY: 1 },
|
||||||
|
'CARELESS WITH FUSES': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 },
|
||||||
|
'HOARDS RATIONS': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 },
|
||||||
|
'FORGETS TO EAT': { EXPLORE: 1 },
|
||||||
|
'ARGUES WITH EVERYONE': { RESEARCH: 1 },
|
||||||
|
'DOUBTS THE CAPTAIN': { JUMP: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECRUIT_CHANCE_BASE = 0.18 // a scan finds a joining member (matches crew.ts's current CREW_CHANCE)
|
||||||
|
export const RECRUIT_CHANCE_TALKER = 0.3 // lead with TALKS TO ANYONE (wired in PR C)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('terminates with numbered variants once all 352 base names are taken', () => {
|
||||||
|
const rng = startingCrewRng('7')
|
||||||
|
const taken = new Set<string>()
|
||||||
|
for (let i = 0; i < 352; i++) makeCrewName(rng, taken) // exhaust the whole pool
|
||||||
|
expect(taken.size).toBe(352)
|
||||||
|
|
||||||
|
const a = makeCrewName(rng, taken) // must return instead of spinning forever
|
||||||
|
const b = makeCrewName(rng, taken)
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
expect(taken.has(a)).toBe(true)
|
||||||
|
expect(taken.has(b)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// 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',
|
||||||
|
]
|
||||||
|
|
||||||
|
// The role universe: the starting-crew draw and the recruit roll both pick from here.
|
||||||
|
// Exported so tests can pin that every role is covered by crew-costs's STARTING_LEVELS.
|
||||||
|
export 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)
|
||||||
|
|
||||||
|
// A task team is one lead plus up to this many members total — the queueing routes reject
|
||||||
|
// a bigger team with a 400 (spec §3 leaves the cap to balance; 4 lets a full crew bring
|
||||||
|
// RESEARCH's 9 work points down to 3 turns, matching a solo L5 specialist).
|
||||||
|
export const MAX_TEAM_SIZE = 4
|
||||||
|
|
||||||
|
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.
|
||||||
|
// The pool is finite (16×22=352) and the roster has no cap, so — like makeName — fall back to
|
||||||
|
// numbered variants once every base is taken instead of spinning forever.
|
||||||
|
export function makeCrewName(rng: () => number, taken: Set<string>): string {
|
||||||
|
for (;;) {
|
||||||
|
const base = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
|
||||||
|
if (!taken.has(base)) {
|
||||||
|
taken.add(base)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if (taken.size >= FIRST_NAMES.length * LAST_NAMES.length) {
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const name = `${base} ${n}`
|
||||||
|
if (!taken.has(name)) {
|
||||||
|
taken.add(name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 the given chance (defaults to CREW_CHANCE, matching
|
||||||
|
// RECRUIT_CHANCE_BASE in crew-costs) 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. The chance is raised to RECRUIT_CHANCE_TALKER when the
|
||||||
|
// assigned team includes TALKS TO ANYONE (wired in save.ts, #43).
|
||||||
|
export function rollCrewMember(rng: () => number, taken: Set<string>, chance: number = CREW_CHANCE): CrewMember | null {
|
||||||
|
if (rng() >= 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getWorldState } from './save'
|
||||||
|
import { DEFAULT_LIMIT, EventError, MAX_LIMIT, getEvents, parseEventLimit } from './events'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-events-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
return { db: await openGameDb(file), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Appends `count` synthetic events (turns 1..count) on top of the turn-0 new_game event.
|
||||||
|
async function seedEvents(db: GameDb, count: number): Promise<void> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
for (let i = 1; i <= count; i++) {
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: i, type: 'test', message: `event ${i}`, createdAt: new Date(Date.UTC(2026, 8, 29, 0, i)).toISOString() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseEventLimit', () => {
|
||||||
|
it('defaults to DEFAULT_LIMIT when the param is absent', () => {
|
||||||
|
expect(parseEventLimit(null)).toBe(DEFAULT_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts integers in [1, MAX_LIMIT]', () => {
|
||||||
|
expect(parseEventLimit('1')).toBe(1)
|
||||||
|
expect(parseEventLimit(String(MAX_LIMIT))).toBe(MAX_LIMIT)
|
||||||
|
expect(parseEventLimit('25')).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-integers and out-of-range values with a 400', () => {
|
||||||
|
for (const bad of ['abc', '', '0', '-3', String(MAX_LIMIT + 1), '2.5']) {
|
||||||
|
try {
|
||||||
|
parseEventLimit(bad)
|
||||||
|
expect.unreachable(`limit ${JSON.stringify(bad)} should have been rejected`)
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(EventError)
|
||||||
|
expect((err as EventError).status).toBe(400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getEvents', () => {
|
||||||
|
it('returns the latest events newest-first, sliced to limit', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // auto-initializes the save (turn-0 new_game event)
|
||||||
|
await seedEvents(db, 5)
|
||||||
|
|
||||||
|
const all = await getEvents(db, DEFAULT_LIMIT)
|
||||||
|
expect(all.map((e) => e.turn)).toEqual([5, 4, 3, 2, 1, 0]) // newest first, turn-0 line included
|
||||||
|
expect(all[all.length - 1].type).toBe('new_game')
|
||||||
|
|
||||||
|
const two = await getEvents(db, 2)
|
||||||
|
expect(two.map((e) => [e.turn, e.message])).toEqual([[5, 'event 5'], [4, 'event 4']])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { desc } from 'drizzle-orm'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import type { GameDb } from '@/lib/db/client'
|
||||||
|
|
||||||
|
export interface EventDto {
|
||||||
|
id: number
|
||||||
|
turn: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LIMIT = 50
|
||||||
|
export const MAX_LIMIT = 200
|
||||||
|
|
||||||
|
export class EventError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses the optional ?limit= query param. Absent → default; anything that is not an integer in
|
||||||
|
// [1, MAX_LIMIT] is a 400 (the route maps EventError to its status).
|
||||||
|
export function parseEventLimit(raw: string | null): number {
|
||||||
|
if (raw === null) return DEFAULT_LIMIT
|
||||||
|
const limit = Number(raw)
|
||||||
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
||||||
|
throw new EventError(400, `limit must be an integer between 1 and ${MAX_LIMIT}`)
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest N events of the save, newest first. Assumes a save exists (the route calls ensureSave).
|
||||||
|
export async function getEvents(db: GameDb, limit: number): Promise<EventDto[]> {
|
||||||
|
const rows = await db.db.select().from(schema.events).orderBy(desc(schema.events.id)).limit(limit)
|
||||||
|
return rows.map((e) => ({ id: e.id, turn: e.turn, type: e.type, message: e.message, createdAt: e.createdAt }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { explorationRng, mulberry32, rollExploration } from './world'
|
||||||
|
import { SCHEMA_VERSION, explorePlanet, getWorldState, jumpToSystem } from './save'
|
||||||
|
import { acceptMission, MIN_AVAILABLE, REWARD_ITEM_NAME, generateMission, missionRng, pickMissionType, topUpMissionBoard, type MissionDef, type MissionSystem } from './missions'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-missions-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
return { db: await openGameDb(file), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// A snapshot with one discovered non-current system to deliver to.
|
||||||
|
const SYSTEMS: MissionSystem[] = [
|
||||||
|
{ id: 1, name: 'Start', discovered: true, isCurrent: true },
|
||||||
|
{ id: 2, name: 'Alpha', discovered: true, isCurrent: false },
|
||||||
|
{ id: 3, name: 'Beta', discovered: false, isCurrent: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('generateMission (pure)', () => {
|
||||||
|
it('DELIVER targets a discovered non-current system and returns null when none exists', () => {
|
||||||
|
const def = generateMission('DELIVER', mulberry32(7), SYSTEMS)!
|
||||||
|
expect(def.type).toBe('DELIVER')
|
||||||
|
expect(def.targetSystemId).toBe(2) // the only candidate
|
||||||
|
expect(def.description).toContain('Alpha')
|
||||||
|
|
||||||
|
expect(generateMission('DELIVER', mulberry32(7), [SYSTEMS[0]])).toBeNull() // only the current system is discovered
|
||||||
|
})
|
||||||
|
|
||||||
|
it('EXPLORE targets 2–4 scans, FIND one item kind; rewards are always 1–3', () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const e = generateMission('EXPLORE', mulberry32(i), SYSTEMS)!
|
||||||
|
expect(e.progressTarget).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(e.progressTarget).toBeLessThanOrEqual(4)
|
||||||
|
expect(e.description).toMatch(/^Scan [2-4] bodies$/)
|
||||||
|
|
||||||
|
const f = generateMission('FIND', mulberry32(i), SYSTEMS)!
|
||||||
|
expect(['resource', 'part']).toContain(f.conditionKind)
|
||||||
|
expect(f.description).toContain(f.conditionKind!)
|
||||||
|
|
||||||
|
for (const d of [e, f]) {
|
||||||
|
expect(d.rewardQty).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(d.rewardQty).toBeLessThanOrEqual(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic — the same (seed, turn) stream generates identical missions', () => {
|
||||||
|
const a = generateMission('DELIVER', missionRng('42', 5), SYSTEMS)
|
||||||
|
const b = generateMission('DELIVER', missionRng('42', 5), SYSTEMS)
|
||||||
|
expect(a).toEqual(b)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pickMissionType (pure)', () => {
|
||||||
|
it('never picks DELIVER without a target, and does pick it when one exists', () => {
|
||||||
|
for (let i = 0; i < 200; i++) expect(pickMissionType(mulberry32(i), false)).not.toBe('DELIVER')
|
||||||
|
|
||||||
|
const types = new Set(Array.from({ length: 200 }, (_, i) => pickMissionType(mulberry32(i), true)))
|
||||||
|
expect(types.has('DELIVER')).toBe(true)
|
||||||
|
for (const t of types) expect(['DELIVER', 'EXPLORE', 'FIND']).toContain(t)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('topUpMissionBoard', () => {
|
||||||
|
it('seeds a fresh game with MIN_AVAILABLE AVAILABLE non-DELIVER missions and is idempotent', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game — createNewGame already topped the board up
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows).toHaveLength(MIN_AVAILABLE)
|
||||||
|
for (const r of rows) {
|
||||||
|
expect(r.state).toBe('AVAILABLE')
|
||||||
|
expect(r.type).not.toBe('DELIVER') // only the start system is discovered yet — no targets exist
|
||||||
|
}
|
||||||
|
|
||||||
|
await topUpMissionBoard(db) // a second pass inserts nothing
|
||||||
|
expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates deterministically from (seed, turn) on a hand-crafted save', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
// A v6 save with start (current), one discovered neighbour and one undiscovered.
|
||||||
|
await db.db.insert(schema.saves).values({ seed: '42', turn: 5, createdAt: new Date().toISOString(), schemaVersion: SCHEMA_VERSION })
|
||||||
|
const [start] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Start', x: 50, y: 50, discovered: true, isCurrent: true }).returning()
|
||||||
|
const [alpha] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Alpha', x: 60, y: 50, discovered: true, isCurrent: false }).returning()
|
||||||
|
const [beta] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Beta', x: 40, y: 50 }).returning()
|
||||||
|
|
||||||
|
await topUpMissionBoard(db)
|
||||||
|
|
||||||
|
// Replay the same pure pipeline with the same (seed, turn) stream — must match row for row.
|
||||||
|
const snapshot: MissionSystem[] = [
|
||||||
|
{ id: start.id, name: 'Start', discovered: true, isCurrent: true },
|
||||||
|
{ id: alpha.id, name: 'Alpha', discovered: true, isCurrent: false },
|
||||||
|
{ id: beta.id, name: 'Beta', discovered: false, isCurrent: false },
|
||||||
|
]
|
||||||
|
const rng = missionRng('42', 5)
|
||||||
|
const expected: MissionDef[] = []
|
||||||
|
while (expected.length < MIN_AVAILABLE) {
|
||||||
|
const type = pickMissionType(rng, snapshot.some((s) => s.discovered && !s.isCurrent))
|
||||||
|
expected.push(generateMission(type, rng, snapshot)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows.map((r) => ({ type: r.type, description: r.description, targetSystemId: r.targetSystemId, conditionKind: r.conditionKind, progressTarget: r.progressTarget, rewardKind: r.rewardKind, rewardQty: r.rewardQty }))).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inserts a mission row directly so tests can control exactly what the board holds.
|
||||||
|
async function insertMission(db: GameDb, fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number }): Promise<number> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db.insert(schema.missions).values({ saveId: save.id, type: fields.type, description: fields.description, targetSystemId: fields.targetSystemId ?? null, conditionKind: fields.conditionKind ?? null, progressTarget: fields.progressTarget ?? 1, rewardKind: fields.rewardKind ?? 'resource', rewardQty: fields.rewardQty ?? 1 }).returning()
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('acceptMission', () => {
|
||||||
|
it('rejects unknown ids with 404 and a second accept while one is active with 409 naming the active mission', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game — board seeded
|
||||||
|
|
||||||
|
await expect(acceptMission(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||||
|
|
||||||
|
const [a, b] = (await db.db.select().from(schema.missions))
|
||||||
|
await acceptMission(db, a.id)
|
||||||
|
await expect(acceptMission(db, a.id)).rejects.toMatchObject({ status: 409, message: /already active/i }) // re-accepting the active one
|
||||||
|
await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates an AVAILABLE contract, spends no 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) // free — no turn cost
|
||||||
|
|
||||||
|
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) // stamped at the current turn — accept spends none
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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(3) // free accept (turn 0) + three scans (1–3); completion is written by the third scan
|
||||||
|
expect(event.message).toContain('Scan 3 bodies')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FIND completes when a haul contains its kind and ignores scans that do not', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const id = await insertMission(db, { type: 'FIND', description: 'Find at least one part while scanning', conditionKind: 'part', rewardKind: 'resource', rewardQty: 3 })
|
||||||
|
|
||||||
|
// Pick bodies by their deterministic per-planet rolls — one without parts first (no progress),
|
||||||
|
// then one with (completes).
|
||||||
|
const planets = await db.db.select().from(schema.planets)
|
||||||
|
const haulOf = (p: { id: number }) => rollExploration(explorationRng(save.seed, p.id))
|
||||||
|
const noPart = planets.find((p) => !haulOf(p).some((i) => i.kind === 'part'))!
|
||||||
|
expect(noPart).toBeDefined()
|
||||||
|
const withPart = planets.find((p) => haulOf(p).some((i) => i.kind === 'part'))!
|
||||||
|
expect(withPart).toBeDefined()
|
||||||
|
|
||||||
|
await acceptMission(db, id)
|
||||||
|
await explorePlanet(db, noPart.id) // haul has no parts — the mission does not advance
|
||||||
|
let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('ACTIVE')
|
||||||
|
expect(mission.progress).toBe(0)
|
||||||
|
|
||||||
|
await explorePlanet(db, withPart.id) // haul has parts — completes
|
||||||
|
mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('DONE')
|
||||||
|
|
||||||
|
const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)!
|
||||||
|
expect(reward.kind).toBe('resource')
|
||||||
|
expect(reward.qty).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELIVER completes on arrival at its target system', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const world = await getWorldState(db) // new game — start is current, six undiscovered neighbours
|
||||||
|
const [start] = await db.db.select().from(schema.solarSystems).where(eq(schema.solarSystems.isCurrent, true))
|
||||||
|
const neighbor = world.systems.find((s) => !s.discovered)!
|
||||||
|
|
||||||
|
await jumpToSystem(db, neighbor.id) // start is now discovered + non-current — a valid target
|
||||||
|
const id = await insertMission(db, { type: 'DELIVER', description: `Jump to ${start.name}`, targetSystemId: start.id, rewardKind: 'resource', rewardQty: 1 })
|
||||||
|
|
||||||
|
await acceptMission(db, id)
|
||||||
|
|
||||||
|
// Wrong destination: one of the ring neighbours generated around `neighbor` on the first jump.
|
||||||
|
const world2 = await getWorldState(db)
|
||||||
|
const wrongEdge = world2.edges.find(([a, b]) => (a === neighbor.id || b === neighbor.id) && a !== start.id && b !== start.id)!
|
||||||
|
const wrongId = wrongEdge[0] === neighbor.id ? wrongEdge[1] : wrongEdge[0]
|
||||||
|
await jumpToSystem(db, wrongId) // not the target — no completion
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE')
|
||||||
|
|
||||||
|
await jumpToSystem(db, neighbor.id) // back through `neighbor`…
|
||||||
|
const result = await jumpToSystem(db, start.id) // …to the target — completes
|
||||||
|
const mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('DONE')
|
||||||
|
expect(result.systems.find((s) => s.current)?.id).toBe(start.id)
|
||||||
|
|
||||||
|
const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)!
|
||||||
|
expect(reward.kind).toBe('resource')
|
||||||
|
expect(reward.qty).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewards of the same kind accumulate into one hold row across missions', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const a = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 2 })
|
||||||
|
const b = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 more bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 3 })
|
||||||
|
const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null)
|
||||||
|
|
||||||
|
await acceptMission(db, a)
|
||||||
|
await explorePlanet(db, planets[0].id)
|
||||||
|
await explorePlanet(db, planets[1].id) // completes A — 2× resource reward
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === a)!.state).toBe('DONE')
|
||||||
|
|
||||||
|
await acceptMission(db, b)
|
||||||
|
await explorePlanet(db, planets[2].id)
|
||||||
|
await explorePlanet(db, planets[3].id) // completes B — 3× more of the same kind+name
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === b)!.state).toBe('DONE')
|
||||||
|
|
||||||
|
const rewards = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.name === REWARD_ITEM_NAME)
|
||||||
|
expect(rewards).toHaveLength(1) // accumulated, not a second row
|
||||||
|
expect(rewards[0].qty).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('backfill v6', () => {
|
||||||
|
it('seeds the board on a pre-#10 save when it opens — deterministic and idempotent', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game at the current version
|
||||||
|
|
||||||
|
// Simulate a save created by pre-v6 code: no mission rows, stamped v5.
|
||||||
|
await db.db.delete(schema.missions)
|
||||||
|
await db.db.update(schema.saves).set({ schemaVersion: 5 })
|
||||||
|
|
||||||
|
await getWorldState(db) // ensureSave → backfill runs the v6 step
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows).toHaveLength(MIN_AVAILABLE)
|
||||||
|
for (const r of rows) expect(r.state).toBe('AVAILABLE')
|
||||||
|
const [save] = await db.db.select().from(schema.saves)
|
||||||
|
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
|
||||||
|
// Idempotent — a second pass changes nothing.
|
||||||
|
await getWorldState(db)
|
||||||
|
expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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.
|
||||||
|
// Turn-advancing actions re-anchor the stream, so top-ups on different turns generate
|
||||||
|
// different contracts. Mission accept is free and tops up at the current turn, so it may
|
||||||
|
// share an anchor with a top-up that already ran at that turn (e.g. the new-game seed at
|
||||||
|
// turn 0) — the generated contract can then mirror an existing one: cosmetic, never a
|
||||||
|
// double reward (rewards are per row).
|
||||||
|
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). Instant and
|
||||||
|
// free (JC-19) — no turn, no cost; the event is stamped at the current turn. 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`)
|
||||||
|
|
||||||
|
await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() })
|
||||||
|
await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
return readMissions(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs after a jump or scan: advances the ACTIVE mission (if any) and, on completion, marks it
|
||||||
|
// DONE, grants its reward into the cargo hold and writes an event. Called from save.ts inside the
|
||||||
|
// action that triggered it, before persist — so everything lands in one atomic write.
|
||||||
|
export async function checkMissionCompletion(db: GameDb, saveId: number, turn: number, action: MissionAction): Promise<void> {
|
||||||
|
const row = (await db.db.select().from(schema.missions)).find((m) => m.state === 'ACTIVE')
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
const active = { type: row.type as MissionType, targetSystemId: row.targetSystemId, conditionKind: row.conditionKind as ItemKind | null, progressTarget: row.progressTarget, progress: row.progress }
|
||||||
|
const newProgress = advanceMission(active, action)
|
||||||
|
if (newProgress === null) return
|
||||||
|
|
||||||
|
if (newProgress >= active.progressTarget) {
|
||||||
|
await db.db.update(schema.missions).set({ state: 'DONE', progress: newProgress }).where(eq(schema.missions.id, row.id))
|
||||||
|
// The reward lands in the hold like any haul — same kind+name accumulates into one row.
|
||||||
|
const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === row.rewardKind && i.name === REWARD_ITEM_NAME)
|
||||||
|
if (existing) {
|
||||||
|
await db.db.update(schema.inventoryItems).set({ qty: existing.qty + row.rewardQty }).where(eq(schema.inventoryItems.id, existing.id))
|
||||||
|
} else {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId, kind: row.rewardKind, name: REWARD_ITEM_NAME, qty: row.rewardQty })
|
||||||
|
}
|
||||||
|
await db.db.insert(schema.events).values({ saveId, turn, type: 'mission', message: `Mission complete — ${row.description} (+${row.rewardQty}× ${kindLabel(row.rewardKind as ItemKind)})`, createdAt: new Date().toISOString() })
|
||||||
|
} else {
|
||||||
|
await db.db.update(schema.missions).set({ progress: newProgress }).where(eq(schema.missions.id, row.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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, spendItems, upgradeSystem, type ShipState } from './ship'
|
||||||
|
import { TECHS } from './tech'
|
||||||
|
import { SCHEMA_VERSION } from './save'
|
||||||
|
import { getTechState, researchTech, resolveResearchTask } from './research'
|
||||||
|
import type { Task } from './tasks'
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- A/B: resolver vs the legacy immediate action (Task 4a) ---------------------------
|
||||||
|
const T0 = '2026-10-01T00:00:00.000Z'
|
||||||
|
|
||||||
|
// Stamped save (no backfill), the full tech tree, nothing researched yet. Identical build
|
||||||
|
// per world → ids line up.
|
||||||
|
async function buildResearchFixture(db: GameDb): Promise<void> {
|
||||||
|
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
|
||||||
|
for (const def of TECHS) {
|
||||||
|
await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = (payload: object): Task =>
|
||||||
|
({ id: 0, saveId: 0, type: 'RESEARCH', payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
|
||||||
|
|
||||||
|
async function snapshot(db: GameDb): Promise<string> {
|
||||||
|
const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([
|
||||||
|
db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves),
|
||||||
|
db.db.select().from(schema.solarSystems),
|
||||||
|
db.db.select().from(schema.jumpRoutes),
|
||||||
|
db.db.select().from(schema.planets),
|
||||||
|
db.db.select().from(schema.inventoryItems),
|
||||||
|
db.db.select().from(schema.crew),
|
||||||
|
db.db.select().from(schema.shipSystems),
|
||||||
|
db.db.select().from(schema.tech),
|
||||||
|
db.db.select().from(schema.missions),
|
||||||
|
db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events),
|
||||||
|
])
|
||||||
|
return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveResearchTask (A/B against researchTech)', () => {
|
||||||
|
// A = legacy immediate action (validates, deducts the cost, +1 turn, marks researched,
|
||||||
|
// event). B = the queued shape: the cost was deducted at queue time (Task 3), the tick
|
||||||
|
// advanced the turn (Task 4b), then the resolver marks the tech. Same starting world →
|
||||||
|
// byte-identical end states.
|
||||||
|
|
||||||
|
it('lands exactly what researchTech lands', async () => {
|
||||||
|
const { db: a } = await freshFile()
|
||||||
|
const { db: b } = await freshFile()
|
||||||
|
await buildResearchFixture(a)
|
||||||
|
await buildResearchFixture(b)
|
||||||
|
await addItem(a, 'resource', 'IRON ORE', 2) // NAVIGATION AI costs 2 resources
|
||||||
|
await addItem(b, 'resource', 'IRON ORE', 2)
|
||||||
|
|
||||||
|
const [navA] = await a.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
|
||||||
|
const [navB] = await b.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
|
||||||
|
await researchTech(a, navA.id)
|
||||||
|
await spendItems(b, 1, 'resource', 2) // the queue-time deduction
|
||||||
|
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||||
|
await resolveResearchTask(b, task({ techId: navB.id }))
|
||||||
|
|
||||||
|
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||||
|
expect((await b.db.select().from(schema.tech)).find((t) => t.id === navB.id)?.researched).toBe(true)
|
||||||
|
const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'research')!
|
||||||
|
expect(ev.turn).toBe(1)
|
||||||
|
expect(ev.message).toBe('Researched NAVIGATION AI (cost: 2× resources)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('guards: 404 unknown tech, 409 already researched (prereqs are monotone — no guard needed)', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildResearchFixture(db)
|
||||||
|
const [nav] = await db.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
|
||||||
|
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||||
|
|
||||||
|
await expect(resolveResearchTask(db, task({ techId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown tech 999' })
|
||||||
|
await resolveResearchTask(db, task({ techId: nav.id }))
|
||||||
|
await expect(resolveResearchTask(db, task({ techId: nav.id }))).rejects.toMatchObject({ status: 409, message: 'NAVIGATION AI is already researched' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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'
|
||||||
|
import type { Task, TaskType } from './tasks'
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntheticTask(type: TaskType, payload: object): Task {
|
||||||
|
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESEARCH — mark the payload's tech researched at the current turn. No cost here (deducted
|
||||||
|
// at queue time — Task 3 — or by researchTech before delegating); no mission hooks (the
|
||||||
|
// current action has none).
|
||||||
|
export async function resolveResearchTask(db: GameDb, task: Task): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const { techId } = JSON.parse(task.payload) as { techId: number }
|
||||||
|
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)!
|
||||||
|
if (target.researched) throw new ResearchError(409, `${def.name} is already researched`)
|
||||||
|
|
||||||
|
await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() })
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The research pre-checks, extracted for the queueing route (5a(ii)): the same codes and
|
||||||
|
// messages as researchTech — unknown tech (404), already researched (409), missing prereqs
|
||||||
|
// (409, naming what is missing). The cost check stays out: the queueing path deducts at
|
||||||
|
// queue time (queueTask) and the research lands when the task completes on the tick.
|
||||||
|
export async function checkResearchPreconditions(db: GameDb, techId: number): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Research one tech: validates prerequisites (409, naming what is missing) and the cost (422),
|
||||||
|
// spends it from the cargo hold, then delegates the unlock to the resolver at the new turn.
|
||||||
|
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.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await resolveResearchTask(db, syntheticTask('RESEARCH', { techId }))
|
||||||
|
|
||||||
|
return getTechState(db)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,812 @@
|
|||||||
|
import { and, eq, inArray } 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 { ACTION_SKILL, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||||
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
|
import { checkMissionCompletion, topUpMissionBoard } from './missions'
|
||||||
|
import { resolveResearchTask, ResearchError } from './research'
|
||||||
|
import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'
|
||||||
|
import { grantXp, makePowerOf, titleFor, type CrewIdentity, type SkillRow } from './skills'
|
||||||
|
import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks'
|
||||||
|
import { TECHS } from './tech'
|
||||||
|
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
|
||||||
|
|
||||||
|
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
||||||
|
// Single-player by design — exactly one save row ever exists, so every query below is
|
||||||
|
// unscoped (there is nothing to scope against).
|
||||||
|
|
||||||
|
export interface WorldSystemDto {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
discovered: boolean
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row of the snapshot task queue — what the GET builders expose of the tasks table
|
||||||
|
// (distinct from the tick's TickResult, which reports every row including DONE): the display
|
||||||
|
// fields the UI needs, names resolved, no raw payload — plus the raw row state (JC-11):
|
||||||
|
// `status` (the array's ACTIVE-block-first order isn't readable back from it) and the crew
|
||||||
|
// ids, which the team builder needs for exact member↔row matching (name matching breaks on
|
||||||
|
// the `unknown` fallback).
|
||||||
|
export interface QueueDto {
|
||||||
|
id: number
|
||||||
|
type: TaskType
|
||||||
|
targetLabel: string
|
||||||
|
leadName: string
|
||||||
|
helperNames: string[]
|
||||||
|
remaining: number
|
||||||
|
status: TaskStatus // ACTIVE block first in the array, then PENDING
|
||||||
|
leadCrewId: number // crew.id of the team lead
|
||||||
|
helperCrewIds: number[] // crew.id of the supporting crew (degrades to [] on a corrupted blob, like helperNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldState {
|
||||||
|
systems: WorldSystemDto[]
|
||||||
|
edges: [number, number][] // undirected system-id pairs, deduped + sorted for stable output
|
||||||
|
turn: number
|
||||||
|
tasks: QueueDto[] // the active + pending queue — DONE rows never appear
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = 8 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table · v8: crew_skills (role-based starting levels)
|
||||||
|
|
||||||
|
// 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: for each crew member with no crew_skills rows yet, seed one row per skill
|
||||||
|
// (level = STARTING_LEVELS[role][skill], xp = the threshold that level implies — levelFromXp
|
||||||
|
// must round-trip: a seeded L2 starts at xp 10, not 0, or Task 14's grantXp (which recomputes
|
||||||
|
// level from cumulative xp) would regress the member L2 → L0). Called by createNewGame and the
|
||||||
|
// v8 backfill. A role missing from STARTING_LEVELS seeds four all-L0 rows instead of throwing
|
||||||
|
// — the seeder must never brick a save.
|
||||||
|
async function seedCrewSkills(db: GameDb, saveId: number) {
|
||||||
|
const crew = await db.db.select().from(schema.crew).where(eq(schema.crew.saveId, saveId))
|
||||||
|
const have = new Set((await db.db.select({ crewId: schema.crewSkills.crewId }).from(schema.crewSkills)).map((r) => r.crewId))
|
||||||
|
for (const m of crew) {
|
||||||
|
if (have.has(m.id)) continue // already seeded — idempotent
|
||||||
|
const levels = STARTING_LEVELS[m.role] ?? { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }
|
||||||
|
for (const skill of Object.keys(levels) as SkillName[]) {
|
||||||
|
await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: levels[skill], xp: levels[skill] > 0 ? XP_THRESHOLDS[levels[skill] - 1] : 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
|
||||||
|
// Safe to call on every request (single player, one process: check-then-insert cannot race).
|
||||||
|
export async function ensureSave(db: GameDb): Promise<void> {
|
||||||
|
const existing = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
if (existing.length === 0) {
|
||||||
|
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 seedCrewSkills(db, save.id)
|
||||||
|
await seedStartingShip(db, save.id)
|
||||||
|
await seedStartingTech(db, save.id)
|
||||||
|
await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game
|
||||||
|
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
||||||
|
await 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 (version < 7) {
|
||||||
|
// The tasks table itself is created by the DDL at open (CREATE TABLE IF NOT EXISTS) for
|
||||||
|
// every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
|
||||||
|
version = 7
|
||||||
|
}
|
||||||
|
if (version < 8) {
|
||||||
|
await seedCrewSkills(db, save.id) // DDL creates the table at open; seed each member's 4 rows
|
||||||
|
version = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from !== SCHEMA_VERSION) {
|
||||||
|
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The snapshot task queue — the GET builders' read of the tasks table (distinct from the
|
||||||
|
// tick's TickResult, which reports every row): one select of the tasks table filtered to
|
||||||
|
// ACTIVE + PENDING (DONE rows never appear) plus one select each of the name-resolution
|
||||||
|
// tables. The ACTIVE block comes first, then the PENDING block, each ordered by
|
||||||
|
// (queuedAtTurn, id). Pure read: `remaining` is the stored row value, never recomputed;
|
||||||
|
// crew names resolve from the roster by id, `unknown` tolerating a deleted member (the same
|
||||||
|
// spirit as 4b's <= 0 check). Private — every caller is a public read that already ran
|
||||||
|
// ensureSave.
|
||||||
|
async function taskQueue(db: GameDb): Promise<QueueDto[]> {
|
||||||
|
const [taskRows, crew, systems, planets, tech] = await Promise.all([
|
||||||
|
db.db.select().from(schema.tasks).where(inArray(schema.tasks.status, ['ACTIVE', 'PENDING'])),
|
||||||
|
db.db.select().from(schema.crew),
|
||||||
|
db.db.select().from(schema.solarSystems),
|
||||||
|
db.db.select().from(schema.planets),
|
||||||
|
db.db.select().from(schema.tech),
|
||||||
|
])
|
||||||
|
|
||||||
|
const systemName = new Map(systems.map((s): [number, string] => [s.id, s.name]))
|
||||||
|
const planetName = new Map(planets.map((p): [number, string] => [p.id, p.name]))
|
||||||
|
const techName = new Map(tech.map((t): [number, string] => [t.id, t.name]))
|
||||||
|
const crewName = (id: number) => crew.find((c) => c.id === id)?.name ?? 'unknown'
|
||||||
|
|
||||||
|
// Corrupted-row tolerance: every JSON read goes through a guarded parse — a malformed
|
||||||
|
// payload / helpers / type degrades to `unknown` / [] instead of throwing out of this
|
||||||
|
// pure read (one bad row must not 500 the /api/world, /api/system and /api/crew routes).
|
||||||
|
const parse = <T>(raw: string): T | null => { try { return JSON.parse(raw) as T } catch { return null } }
|
||||||
|
const parseHelpers = (raw: string): number[] => { const v = parse<unknown>(raw); return Array.isArray(v) ? (v as number[]) : [] }
|
||||||
|
|
||||||
|
// targetLabel from the 4a payload shapes, per type (the Task interface in tasks.ts): the
|
||||||
|
// target's display name (the jump destination, the scanned body, the researched tech), the
|
||||||
|
// ship system's name with the verb suffixed for upgrade/expand, and the SHIP_TYPES string
|
||||||
|
// as-is for a build. A deleted lookup target, a malformed payload, or a missing field
|
||||||
|
// falls back to `unknown`.
|
||||||
|
const labelFor: Record<TaskType, (payload: string) => string> = {
|
||||||
|
JUMP: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? (systemName.get(p.systemId) ?? 'unknown') : 'unknown' },
|
||||||
|
EXPLORE: (payload) => { const p = parse<{ planetId: number }>(payload); return p ? (planetName.get(p.planetId) ?? 'unknown') : 'unknown' },
|
||||||
|
SHIP_UPGRADE: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? `${systemName.get(p.systemId) ?? 'unknown'} — upgrade` : 'unknown' },
|
||||||
|
SHIP_EXPAND: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? `${systemName.get(p.systemId) ?? 'unknown'} — expand` : 'unknown' },
|
||||||
|
SHIP_BUILD: (payload) => parse<{ type: string }>(payload)?.type ?? 'unknown',
|
||||||
|
RESEARCH: (payload) => { const p = parse<{ techId: number }>(payload); return p ? (techName.get(p.techId) ?? 'unknown') : 'unknown' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = taskRows.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
type: t.type as TaskType,
|
||||||
|
targetLabel: labelFor[t.type as TaskType]?.(t.payload) ?? 'unknown',
|
||||||
|
leadName: crewName(t.leadCrewId),
|
||||||
|
helperNames: parseHelpers(t.helpers).map(crewName),
|
||||||
|
remaining: t.remaining,
|
||||||
|
status: t.status as TaskStatus,
|
||||||
|
leadCrewId: t.leadCrewId,
|
||||||
|
helperCrewIds: parseHelpers(t.helpers),
|
||||||
|
queuedAtTurn: t.queuedAtTurn,
|
||||||
|
}))
|
||||||
|
const byQueueOrder = (rows: typeof queue) => rows.sort((a, b) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id)
|
||||||
|
const ordered = [...byQueueOrder(queue.filter((t) => t.status === 'ACTIVE')), ...byQueueOrder(queue.filter((t) => t.status === 'PENDING'))]
|
||||||
|
// queuedAtTurn is only for ordering — the exposed shape is exactly QueueDto (status stays: JC-11).
|
||||||
|
return ordered.map(({ queuedAtTurn, ...dto }) => dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
tasks: await taskQueue(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-type resolvers (PR A, spec §3) -------------------------------------------
|
||||||
|
//
|
||||||
|
// The effect bodies of the instant actions, extracted so the turn tick (Task 4b) can
|
||||||
|
// resolve a completed task row against whatever the world looks like at resolution time.
|
||||||
|
// Contract (all six resolvers across save.ts / ship.ts / research.ts — see the Interfaces
|
||||||
|
// block of this plan): effects land on the PAYLOAD target, never on the current location;
|
||||||
|
// no turn advance (the caller owns the turn); no cost deduction (queueTask deducted at
|
||||||
|
// queue time — Task 3; the immediate public functions deduct before delegating); a minimal
|
||||||
|
// guard against a stale or double-resolved target (existing error class + exact message —
|
||||||
|
// a throw is the signal for the 4b tick's re-validation); the existing event text formats
|
||||||
|
// at the current turn; persist at the end.
|
||||||
|
|
||||||
|
// A stand-in task row for the immediate public functions delegating to a resolver: the
|
||||||
|
// resolvers only read `type` + `payload`, so the identity fields are zeroed.
|
||||||
|
function syntheticTask(type: TaskType, payload: object): Task {
|
||||||
|
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// JUMP — move the ship to the payload's system: flip isCurrent, discover the target, and
|
||||||
|
// when it was undiscovered grow the chart ring off the same (seed, turn) stream as before.
|
||||||
|
export async function resolveJumpTask(db: GameDb, task: Task): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const { systemId } = JSON.parse(task.payload) as { systemId: number }
|
||||||
|
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}`)
|
||||||
|
if (target.isCurrent) throw new JumpError(409, 'Already at this system')
|
||||||
|
const current = systems.find((s) => s.isCurrent)!
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// #51 — growing the chart: a seeded-random 1–4 leg ring around the newly reached
|
||||||
|
// system; each leg may attach an existing system (closing a loop) instead of minting.
|
||||||
|
const rng = expansionRng(save.seed, save.turn)
|
||||||
|
const takenNames = new Set(systems.map((s) => s.name))
|
||||||
|
|
||||||
|
// Edge keys already touching any pair of this save's systems — an attach must not
|
||||||
|
// duplicate one (the PK on (fromSystem, toSystem) would reject it).
|
||||||
|
const systemIds = new Set(systems.map((s) => s.id))
|
||||||
|
const routes = await db.db.select().from(schema.jumpRoutes)
|
||||||
|
const existingEdgeKeys = new Set(
|
||||||
|
routes
|
||||||
|
.filter((r) => systemIds.has(r.fromSystem) && systemIds.has(r.toSystem))
|
||||||
|
.map((r) => `${Math.min(r.fromSystem, r.toSystem)}-${Math.max(r.fromSystem, r.toSystem)}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
const candidates = systems.filter((s) => s.id !== target.id)
|
||||||
|
const ring = generateRing(rng, target.x, target.y, takenNames, systems, candidates, existingEdgeKeys, target.id)
|
||||||
|
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)
|
||||||
|
// Loop-closing attaches: a two-way route from the anchor to each attached candidate.
|
||||||
|
for (const ci of ring.attaches) {
|
||||||
|
const cand = candidates[ci]!
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: target.id, toSystem: cand.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: cand.id, toSystem: target.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
||||||
|
await checkMissionCompletion(db, save.id, save.turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
|
||||||
|
await topUpMissionBoard(db)
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop
|
||||||
|
// jump costs one turn per hop, so single-hop jumps keep their existing cost.
|
||||||
|
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
|
||||||
|
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
|
||||||
|
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
|
||||||
|
|
||||||
|
// The immediate jump keeps its own turn cost (the tick owns turn advances for queued
|
||||||
|
// tasks): advance it before delegating so the resolver's event lands at exactly the
|
||||||
|
// turn the old code wrote.
|
||||||
|
const turn = save.turn + hopCount
|
||||||
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await resolveJumpTask(db, syntheticTask('JUMP', { systemId, hops: hopCount }))
|
||||||
|
|
||||||
|
return getWorldState(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The jump pre-checks, extracted for the queueing route (5a): the same codes and messages
|
||||||
|
// as jumpToSystem — unknown system (404), already there (409), outside range (409) — and
|
||||||
|
// the hop count (reachableWithin + NAV AI, the existing reachability logic) the route puts
|
||||||
|
// in the queued JUMP payload.
|
||||||
|
export async function checkJumpTarget(db: GameDb, systemId: number): Promise<number> {
|
||||||
|
await ensureSave(db)
|
||||||
|
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)
|
||||||
|
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops.
|
||||||
|
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
|
||||||
|
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
|
||||||
|
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
|
||||||
|
return hopCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
tasks: QueueDto[] // the active + pending queue — DONE rows never appear
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, tasks: await taskQueue(db) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
skills: { skill: SkillName; level: number }[] // the member's four crew_skills rows, fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (JC-27); display-oriented — no xp (JC-26)
|
||||||
|
title: string | null // 'SENIOR <ROLE>' from the member's highest level ≥ 3, 'MASTER <ROLE>' from ≥ 5, null below (titleFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixed display order of the four skills (JC-27, spec §6) — the CREW-tab line and the
|
||||||
|
// /api/crew payload never depend on DB row order.
|
||||||
|
const SKILL_ORDER: SkillName[] = ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH']
|
||||||
|
|
||||||
|
// One CrewDto from a crew row and that member's crew_skills rows: the four fixed-order
|
||||||
|
// { skill, level } entries (JC-26/27) plus the title from the member's highest level
|
||||||
|
// (titleFor — null below L3). A member with no rows (a hand-built save whose crew was never
|
||||||
|
// seeded) reads all-L0 with a null title — the same fallback as makePowerOf's missing-row →
|
||||||
|
// L0 → power 1, so a preview built from the DTO matches the queueing route's rows.
|
||||||
|
function buildCrewDto(
|
||||||
|
c: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string },
|
||||||
|
skillRows: { crewId: number; skill: string; level: number }[],
|
||||||
|
): CrewDto {
|
||||||
|
const levelBySkill = new Map<SkillName, number>()
|
||||||
|
for (const r of skillRows) levelBySkill.set(r.skill as SkillName, r.level)
|
||||||
|
const skills = SKILL_ORDER.map((skill) => ({ skill, level: levelBySkill.get(skill) ?? 0 }))
|
||||||
|
const title = titleFor({ name: c.name, role: c.role }, skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))
|
||||||
|
return { id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin, skills, title }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot
|
||||||
|
// carry a sibling tasks field, so the endpoint returns this object.
|
||||||
|
export interface CrewState {
|
||||||
|
crew: CrewDto[]
|
||||||
|
tasks: QueueDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full roster in join order (starting crew first, then finds), wrapped with the queue.
|
||||||
|
// Auto-initializes like the other reads.
|
||||||
|
export async function getCrewState(db: GameDb): Promise<CrewState> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [crew, skillRows] = await Promise.all([
|
||||||
|
db.db.select().from(schema.crew),
|
||||||
|
db.db.select().from(schema.crewSkills),
|
||||||
|
])
|
||||||
|
const rowsByCrew = new Map<number, typeof skillRows>()
|
||||||
|
for (const r of skillRows) {
|
||||||
|
const list = rowsByCrew.get(r.crewId) ?? []
|
||||||
|
list.push(r)
|
||||||
|
rowsByCrew.set(r.crewId, list)
|
||||||
|
}
|
||||||
|
return { crew: crew.map((c) => buildCrewDto(c, rowsByCrew.get(c.id) ?? [])), tasks: await taskQueue(db) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPLORE — scan the payload's planet (any system — the UI only offered the current
|
||||||
|
// system's rows; a queued scan may resolve while the ship is elsewhere): deterministic
|
||||||
|
// haul into the inventory, planet marked explored, crew-join roll on the same
|
||||||
|
// (seed, planet) stream.
|
||||||
|
export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outcome: FoundItem[]; crewFound: CrewDto | null }> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const { planetId } = JSON.parse(task.payload) as { planetId: number }
|
||||||
|
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)
|
||||||
|
let outcome = rollExploration(rng) // resource/part haul first…
|
||||||
|
|
||||||
|
// Trait outcome effects (#43): the assigned team (lead + helpers) can change how this
|
||||||
|
// per-planet stream is drawn. Read their traits from the roster (a plain select — no rng draw).
|
||||||
|
const assigned = new Set([task.leadCrewId, ...task.helpers].filter((id) => id > 0))
|
||||||
|
const crewRows = await db.db.select().from(schema.crew)
|
||||||
|
let hasFHT = false
|
||||||
|
let hasTalker = false
|
||||||
|
for (const c of crewRows) {
|
||||||
|
if (!assigned.has(c.id)) continue
|
||||||
|
if (c.traitGood === 'FINDS HIDDEN THINGS' || c.traitBad === 'FINDS HIDDEN THINGS') hasFHT = true
|
||||||
|
if (c.traitGood === 'TALKS TO ANYONE' || c.traitBad === 'TALKS TO ANYONE') hasTalker = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// FINDS HIDDEN THINGS: one extra haul roll on the same seeded stream, appended (spec §5).
|
||||||
|
if (hasFHT) outcome = [...outcome, ...rollExploration(rng)]
|
||||||
|
|
||||||
|
const takenNames = new Set(crewRows.map((c) => c.name))
|
||||||
|
// TALKS TO ANYONE: recruit chance 30% instead of the base 18% (base === crew.ts CREW_CHANCE).
|
||||||
|
const foundMember = rollCrewMember(rng, takenNames, hasTalker ? RECRUIT_CHANCE_TALKER : RECRUIT_CHANCE_BASE)
|
||||||
|
|
||||||
|
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()
|
||||||
|
await seedCrewSkills(db, save.id) // the recruit gets role-based starting rows like the starting crew — the seeder is idempotent, so only the new member is seeded (R-P6)
|
||||||
|
crewFound = buildCrewDto(row, await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, row.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
|
||||||
|
await checkMissionCompletion(db, save.id, save.turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
|
||||||
|
await topUpMissionBoard(db)
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
return { outcome, crewFound }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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`)
|
||||||
|
|
||||||
|
// The immediate scan keeps its one-turn cost; the resolver applies everything else at that turn.
|
||||||
|
const turn = save.turn + 1
|
||||||
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
const { outcome, crewFound } = await resolveExploreTask(db, syntheticTask('EXPLORE', { planetId }))
|
||||||
|
|
||||||
|
return { ...(await readSystemState(db)), outcome, crewFound }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The explore pre-checks, extracted for the queueing route (5a): the same codes and messages
|
||||||
|
// as explorePlanet — unknown body (404), already scanned (409).
|
||||||
|
export async function checkExploreTarget(db: GameDb, planetId: number): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The turn tick on the DB (POST /api/turn — Task 5 wires the route) ---------------------
|
||||||
|
//
|
||||||
|
// The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
|
||||||
|
// The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the
|
||||||
|
// effects and the mission hooks (their contract — invoked here, at the tick's new turn,
|
||||||
|
// before the final persist); this function owns the table writes.
|
||||||
|
|
||||||
|
// The 4a contract names the resolvers 1:1 exactly for this dispatch; exhaustive by type —
|
||||||
|
// a TaskType without a resolver is a compile error.
|
||||||
|
const TASK_RESOLVERS: Record<TaskType, (db: GameDb, task: Task) => Promise<unknown>> = {
|
||||||
|
JUMP: resolveJumpTask,
|
||||||
|
EXPLORE: resolveExploreTask,
|
||||||
|
SHIP_UPGRADE: resolveShipUpgradeTask,
|
||||||
|
SHIP_EXPAND: resolveShipExpandTask,
|
||||||
|
SHIP_BUILD: resolveShipBuildTask,
|
||||||
|
RESEARCH: resolveResearchTask,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row→domain at the save.ts boundary (the inverse — Task→row at insert — stays with the
|
||||||
|
// routes' queueing path).
|
||||||
|
function rowToTask(row: { id: number; saveId: number; type: string; payload: string; leadCrewId: number; helpers: string; duration: number; remaining: number; status: string; queuedAtTurn: number }): Task {
|
||||||
|
return { id: row.id, saveId: row.saveId, type: row.type as TaskType, payload: row.payload, leadCrewId: row.leadCrewId, helpers: JSON.parse(row.helpers) as number[], duration: row.duration, remaining: row.remaining, status: row.status as TaskStatus, queuedAtTurn: row.queuedAtTurn }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plain queue snapshot Task 3's engine runs over, mapped from the schema's rows: crew,
|
||||||
|
// tasks, inventory, shipSystems and tech, all in id order (single save, unscoped). The route
|
||||||
|
// never touches lib/db/schema, so the single save row is resolved here, not passed in.
|
||||||
|
export async function queueStateFromSave(db: GameDb): Promise<QueueState> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [crew, taskRows, inventory, shipSystems, tech, skillRows] = await Promise.all([
|
||||||
|
db.db.select().from(schema.crew),
|
||||||
|
db.db.select().from(schema.tasks),
|
||||||
|
db.db.select().from(schema.inventoryItems),
|
||||||
|
db.db.select().from(schema.shipSystems),
|
||||||
|
db.db.select().from(schema.tech),
|
||||||
|
db.db.select().from(schema.crewSkills),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
turn: save.turn,
|
||||||
|
crew: crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id))),
|
||||||
|
tasks: taskRows.map(rowToTask),
|
||||||
|
inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })),
|
||||||
|
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
|
||||||
|
tech: tech.map((t) => ({ id: t.id, slug: t.slug })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The route's DB-boundary insert (the route→save pattern): queueTask's row minus the two
|
||||||
|
// values only the DB boundary knows (id autoincrement, saveId = the single save's id).
|
||||||
|
// payload is already a JSON string from queueTask (passed through); the crew ids serialize
|
||||||
|
// to the JSON column. Persists so the queue survives a crash; returns the new row id.
|
||||||
|
export async function saveTaskRow(db: GameDb, task: Omit<Task, 'id' | 'saveId'>): Promise<number> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db
|
||||||
|
.insert(schema.tasks)
|
||||||
|
.values({ saveId: save.id, type: task.type, payload: task.payload, leadCrewId: task.leadCrewId, helpers: JSON.stringify(task.helpers), duration: task.duration, remaining: task.remaining, status: task.status, queuedAtTurn: task.queuedAtTurn })
|
||||||
|
.returning({ id: schema.tasks.id })
|
||||||
|
await persist(db)
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// The route's DB-boundary hold write (the route→save pattern): queueTask's deducted inventory
|
||||||
|
// replaces the save's rows — array order = oldest first = id order, so spendItems' oldest-row-
|
||||||
|
// first consumption and the id-ordered reads stay consistent after the rewrite. Persists so
|
||||||
|
// the deduction survives a crash; the queueing routes call it with queueTask's result.
|
||||||
|
export async function saveInventory(db: GameDb, items: InventoryItemDto[]): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.saveId, save.id))
|
||||||
|
for (const item of items) {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty })
|
||||||
|
}
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTurn(db: GameDb): Promise<TickResult> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const state = await queueStateFromSave(db)
|
||||||
|
|
||||||
|
// PR B (§6): the skill rows this tick's XP grant runs over — string ids end to end (JC-24):
|
||||||
|
// crew_skills' numeric id columns stringify into SkillRow's, and `members` keys off
|
||||||
|
// String(crew.id) so grantXp's crewIds.includes(r.crewId) matches. The snapshot is taken
|
||||||
|
// before any resolution: a recruit who joins mid-tick has no rows in it — and a recruit is
|
||||||
|
// never on a task that completes in the same tick.
|
||||||
|
const skillRows0: SkillRow[] = (await db.db.select().from(schema.crewSkills)).map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
|
||||||
|
let skillRows = skillRows0
|
||||||
|
const members: Record<string, CrewIdentity> = Object.fromEntries(state.crew.map((c): [string, CrewIdentity] => [String(c.id), { name: c.name, role: c.role }]))
|
||||||
|
const levelUpEvents: string[] = []
|
||||||
|
|
||||||
|
const result = tickTurn(state)
|
||||||
|
|
||||||
|
// (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
|
||||||
|
// and the checkMissionCompletion / topUpMissionBoard hooks (4a contract) land at the new
|
||||||
|
// turn, exactly where today's actions place them.
|
||||||
|
await db.db.update(schema.saves).set({ turn: result.turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
|
||||||
|
// (b) + (d) resolve in the order tickTurn returned. A resolver throw is its 4a guard
|
||||||
|
// (stale or double-resolved target): the task fails — it is already DONE (the tick freed
|
||||||
|
// the team; the turn is spent), the effect simply does not land, and no event is written
|
||||||
|
// for it. Only the four domain error classes fail a task; anything else rethrows.
|
||||||
|
for (const task of result.completed) {
|
||||||
|
try {
|
||||||
|
await TASK_RESOLVERS[task.type](db, task)
|
||||||
|
// §6: every assigned member — lead and helpers equally — gains +1 XP in the action's
|
||||||
|
// skill. The rows reassign, so a member on several completions this tick accumulates,
|
||||||
|
// and the level-up events collect for the batch write below. A task whose resolver
|
||||||
|
// failed its 4a guard grants nothing: its effect did not land.
|
||||||
|
const { rows, events } = grantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members)
|
||||||
|
skillRows = rows
|
||||||
|
levelUpEvents.push(...events)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tick's level-up events as one batch at the new turn, and the crew_skills rows that
|
||||||
|
// changed — both land in the final whole-DB write (JC-25): the resolution, the events and
|
||||||
|
// the skill progression commit together. Only rows whose level or xp moved are written.
|
||||||
|
for (const message of levelUpEvents) {
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: result.turn, type: 'level_up', message, createdAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
const beforeSkill = new Map(skillRows0.map((r): [string, SkillRow] => [`${r.crewId}:${r.skill}`, r]))
|
||||||
|
for (const row of skillRows) {
|
||||||
|
const old = beforeSkill.get(`${row.crewId}:${row.skill}`)!
|
||||||
|
if (old.level !== row.level || old.xp !== row.xp) {
|
||||||
|
await db.db.update(schema.crewSkills).set({ level: row.level, xp: row.xp }).where(and(eq(schema.crewSkills.crewId, Number(row.crewId)), eq(schema.crewSkills.skill, row.skill)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the tick's queue changes (the resolvers already persisted their own effects) —
|
||||||
|
// one final whole-DB write makes the turn atomic on disk.
|
||||||
|
for (const before of state.tasks) {
|
||||||
|
const after = result.tasks.find((t) => t.id === before.id)
|
||||||
|
if (after && (after.status !== before.status || after.remaining !== before.remaining)) {
|
||||||
|
await db.db.update(schema.tasks).set({ status: after.status, remaining: after.remaining }).where(eq(schema.tasks.id, after.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queueing routes' power-band builder (PR B, §6): the save's roster and crew_skills rows
|
||||||
|
// folded into makePowerOf's closure for the action's skill — the same closure the live
|
||||||
|
// duration preview uses, so a queued task's stored duration matches what the crew picker
|
||||||
|
// shows. The single save row is passed in by the route rather than re-selected here.
|
||||||
|
export async function makePowerOfFor(db: GameDb, save: { id: number }, type: TaskType): Promise<(m: CrewMember) => number> {
|
||||||
|
const [crew, skillRows] = await Promise.all([
|
||||||
|
db.db.select().from(schema.crew).where(eq(schema.crew.saveId, save.id)),
|
||||||
|
db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.saveId, save.id)),
|
||||||
|
])
|
||||||
|
const roster: CrewDto[] = crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id)))
|
||||||
|
const rows: SkillRow[] = skillRows.map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
|
||||||
|
return makePowerOf(roster, rows, type)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Pure ship-system model: the known system types, base values and the action cost model.
|
||||||
|
// No DB imports — both lib/game/ship.ts (server) and the frontend import from here so the
|
||||||
|
// UI's affordability/cap logic can never drift from what the API enforces.
|
||||||
|
|
||||||
|
export interface ShipTypeDef {
|
||||||
|
type: string
|
||||||
|
builtAtStart: boolean // part of the starting ship? (otherwise unbuilt until BUILD)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The known system types — display order. Reuses the placeholder SHIP SYSTEMS tab card names
|
||||||
|
// for continuity, plus HULL so the vessel-metrics hull bar can be driven by a real level.
|
||||||
|
export const SHIP_TYPES: ShipTypeDef[] = [
|
||||||
|
{ type: 'HULL', builtAtStart: true },
|
||||||
|
{ type: 'REACTOR', builtAtStart: true },
|
||||||
|
{ type: 'LIFE SUPPORT', builtAtStart: true },
|
||||||
|
{ type: 'NAV COMPUTER', builtAtStart: true },
|
||||||
|
{ type: 'COMMS ARRAY', builtAtStart: true },
|
||||||
|
{ type: 'WEAPONS', builtAtStart: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BASE_LEVEL = 1 // what a system starts at when built (starting ship or BUILD)
|
||||||
|
export const BASE_CAPACITY = 2
|
||||||
|
export const MAX_LEVEL = 5 // UPGRADE cap
|
||||||
|
export const MAX_CAPACITY = 10 // EXPAND cap
|
||||||
|
|
||||||
|
// Display-only cap for the VESSEL METRICS crew roster bar (no gameplay effect yet).
|
||||||
|
export const CREW_CAPACITY = 6
|
||||||
|
|
||||||
|
// Cost model — all costs are in inventory items, consumed from the oldest cargo row of the
|
||||||
|
// required kind first (see spendItems in lib/game/ship.ts):
|
||||||
|
// UPGRADE {systemId} — `level` units of any resource (scales with current level)
|
||||||
|
// EXPAND {systemId} — `capacity` units of any resource (scales with current capacity)
|
||||||
|
// BUILD {type} — a flat amount of parts (parts are rarer than resources, so builds
|
||||||
|
// are the bigger investment)
|
||||||
|
export function upgradeCost(level: number): number {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandCost(capacity: number): number {
|
||||||
|
return capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUILD_PART_COST = 3
|
||||||
|
|
||||||
|
// Total qty of one kind across the cargo hold — used for affordability checks.
|
||||||
|
export function totalQty(items: { kind: string; qty: number }[], kind: 'resource' | 'part'): number {
|
||||||
|
return items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, spendItems, upgradeSystem, type ShipState } from './ship'
|
||||||
|
import { SCHEMA_VERSION } from './save'
|
||||||
|
import { TECHS } from './tech'
|
||||||
|
import type { Task } from './tasks'
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- A/B: resolvers vs the legacy immediate ship actions (Task 4a) ---------------------
|
||||||
|
const T0 = '2026-10-01T00:00:00.000Z'
|
||||||
|
|
||||||
|
// Stamped save (no backfill), the full ship per SHIP_TYPES, the full tech tree with
|
||||||
|
// WEAPONS_SYSTEMS researched (unlocks the BUILD row). Identical build per world → ids line up.
|
||||||
|
async function buildShipFixture(db: GameDb): Promise<void> {
|
||||||
|
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
|
||||||
|
for (const def of SHIP_TYPES) {
|
||||||
|
await db.db.insert(schema.shipSystems).values({ saveId: save.id, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 })
|
||||||
|
}
|
||||||
|
for (const def of TECHS) {
|
||||||
|
await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: def.slug === 'WEAPONS_SYSTEMS' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = (type: 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD', payload: object): Task =>
|
||||||
|
({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
|
||||||
|
|
||||||
|
async function snapshot(db: GameDb): Promise<string> {
|
||||||
|
const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([
|
||||||
|
db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves),
|
||||||
|
db.db.select().from(schema.solarSystems),
|
||||||
|
db.db.select().from(schema.jumpRoutes),
|
||||||
|
db.db.select().from(schema.planets),
|
||||||
|
db.db.select().from(schema.inventoryItems),
|
||||||
|
db.db.select().from(schema.crew),
|
||||||
|
db.db.select().from(schema.shipSystems),
|
||||||
|
db.db.select().from(schema.tech),
|
||||||
|
db.db.select().from(schema.missions),
|
||||||
|
db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events),
|
||||||
|
])
|
||||||
|
return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveShip* (A/B against the immediate actions)', () => {
|
||||||
|
// A = legacy immediate action (validates, deducts the cost, +1 turn, effect, event).
|
||||||
|
// B = the queued shape: the cost is deducted up front (queueTask did it at queue time —
|
||||||
|
// Task 3), the tick advanced the turn (Task 4b), then the resolver applies the effect.
|
||||||
|
// Same starting world → byte-identical end states.
|
||||||
|
|
||||||
|
it('UPGRADE: lands exactly what upgradeSystem lands', async () => {
|
||||||
|
const { db: a } = await freshFile()
|
||||||
|
const { db: b } = await freshFile()
|
||||||
|
await buildShipFixture(a)
|
||||||
|
await buildShipFixture(b)
|
||||||
|
await addItem(a, 'resource', 'IRON ORE', 1) // level 1 → cost 1
|
||||||
|
await addItem(b, 'resource', 'IRON ORE', 1)
|
||||||
|
|
||||||
|
const reactorId = systemOf(await getShipState(a), 'REACTOR').id
|
||||||
|
await upgradeSystem(a, reactorId)
|
||||||
|
await spendItems(b, 1, 'resource', 1) // the queue-time deduction
|
||||||
|
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||||
|
await resolveShipUpgradeTask(b, task('SHIP_UPGRADE', { systemId: reactorId }))
|
||||||
|
|
||||||
|
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||||
|
const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'upgrade')!
|
||||||
|
expect(ev.turn).toBe(1)
|
||||||
|
expect(ev.message).toBe('Upgraded REACTOR to level 2 (cost: 1× resources)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('EXPAND: lands exactly what expandSystem lands', async () => {
|
||||||
|
const { db: a } = await freshFile()
|
||||||
|
const { db: b } = await freshFile()
|
||||||
|
await buildShipFixture(a)
|
||||||
|
await buildShipFixture(b)
|
||||||
|
await addItem(a, 'resource', 'CRYSTAL DUST', 2) // capacity 2 → cost 2
|
||||||
|
await addItem(b, 'resource', 'CRYSTAL DUST', 2)
|
||||||
|
|
||||||
|
const hullId = systemOf(await getShipState(a), 'HULL').id
|
||||||
|
await expandSystem(a, hullId)
|
||||||
|
await spendItems(b, 1, 'resource', 2)
|
||||||
|
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||||
|
await resolveShipExpandTask(b, task('SHIP_EXPAND', { systemId: hullId }))
|
||||||
|
|
||||||
|
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||||
|
const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'expand')!
|
||||||
|
expect(ev.turn).toBe(1)
|
||||||
|
expect(ev.message).toBe('Expanded HULL capacity to 3 (cost: 2× resources)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('BUILD: lands exactly what buildSystem lands', async () => {
|
||||||
|
const { db: a } = await freshFile()
|
||||||
|
const { db: b } = await freshFile()
|
||||||
|
await buildShipFixture(a)
|
||||||
|
await buildShipFixture(b)
|
||||||
|
await addItem(a, 'part', 'SERVO ACTUATOR', 3)
|
||||||
|
await addItem(b, 'part', 'SERVO ACTUATOR', 3)
|
||||||
|
|
||||||
|
await buildSystem(a, 'WEAPONS')
|
||||||
|
await spendItems(b, 1, 'part', 3)
|
||||||
|
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||||
|
await resolveShipBuildTask(b, task('SHIP_BUILD', { type: 'WEAPONS' }))
|
||||||
|
|
||||||
|
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||||||
|
const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'build')!
|
||||||
|
expect(ev.turn).toBe(1)
|
||||||
|
expect(ev.message).toBe('Built WEAPONS at base values (cost: 3× parts)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('guards: 404 unknown, 409 unbuilt / max level / max capacity / gate / already built', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await buildShipFixture(db)
|
||||||
|
const state = await getShipState(db)
|
||||||
|
const reactorId = systemOf(state, 'REACTOR').id
|
||||||
|
const hullId = systemOf(state, 'HULL').id
|
||||||
|
const weaponsId = systemOf(state, 'WEAPONS').id
|
||||||
|
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||||||
|
|
||||||
|
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' })
|
||||||
|
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: weaponsId }))).rejects.toMatchObject({ status: 409, message: 'WEAPONS is not built' })
|
||||||
|
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId))
|
||||||
|
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: reactorId }))).rejects.toMatchObject({ status: 409, message: `REACTOR is at max level (${MAX_LEVEL})` })
|
||||||
|
|
||||||
|
await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' })
|
||||||
|
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
|
||||||
|
await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: hullId }))).rejects.toMatchObject({ status: 409, message: `HULL is at max capacity (${MAX_CAPACITY})` })
|
||||||
|
|
||||||
|
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'SHIELDS' }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system type SHIELDS' })
|
||||||
|
await db.db.update(schema.tech).set({ researched: false }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS'))
|
||||||
|
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'WEAPONS' }))).rejects.toMatchObject({ status: 409, message: 'Building WEAPONS requires the WEAPONS SYSTEMS tech' })
|
||||||
|
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'HULL' }))).rejects.toMatchObject({ status: 409, message: 'HULL is already built' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
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'
|
||||||
|
import type { Task, TaskType } from './tasks'
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntheticTask(type: TaskType, payload: object): Task {
|
||||||
|
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHIP_UPGRADE — level a built system by one, at whatever level it sits at resolution
|
||||||
|
// time. No cost here (deducted at queue time — Task 3 — or by upgradeSystem before
|
||||||
|
// delegating); no mission hooks (the current action has none).
|
||||||
|
export async function resolveShipUpgradeTask(db: GameDb, task: Task): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const { systemId } = JSON.parse(task.payload) as { systemId: number }
|
||||||
|
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 db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'upgrade', message: `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() })
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHIP_EXPAND — same shape for capacity (cost: `capacity` units of any resource, deducted).
|
||||||
|
export async function resolveShipExpandTask(db: GameDb, task: Task): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const { systemId } = JSON.parse(task.payload) as { systemId: number }
|
||||||
|
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 db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'expand', message: `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() })
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHIP_BUILD — build an unbuilt type at base values. The flat parts cost is already
|
||||||
|
// deducted; some types are gated behind a researched tech (see EFFECTS in lib/game/tech.ts).
|
||||||
|
export async function resolveShipBuildTask(db: GameDb, task: Task): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const { type } = JSON.parse(task.payload) as { type: string }
|
||||||
|
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 db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'build', message: `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`, createdAt: new Date().toISOString() })
|
||||||
|
await persist(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upgrade pre-checks, extracted for the queueing route (5a(ii)): the same codes and
|
||||||
|
// messages as upgradeSystem — unknown system (404), not built (409), at max level (409).
|
||||||
|
// Cost, turn and the level-up effect stay out: the queueing path deducts at queue time
|
||||||
|
// (queueTask) and the effect lands when the task completes on the tick.
|
||||||
|
export async function checkUpgradePreconditions(db: GameDb, systemId: number): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
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})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs).
|
||||||
|
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})`)
|
||||||
|
|
||||||
|
await spendItems(db, save.id, 'resource', upgradeCost(target.level))
|
||||||
|
const turn = save.turn + 1
|
||||||
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await resolveShipUpgradeTask(db, syntheticTask('SHIP_UPGRADE', { systemId }))
|
||||||
|
return readShipState(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expand pre-checks, extracted for the queueing route (5a(ii)): the same codes and
|
||||||
|
// messages as expandSystem — unknown system (404), not built (409), at max capacity (409).
|
||||||
|
export async function checkExpandPreconditions(db: GameDb, systemId: number): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
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})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs).
|
||||||
|
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})`)
|
||||||
|
|
||||||
|
await spendItems(db, save.id, 'resource', expandCost(target.capacity))
|
||||||
|
const turn = save.turn + 1
|
||||||
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await resolveShipExpandTask(db, syntheticTask('SHIP_EXPAND', { systemId }))
|
||||||
|
return readShipState(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The build pre-checks, extracted for the queueing route (5a(ii)): the same codes and
|
||||||
|
// messages as buildSystem — unknown type (404), already built (409), build-gate tech
|
||||||
|
// missing (409). The parts cost stays out: the queueing path deducts it at queue time.
|
||||||
|
export async function checkBuildPreconditions(db: GameDb, type: string): Promise<void> {
|
||||||
|
await ensureSave(db)
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
|
||||||
|
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
||||||
|
await ensureSave(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
|
||||||
|
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
|
||||||
|
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
|
||||||
|
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.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await resolveShipBuildTask(db, syntheticTask('SHIP_BUILD', { type }))
|
||||||
|
return readShipState(db)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { SkillName } from './crew-costs'
|
||||||
|
import { grantXp, levelFromXp, powerForLevel, titleFor, type SkillRow } from './skills'
|
||||||
|
const pilot = { name: 'BEN OKONKWO', role: 'PILOT' }
|
||||||
|
const row = (crewId: string, skill: SkillName, level: number, xp: number): SkillRow =>
|
||||||
|
({ saveId: 's1', crewId, skill, level, xp })
|
||||||
|
const XP: [number, number][] = [[4, 0], [5, 1], [10, 2], [15, 3], [25, 5], [100, 5]]
|
||||||
|
describe('levelFromXp (thresholds [5,10,15,20,25], cap 5)', () => {
|
||||||
|
it('maps cumulative xp → highest level reached', () => {
|
||||||
|
for (const [xp, lvl] of XP) expect(levelFromXp(xp), `xp ${xp}`).toBe(lvl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const POWER: [number, number][] = [[0, 1], [2, 1], [3, 2], [4, 2], [5, 3]]
|
||||||
|
describe('powerForLevel (bands [1,1,1,2,2,3])', () => {
|
||||||
|
it('L0–2 → 1 · L3–4 → 2 · L5 → 3', () => {
|
||||||
|
for (const [lvl, p] of POWER) expect(powerForLevel(lvl), `L${lvl}`).toBe(p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('grantXp — +1 XP to every listed member; event only on a titled-band crossing', () => {
|
||||||
|
const pilotRows = (): SkillRow[] => [
|
||||||
|
row('c1', 'NAVIGATION', 2, 14), row('c1', 'SCANNING', 0, 0),
|
||||||
|
row('c1', 'ENGINEERING', 0, 0), row('c1', 'RESEARCH', 0, 0),
|
||||||
|
]
|
||||||
|
it('L0 → L1: xp 4 +1 → 5 (L1), no event (no event for plain L1/L2)', () => {
|
||||||
|
const { rows, events } = grantXp([row('c1', 'NAVIGATION', 0, 4)], ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(rows[0]).toEqual({ saveId: 's1', crewId: 'c1', skill: 'NAVIGATION', level: 1, xp: 5 })
|
||||||
|
expect(events).toEqual([])
|
||||||
|
})
|
||||||
|
it('L2 → L3: xp 14 +1 → 15, SENIOR event in the exact spec §6 text', () => {
|
||||||
|
const { rows, events } = grantXp(pilotRows(), ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(rows.find((r) => r.skill === 'NAVIGATION')!.level).toBe(3)
|
||||||
|
expect(events).toEqual(['BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3'])
|
||||||
|
})
|
||||||
|
it('L4 → L5: xp 24 +1 → 25, MASTER event', () => {
|
||||||
|
const { events } = grantXp([row('c1', 'NAVIGATION', 4, 24), row('c1', 'SCANNING', 0, 0)], ['c1'], 'NAVIGATION', { c1: pilot })
|
||||||
|
expect(events).toEqual(['BEN OKONKWO is now a MASTER PILOT — NAVIGATION L5'])
|
||||||
|
})
|
||||||
|
it('no event when a member is already at the titled level (stays at L3 after gaining xp)', () => {
|
||||||
|
// Member at SCANNING L3 (xp 16) gains 1 → xp 17, still L3 → NO event
|
||||||
|
const rows = [row('c1', 'SCANNING', 3, 16), row('c1', 'NAVIGATION', 0, 0)]
|
||||||
|
const { events } = grantXp(rows, ['c1'], 'SCANNING', { c1: { name: 'X', role: 'SCIENTIST' } })
|
||||||
|
expect(events).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('titleFor — from the member\'s HIGHEST skill level', () => {
|
||||||
|
it('SCIENTIST with SCANNING L3 + RESEARCH L5 → MASTER SCIENTIST (highest wins)', () => {
|
||||||
|
const scientist = { name: 'AVA VANCE', role: 'SCIENTIST' }
|
||||||
|
const rows = [row('c2', 'SCANNING', 3, 15), row('c2', 'RESEARCH', 5, 25), row('c2', 'NAVIGATION', 0, 0), row('c2', 'ENGINEERING', 0, 0)]
|
||||||
|
expect(titleFor(scientist, rows)).toBe('MASTER SCIENTIST')
|
||||||
|
})
|
||||||
|
it('GUNNER with all L0 → null', () => {
|
||||||
|
const gunner = { name: 'RIG HOLLOWAY', role: 'GUNNER' }
|
||||||
|
const rows = [row('c3', 'NAVIGATION', 0, 0), row('c3', 'SCANNING', 0, 0), row('c3', 'ENGINEERING', 0, 0), row('c3', 'RESEARCH', 0, 0)]
|
||||||
|
expect(titleFor(gunner, rows)).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Pure skill-progression module (spec §6): XP accrual, level→power, titles. No lib/db imports — operates on plain SkillRow data (Task 12's crew_skills table); the frontend imports it for the CREW-tab display.
|
||||||
|
import { ACTION_SKILL, POWER_BANDS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||||||
|
import type { CrewMember } from './crew'
|
||||||
|
import type { CrewDto } from './save'
|
||||||
|
import type { TaskType } from './tasks'
|
||||||
|
export interface SkillRow {
|
||||||
|
saveId: string
|
||||||
|
crewId: string
|
||||||
|
skill: SkillName
|
||||||
|
level: number
|
||||||
|
xp: number
|
||||||
|
}
|
||||||
|
export type CrewIdentity = { name: string; role: string }
|
||||||
|
// Highest level 0–5 with xp ≥ XP_THRESHOLDS[level−1]; thresholds [5,10,15,20,25], cap 5.
|
||||||
|
export function levelFromXp(xp: number): number {
|
||||||
|
let level = 0
|
||||||
|
for (let l = 1; l <= XP_THRESHOLDS.length; l++) if (xp >= XP_THRESHOLDS[l - 1]) level = l
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
// L0–2 → 1 · L3–4 → 2 · L5 → 3 (direct lookup into crew-costs POWER_BANDS).
|
||||||
|
export function powerForLevel(level: number): number {
|
||||||
|
return POWER_BANDS[level]
|
||||||
|
}
|
||||||
|
// From the member's HIGHEST skill level: ≥5 → "MASTER <ROLE>", ≥3 → "SENIOR <ROLE>", below → null.
|
||||||
|
export function titleFor(member: CrewIdentity, skillRows: SkillRow[]): string | null {
|
||||||
|
const highest = skillRows.reduce((max, r) => Math.max(max, r.level), 0)
|
||||||
|
if (highest >= 5) return `MASTER ${member.role}`
|
||||||
|
if (highest >= 3) return `SENIOR ${member.role}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// +1 XP to every listed member's row for `skill`; returns updated rows + any level-up events. An event
|
||||||
|
// fires when the gained skill steps into a titled band (3 → SENIOR, 5 → MASTER), e.g. "BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3" (title from the member's HIGHEST skill).
|
||||||
|
export function grantXp(
|
||||||
|
skillRows: SkillRow[],
|
||||||
|
crewIds: string[],
|
||||||
|
skill: SkillName,
|
||||||
|
members: Record<string, CrewIdentity>,
|
||||||
|
): { rows: SkillRow[]; events: string[] } {
|
||||||
|
const rows = skillRows.map((r) =>
|
||||||
|
r.skill === skill && crewIds.includes(r.crewId)
|
||||||
|
? { ...r, xp: r.xp + 1, level: levelFromXp(r.xp + 1) }
|
||||||
|
: r,
|
||||||
|
)
|
||||||
|
const events: string[] = []
|
||||||
|
for (const crewId of crewIds) {
|
||||||
|
const member = members[crewId]
|
||||||
|
if (!member) continue
|
||||||
|
const oldLevel = skillRows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
|
||||||
|
const newLevel = rows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
|
||||||
|
if (newLevel > oldLevel && (newLevel === 3 || newLevel === 5)) {
|
||||||
|
const title = titleFor(member, rows.filter((r) => r.crewId === crewId))
|
||||||
|
if (title) events.push(`${member.name} is now a ${title} — ${skill} L${newLevel}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rows, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The duration formula's powerOf (Task 1) driven by the skill band (PR B, §6): each member's
|
||||||
|
// power is the band of that member's level in the action's skill (ACTION_SKILL[type]). Names
|
||||||
|
// map to crew ids (unique per single-player save); an unknown member or a missing row falls
|
||||||
|
// back to L0 → power 1 (Task 1's default). Called by save.ts's makePowerOfFor (the queueing
|
||||||
|
// routes) and — once /api/crew carries each member's skill rows — by the team builder's live
|
||||||
|
// preview (Task 15).
|
||||||
|
export function makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType): (m: CrewMember) => number {
|
||||||
|
const idByName = new Map(roster.map((c): [string, string] => [c.name, String(c.id)]))
|
||||||
|
const levelByCrew = new Map(skillRows.filter((r) => r.skill === ACTION_SKILL[type]).map((r): [string, number] => [r.crewId, r.level]))
|
||||||
|
return (m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { CrewMember } from './crew'
|
||||||
|
import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, POWER_BANDS, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, TRAIT_DURATION_MODIFIERS, WORK_POINTS, XP_THRESHOLDS } from './crew-costs'
|
||||||
|
import type { CrewDto } from './save'
|
||||||
|
import { computeDuration, queueTask, tickTurn, TaskError, type QueueState, type Task, type TaskType } from './tasks'
|
||||||
|
|
||||||
|
// Trait strings copied verbatim from lib/game/crew.ts GOOD_TRAITS/BAD_TRAITS — TRAIT_DURATION_MODIFIERS
|
||||||
|
// is keyed by exactly these strings, so a rename in crew.ts breaks these tests.
|
||||||
|
const NEUTRAL_GOOD = 'FINDS HIDDEN THINGS' // no duration entry → no effect
|
||||||
|
const NEUTRAL_BAD = 'JINXED IN A DOGFIGHT' // STANDBY combat trait → no duration effect
|
||||||
|
const crew = (role: string, traitGood = NEUTRAL_GOOD, traitBad = NEUTRAL_BAD): CrewMember => ({ name: 'TEST', role, traitGood, traitBad })
|
||||||
|
const WORK: Record<TaskType, number> = { JUMP: 3, EXPLORE: 4, SHIP_UPGRADE: 6, SHIP_EXPAND: 6, SHIP_BUILD: 6, RESEARCH: 9 }
|
||||||
|
const TYPES = Object.keys(WORK) as TaskType[]
|
||||||
|
const SHIP: TaskType[] = ['SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD']
|
||||||
|
|
||||||
|
describe('solo baselines (one crew, power 1, no modifiers)', () => {
|
||||||
|
it('work points are the duration for the preferred role: EXPLORE 4 · JUMP 3 / 6 · SHIP 6 · RESEARCH 9', () => {
|
||||||
|
for (const type of TYPES) {
|
||||||
|
expect(computeDuration(type, 1, crew(PREFERRED_ROLES[type][0]), [])).toBe(WORK[type])
|
||||||
|
}
|
||||||
|
expect(computeDuration('JUMP', 2, crew('PILOT'), [])).toBe(6) // two-hop = 2 × 3
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('role mismatch (lead only, helpers never penalised)', () => {
|
||||||
|
it('a lead outside the preferred role adds +1; helpers contribute power only', () => {
|
||||||
|
expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [])).toBe(4) // 3 + 1 mismatch
|
||||||
|
expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [crew('PILOT')])).toBe(3) // ceil(3/2) + 1 — power 2, mismatch still on the lead
|
||||||
|
expect(computeDuration('EXPLORE', 1, crew('SCIENTIST'), [crew('PILOT', 'QUICK HANDS')])).toBe(2) // 4/2 — the helper's trait does not apply
|
||||||
|
})
|
||||||
|
it('STEADY UNDER PRESSURE cancels the mismatch penalty and adds nothing of its own', () => {
|
||||||
|
expect(computeDuration('JUMP', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(3)
|
||||||
|
expect(computeDuration('EXPLORE', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('floor', () => {
|
||||||
|
it('never returns below 1 turn', () => {
|
||||||
|
const lead = crew('PILOT', 'QUICK HANDS')
|
||||||
|
expect(computeDuration('JUMP', 1, lead, [crew('PILOT'), crew('PILOT')], () => 3)).toBe(1) // ceil(3/9) − 1 = 0 → floor 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// All 15 duration traits from spec §5, each tested once in its own action context (plus its
|
||||||
|
// non-applicable types). target 'SHIP_UPGRADE' stands for the whole "SHIP work" class.
|
||||||
|
const TABLE: [trait: string, isGood: boolean, target: TaskType | 'ANY', delta: number][] = [
|
||||||
|
['RECKLESS COURAGE', true, 'JUMP', -1],
|
||||||
|
['CALM IN A CRISIS', true, 'EXPLORE', -1],
|
||||||
|
['FIXES ANYTHING', true, 'SHIP_UPGRADE', -1],
|
||||||
|
['READS STARS BLIND', true, 'JUMP', -1],
|
||||||
|
['QUICK HANDS', true, 'ANY', -1],
|
||||||
|
['IRON WILL', true, 'RESEARCH', -1],
|
||||||
|
['RIGID — SLOW TO ADAPT', false, 'RESEARCH', 1],
|
||||||
|
['OVERCAUTIOUS', false, 'ANY', 1],
|
||||||
|
['SLOWS THE CREW DOWN', false, 'ANY', 1],
|
||||||
|
['CARELESS WITH FUSES', false, 'SHIP_UPGRADE', 1],
|
||||||
|
['HOARDS RATIONS', false, 'SHIP_UPGRADE', 1],
|
||||||
|
['FORGETS TO EAT', false, 'EXPLORE', 1],
|
||||||
|
['ARGUES WITH EVERYONE', false, 'RESEARCH', 1],
|
||||||
|
['DOUBTS THE CAPTAIN', false, 'JUMP', 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('the 15 duration traits (spec §5, exact strings from crew.ts)', () => {
|
||||||
|
const applies = (type: TaskType, target: TaskType | 'ANY') => {
|
||||||
|
if (target === 'ANY') return true
|
||||||
|
if (target === type) return true
|
||||||
|
return SHIP.includes(target) && SHIP.includes(type) // "SHIP work" class: any SHIP type → any SHIP type
|
||||||
|
}
|
||||||
|
it('each trait shifts only its own action class, lead only, across all six types', () => {
|
||||||
|
for (const [trait, isGood, target, delta] of TABLE) {
|
||||||
|
for (const type of TYPES) {
|
||||||
|
const lead = isGood ? crew(PREFERRED_ROLES[type][0], trait) : crew(PREFERRED_ROLES[type][0], NEUTRAL_GOOD, trait)
|
||||||
|
expect(computeDuration(type, 1, lead, []), `${trait} → ${type}`).toBe(WORK[type] + (applies(type, target) ? delta : 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('crew-costs — the §11 tuning surface', () => {
|
||||||
|
it('pins the balance knobs', () => {
|
||||||
|
expect(WORK_POINTS).toEqual({ EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 })
|
||||||
|
expect(XP_THRESHOLDS).toEqual([5, 10, 15, 20, 25])
|
||||||
|
expect(POWER_BANDS).toEqual([1, 1, 1, 2, 2, 3]) // L0–L2 → 1 · L3–L4 → 2 · L5 → 3
|
||||||
|
expect(STARTING_LEVELS.PILOT).toEqual({ NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
|
||||||
|
expect(STARTING_LEVELS.SCIENTIST).toEqual({ NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 })
|
||||||
|
expect(STARTING_LEVELS.COMMANDER).toEqual({ NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 })
|
||||||
|
expect(RECRUIT_CHANCE_BASE).toBe(0.18)
|
||||||
|
expect(RECRUIT_CHANCE_TALKER).toBe(0.3)
|
||||||
|
expect(Object.keys(TRAIT_DURATION_MODIFIERS)).toHaveLength(15) // all 15 duration traits of spec §5, nothing else
|
||||||
|
expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Shared queue fixtures (file scope — reused by the queueTask and tickTurn blocks): the roster,
|
||||||
|
// a state factory (turn 10, empty queue) and a one-turn row factory.
|
||||||
|
// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` and
|
||||||
|
// `skills` fill the CrewDto shape (unused by the duration model but part of the type):
|
||||||
|
// each role's STARTING_LEVELS (all below L3, so every fixture member's title is null).
|
||||||
|
const skillsOf = (n: number, s: number, e: number, r: number) => [
|
||||||
|
{ skill: 'NAVIGATION' as const, level: n },
|
||||||
|
{ skill: 'SCANNING' as const, level: s },
|
||||||
|
{ skill: 'ENGINEERING' as const, level: e },
|
||||||
|
{ skill: 'RESEARCH' as const, level: r },
|
||||||
|
]
|
||||||
|
const CREW: CrewDto[] = [
|
||||||
|
{ id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW', skills: skillsOf(2, 0, 0, 0), title: null },
|
||||||
|
{ id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW', skills: skillsOf(0, 0, 2, 0), title: null },
|
||||||
|
{ id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW', skills: skillsOf(0, 2, 0, 2), title: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = (over: Partial<QueueState> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
|
||||||
|
const task = (over: Partial<Task> = {}): Task => ({ id: 90, saveId: 1, type: 'SHIP_UPGRADE', payload: '{}', leadCrewId: 1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 9, ...over })
|
||||||
|
|
||||||
|
describe('queueTask', () => {
|
||||||
|
// Sync twin of the rejects.toMatchObject house style — queueTask throws instead of rejecting.
|
||||||
|
const expectErr = (fn: () => unknown, status: number, message?: RegExp): void => {
|
||||||
|
let err: unknown
|
||||||
|
try {
|
||||||
|
fn()
|
||||||
|
} catch (e) {
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
expect(err).toBeInstanceOf(TaskError)
|
||||||
|
expect((err as TaskError).status).toBe(status)
|
||||||
|
if (message) expect((err as TaskError).message).toMatch(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('unknown lead or helper id → 404', () => {
|
||||||
|
expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 42, helperCrewIds: [] }), 404, /Unknown crew member 42/)
|
||||||
|
expectErr(() => queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [42] }), 404, /Unknown crew member 42/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('busy lead → 409 — already committed to a PENDING task (as lead or helper of one)', () => {
|
||||||
|
expectErr(() => queueTask(state({ tasks: [task({ status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/)
|
||||||
|
expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [1], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /AVA VANCE is already committed to a pending task/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('busy helper → 409 — on an ACTIVE task, or committed to a PENDING one', () => {
|
||||||
|
expectErr(() => queueTask(state({ tasks: [task({ leadCrewId: 3, helpers: [2] })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already busy/)
|
||||||
|
expectErr(() => queueTask(state({ tasks: [task({ id: 91, leadCrewId: 3, helpers: [2], status: 'PENDING' })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] }), 409, /BEN OKONKWO is already committed to a pending task/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('in transit (a JUMP is ACTIVE) → 409 for a non-jump action', () => {
|
||||||
|
expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3 })] }), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [] }), 409, /in transit/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('second jump → 409 while one is pending', () => {
|
||||||
|
expectErr(() => queueTask(state({ tasks: [task({ id: 91, type: 'JUMP', leadCrewId: 3, status: 'PENDING' })] }), { type: 'JUMP', payload: { systemId: 2, hops: 1 }, leadCrewId: 1, helperCrewIds: [] }), 409, /A jump is already queued/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('SHIP / RESEARCH cost deducts at queue time; shortfall → the existing 422', () => {
|
||||||
|
const hold = [{ kind: 'resource' as const, name: 'IRON', qty: 5 }, { kind: 'part' as const, name: 'ALLOY', qty: 4 }]
|
||||||
|
const systems = [{ id: 7, type: 'REACTOR', level: 2, capacity: 3 }]
|
||||||
|
const techRows = [{ id: 3, slug: 'REACTOR_OVERCLOCK' }] // the def's cost: 3 resources (TECHS)
|
||||||
|
const upgraded = queueTask(state({ inventory: hold, shipSystems: systems, tech: techRows }), { type: 'SHIP_UPGRADE', payload: { systemId: 7 }, leadCrewId: 2, helperCrewIds: [] })
|
||||||
|
expect(upgraded.inventory).toEqual([{ kind: 'resource', name: 'IRON', qty: 3 }, { kind: 'part', name: 'ALLOY', qty: 4 }]) // upgradeCost(2) = 2, oldest row first
|
||||||
|
const shortHold = [{ kind: 'resource' as const, name: 'IRON', qty: 2 }]
|
||||||
|
expectErr(() => queueTask(state({ inventory: shortHold, tech: techRows }), { type: 'RESEARCH', payload: { techId: 3 }, leadCrewId: 2, helperCrewIds: [] }), 422, /Insufficient resources in the cargo hold — need 3/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ACTIVE-vs-PENDING rule: PENDING while the lead is busy (no turn change); all free → ACTIVE', () => {
|
||||||
|
const s = state({ tasks: [task({ leadCrewId: 1 })] })
|
||||||
|
const pending = queueTask(s, { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] })
|
||||||
|
expect(pending.task.status).toBe('PENDING')
|
||||||
|
expect(pending.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
|
||||||
|
expect(pending.task.queuedAtTurn).toBe(10)
|
||||||
|
expect(s.turn).toBe(10) // queueing never advances the turn — only the tick does
|
||||||
|
const active = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 1, helperCrewIds: [2] })
|
||||||
|
expect(active.task.status).toBe('ACTIVE')
|
||||||
|
expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
|
||||||
|
expect(active.task.queuedAtTurn).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('power-band param: a powerOf returning 2 halves a solo EXPLORE (ceil(4/2) = 2); the default flat 1 still stores 4 (backward-compatible)', () => {
|
||||||
|
// CASS (id 3) is a SCIENTIST — the preferred EXPLORE role, so no mismatch penalty; her
|
||||||
|
// trait pair (CALM IN A CRISIS −1 / FORGETS TO EAT +1) cancels on EXPLORE.
|
||||||
|
const withPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] }, () => 2)
|
||||||
|
expect(withPower.task.remaining).toBe(2) // ceil(4/2)
|
||||||
|
const defaultPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] })
|
||||||
|
expect(defaultPower.task.remaining).toBe(4) // no powerOf → the default flat 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) -------------------
|
||||||
|
//
|
||||||
|
// tickTurn is the whole §3 tick over the same plain QueueState queueTask consumes. It owns
|
||||||
|
// the turn increment and nothing else: it applies no effect and runs no mission logic —
|
||||||
|
// the save.ts orchestrator (runTurn) calls the 4a resolvers for the returned `completed`
|
||||||
|
// tasks, in the order returned.
|
||||||
|
describe('tickTurn', () => {
|
||||||
|
// The queueTask block's file-scope fixture helpers (above): CREW = [1 AVA PILOT,
|
||||||
|
// 2 BEN ENGINEER, 3 CASS SCIENTIST]; state() → { turn: 10, crew: CREW, tasks: [], … };
|
||||||
|
// task() → a one-turn row. The tick never parses payloads — the targets below are inert.
|
||||||
|
const JUMP = (id: number, over: Partial<Task> = {}): Task => task({ id, type: 'JUMP', payload: '{"systemId":2,"hops":1}', ...over })
|
||||||
|
const EXPLORE = (id: number, planetId: number, over: Partial<Task> = {}): Task => task({ id, type: 'EXPLORE', payload: `{"planetId":${planetId}}`, ...over })
|
||||||
|
|
||||||
|
it('decrements every ACTIVE task by one; a task that hits 0 is DONE (its team freed); PENDING rows do not age; the input is never mutated', () => {
|
||||||
|
const s = state({
|
||||||
|
tasks: [
|
||||||
|
JUMP(1, { duration: 3, remaining: 3, leadCrewId: 1, queuedAtTurn: 8 }),
|
||||||
|
EXPLORE(2, 5, { leadCrewId: 3, helpers: [2], duration: 1, remaining: 1, queuedAtTurn: 9 }),
|
||||||
|
EXPLORE(3, 6, { leadCrewId: 1, duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 10 }), // member 1 is on ACTIVE 1
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const r = tickTurn(s)
|
||||||
|
expect(r.turn).toBe(11) // the tick is the increment
|
||||||
|
expect(r.tasks.find((t) => t.id === 1)!.remaining).toBe(2)
|
||||||
|
expect(r.tasks.find((t) => t.id === 1)!.status).toBe('ACTIVE')
|
||||||
|
expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE')
|
||||||
|
expect(r.completed.map((t) => t.id)).toEqual([2])
|
||||||
|
expect(r.tasks.find((t) => t.id === 3)!.status).toBe('PENDING')
|
||||||
|
expect(r.tasks.find((t) => t.id === 3)!.remaining).toBe(4) // pending does not age
|
||||||
|
expect(s.tasks.find((t) => t.id === 1)!.remaining).toBe(3) // input state untouched
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a 3-deep PENDING chain on one lead: when the lead task completes, exactly ONE successor starts — earliest queuedAtTurn wins (Review Focus 2)', () => {
|
||||||
|
let s = state({
|
||||||
|
tasks: [
|
||||||
|
JUMP(9, { duration: 3, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }),
|
||||||
|
EXPLORE(10, 5, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
|
||||||
|
EXPLORE(11, 6, { leadCrewId: 1, duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 9 }),
|
||||||
|
EXPLORE(12, 7, { leadCrewId: 1, duration: 1, remaining: 1, status: 'PENDING', queuedAtTurn: 10 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
let r = tickTurn(s)
|
||||||
|
expect(r.completed.map((t) => t.id)).toEqual([9])
|
||||||
|
expect(r.tasks.find((t) => t.id === 9)!.status).toBe('DONE')
|
||||||
|
expect(r.tasks.find((t) => t.id === 10)!.status).toBe('ACTIVE') // earliest queuedAtTurn starts
|
||||||
|
expect(r.tasks.find((t) => t.id === 10)!.remaining).toBe(2) // full duration — first decrement next tick
|
||||||
|
expect(r.tasks.find((t) => t.id === 11)!.status).toBe('PENDING') // the others wait for their own tick
|
||||||
|
expect(r.tasks.find((t) => t.id === 12)!.status).toBe('PENDING')
|
||||||
|
|
||||||
|
// Walk the chain to the end: each successor starts on exactly the tick its predecessor completes.
|
||||||
|
const EXPECT: [completed: number[], active: number[]][] = [
|
||||||
|
[[], [10]],
|
||||||
|
[[10], [11]],
|
||||||
|
[[], [11]],
|
||||||
|
[[11], [12]],
|
||||||
|
[[12], []],
|
||||||
|
]
|
||||||
|
for (const [done, active] of EXPECT) {
|
||||||
|
s = { ...s, turn: r.turn, tasks: r.tasks }
|
||||||
|
r = tickTurn(s)
|
||||||
|
expect(r.completed.map((t) => t.id)).toEqual(done)
|
||||||
|
expect(r.tasks.filter((t) => t.status === 'ACTIVE').map((t) => t.id)).toEqual(active)
|
||||||
|
}
|
||||||
|
expect(r.turn).toBe(16) // 10 + 1 (the first tick) + 5 (the walk)
|
||||||
|
expect(r.tasks.every((t) => t.status === 'DONE')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('same queuedAtTurn ties break by id, and a started task blocks a later one sharing a member — at most one start per freed member (Review Focus 2, corrupt-row half)', () => {
|
||||||
|
// queueTask forbids a member committed to two PENDINGs (rule 2), so this shape only
|
||||||
|
// exists on admin-corrupted rows — the tick must still be deterministic about it.
|
||||||
|
const s = state({
|
||||||
|
tasks: [
|
||||||
|
JUMP(19, { duration: 1, remaining: 1, leadCrewId: 1, queuedAtTurn: 5 }),
|
||||||
|
EXPLORE(20, 5, { leadCrewId: 2, helpers: [3], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
|
||||||
|
EXPLORE(21, 6, { leadCrewId: 3, helpers: [2], duration: 2, remaining: 2, status: 'PENDING', queuedAtTurn: 8 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const r = tickTurn(s)
|
||||||
|
expect(r.tasks.find((t) => t.id === 20)!.status).toBe('ACTIVE') // (8, 20) < (8, 21) — starts first and takes members 2 and 3
|
||||||
|
expect(r.tasks.find((t) => t.id === 21)!.status).toBe('PENDING')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a JUMP and an EXPLORE can both resolve on the same tick — completion is location-independent (Review Focus 3, tick half)', () => {
|
||||||
|
const s = state({
|
||||||
|
tasks: [
|
||||||
|
EXPLORE(1, 5, { leadCrewId: 1, duration: 1, remaining: 1, queuedAtTurn: 8 }),
|
||||||
|
JUMP(2, { leadCrewId: 2, duration: 1, remaining: 1, queuedAtTurn: 8 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const r = tickTurn(s)
|
||||||
|
expect(r.completed.map((t) => t.id)).toEqual([1, 2]) // both this tick, queue order (explore first)
|
||||||
|
expect(r.tasks.find((t) => t.id === 1)!.status).toBe('DONE')
|
||||||
|
expect(r.tasks.find((t) => t.id === 2)!.status).toBe('DONE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('free skip: an empty queue advances exactly one turn and changes nothing; a fully-free PENDING still starts (Review Focus 4)', () => {
|
||||||
|
const r = tickTurn(state())
|
||||||
|
expect(r.turn).toBe(11)
|
||||||
|
expect(r.tasks).toEqual([])
|
||||||
|
expect(r.completed).toEqual([])
|
||||||
|
|
||||||
|
// No ACTIVE tasks and a startable PENDING (a corrupt-row shape — a legal PENDING always
|
||||||
|
// has a busy member): the pass still starts it. Skipping means "no resolution", not
|
||||||
|
// "no starts".
|
||||||
|
const r2 = tickTurn(state({ tasks: [EXPLORE(30, 5, { leadCrewId: 1, helpers: [2, 3], duration: 3, remaining: 3, status: 'PENDING', queuedAtTurn: 9 })] }))
|
||||||
|
expect(r2.turn).toBe(11)
|
||||||
|
expect(r2.tasks.find((t) => t.id === 30)!.status).toBe('ACTIVE')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// Pure task-engine model (spec §3–§4): the task types and the duration formula. No lib/db
|
||||||
|
// imports — the frontend imports this for the live duration preview in the crew picker
|
||||||
|
// (same precedent as tech.ts / ship-costs.ts).
|
||||||
|
import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, TRAIT_DURATION_MODIFIERS, WORK_POINTS } from './crew-costs'
|
||||||
|
import type { CrewMember } from './crew'
|
||||||
|
import type { CrewDto, InventoryItemDto } from './save'
|
||||||
|
import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from './ship-costs'
|
||||||
|
import { TECHS } from './tech'
|
||||||
|
|
||||||
|
export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH'
|
||||||
|
|
||||||
|
export type TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE'
|
||||||
|
|
||||||
|
// One queued/active/done action (spec §3). `payload` is a JSON string whose shape depends on
|
||||||
|
// the TaskType: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} ·
|
||||||
|
// RESEARCH {techId} · EXPLORE {planetId}.
|
||||||
|
export interface Task {
|
||||||
|
id: number
|
||||||
|
saveId: number
|
||||||
|
type: TaskType
|
||||||
|
payload: string
|
||||||
|
leadCrewId: number
|
||||||
|
helpers: number[]
|
||||||
|
duration: number
|
||||||
|
remaining: number
|
||||||
|
status: TaskStatus
|
||||||
|
queuedAtTurn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work points per action (spec §4): JUMP scales with hop count, the rest are flat.
|
||||||
|
function workPoints(type: TaskType, hopCount: number): number {
|
||||||
|
switch (type) {
|
||||||
|
case 'JUMP':
|
||||||
|
return hopCount * WORK_POINTS.JUMP_PER_HOP
|
||||||
|
case 'EXPLORE':
|
||||||
|
return WORK_POINTS.EXPLORE
|
||||||
|
case 'RESEARCH':
|
||||||
|
return WORK_POINTS.RESEARCH
|
||||||
|
default:
|
||||||
|
return WORK_POINTS.SHIP_WORK // SHIP_UPGRADE | SHIP_EXPAND | SHIP_BUILD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One trait's modifier for a lead on an action type: its per-type entry plus the 'ANY' entry.
|
||||||
|
// Traits without a map entry contribute 0.
|
||||||
|
function traitModifier(trait: string, type: TaskType): number {
|
||||||
|
const mods = TRAIT_DURATION_MODIFIERS[trait]
|
||||||
|
if (!mods) return 0
|
||||||
|
return (mods[type] ?? 0) + (mods['ANY'] ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4.
|
||||||
|
// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the
|
||||||
|
// constant 1 — queueTask's optional powerOf param feeds the skill band instead (PR B). The
|
||||||
|
// mismatch penalty and the trait modifiers apply to the LEAD only — helpers contribute
|
||||||
|
// power, are never penalised, and their traits do not apply. `hopCount` is only meaningful
|
||||||
|
// for JUMP (pass 1 for the other types).
|
||||||
|
export function computeDuration(
|
||||||
|
type: TaskType,
|
||||||
|
hopCount: number,
|
||||||
|
lead: CrewMember,
|
||||||
|
helpers: CrewMember[],
|
||||||
|
powerOf: (m: CrewMember) => number = () => 1,
|
||||||
|
): number {
|
||||||
|
const totalPower = Math.max(1, [lead, ...helpers].reduce((sum, m) => sum + powerOf(m), 0))
|
||||||
|
const mismatchPenalty = lead.traitGood === MISMATCH_CANCEL_TRAIT || PREFERRED_ROLES[type].includes(lead.role) ? 0 : 1
|
||||||
|
const traitModifierSum = traitModifier(lead.traitGood, type) + traitModifier(lead.traitBad, type)
|
||||||
|
return Math.max(1, Math.ceil(workPoints(type, hopCount) / totalPower) + mismatchPenalty + traitModifierSum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Queueing: validation + row construction (pure, no DB) -------------------------------
|
||||||
|
//
|
||||||
|
// queueTask runs over a plain snapshot of the save (QueueState) and decides whether a new
|
||||||
|
// task can be queued and what row it becomes. Pure like crew.ts / ship-costs.ts — the route
|
||||||
|
// (a later task) maps schema rows into QueueState, calls queueTask, and persists the result:
|
||||||
|
// insert the task row (adding `id` + `saveId` at the DB boundary) and the deducted hold.
|
||||||
|
|
||||||
|
// Carries an HTTP status so route handlers can map failures to responses without re-parsing
|
||||||
|
// (same pattern as JumpError / ShipError / ResearchError / ExploreError).
|
||||||
|
export class TaskError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queueing request — the existing endpoints' bodies plus the crew team. Payload field
|
||||||
|
// names follow the routes (the spec's names differ):
|
||||||
|
// JUMP { systemId, hops } — the route computes `hops` with jumpToSystem's logic
|
||||||
|
// (reachableWithin + NAV AI) and passes it in; the body field is `systemId`, not `toSystemId`
|
||||||
|
// EXPLORE { planetId }
|
||||||
|
// SHIP_UPGRADE { systemId } · SHIP_EXPAND { systemId } · SHIP_BUILD { type }
|
||||||
|
// RESEARCH { techId } — the tech row's id, not the slug
|
||||||
|
export interface QueueTaskRequest {
|
||||||
|
type: TaskType
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
leadCrewId: number
|
||||||
|
helperCrewIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain per-save snapshot the decision runs over — the route maps schema rows into these
|
||||||
|
// (same row↔domain boundary as the tasks table in save.ts).
|
||||||
|
export interface QueueState {
|
||||||
|
turn: number
|
||||||
|
crew: CrewDto[]
|
||||||
|
tasks: Task[]
|
||||||
|
inventory: InventoryItemDto[] // cargo hold, id order (oldest first)
|
||||||
|
shipSystems: { id: number; type: string; level: number; capacity: number }[] // UPGRADE/EXPAND costs scale with the target's current level/capacity
|
||||||
|
tech: { id: number; slug: string }[] // RESEARCH cost comes from the matching TECHS def, like research.ts
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new row minus the two values only the DB boundary knows: `id` (autoincrement) and
|
||||||
|
// `saveId` (filled in by save.ts's row↔Task mapping on insert).
|
||||||
|
export interface QueueResult {
|
||||||
|
task: Omit<Task, 'id' | 'saveId'>
|
||||||
|
inventory: InventoryItemDto[] // unchanged for JUMP/EXPLORE; deducted for SHIP_* / RESEARCH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all
|
||||||
|
// types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn.
|
||||||
|
// The optional powerOf (default: the constant 1) maps each team member to their power for
|
||||||
|
// the stored duration — the queueing routes pass makePowerOf over the roster's skill rows
|
||||||
|
// (PR B); callers without a skill context (tests, the immediate actions) use the default.
|
||||||
|
export function queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1): QueueResult {
|
||||||
|
const lead = state.crew.find((c) => c.id === req.leadCrewId)
|
||||||
|
if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`)
|
||||||
|
const helpers: CrewDto[] = []
|
||||||
|
for (const id of req.helperCrewIds) {
|
||||||
|
const helper = state.crew.find((c) => c.id === id)
|
||||||
|
if (!helper) throw new TaskError(404, `Unknown crew member ${id}`)
|
||||||
|
helpers.push(helper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busy (409). Spec §3: one ACTIVE task per crew member, and a lead may chain one PENDING
|
||||||
|
// task behind it (FIFO — auto-starts when the current one completes). So: any crew member
|
||||||
|
// committed to a PENDING task is blocked; a helper on an ACTIVE task is blocked; a lead on
|
||||||
|
// an ACTIVE task passes and the new task is queued PENDING behind it.
|
||||||
|
const inStatus = (id: number, status: TaskStatus) => state.tasks.some((t) => t.status === status && (t.leadCrewId === id || t.helpers.includes(id)))
|
||||||
|
const checkBusy = (c: CrewDto, isLead: boolean) => {
|
||||||
|
if (inStatus(c.id, 'PENDING')) throw new TaskError(409, `${c.name} is already committed to a pending task`)
|
||||||
|
if (!isLead && inStatus(c.id, 'ACTIVE')) throw new TaskError(409, `${c.name} is already busy`)
|
||||||
|
}
|
||||||
|
checkBusy(lead, true)
|
||||||
|
for (const helper of helpers) checkBusy(helper, false)
|
||||||
|
|
||||||
|
// In transit (409): while any JUMP is ACTIVE, no new tasks of any type (spec §3).
|
||||||
|
const jumps = state.tasks.filter((t) => t.type === 'JUMP')
|
||||||
|
if (jumps.some((t) => t.status === 'ACTIVE')) throw new TaskError(409, 'A jump is in transit — no new tasks can be queued')
|
||||||
|
// Only one JUMP at a time, active or pending (two arrivals could never resolve on one tick).
|
||||||
|
// The ACTIVE half is already caught above, so this fires on a pending jump.
|
||||||
|
if (req.type === 'JUMP' && jumps.some((t) => t.status !== 'DONE')) throw new TaskError(409, 'A jump is already queued')
|
||||||
|
|
||||||
|
// Costs deduct at queue time (spec §3) — the existing cost logic from ship-costs.ts /
|
||||||
|
// tech.ts and spendItems' 422. JUMP/EXPLORE have no resource cost. Target existence and
|
||||||
|
// the 409 gates (max level / capacity, not built, already built, build gate, tech prereqs)
|
||||||
|
// stay in the routes with their existing codes — the route resolves ids before calling.
|
||||||
|
let inventory = state.inventory
|
||||||
|
if (req.type === 'SHIP_UPGRADE' || req.type === 'SHIP_EXPAND') {
|
||||||
|
const system = state.shipSystems.find((s) => s.id === req.payload.systemId)!
|
||||||
|
inventory = deduct(inventory, 'resource', req.type === 'SHIP_UPGRADE' ? upgradeCost(system.level) : expandCost(system.capacity))
|
||||||
|
} else if (req.type === 'SHIP_BUILD') {
|
||||||
|
inventory = deduct(inventory, 'part', BUILD_PART_COST)
|
||||||
|
} else if (req.type === 'RESEARCH') {
|
||||||
|
const def = TECHS.find((t) => t.slug === state.tech.find((r) => r.id === req.payload.techId)?.slug)!
|
||||||
|
inventory = deduct(inventory, def.costKind, def.costQty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: duration from the Task-1 formula, status from what the team is doing right now.
|
||||||
|
// A crew member is "free" when on no ACTIVE/PENDING task; after the checks above only a
|
||||||
|
// lead chaining behind its own ACTIVE can be non-free.
|
||||||
|
const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING')
|
||||||
|
const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0
|
||||||
|
const duration = computeDuration(req.type, hops, lead, helpers, powerOf)
|
||||||
|
const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING'
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: {
|
||||||
|
type: req.type,
|
||||||
|
payload: JSON.stringify(req.payload),
|
||||||
|
leadCrewId: req.leadCrewId,
|
||||||
|
helpers: req.helperCrewIds,
|
||||||
|
duration,
|
||||||
|
remaining: duration,
|
||||||
|
status,
|
||||||
|
queuedAtTurn: state.turn, // queueing is free — the turn advances only on the tick
|
||||||
|
},
|
||||||
|
inventory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure version of spendItems' consumption (lib/game/ship.ts): the total is checked up front
|
||||||
|
// (shortfall → the same 422 spendItems throws), then the oldest rows first (array order = id
|
||||||
|
// order); rows that hit zero are dropped. The input array is never mutated.
|
||||||
|
function deduct(items: InventoryItemDto[], kind: 'resource' | 'part', amount: number): InventoryItemDto[] {
|
||||||
|
if (totalQty(items, kind) < amount) {
|
||||||
|
throw new TaskError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
|
||||||
|
}
|
||||||
|
let remaining = amount
|
||||||
|
const out: InventoryItemDto[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind !== kind) {
|
||||||
|
out.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const take = Math.min(item.qty, remaining)
|
||||||
|
remaining -= take
|
||||||
|
if (take < item.qty) out.push({ ...item, qty: item.qty - take })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) -------------------
|
||||||
|
//
|
||||||
|
// tickTurn is the whole §3 tick over the same plain QueueState queueTask consumes. It owns
|
||||||
|
// the turn increment and nothing else: it applies no effect and runs no mission logic —
|
||||||
|
// the save.ts orchestrator (runTurn) calls the 4a resolvers for the returned `completed`
|
||||||
|
// tasks, in the order returned.
|
||||||
|
export interface TickResult {
|
||||||
|
turn: number // state.turn + 1 — the tick IS the turn increment
|
||||||
|
tasks: Task[] // every task, updated: ACTIVE decremented, finished → DONE, started PENDING → ACTIVE
|
||||||
|
completed: Task[] // the tasks that hit 0 this tick, in resolution order
|
||||||
|
}
|
||||||
|
|
||||||
|
const byQueueOrder = (a: Task, b: Task) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id
|
||||||
|
|
||||||
|
export function tickTurn(state: QueueState): TickResult {
|
||||||
|
const turn = state.turn + 1
|
||||||
|
const tasks = state.tasks.map((t) => ({ ...t })) // never mutate the input (house style — deduct)
|
||||||
|
|
||||||
|
// (a) every ACTIVE task ages by one; PENDING/DONE rows do not age.
|
||||||
|
for (const t of tasks) if (t.status === 'ACTIVE') t.remaining -= 1
|
||||||
|
|
||||||
|
// (b) everything that hit 0 resolves this tick. Marking DONE frees the whole team
|
||||||
|
// (busyness is derived from the tasks table — there is no crew status column to update).
|
||||||
|
// <= 0, not === 0: a corrupt row at or below zero still completes on the next tick
|
||||||
|
// instead of dead-ending the queue (spec focus 4: no dead ends).
|
||||||
|
const completed = tasks
|
||||||
|
.filter((t) => t.status === 'ACTIVE' && t.remaining <= 0)
|
||||||
|
.sort(byQueueOrder)
|
||||||
|
for (const t of completed) t.status = 'DONE'
|
||||||
|
|
||||||
|
// (c) auto-start: ONE forward pass over PENDING in (queuedAtTurn ASC, id ASC). A task starts
|
||||||
|
// (PENDING → ACTIVE, remaining unchanged — it keeps its full duration; its first decrement
|
||||||
|
// lands on the next tick) when its FULL team (lead + all helpers) is free: no member on an
|
||||||
|
// ACTIVE task, and no member committed to an EARLIER (queuedAtTurn, id) PENDING that is still
|
||||||
|
// pending. A member on a LATER PENDING does not block — this task has first claim, and once
|
||||||
|
// started it holds that member via the ACTIVE clause, blocking the later PENDING in the same
|
||||||
|
// pass. Under queueTask's invariants (one PENDING per member) the PENDING clause only bites
|
||||||
|
// on admin-corrupted rows, where it keeps the pass deterministic: of a contested member's
|
||||||
|
// PENDINGs the earliest (queuedAtTurn, id) always gets first claim, so at most ONE of their
|
||||||
|
// PENDINGs starts per tick (the FIFO chain of spec §3), and no PENDING starves behind a peer.
|
||||||
|
// A start only makes its team busier — never frees anyone — so a task that cannot start at
|
||||||
|
// its position cannot start later in the same pass: one pass is complete.
|
||||||
|
for (const t of tasks.filter((t) => t.status === 'PENDING').sort(byQueueOrder)) {
|
||||||
|
const free = (id: number) =>
|
||||||
|
!tasks.some((o) => o.status === 'ACTIVE' && (o.leadCrewId === id || o.helpers.includes(id))) &&
|
||||||
|
!tasks.some((o) => o.status === 'PENDING' && o.id !== t.id && byQueueOrder(o, t) < 0 && (o.leadCrewId === id || o.helpers.includes(id)))
|
||||||
|
if ([t.leadCrewId, ...t.helpers].every(free)) t.status = 'ACTIVE'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { turn, tasks, completed }
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// 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})` },
|
||||||
|
// #35 — the researched state of this tech also extends the jump range to 2 hops (enforced in
|
||||||
|
// lib/game/save.ts, which keys off the NAV_AI slug); the description is what the SKILL TREE card shows.
|
||||||
|
NAV_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'NAV COMPUTER', bonus: 2, description: `Raises NAV COMPUTER max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2}) and enables multi-system jumps (up to 2 hops in one action)` },
|
||||||
|
WEAPONS_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'WEAPONS', bonus: 2, description: `Raises WEAPONS max level by 2 (once built)` },
|
||||||
|
REACTOR_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'REACTOR', bonus: 3, description: `Raises REACTOR max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Shared fixture suite for the game tests (save.test.ts, the 5a route tests, and the later
|
||||||
|
// ship/research route tests): one deterministic world — seed '42', save stamped at the
|
||||||
|
// current schema version so no backfill runs — plus the crew and task-row minters the
|
||||||
|
// queueing tests use. Ids line up across worlds built in fresh files (same insert order),
|
||||||
|
// so two fixture worlds are comparable row for row.
|
||||||
|
import type { GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { SCHEMA_VERSION } from './save'
|
||||||
|
|
||||||
|
// Fixed so a fixture's event rows carry a stable created_at (tests never assert on it, but
|
||||||
|
// a wall-clock Date would make two fixture worlds differ byte-for-byte).
|
||||||
|
export const T0 = '2026-10-01T00:00:00.000Z'
|
||||||
|
|
||||||
|
// Origin (s0, id 1, current) — Outpost (s1, one hop: the jump target) — Farpoint (s2, two
|
||||||
|
// hops: beyond one-hop range, so the unreachable pre-check has a target to 409 on; with
|
||||||
|
// NAVIGATION AI researched it would be reachable). p0 'Alder' in s0, p1 'Basalt' in s1.
|
||||||
|
// No crew, tasks, ship, or tech rows — the caller mints exactly what a test needs.
|
||||||
|
export async function buildWorldFixture(
|
||||||
|
db: GameDb,
|
||||||
|
outpostDiscovered = false,
|
||||||
|
): Promise<{ sys1: number; p0: number; p1: number; farSystem: number }> {
|
||||||
|
const [save] = await db.db
|
||||||
|
.insert(schema.saves)
|
||||||
|
.values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION })
|
||||||
|
.returning()
|
||||||
|
const [s0] = await db.db
|
||||||
|
.insert(schema.solarSystems)
|
||||||
|
.values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true })
|
||||||
|
.returning({ id: schema.solarSystems.id })
|
||||||
|
const [s1] = await db.db
|
||||||
|
.insert(schema.solarSystems)
|
||||||
|
.values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered })
|
||||||
|
.returning({ id: schema.solarSystems.id })
|
||||||
|
const [s2] = await db.db
|
||||||
|
.insert(schema.solarSystems)
|
||||||
|
.values({ saveId: save.id, name: 'Farpoint', x: 60, y: 50, discovered: false })
|
||||||
|
.returning({ id: schema.solarSystems.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s2.id })
|
||||||
|
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s2.id, toSystem: s1.id })
|
||||||
|
const [p0] = await db.db
|
||||||
|
.insert(schema.planets)
|
||||||
|
.values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false })
|
||||||
|
.returning({ id: schema.planets.id })
|
||||||
|
const [p1] = await db.db
|
||||||
|
.insert(schema.planets)
|
||||||
|
.values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false })
|
||||||
|
.returning({ id: schema.planets.id })
|
||||||
|
return { sys1: s1.id, p0: p0.id, p1: p1.id, farSystem: s2.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `count` identical crew members, in insertion order — the queueing tests need real crew
|
||||||
|
// ids to lead and help with.
|
||||||
|
export async function addCrew(db: GameDb, count: number): Promise<number[]> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const ids: number[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const [row] = await db.db
|
||||||
|
.insert(schema.crew)
|
||||||
|
.values({ saveId: save.id, name: `CREW ${i + 1}`, role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' })
|
||||||
|
.returning({ id: schema.crew.id })
|
||||||
|
ids.push(row.id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint one tasks-table row with a chosen state — the queueing/tick tests seed the queue
|
||||||
|
// directly instead of going through the routes (which would run the same validation).
|
||||||
|
export async function insertTaskRow(
|
||||||
|
db: GameDb,
|
||||||
|
over: { type: string; payload: object; leadCrewId: number; helpers: number[]; duration: number; remaining: number; status: string; queuedAtTurn: number },
|
||||||
|
): Promise<number> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db
|
||||||
|
.insert(schema.tasks)
|
||||||
|
.values({ saveId: save.id, type: over.type, payload: JSON.stringify(over.payload), leadCrewId: over.leadCrewId, helpers: JSON.stringify(over.helpers), duration: over.duration, remaining: over.remaining, status: over.status, queuedAtTurn: over.queuedAtTurn })
|
||||||
|
.returning({ id: schema.tasks.id })
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ATTACH_PROB, EXPAND_MAX, EXPAND_MIN, bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, ringEdges, rollExploration } from './world'
|
||||||
|
import type { WorldSystem } 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('ringEdges (#51 dedup)', () => {
|
||||||
|
it('n=0 is empty and n=1 is the single anchor↔member edge (the old wheel emitted a [1,1] self-loop here)', () => {
|
||||||
|
expect(ringEdges(0)).toEqual([])
|
||||||
|
expect(ringEdges(1)).toEqual([[0, 1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n=2 is the complete triangle (the old wheel emitted the [1,2] edge twice, as [1,2] and [2,1])', () => {
|
||||||
|
expect(ringEdges(2)).toEqual([[0, 1], [0, 2], [1, 2]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n>=3 stays the simple wheel, byte-identical to the legacy output', () => {
|
||||||
|
expect(ringEdges(3)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 1]])
|
||||||
|
expect(ringEdges(4)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 4], [0, 4], [4, 1]])
|
||||||
|
expect(ringEdges(6)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 4], [0, 4], [4, 5], [0, 5], [5, 6], [0, 6], [6, 1]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateRing (expansion)', () => {
|
||||||
|
// A scripted rng: returns the given values in order, then a constant fallback for the rest
|
||||||
|
// (placeRing's position jitter/radius draws) so a test can pin the leg-drawing phase exactly.
|
||||||
|
function scriptedRng(script: number[], fallback = 0.5): () => number {
|
||||||
|
let i = 0
|
||||||
|
return () => (i < script.length ? script[i++] : fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('#51: mints a seeded-random 1–4 new systems, and all four widths occur across seeds', () => {
|
||||||
|
const counts = new Set<number>()
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
|
||||||
|
expect(ring.systems.length).toBeGreaterThanOrEqual(EXPAND_MIN)
|
||||||
|
expect(ring.systems.length).toBeLessThanOrEqual(EXPAND_MAX)
|
||||||
|
counts.add(ring.systems.length)
|
||||||
|
}
|
||||||
|
expect(counts).toEqual(new Set([EXPAND_MIN, EXPAND_MIN + 1, EXPAND_MIN + 2, EXPAND_MAX]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with no attach candidates every leg mints, so systems.length is the leg count and attaches is empty', () => {
|
||||||
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
|
||||||
|
expect(ring.attaches).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mints are spaced away from the anchor and existing positions and never reuse a taken name', () => {
|
||||||
|
const takenNames = new Set(['Kepler Drift'])
|
||||||
|
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
|
||||||
|
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
|
||||||
|
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(ringNames.length) // and makeName claimed them into the set
|
||||||
|
for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edges are exactly the wheel for the mint count, indexing into [anchor, ...systems]', () => {
|
||||||
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
|
||||||
|
const n = ring.systems.length
|
||||||
|
expect(ring.edges).toEqual(ringEdges(n)) // anchor↔each member + the member cycle
|
||||||
|
for (const [a, b] of ring.edges) {
|
||||||
|
expect(a).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(a).toBeLessThan(n + 1)
|
||||||
|
expect(b).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(b).toBeLessThan(n + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches existing systems to close loops, but never one already routed to the anchor', () => {
|
||||||
|
const candidates: (WorldSystem & { id: number })[] = [
|
||||||
|
{ id: 100, name: 'A', x: 10, y: 10 },
|
||||||
|
{ id: 200, name: 'B', x: 20, y: 20 },
|
||||||
|
{ id: 300, name: 'C', x: 30, y: 30 },
|
||||||
|
]
|
||||||
|
// The anchor (id 7) is already routed to candidate 200 — that one can never be attached.
|
||||||
|
const existing = new Set(['7-200'])
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [], candidates, existing, 7)
|
||||||
|
for (const ci of ring.attaches) {
|
||||||
|
expect(ci).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(ci).toBeLessThan(candidates.length)
|
||||||
|
expect(candidates[ci].id).not.toBe(200) // the already-routed candidate is never re-routed
|
||||||
|
}
|
||||||
|
expect(new Set(ring.attaches).size).toBe(ring.attaches.length) // attaches are unique
|
||||||
|
expect(ring.systems.length).toBeGreaterThanOrEqual(EXPAND_MIN) // the frontier still grows
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('frontier guarantee: a scripted all-attach run still mints exactly one system', () => {
|
||||||
|
// Force legCount=4 (first draw 0.8) and all four legs to attach to four DISTINCT candidates
|
||||||
|
// (attach-check 0.1 < ATTACH_PROB, ci draws 0.1/0.3/0.6/0.9 → candidates 0/1/2/3). Without the
|
||||||
|
// guarantee this would mint zero systems and strand the chart; with it, one attach becomes a mint.
|
||||||
|
const candidates: (WorldSystem & { id: number })[] = [
|
||||||
|
{ id: 1, name: 'A', x: 10, y: 10 },
|
||||||
|
{ id: 2, name: 'B', x: 20, y: 20 },
|
||||||
|
{ id: 3, name: 'C', x: 30, y: 30 },
|
||||||
|
{ id: 4, name: 'D', x: 40, y: 40 },
|
||||||
|
]
|
||||||
|
const ring = generateRing(scriptedRng([0.8, 0.1, 0.1, 0.1, 0.3, 0.1, 0.6, 0.1, 0.9]), 50, 50, new Set(), [], candidates, new Set(), 99)
|
||||||
|
expect(ring.systems.length).toBe(1) // one attach converted into a mint
|
||||||
|
expect(ring.attaches).toEqual([0, 1, 2]) // the last-drawn attach (index 3) became the mint
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic per seed and differs across seeds (candidates + existing edges included)', () => {
|
||||||
|
const candidates: (WorldSystem & { id: number })[] = [
|
||||||
|
{ id: 100, name: 'A', x: 10, y: 10 },
|
||||||
|
{ id: 200, name: 'B', x: 20, y: 20 },
|
||||||
|
]
|
||||||
|
const existing = new Set(['7-100'])
|
||||||
|
const positions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
|
||||||
|
const a = generateRing(mulberry32(11), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
|
||||||
|
const b = generateRing(mulberry32(11), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
|
||||||
|
expect(b).toEqual(a) // same seed + inputs → identical region
|
||||||
|
const c = generateRing(mulberry32(12), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
|
||||||
|
expect(JSON.stringify(c)).not.toBe(JSON.stringify(a))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makeName 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makeName terminates with numbered variants once all 128 base names are taken', () => {
|
||||||
|
const rng = mulberry32(9)
|
||||||
|
const taken = new Set<string>()
|
||||||
|
for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool
|
||||||
|
expect(taken.size).toBe(128)
|
||||||
|
|
||||||
|
const a = makeName(rng, taken) // must return instead of spinning forever
|
||||||
|
const b = makeName(rng, taken)
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
expect(taken.has(a)).toBe(true)
|
||||||
|
expect(taken.has(b)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generatePlanets', () => {
|
||||||
|
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', '--']))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reachableWithin (#35 jump range)', () => {
|
||||||
|
// Chain 1—2—3—4 with a branch off 2: 2—5. Distances from 1: 2→1, 3/5→2, 4→3.
|
||||||
|
const edges: [number, number][] = [[1, 2], [2, 3], [3, 4], [2, 5]]
|
||||||
|
|
||||||
|
it('maxHops=1 returns only direct neighbours at distance 1 (the pre-NAV-AI range)', () => {
|
||||||
|
expect(reachableWithin(edges, 1, 1)).toEqual(new Map([[2, 1]]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maxHops=2 adds the two-hop systems and stops before three', () => {
|
||||||
|
expect(reachableWithin(edges, 1, 2)).toEqual(new Map([ [2, 1], [3, 2], [5, 2] ]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('larger maxHops keeps walking until the graph is exhausted', () => {
|
||||||
|
expect(reachableWithin(edges, 1, 5)).toEqual(new Map([ [2, 1], [3, 2], [5, 2], [4, 3] ]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never includes the origin and handles empty/isolated input', () => {
|
||||||
|
expect(reachableWithin(edges, 1, 3).has(1)).toBe(false)
|
||||||
|
expect(reachableWithin([], 1, 2)).toEqual(new Map())
|
||||||
|
expect(reachableWithin([[9, 10]], 1, 2)).toEqual(new Map())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts double-directed route rows (the save.ts input shape) identically', () => {
|
||||||
|
const directed: [number, number][] = [[1, 2], [2, 1], [2, 3], [3, 2]]
|
||||||
|
expect(reachableWithin(directed, 1, 2)).toEqual(new Map([ [2, 1], [3, 2] ]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('on a fresh start region every system is one hop away — two-hop targets only exist after an expansion', () => {
|
||||||
|
const region = generateStartRegion(mulberry32(42)) // edges index into [start, ...neighbors], start = 0
|
||||||
|
expect(reachableWithin(region.edges, 0, 1).size).toBe(region.neighbors.length)
|
||||||
|
expect(reachableWithin(region.edges, 0, 2).size).toBe(region.neighbors.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
// 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; seeds the force-directed WORLD MAP layout
|
||||||
|
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
|
||||||
|
// #51 — the spread limit: jumping into an undiscovered system mints a seeded-random number of
|
||||||
|
// new systems in [EXPAND_MIN, EXPAND_MAX]. Each "leg" can instead attach an already-known
|
||||||
|
// system (closing a loop) with probability ATTACH_PROB, so the chart grows slower and more
|
||||||
|
// circular. EXPAND_RING (the old fixed count) is replaced by this range.
|
||||||
|
export const EXPAND_MIN = 1
|
||||||
|
export const EXPAND_MAX = 4
|
||||||
|
export const ATTACH_PROB = 0.35
|
||||||
|
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. The base
|
||||||
|
// pool is finite (16×8=128), so once every base is taken the loop would spin forever — which
|
||||||
|
// froze the whole server on a save that had grown past 128 systems (synchronous sql.js runs on
|
||||||
|
// Node's single thread). Numbered variants keep names unique and terminate.
|
||||||
|
export function makeName(rng: () => number, taken: Set<string>): string {
|
||||||
|
for (;;) {
|
||||||
|
const base = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
||||||
|
if (!taken.has(base)) {
|
||||||
|
taken.add(base)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if (taken.size >= FIRST.length * SECOND.length) {
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const name = `${base} ${n}`
|
||||||
|
if (!taken.has(name)) {
|
||||||
|
taken.add(name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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].
|
||||||
|
// #51 — small-n special cases: n=0 is empty, n=1 is the single spoke, n=2 is the complete
|
||||||
|
// triangle (the old wheel formula produced a self-loop at n=1 and a duplicate edge at n=2).
|
||||||
|
// n≥3 is the unchanged wheel.
|
||||||
|
export function ringEdges(n: number): [number, number][] {
|
||||||
|
if (n === 0) return []
|
||||||
|
if (n === 1) return [[0, 1]]
|
||||||
|
if (n === 2) return [[0, 1], [0, 2], [1, 2]]
|
||||||
|
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
|
||||||
|
attaches: number[] // indices into the attachCandidates array that were attached (loop-closed) rather than minted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
|
||||||
|
// neighbours around it. #51 — the ring width is a seeded-random 1–4 legs; each leg may
|
||||||
|
// instead attach an already-known system (closing a loop) with probability ATTACH_PROB.
|
||||||
|
// `takenNames`/`takenPositions` are every system already in the save. `attachCandidates`
|
||||||
|
// are the systems eligible for loop-closing (all save systems except the anchor).
|
||||||
|
// `existingEdgeKeys` is the set of `${min(a,b)}-${max(a,b)}` keys for routes already
|
||||||
|
// touching the anchor, so an attach never duplicates one. `anchorId` is the DB id of the
|
||||||
|
// anchor system (used to build edge keys).
|
||||||
|
export function generateRing(
|
||||||
|
rng: () => number,
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
takenNames: Set<string>,
|
||||||
|
takenPositions: WorldSystem[],
|
||||||
|
attachCandidates: (WorldSystem & { id: number })[] = [],
|
||||||
|
existingEdgeKeys: Set<string> = new Set(),
|
||||||
|
anchorId: number = 0,
|
||||||
|
): RingRegion {
|
||||||
|
const legCount = EXPAND_MIN + Math.floor(rng() * (EXPAND_MAX - EXPAND_MIN + 1))
|
||||||
|
|
||||||
|
let mints = 0
|
||||||
|
const attachSet = new Set<number>()
|
||||||
|
let lastAttachCi = -1
|
||||||
|
|
||||||
|
for (let leg = 0; leg < legCount; leg++) {
|
||||||
|
let isAttach = false
|
||||||
|
if (attachCandidates.length > 0 && rng() < ATTACH_PROB) {
|
||||||
|
const ci = Math.floor(rng() * attachCandidates.length)
|
||||||
|
const a = Math.min(anchorId, attachCandidates[ci]!.id)
|
||||||
|
const b = Math.max(anchorId, attachCandidates[ci]!.id)
|
||||||
|
if (!existingEdgeKeys.has(`${a}-${b}`)) {
|
||||||
|
attachSet.add(ci)
|
||||||
|
lastAttachCi = ci
|
||||||
|
isAttach = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isAttach) mints++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontier guarantee: at least one new system must be minted so the chart always grows.
|
||||||
|
if (mints === 0 && lastAttachCi >= 0) {
|
||||||
|
attachSet.delete(lastAttachCi)
|
||||||
|
mints = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const systems = placeRing(rng, anchorX, anchorY, mints, takenNames, takenPositions)
|
||||||
|
return { systems, edges: ringEdges(mints), attaches: [...attachSet] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count.
|
||||||
|
// `edges` may be undirected pairs (WorldState.edges) or double-directed route rows — duplicates are
|
||||||
|
// harmless because a visited system is never re-queued. fromId itself is not in the result.
|
||||||
|
export function reachableWithin(edges: [number, number][], fromId: number, maxHops: number): Map<number, number> {
|
||||||
|
const adj = new Map<number, number[]>()
|
||||||
|
for (const [a, b] of edges) {
|
||||||
|
if (!adj.has(a)) adj.set(a, [])
|
||||||
|
if (!adj.has(b)) adj.set(b, [])
|
||||||
|
adj.get(a)!.push(b)
|
||||||
|
adj.get(b)!.push(a)
|
||||||
|
}
|
||||||
|
const dist = new Map<number, number>()
|
||||||
|
const seen = new Set([fromId]) // the origin is never re-queued or reported
|
||||||
|
let frontier = [fromId]
|
||||||
|
for (let hop = 1; hop <= maxHops; hop++) {
|
||||||
|
const next: number[] = []
|
||||||
|
for (const id of frontier) {
|
||||||
|
for (const nb of adj.get(id) ?? []) {
|
||||||
|
if (!seen.has(nb)) {
|
||||||
|
seen.add(nb)
|
||||||
|
dist.set(nb, hop)
|
||||||
|
next.push(nb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next
|
||||||
|
}
|
||||||
|
return dist
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bodies (planets + moons) and exploration -------------------------------
|
||||||
|
|
||||||
|
export interface WorldMoon {
|
||||||
|
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
|
||||||
|
}
|
||||||
+213
-2
@@ -3,8 +3,219 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { openApiSpec } from './openapi';
|
import { openApiSpec } from './openapi';
|
||||||
|
|
||||||
describe('OpenAPI spec', () => {
|
describe('OpenAPI spec', () => {
|
||||||
it('documents exactly the one public endpoint (scaffold stage)', () => {
|
it('documents every public endpoint', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/version']);
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||||
|
'/api/admin/tables',
|
||||||
|
'/api/admin/tables/{name}/rows',
|
||||||
|
'/api/crew',
|
||||||
|
'/api/events',
|
||||||
|
'/api/explore',
|
||||||
|
'/api/jump',
|
||||||
|
'/api/missions',
|
||||||
|
'/api/missions/{id}/accept',
|
||||||
|
'/api/research',
|
||||||
|
'/api/ship',
|
||||||
|
'/api/ship/build',
|
||||||
|
'/api/ship/expand',
|
||||||
|
'/api/ship/upgrade',
|
||||||
|
'/api/system',
|
||||||
|
'/api/tech',
|
||||||
|
'/api/turn',
|
||||||
|
'/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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queued-ship-work requests: the action target plus the crew team (lead required,
|
||||||
|
// helpers optional) — the same team fields as JumpRequest / ExploreRequest.
|
||||||
|
const upgradeReq = openApiSpec.components.schemas.UpgradeRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(upgradeReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(upgradeReq.required).toEqual(['systemId', 'leadCrewId']);
|
||||||
|
expect((upgradeReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
|
||||||
|
const expandReq = openApiSpec.components.schemas.ExpandRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(expandReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(expandReq.required).toEqual(['systemId', 'leadCrewId']);
|
||||||
|
expect((expandReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
|
||||||
|
const buildReq = openApiSpec.components.schemas.BuildRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(buildReq.properties)).toEqual(['type', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(buildReq.required).toEqual(['type', 'leadCrewId']);
|
||||||
|
expect((buildReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queued-research request: the tech target plus the crew team (lead required, helpers optional).
|
||||||
|
const researchReq = openApiSpec.components.schemas.ResearchRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(researchReq.properties)).toEqual(['techId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(researchReq.required).toEqual(['techId', 'leadCrewId']);
|
||||||
|
expect((researchReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
});
|
||||||
|
|
||||||
|
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', 'level_up']);
|
||||||
|
|
||||||
|
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 the CrewState { crew, tasks } object and crewFound on ExploreResult', () => {
|
||||||
|
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
||||||
|
expect(crew.$ref).toBe('#/components/schemas/CrewState');
|
||||||
|
|
||||||
|
const crewState = openApiSpec.components.schemas.CrewState as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(crewState.properties)).toEqual(['crew', 'tasks']);
|
||||||
|
expect(crewState.required).toEqual(['crew', 'tasks']);
|
||||||
|
expect((crewState.properties.crew as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/CrewMember');
|
||||||
|
expect((crewState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto');
|
||||||
|
|
||||||
|
const taskDto = openApiSpec.components.schemas.TaskDto as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect((taskDto.properties.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']);
|
||||||
|
expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']);
|
||||||
|
expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
|
||||||
|
// R-P1: the response object is camelCase (traitGood/traitBad), and since the skill-
|
||||||
|
// progression work the member also carries its four fixed-order skill levels + title.
|
||||||
|
const member = openApiSpec.components.schemas.CrewMember as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(member.properties)).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']);
|
||||||
|
expect(member.required).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']);
|
||||||
|
expect((member.properties.skills as { items: { required: string[] } }).items.required).toEqual(['skill', 'level']);
|
||||||
|
expect((member.properties.title as { nullable: boolean }).nullable).toBe(true);
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// The snapshot carries the active + pending task queue.
|
||||||
|
const systemState = openApiSpec.components.schemas.SystemState as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(systemState.required).toEqual(['systemId', 'systemName', 'planets', 'inventory', 'turn', 'tasks']);
|
||||||
|
expect((systemState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto');
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queued-scan request: the scan target plus the crew team (lead required, helpers optional).
|
||||||
|
const exploreReq = openApiSpec.components.schemas.ExploreRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(exploreReq.properties)).toEqual(['planetId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(exploreReq.required).toEqual(['planetId', 'leadCrewId']);
|
||||||
|
expect((exploreReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// The snapshot carries the active + pending task queue.
|
||||||
|
const worldState = openApiSpec.components.schemas.WorldState as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(worldState.required).toEqual(['systems', 'edges', 'turn', 'tasks']);
|
||||||
|
expect((worldState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto');
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queued-jump request: the jump target plus the crew team (lead required, helpers optional).
|
||||||
|
const jumpReq = openApiSpec.components.schemas.JumpRequest as { properties: Record<string, unknown>; required: string[] };
|
||||||
|
expect(Object.keys(jumpReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']);
|
||||||
|
expect(jumpReq.required).toEqual(['systemId', 'leadCrewId']);
|
||||||
|
expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the turn endpoint: POST /api/turn with no body → WorldState + tasks, 500 on internal error', () => {
|
||||||
|
const post = openApiSpec.paths['/api/turn'].post;
|
||||||
|
expect(post).not.toHaveProperty('requestBody');
|
||||||
|
expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/TurnResult');
|
||||||
|
expect(post.responses[500].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
|
|
||||||
|
// TurnResult extends WorldState with the task queue after the tick.
|
||||||
|
const turnResult = openApiSpec.components.schemas.TurnResult as { allOf: unknown[] };
|
||||||
|
expect((turnResult.allOf[0] as { $ref: string }).$ref).toBe('#/components/schemas/WorldState');
|
||||||
|
const extra = (turnResult.allOf[1] as { properties: Record<string, unknown> }).properties;
|
||||||
|
expect(Object.keys(extra)).toEqual(['tasks']);
|
||||||
|
|
||||||
|
const props = openApiSpec.components.schemas.Task.properties;
|
||||||
|
expect(Object.keys(props)).toEqual(['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn']);
|
||||||
|
expect((props.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']);
|
||||||
|
expect((props.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING', 'DONE']);
|
||||||
|
expect((props.helpers as { items: { type: string } }).items.type).toBe('integer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps info.version in sync with package.json', () => {
|
it('keeps info.version in sync with package.json', () => {
|
||||||
|
|||||||
+1215
-1
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.2.0",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
@@ -25,11 +28,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.2.0",
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
"tailwindcss": "^4.2.0",
|
"@types/d3-force": "^3.0.10",
|
||||||
|
"@types/d3-selection": "^3.0.12",
|
||||||
|
"@types/d3-zoom": "^3.0.9",
|
||||||
"@types/node": "^22.20.3",
|
"@types/node": "^22.20.3",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/sql.js": "^1.4.11",
|
"@types/sql.js": "^1.4.11",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user