Compare commits
35
Commits
skill-tree
...
v0.9.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,5 +1,62 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { ensureSave } from '@/lib/game/save'
|
||||||
|
import { EventError, getEvents, parseEventLimit } from '@/lib/game/events'
|
||||||
|
|
||||||
|
// The latest game events, newest first; auto-initializes on first hit.
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await ensureSave(db) // getEvents assumes a save exists
|
||||||
|
|
||||||
|
let limit: number
|
||||||
|
try {
|
||||||
|
limit = parseEventLimit(new URL(req.url).searchParams.get('limit'))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof EventError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(await getEvents(db, limit))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { ensureSave } from '@/lib/game/save'
|
||||||
|
import { acceptMission, MissionError } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// Accept an AVAILABLE mission — it becomes the single active one. Costs a turn; returns the fresh board.
|
||||||
|
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await ensureSave(db) // acceptMission assumes a save exists
|
||||||
|
|
||||||
|
const { id } = await ctx.params
|
||||||
|
const missionId = Number(id)
|
||||||
|
if (!Number.isInteger(missionId)) {
|
||||||
|
return Response.json({ error: 'mission id must be an integer' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Response.json(await acceptMission(db, missionId))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MissionError) {
|
||||||
|
return Response.json({ error: err.message }, { status: err.status })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getGameDb } from '@/lib/db/client'
|
||||||
|
import { ensureSave } from '@/lib/game/save'
|
||||||
|
import { getMissions } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// The full mission board for the single active save; auto-initializes on first hit.
|
||||||
|
export async function GET() {
|
||||||
|
const db = await getGameDb()
|
||||||
|
await ensureSave(db) // top-up inside getMissions assumes a save exists
|
||||||
|
return Response.json(await getMissions(db))
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||||
|
import type { EventDto } from '@/lib/game/events'
|
||||||
|
|
||||||
|
// EVENT LOG tab — live data. Fetches /api/events on mount (Radix Tabs unmounts inactive tabs, so it
|
||||||
|
// refetches whenever the tab is opened). One terminal line per event: T<turn> // <MESSAGE>, newest first.
|
||||||
|
|
||||||
|
export function EventLogPanel() {
|
||||||
|
const [lines, setLines] = useState<TerminalLine[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/events')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as EventDto[]
|
||||||
|
})
|
||||||
|
.then((events) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLines(
|
||||||
|
events.map((e) => ({
|
||||||
|
type: e.type === 'new_game' ? 'system' : 'output',
|
||||||
|
text: `T${e.turn} // ${e.message.toUpperCase()}`,
|
||||||
|
timestamp: new Date(e.createdAt).toLocaleTimeString('en-GB', { hour12: false }),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lines) {
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING EVENT LOG…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Terminal lines={lines} title="SHIP LOG" height="22rem" style={{ width: '100%' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
+37
-86
@@ -1,69 +1,50 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { ExploreResult, SystemState } from '@/lib/game/save'
|
import type { ExploreResult, SystemState } from '@/lib/game/save'
|
||||||
import type { FoundItem } from '@/lib/game/world'
|
import type { FoundItem } from '@/lib/game/world'
|
||||||
|
|
||||||
// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an
|
// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an
|
||||||
// EXPLORE button per unexplored row. Fetches /api/system on mount; explore responses are
|
// EXPLORE button per unexplored row. The parent (MissionControl) owns the /api/system state so
|
||||||
// applied directly (no polling). Turn-based: one scan costs a turn, each body once.
|
// the right column's CARGO HOLD mirrors scans immediately; this panel reports each scan's
|
||||||
|
// response up via onChanged and keeps only its own UI bits locally (no polling). Turn-based:
|
||||||
|
// one scan costs a turn, each body once.
|
||||||
|
|
||||||
export function LocalSystemPanel() {
|
interface LocalSystemPanelProps {
|
||||||
const [state, setState] = useState<SystemState | null>(null)
|
system: SystemState | null
|
||||||
|
onChanged: (next: SystemState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) {
|
||||||
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
|
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [exploringId, setExploringId] = useState<number | null>(null)
|
const [exploringId, setExploringId] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const explore = useCallback(
|
||||||
let cancelled = false
|
async (planetId: number) => {
|
||||||
fetch('/api/system')
|
setExploringId(planetId)
|
||||||
.then(async (res) => {
|
try {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
const res = await fetch('/api/explore', {
|
||||||
return (await res.json()) as SystemState
|
method: 'POST',
|
||||||
})
|
headers: { 'content-type': 'application/json' },
|
||||||
.then((s) => {
|
body: JSON.stringify({ planetId }),
|
||||||
if (!cancelled) setState(s)
|
})
|
||||||
})
|
const body = (await res.json()) as ExploreResult & { error?: string }
|
||||||
.catch((err: unknown) => {
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
onChanged(body) // fresh state straight from the response — parent mirrors it to CARGO HOLD
|
||||||
})
|
setLastOutcome(body.outcome)
|
||||||
return () => {
|
} catch (err: unknown) {
|
||||||
cancelled = true
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
}
|
} finally {
|
||||||
}, [])
|
setExploringId(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChanged]
|
||||||
|
)
|
||||||
|
|
||||||
const explore = useCallback(async (planetId: number) => {
|
if (!system) {
|
||||||
setExploringId(planetId)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/explore', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ planetId }),
|
|
||||||
})
|
|
||||||
const body = (await res.json()) as ExploreResult & { error?: string }
|
|
||||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
|
||||||
setState(body) // fresh state straight from the response — no refetch needed
|
|
||||||
setLastOutcome(body.outcome)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
|
||||||
} finally {
|
|
||||||
setExploringId(null)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (error && !state) {
|
|
||||||
return (
|
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
|
||||||
SYSTEM SCAN OFFLINE // {error.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING SYSTEM…</span>
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING SYSTEM…</span>
|
||||||
@@ -88,9 +69,9 @@ export function LocalSystemPanel() {
|
|||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
EXPLORABLE WORLDS // {state.systemName.toUpperCase()} — TURN {state.turn}
|
EXPLORABLE WORLDS // {system.systemName.toUpperCase()} — TURN {system.turn}
|
||||||
</div>
|
</div>
|
||||||
{state.planets.map((row, i) => {
|
{system.planets.map((row, i) => {
|
||||||
const depth = row.parent !== null ? 1 : 0
|
const depth = row.parent !== null ? 1 : 0
|
||||||
const badge = badgeFor(row.explored, row.hazard)
|
const badge = badgeFor(row.explored, row.hazard)
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +83,7 @@ export function LocalSystemPanel() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
padding: depth ? '0.6rem 0.75rem 0.6rem 1.75rem' : '0.6rem 0.75rem',
|
padding: depth ? '0.6rem 0.75rem 0.6rem 1.75rem' : '0.6rem 0.75rem',
|
||||||
borderBottom: i < state.planets.length - 1 ? '1px solid var(--border)' : 'none',
|
borderBottom: i < system.planets.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ overflow: 'hidden' }}>
|
<div style={{ overflow: 'hidden' }}>
|
||||||
@@ -117,7 +98,7 @@ export function LocalSystemPanel() {
|
|||||||
{row.name}
|
{row.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.57rem', color: 'var(--text-muted)', letterSpacing: '0.06em', marginTop: '2px' }}>
|
<div style={{ fontSize: '0.57rem', color: 'var(--text-muted)', letterSpacing: '0.06em', marginTop: '2px' }}>
|
||||||
{depth ? `MOON OF ${state.planets.find((p) => p.id === row.parent)?.name ?? '?'} // ${row.type}` : row.type}
|
{depth ? `MOON OF ${system.planets.find((p) => p.id === row.parent)?.name ?? '?'} // ${row.type}` : row.type}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
@@ -147,36 +128,6 @@ export function LocalSystemPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '0.4rem 0.75rem',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
background: 'var(--surface-raised)',
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
CARGO HOLD // INVENTORY
|
|
||||||
</div>
|
|
||||||
{state.inventory.length === 0 ? (
|
|
||||||
<div style={{ padding: '0.6rem 0.75rem' }}>
|
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY — EXPLORE WORLDS TO FIND THINGS</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
state.inventory.map((item) => (
|
|
||||||
<div key={`${item.kind}-${item.name}`} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0.75rem' }}>
|
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
|
||||||
{item.name} <span style={{ color: 'var(--text-muted)' }}>// {item.kind}</span>
|
|
||||||
</span>
|
|
||||||
<Badge variant="ACTIVE">×{item.qty}</Badge>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>SCAN FAILED // {error.toUpperCase()}</span>
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>SCAN FAILED // {error.toUpperCase()}</span>
|
||||||
|
|||||||
+127
-213
@@ -1,29 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { StatCard } from '@/components/ui/stat-card'
|
import { StatCard } from '@/components/ui/stat-card'
|
||||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
|
||||||
import { CrewPanel } from '@/components/crew-panel'
|
import { CrewPanel } from '@/components/crew-panel'
|
||||||
|
import { EventLogPanel } from '@/components/event-log'
|
||||||
import { LocalSystemPanel } from '@/components/local-system'
|
import { LocalSystemPanel } from '@/components/local-system'
|
||||||
|
import { MissionPanel } from '@/components/missions'
|
||||||
import { ShipSystemsPanel } from '@/components/ship-systems'
|
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||||
import { SkillTreePanel } from '@/components/skill-tree'
|
import { SkillTreePanel } from '@/components/skill-tree'
|
||||||
import { WorldMapPanel } from '@/components/world-map'
|
import { WorldMapPanel } from '@/components/world-map'
|
||||||
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
||||||
import type { CrewDto } from '@/lib/game/save'
|
import type { CrewDto, SystemState } from '@/lib/game/save'
|
||||||
import type { ShipState } from '@/lib/game/ship'
|
import type { ShipState } from '@/lib/game/ship'
|
||||||
import type { TechDto } from '@/lib/game/tech'
|
import type { TechDto } from '@/lib/game/tech'
|
||||||
|
import pkg from '@/package.json'
|
||||||
|
|
||||||
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
||||||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven
|
||||||
// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS and SKILL TREE tabs render live data (/api/world,
|
// tabs render live data (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions,
|
||||||
// /api/system, /api/crew, /api/ship, /api/tech); the other tabs keep placeholder content until
|
// /api/events); the right column's CARGO HOLD renders from the lifted /api/system state. Single
|
||||||
// their own issues land. Single player, no auth, by design.
|
// player, no auth, by design.
|
||||||
|
|
||||||
function useNarrow(threshold = 900): boolean {
|
function useNarrow(threshold = 900): boolean {
|
||||||
const [narrow, setNarrow] = useState(
|
const [narrow, setNarrow] = useState(
|
||||||
@@ -37,46 +38,25 @@ function useNarrow(threshold = 900): boolean {
|
|||||||
return narrow
|
return narrow
|
||||||
}
|
}
|
||||||
|
|
||||||
function now(): string {
|
// Must match the TabsTrigger values in the render below — used to validate the tab persisted for
|
||||||
return new Date().toLocaleTimeString('en-GB', { hour12: false })
|
// #27 (an unknown stored value falls back to the default).
|
||||||
}
|
const TAB_IDS = ['world-map', 'local-system', 'crew', 'ship-systems', 'skill-tree', 'missions', 'event-log']
|
||||||
|
const ACTIVE_TAB_KEY = 'sw-active-tab'
|
||||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
|
||||||
|
|
||||||
const encounters = [
|
|
||||||
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
|
||||||
{ name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
|
|
||||||
{ name: 'ANOMALY FIELD', when: 'T-48:12', id: '#E-104' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const shipData = [
|
|
||||||
{ label: 'TOTAL MODULES', value: '34' },
|
|
||||||
{ label: 'HULL INTEGRITY', value: '87%' },
|
|
||||||
{ label: 'REACTOR OUTPUT', value: '94.2%' },
|
|
||||||
{ label: 'CREW ACTIVE', value: '3 / 6' },
|
|
||||||
{ label: 'SECTOR', value: '7G // DRIFT LANE' },
|
|
||||||
{ label: 'DRIFT VELOCITY', value: '0.4 KM/S' },
|
|
||||||
{ label: 'TURN', value: '12' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const logLines: TerminalLine[] = [
|
|
||||||
{ type: 'system', text: 'SPACEWANDERER // INITIALIZING SHIP SYSTEMS...', timestamp: now() },
|
|
||||||
{ type: 'output', text: 'HULL SCAN COMPLETE — INTEGRITY 87%', timestamp: now() },
|
|
||||||
{ type: 'output', text: 'REACTOR OUTPUT STABLE AT 94.2%', timestamp: now() },
|
|
||||||
{ type: 'output', text: 'CREW MANIFEST LOADED — 3 ACTIVE PERSONNEL', timestamp: now() },
|
|
||||||
{ type: 'warn', text: 'WEAPONS OFFLINE — MODULE NOT INSTALLED', timestamp: now() },
|
|
||||||
{ type: 'system', text: 'ALL SYSTEMS NOMINAL. AWAITING FIRST TURN.', timestamp: now() },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function MissionControl() {
|
export function MissionControl() {
|
||||||
const narrow = useNarrow()
|
const narrow = useNarrow()
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
// Ship state is owned here (not in the tab panel) so the left column mirrors ship actions
|
// Ship + system state is owned here (not in the tab panels): the SHIP SYSTEMS tab mirrors ship
|
||||||
// immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count
|
// actions via setShip, and the right column's CARGO HOLD renders from the lifted inventory —
|
||||||
// feeds the roster bar — refetched on page load, like the other panels.
|
// the LOCAL SYSTEM tab reports each scan's response up via setSystem. Crew count feeds the
|
||||||
|
// roster bar — refetched on page load, like the other panels.
|
||||||
const [ship, setShip] = useState<ShipState | null>(null)
|
const [ship, setShip] = useState<ShipState | null>(null)
|
||||||
|
const [system, setSystem] = useState<SystemState | null>(null)
|
||||||
const [techs, setTechs] = useState<TechDto[] | null>(null)
|
const [techs, setTechs] = useState<TechDto[] | null>(null)
|
||||||
const [crewCount, setCrewCount] = useState<number | null>(null)
|
const [crewCount, setCrewCount] = useState<number | null>(null)
|
||||||
|
// #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until
|
||||||
|
// then so SSR and the first client render agree on the default tab (no hydration mismatch).
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -89,6 +69,15 @@ export function MissionControl() {
|
|||||||
if (!cancelled) setShip(s)
|
if (!cancelled) setShip(s)
|
||||||
})
|
})
|
||||||
.catch(() => {}) // left column + tab keep their loading state on failure
|
.catch(() => {}) // left column + tab keep their loading state on failure
|
||||||
|
fetch('/api/system')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as SystemState
|
||||||
|
})
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled) setSystem(s)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
fetch('/api/tech')
|
fetch('/api/tech')
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
@@ -117,6 +106,25 @@ export function MissionControl() {
|
|||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// #27 — restore the persisted tab once on mount (validated against TAB_IDS).
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem(ACTIVE_TAB_KEY)
|
||||||
|
if (stored && TAB_IDS.includes(stored)) setActiveTab(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// #30 — re-fetch /api/system after a jump: WorldMapPanel owns the world state, so without this
|
||||||
|
// the lifted system state stays on the previous system until a page refresh. Same failure
|
||||||
|
// policy as the mount fetch — keep the old state on error.
|
||||||
|
const refreshSystem = useCallback(() => {
|
||||||
|
fetch('/api/system')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as SystemState
|
||||||
|
})
|
||||||
|
.then((s) => setSystem(s))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||||
|
|
||||||
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
|
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
|
||||||
@@ -127,6 +135,10 @@ export function MissionControl() {
|
|||||||
<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',
|
||||||
@@ -157,7 +169,7 @@ export function MissionControl() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
◄ SHIP
|
v{pkg.version}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
||||||
@@ -216,6 +228,7 @@ export function MissionControl() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: 0, // let the grid below be constrained to the viewport instead of growing it
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '1px',
|
gap: '1px',
|
||||||
@@ -262,8 +275,9 @@ export function MissionControl() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: 0, // same — the columns' overflowY:auto then scrolls inside their tracks
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
|
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
|
||||||
gap: '1px',
|
gap: '1px',
|
||||||
background: 'var(--border)',
|
background: 'var(--border)',
|
||||||
}}
|
}}
|
||||||
@@ -311,11 +325,34 @@ export function MissionControl() {
|
|||||||
<PanelContent>
|
<PanelContent>
|
||||||
{ship ? (
|
{ship ? (
|
||||||
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
||||||
// crew roster vs the display cap.
|
// crew roster vs the display cap; SECTOR/TURN rows folded in from the old SHIP DATA
|
||||||
|
// placeholder (the rest of it duplicated these bars or had no game equivalent).
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||||
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
|
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
|
||||||
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
|
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
|
||||||
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
|
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'SECTOR', value: (system?.systemName ?? '—').toUpperCase() },
|
||||||
|
{ label: 'TURN', value: String(ship.turn) },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0.4rem 0',
|
||||||
|
borderBottom: i < 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>{item.label}</span>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: 'var(--color-green)', textShadow: 'var(--text-glow-green)', letterSpacing: '0.04em', fontWeight: 600 }}>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
@@ -335,7 +372,9 @@ export function MissionControl() {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Panel notch="md" style={{ flex: 1 }}>
|
{/* flex column + minHeight:0 so the tab content scrolls inside the panel (#21) —
|
||||||
|
the Panel root's default overflow:hidden would otherwise clip it */}
|
||||||
|
<Panel notch="md" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||||
@@ -343,13 +382,22 @@ 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">
|
<Tabs
|
||||||
|
value={activeTab ?? 'world-map'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setActiveTab(v)
|
||||||
|
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TabsList
|
<TabsList
|
||||||
style={{
|
style={{
|
||||||
padding: '0 1rem',
|
padding: '0 1rem',
|
||||||
background: 'var(--surface-raised)',
|
background: 'var(--surface-raised)', // opaque — content scrolls under it (#31)
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
|
position: 'sticky', // #31 — pinned in both modes: to the top of the OPERATIONS
|
||||||
|
top: 0, // scroll area (wide) and to the viewport when the body scrolls (narrow)
|
||||||
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
|
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
|
||||||
@@ -363,12 +411,12 @@ export function MissionControl() {
|
|||||||
|
|
||||||
{/* WORLD MAP TAB — live data from /api/world */}
|
{/* WORLD MAP TAB — live data from /api/world */}
|
||||||
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<WorldMapPanel height={380} />
|
<WorldMapPanel height={380} onJumped={refreshSystem} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* LOCAL SYSTEM TAB — live data from /api/system */}
|
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
|
||||||
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<LocalSystemPanel />
|
<LocalSystemPanel system={system} onChanged={setSystem} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* CREW TAB — live data from /api/crew */}
|
{/* CREW TAB — live data from /api/crew */}
|
||||||
@@ -386,79 +434,21 @@ export function MissionControl() {
|
|||||||
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
||||||
</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)',
|
||||||
@@ -471,110 +461,34 @@ 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
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '0.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.68rem',
|
|
||||||
color: isNext ? 'var(--text-secondary)' : 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{enc.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.57rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.06em',
|
|
||||||
marginTop: '2px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{enc.when} // {enc.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant={isNext ? 'WARNING' : 'SCANNING'}>
|
|
||||||
{isNext ? 'NEXT' : 'SCHED'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PanelContent>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel notch="sm">
|
|
||||||
<PanelHeader>
|
|
||||||
<PanelTitle>SHIP DATA</PanelTitle>
|
|
||||||
</PanelHeader>
|
|
||||||
<PanelContent>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{shipData.map((item, i) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '0.4rem 0',
|
|
||||||
borderBottom:
|
|
||||||
i < shipData.length - 1 ? '1px solid var(--border)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.1em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
color: 'var(--color-green)',
|
|
||||||
textShadow: 'var(--text-glow-green)',
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</span>
|
</span>
|
||||||
|
<Badge variant="ACTIVE">×{item.qty}</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
</div>
|
)}
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import type { MissionDto } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// MISSIONS tab — live data. Fetches /api/missions on mount (Radix Tabs unmounts inactive tabs, so it
|
||||||
|
// refetches whenever the tab is opened); accept responses are applied directly (no polling). ACCEPT is
|
||||||
|
// enabled only when the contract is AVAILABLE and no other mission is ACTIVE — mirroring what
|
||||||
|
// POST /api/missions/{id}/accept enforces.
|
||||||
|
|
||||||
|
export function MissionPanel() {
|
||||||
|
const [missions, setMissions] = useState<MissionDto[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null) // one action at a time — turn-based game
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/missions')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as MissionDto[]
|
||||||
|
})
|
||||||
|
.then((m) => {
|
||||||
|
if (!cancelled) setMissions(m)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const accept = useCallback(async (mission: MissionDto) => {
|
||||||
|
setBusyId(mission.id)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/missions/${mission.id}/accept`, { method: 'POST' })
|
||||||
|
const data = (await res.json()) as MissionDto[] & { error?: string }
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||||
|
setMissions(data) // response is the fresh board — apply directly
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!missions && !error) {
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING MISSIONS…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = (missions ?? []).find((m) => m.state === 'ACTIVE')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{error && (
|
||||||
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem' }}>
|
||||||
|
{(missions ?? []).map((mission) => (
|
||||||
|
<MissionCard key={mission.id} mission={mission} busy={busyId === mission.id} blockedByActive={!!active && active.id !== mission.id} onAccept={accept} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissionCard({
|
||||||
|
mission,
|
||||||
|
busy,
|
||||||
|
blockedByActive,
|
||||||
|
onAccept,
|
||||||
|
}: {
|
||||||
|
mission: MissionDto
|
||||||
|
busy: boolean
|
||||||
|
blockedByActive: boolean
|
||||||
|
onAccept: (mission: MissionDto) => void
|
||||||
|
}) {
|
||||||
|
const acceptDisabled = busy || mission.state !== 'AVAILABLE' || blockedByActive
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{mission.type}</CardTitle>
|
||||||
|
<Badge variant={mission.state === 'ACTIVE' ? 'ACTIVE' : mission.state === 'AVAILABLE' ? 'SCANNING' : 'OFFLINE'} className="ml-auto">
|
||||||
|
{mission.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>CONTRACT // #{mission.id}</p>
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>{mission.description.toUpperCase()}</p>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.08em' }}>
|
||||||
|
REWARD {mission.rewardQty}× {mission.rewardKind === 'resource' ? 'RESOURCES' : 'PARTS'}
|
||||||
|
</p>
|
||||||
|
{mission.type === 'EXPLORE' && (
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
PROGRESS // {mission.progress}/{mission.progressTarget}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="OUTLINE" size="SM" disabled={acceptDisabled} onClick={() => onAccept(mission)}>
|
||||||
|
ACCEPT
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface NodeGraphNode {
|
|||||||
y: number
|
y: number
|
||||||
status?: NodeStatus
|
status?: NodeStatus
|
||||||
sublabel?: string
|
sublabel?: string
|
||||||
|
disabled?: boolean // not clickable even when onNodeClick is set (e.g. the current system)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeGraphEdge {
|
export interface NodeGraphEdge {
|
||||||
@@ -29,6 +30,7 @@ export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
title?: string
|
title?: string
|
||||||
height?: number
|
height?: number
|
||||||
directed?: boolean
|
directed?: boolean
|
||||||
|
onNodeClick?: (id: string) => void // makes non-disabled nodes clickable buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
|
||||||
@@ -59,6 +61,7 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
|||||||
title,
|
title,
|
||||||
height = 320,
|
height = 320,
|
||||||
directed = false,
|
directed = false,
|
||||||
|
onNodeClick,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -219,13 +222,21 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
|
|||||||
const cy = nodePy(node)
|
const cy = nodePy(node)
|
||||||
const isHovered = hovered === node.id
|
const isHovered = hovered === node.id
|
||||||
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
const statusColor = node.status ? STATUS_COLOR[node.status] : color
|
||||||
|
const clickable = !!onNodeClick && !node.disabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={node.id}
|
key={node.id}
|
||||||
onMouseEnter={() => setHovered(node.id)}
|
onMouseEnter={() => setHovered(node.id)}
|
||||||
onMouseLeave={() => setHovered(null)}
|
onMouseLeave={() => setHovered(null)}
|
||||||
style={{ cursor: 'default' }}
|
onClick={clickable ? () => onNodeClick(node.id) : undefined}
|
||||||
|
onKeyDown={clickable
|
||||||
|
? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNodeClick(node.id) } }
|
||||||
|
: undefined}
|
||||||
|
role={clickable ? 'button' : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
aria-label={clickable ? node.label.toUpperCase() : undefined}
|
||||||
|
style={{ cursor: clickable ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<rect
|
<rect
|
||||||
|
|||||||
+24
-47
@@ -1,20 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { NodeGraph } from '@/components/ui/node-graph'
|
import { NodeGraph } from '@/components/ui/node-graph'
|
||||||
import type { WorldState } from '@/lib/game/save'
|
import type { WorldState } from '@/lib/game/save'
|
||||||
|
|
||||||
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and
|
// WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the
|
||||||
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not
|
// current one on the galactic chart jumps there (same POST /api/jump flow as before); the
|
||||||
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount,
|
// current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount,
|
||||||
// refetch via the jump response — no polling.
|
// refetch via the jump response — no polling.
|
||||||
|
|
||||||
interface WorldMapPanelProps {
|
interface WorldMapPanelProps {
|
||||||
height?: number
|
height?: number
|
||||||
|
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
||||||
const [world, setWorld] = useState<WorldState | null>(null)
|
const [world, setWorld] = useState<WorldState | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||||
@@ -48,12 +48,13 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
|||||||
const body = (await res.json()) as WorldState & { error?: string }
|
const body = (await res.json()) as WorldState & { error?: string }
|
||||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
setWorld(body) // fresh state straight from the response — no refetch needed
|
setWorld(body) // fresh state straight from the response — no refetch needed
|
||||||
|
onJumped?.() // #30 — parent re-fetches /api/system (LOCAL SYSTEM + CARGO HOLD mirror it)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
setJumpingId(null)
|
setJumpingId(null)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [onJumped])
|
||||||
|
|
||||||
if (error && !world) {
|
if (error && !world) {
|
||||||
return (
|
return (
|
||||||
@@ -74,58 +75,34 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = world.systems.find((s) => s.current)!
|
const current = world.systems.find((s) => s.current)!
|
||||||
const reachable = world.edges
|
// Systems one jump away — the only clickable chart fields (the same set as the old JUMP ROUTES list).
|
||||||
.filter(([a, b]) => a === current.id || b === current.id)
|
const reachableIds = new Set(
|
||||||
.map(([a, b]) => (a === current.id ? b : a))
|
world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
|
||||||
.map((id) => world.systems.find((s) => s.id === id)!)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
<NodeGraph
|
<NodeGraph
|
||||||
nodes={world.systems.map((s) => ({
|
nodes={world.systems.map((s) => {
|
||||||
id: String(s.id),
|
const jumpable = !s.current && reachableIds.has(s.id)
|
||||||
label: s.name,
|
return {
|
||||||
x: s.x,
|
id: String(s.id),
|
||||||
y: s.y,
|
label: s.name,
|
||||||
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE',
|
x: s.x,
|
||||||
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
y: s.y,
|
||||||
}))}
|
status: s.current ? 'ACTIVE' : jumpable ? 'NEUTRAL' : 'OFFLINE',
|
||||||
|
sublabel: s.current ? 'CURRENT' : jumpable ? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
||||||
|
disabled: !jumpable || jumpingId !== null, // dimmed + not clickable; all locked while a jump is in flight
|
||||||
|
}
|
||||||
|
})}
|
||||||
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
|
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
|
||||||
variant="ACTIVE"
|
variant="ACTIVE"
|
||||||
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
|
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
|
||||||
height={height}
|
height={height}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
onNodeClick={(id) => void jump(Number(id))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
JUMP ROUTES // FROM {current.name.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', padding: '0.6rem 0.75rem' }}>
|
|
||||||
{reachable.map((s) => (
|
|
||||||
<Button
|
|
||||||
key={s.id}
|
|
||||||
variant="OUTLINE"
|
|
||||||
size="SM"
|
|
||||||
disabled={jumpingId !== null}
|
|
||||||
onClick={() => void jump(s.id)}
|
|
||||||
>
|
|
||||||
{jumpingId === s.id ? 'JUMPING…' : `${s.name.toUpperCase()} // JUMP`}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
|
||||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||||
|
|||||||
+222
-6
@@ -12,9 +12,41 @@ interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: u
|
|||||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||||
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
|
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
|
||||||
|
interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string }
|
||||||
|
interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string }
|
||||||
|
|
||||||
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
|
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
|
||||||
|
|
||||||
|
// BFS over the undirected jump routes — [next, ..., target], empty when already there.
|
||||||
|
const shortestJumpPath = (fromId: number, toId: number, edges: [number, number][]): number[] => {
|
||||||
|
const adj = new Map<number, number[]>();
|
||||||
|
for (const [a, b] of edges) {
|
||||||
|
if (!adj.has(a)) adj.set(a, []);
|
||||||
|
if (!adj.has(b)) adj.set(b, []);
|
||||||
|
adj.get(a)!.push(b);
|
||||||
|
adj.get(b)!.push(a);
|
||||||
|
}
|
||||||
|
const prev = new Map<number, number>();
|
||||||
|
const seen = new Set([fromId]);
|
||||||
|
const queue = [fromId];
|
||||||
|
while (queue.length > 0 && !seen.has(toId)) {
|
||||||
|
const cur = queue.shift()!;
|
||||||
|
for (const next of adj.get(cur) ?? []) {
|
||||||
|
if (!seen.has(next)) {
|
||||||
|
seen.add(next);
|
||||||
|
prev.set(next, cur);
|
||||||
|
queue.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(seen.has(toId)).toBe(true); // the world graph is connected by construction
|
||||||
|
const path: number[] = [];
|
||||||
|
for (let cur = toId; cur !== fromId; cur = prev.get(cur)!) {
|
||||||
|
path.unshift(cur);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
@@ -33,16 +65,65 @@ test('game template tabs render (scificn-ui component layer wired in)', async ({
|
|||||||
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
|
test('tabs switch panels client-side (event log terminal)', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||||||
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
|
// The placeholder feed was replaced by the real log (v0.9.0): at least one T<turn> // <MESSAGE> line
|
||||||
|
// must render — every save has the turn-0 new_game event, so this holds on a fresh stack and across retries.
|
||||||
|
await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active tab survives a page reload (#27)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Fresh context → nothing stored yet, so CREW starts unselected (the default is WORLD MAP).
|
||||||
|
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'false');
|
||||||
|
await page.getByRole('tab', { name: 'CREW' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
// The tab is restored from localStorage on mount — no API state involved, so this holds across retries.
|
||||||
|
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab bar stays pinned while panel content scrolls (#31)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Shrink the viewport so the center pane's content overflows its scroll area regardless of save state.
|
||||||
|
await page.setViewportSize({ width: 1280, height: 560 });
|
||||||
|
|
||||||
|
// WORLD MAP is always tall enough to overflow (fixed-height chart) — deterministic across retries.
|
||||||
|
await page.getByRole('tab', { name: 'WORLD MAP' }).click();
|
||||||
|
|
||||||
|
const tablist = page.getByRole('tablist');
|
||||||
|
await expect(tablist).toBeVisible();
|
||||||
|
const before = await tablist.boundingBox();
|
||||||
|
expect(before).not.toBeNull();
|
||||||
|
|
||||||
|
// Scroll the nearest scroll container above the tab list all the way down.
|
||||||
|
const scrolled = await page.evaluate(() => {
|
||||||
|
let el = document.querySelector('[role="tablist"]')?.parentElement ?? null;
|
||||||
|
while (el && !['auto', 'scroll'].includes(getComputedStyle(el).overflowY)) el = el.parentElement;
|
||||||
|
if (!el) throw new Error('no scroll container above the tab list');
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
return el.scrollTop;
|
||||||
|
});
|
||||||
|
expect(scrolled).toBeGreaterThan(50); // content really overflowed — guards against a vacuous pass
|
||||||
|
|
||||||
|
await expect(tablist).toBeVisible();
|
||||||
|
const after = await tablist.boundingBox();
|
||||||
|
expect(after).not.toBeNull();
|
||||||
|
// Pinned: the tab bar's viewport position is unchanged even though the content scrolled away (#31).
|
||||||
|
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('world map shows the generated starting system and its neighbours', async ({ page }) => {
|
test('world map shows the generated starting system and its neighbours', async ({ page }) => {
|
||||||
|
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// Live chart title carries the turn counter; a fresh save sits at the start with six jump routes.
|
// Live chart title carries the turn counter.
|
||||||
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
|
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
|
||||||
const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ });
|
// The current system is marked, and every adjacent system is a clickable chart field (role=button).
|
||||||
await expect(jumpButtons.first()).toBeVisible();
|
const current = world.systems.find((s) => s.current)!;
|
||||||
await expect(jumpButtons).toHaveCount(6);
|
await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
|
||||||
|
const panel = page.getByRole('tabpanel');
|
||||||
|
const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save
|
||||||
|
await expect(panel.getByRole('button')).toHaveCount(adjacent.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
|
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
|
||||||
@@ -53,13 +134,38 @@ test('jumping to an adjacent system updates the world state and turn counter', a
|
|||||||
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: `${targetName.toUpperCase()} // JUMP` }).click();
|
// Click the target's chart field — clickable nodes are role=button with their name as accessible label.
|
||||||
|
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
||||||
|
|
||||||
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
expect(after.turn).toBe(before.turn + 1);
|
expect(after.turn).toBe(before.turn + 1);
|
||||||
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
|
||||||
|
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
const currentId = before.systems.find((s) => s.current)!.id;
|
||||||
|
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||||
|
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||||
|
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
// Jump through the UI: click the target's chart field on WORLD MAP (the default tab).
|
||||||
|
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
||||||
|
|
||||||
|
// Wait until the jump response is applied to the chart — server state is committed by then, so
|
||||||
|
// the /api/world read below can't race the in-flight POST.
|
||||||
|
await expect(page.getByText(`GALACTIC CHART // JUMP ROUTES — TURN ${before.turn + 1}`)).toBeVisible();
|
||||||
|
|
||||||
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
const newCurrent = after.systems.find((s) => s.current)!;
|
||||||
|
expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed
|
||||||
|
|
||||||
|
// Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30).
|
||||||
|
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
||||||
|
await expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
|
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
|
||||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
|
|
||||||
@@ -255,6 +361,116 @@ test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS bu
|
|||||||
await expect(weaponsCard.getByText('LV 1')).toBeVisible();
|
await expect(weaponsCard.getByText('LV 1')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MISSIONS tab accepts a contract and completing it grants the reward', async ({ page }) => {
|
||||||
|
const board = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
||||||
|
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(3); // the board is always topped up
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('tab', { name: 'MISSIONS' }).click();
|
||||||
|
const acceptButtons = page.getByRole('button', { name: 'ACCEPT' });
|
||||||
|
const panel = page.getByRole('tabpanel');
|
||||||
|
|
||||||
|
// Idempotent across retries sharing a DB: if a previous attempt already accepted a mission, it's still
|
||||||
|
// ACTIVE and every ACCEPT button is disabled — complete that one instead of accepting again.
|
||||||
|
const leftover = board.find((m) => m.state === 'ACTIVE');
|
||||||
|
let picked: MissionDto;
|
||||||
|
|
||||||
|
if (leftover) {
|
||||||
|
picked = leftover;
|
||||||
|
// No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE".
|
||||||
|
await expect(panel.getByText('ACTIVE')).toBeVisible();
|
||||||
|
for (const button of await acceptButtons.all()) {
|
||||||
|
await expect(button).toBeDisabled();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Prefer a scan-completable contract — fresh boards are all EXPLORE/FIND (DELIVER only appears once systems are discovered).
|
||||||
|
picked = board.find((m) => m.state === 'AVAILABLE' && m.type !== 'DELIVER') ?? board.find((m) => m.state === 'AVAILABLE')!;
|
||||||
|
const idx = board.indexOf(picked);
|
||||||
|
|
||||||
|
// One ACCEPT button per card, in board order — the picked contract's is enabled (AVAILABLE, nothing active).
|
||||||
|
await expect(acceptButtons.nth(idx)).toBeEnabled();
|
||||||
|
await acceptButtons.nth(idx).click();
|
||||||
|
|
||||||
|
// UI flipped: exactly one ACTIVE badge on the board and the accepted card's button is now disabled.
|
||||||
|
// No exact match — ACTIVE badges render a "●" dot prefix (components/ui/badge.tsx), so their text is "●ACTIVE".
|
||||||
|
await expect(panel.getByText('ACTIVE')).toBeVisible();
|
||||||
|
await expect(acceptButtons.nth(idx)).toBeDisabled();
|
||||||
|
|
||||||
|
// API confirms it: exactly one ACTIVE mission, the picked one (accepting cost a turn).
|
||||||
|
const boardAfterAccept = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
||||||
|
expect(boardAfterAccept.filter((m) => m.state === 'ACTIVE')).toEqual([boardAfterAccept.find((m) => m.id === picked.id)!]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline of the reward kind in the hold — scans during completion may add more, so assert >=.
|
||||||
|
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
const baseline = totalQty(shipBefore.inventory, picked.rewardKind);
|
||||||
|
|
||||||
|
// Complete it. EXPLORE/FIND: scan until done, jumping when a system exhausts (parts drop on ~30% of
|
||||||
|
// scans, so P(a FIND-part misses in 80 scans) ≈ 0.7^80 — negligible). DELIVER: follow the shortest
|
||||||
|
// jump path to its target (edges only ever get added, so the precomputed path stays valid).
|
||||||
|
let deliverPath: number[] = [];
|
||||||
|
if (picked.type === 'DELIVER') {
|
||||||
|
const worldNow = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
deliverPath = shortestJumpPath(worldNow.systems.find((s) => s.current)!.id, picked.targetSystemId!, worldNow.edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = 'ACTIVE';
|
||||||
|
let iterations = 0;
|
||||||
|
while (state !== 'DONE' && iterations < 80) {
|
||||||
|
iterations++;
|
||||||
|
if (deliverPath.length > 0) {
|
||||||
|
expect((await page.request.post('/api/jump', { data: { systemId: deliverPath.shift()! } })).status()).toBe(200);
|
||||||
|
} else {
|
||||||
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
|
const unexplored = sys.planets.filter((p) => !p.explored);
|
||||||
|
if (unexplored.length > 0) {
|
||||||
|
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
||||||
|
} else {
|
||||||
|
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = ((await (await page.request.get('/api/missions')).json()) as MissionDto[]).find((m) => m.id === picked.id)?.state ?? 'ACTIVE';
|
||||||
|
}
|
||||||
|
expect(state).toBe('DONE'); // the bounded loop always completes: scans are plentiful, jump paths finite
|
||||||
|
|
||||||
|
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||||
|
expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EVENT LOG tab shows real game events, newest first', async ({ page }) => {
|
||||||
|
// Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and
|
||||||
|
// message), never on absolute counts — earlier attempts leave their events behind.
|
||||||
|
const afterJump = await jumpToFreshSystem(page);
|
||||||
|
const jumpedName = afterJump.systems.find((s) => s.current)!.name;
|
||||||
|
const jumpTurn = afterJump.turn;
|
||||||
|
|
||||||
|
// Scan one body — if the current system is exhausted (leftover from a previous attempt), hop again first.
|
||||||
|
let sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
|
if (!sys.planets.some((p) => !p.explored)) {
|
||||||
|
await jumpToFreshSystem(page); // fresh systems always have unscanned bodies
|
||||||
|
sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
|
}
|
||||||
|
const planet = sys.planets.find((p) => !p.explored)!;
|
||||||
|
const exploreRes = await page.request.post('/api/explore', { data: { planetId: planet.id } });
|
||||||
|
expect(exploreRes.status()).toBe(200);
|
||||||
|
const explored = (await exploreRes.json()) as ExploreResult;
|
||||||
|
|
||||||
|
// API: this run's jump + scan are in the log, and the newer one (the scan) comes first.
|
||||||
|
const events = (await (await page.request.get('/api/events')).json()) as EventDto[];
|
||||||
|
const exploreEvent = events.find((e) => e.turn === explored.turn && e.type === 'explore');
|
||||||
|
expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`));
|
||||||
|
const jumpEvent = events.find((e) => e.type === 'jump' && e.message === `Jumped to ${jumpedName}`);
|
||||||
|
expect(jumpEvent).toBeDefined(); // system names are unique, so the message pins this run's jump
|
||||||
|
expect(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!));
|
||||||
|
|
||||||
|
// UI: the terminal renders both lines (T<turn> // <MESSAGE>, uppercased).
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('tab', { name: 'EVENT LOG' }).click();
|
||||||
|
const panel = page.getByRole('tabpanel');
|
||||||
|
await expect(panel.getByText(new RegExp(`T${explored.turn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible();
|
||||||
|
await expect(panel.getByText(new RegExp(`T${jumpTurn} // JUMPED TO ${jumpedName.toUpperCase()}`))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||||
const res = await page.request.get('/api/version');
|
const res = await page.request.get('/api/version');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface CrewDto { id: number; name: string; role: string; traitGood: string; t
|
|||||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||||
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||||
interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] }
|
interface 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);
|
||||||
@@ -53,6 +55,16 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
||||||
expect(techBefore.length).toBeGreaterThan(0); // the tech tree is always seeded
|
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' });
|
||||||
|
|
||||||
@@ -95,6 +107,14 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
||||||
expect(techAfter).toEqual(techBefore);
|
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();
|
||||||
|
|||||||
@@ -94,6 +94,20 @@ CREATE TABLE IF NOT EXISTS tech (
|
|||||||
UNIQUE (save_id, slug)
|
UNIQUE (save_id, slug)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS missions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
type TEXT NOT NULL, -- 'DELIVER' | 'EXPLORE' | 'FIND'
|
||||||
|
description TEXT NOT NULL, -- human-readable objective, e.g. "Jump to Kepler Drift"
|
||||||
|
target_system_id INTEGER, -- DELIVER only — destination system (NULL otherwise)
|
||||||
|
condition_kind TEXT, -- FIND only — 'resource' | 'part' (NULL otherwise)
|
||||||
|
progress_target INTEGER NOT NULL DEFAULT 1, -- EXPLORE: scans required; 1 for the others
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0, -- scans counted since acceptance; 0/1 for DELIVER/FIND
|
||||||
|
reward_kind TEXT NOT NULL, -- 'resource' | 'part'
|
||||||
|
reward_qty INTEGER NOT NULL DEFAULT 1,
|
||||||
|
state TEXT NOT NULL DEFAULT 'AVAILABLE' -- 'AVAILABLE' | 'ACTIVE' | 'DONE'
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
|
|||||||
@@ -108,6 +108,24 @@ export const tech = sqliteTable(
|
|||||||
(t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)],
|
(t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The mission board (#10): contracts generated deterministically from the save seed, one ACTIVE
|
||||||
|
// at a time. `type` drives completion semantics (see advanceMission in lib/game/missions.ts):
|
||||||
|
// DELIVER jumps to target_system_id, EXPLORE counts scans up to progress_target, FIND completes
|
||||||
|
// when a scan's haul contains condition_kind. The reward lands in inventory_items on completion.
|
||||||
|
export const missions = sqliteTable('missions', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||||
|
type: text('type').notNull(), // 'DELIVER' | 'EXPLORE' | 'FIND'
|
||||||
|
description: text('description').notNull(), // human-readable objective, e.g. "Jump to Kepler Drift"
|
||||||
|
targetSystemId: integer('target_system_id'), // DELIVER only — destination system (NULL otherwise)
|
||||||
|
conditionKind: text('condition_kind'), // FIND only — 'resource' | 'part' (NULL otherwise)
|
||||||
|
progressTarget: integer('progress_target').notNull().default(1), // EXPLORE: scans required; 1 for the others
|
||||||
|
progress: integer('progress').notNull().default(0), // scans counted since acceptance; 0/1 for DELIVER/FIND
|
||||||
|
rewardKind: text('reward_kind').notNull(), // 'resource' | 'part'
|
||||||
|
rewardQty: integer('reward_qty').notNull().default(1),
|
||||||
|
state: text('state').notNull().default('AVAILABLE'), // 'AVAILABLE' | 'ACTIVE' | 'DONE'
|
||||||
|
})
|
||||||
|
|
||||||
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
||||||
// writers and a read endpoint.
|
// writers and a read endpoint.
|
||||||
export const events = sqliteTable('events', {
|
export const events = sqliteTable('events', {
|
||||||
|
|||||||
@@ -71,4 +71,17 @@ describe('makeCrewName', () => {
|
|||||||
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
|
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
|
||||||
expect(taken.size).toBe(40)
|
expect(taken.size).toBe(40)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('terminates with numbered variants once all 352 base names are taken', () => {
|
||||||
|
const rng = startingCrewRng('7')
|
||||||
|
const taken = new Set<string>()
|
||||||
|
for (let i = 0; i < 352; i++) makeCrewName(rng, taken) // exhaust the whole pool
|
||||||
|
expect(taken.size).toBe(352)
|
||||||
|
|
||||||
|
const a = makeCrewName(rng, taken) // must return instead of spinning forever
|
||||||
|
const b = makeCrewName(rng, taken)
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
expect(taken.has(a)).toBe(true)
|
||||||
|
expect(taken.has(b)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+15
-5
@@ -49,13 +49,23 @@ function pick<T>(rng: () => number, arr: T[]): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A name unique against `taken` (the current roster), claimed into the set on success.
|
// A name unique against `taken` (the current roster), claimed into the set on success.
|
||||||
// 16×22 = 352 combinations — a roster realistically never approaches that size.
|
// The pool is finite (16×22=352) and the roster has no cap, so — like makeName — fall back to
|
||||||
|
// numbered variants once every base is taken instead of spinning forever.
|
||||||
export function makeCrewName(rng: () => number, taken: Set<string>): string {
|
export function makeCrewName(rng: () => number, taken: Set<string>): string {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const name = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
|
const base = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
|
||||||
if (!taken.has(name)) {
|
if (!taken.has(base)) {
|
||||||
taken.add(name)
|
taken.add(base)
|
||||||
return name
|
return base
|
||||||
|
}
|
||||||
|
if (taken.size >= FIRST_NAMES.length * LAST_NAMES.length) {
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const name = `${base} ${n}`
|
||||||
|
if (!taken.has(name)) {
|
||||||
|
taken.add(name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getWorldState } from './save'
|
||||||
|
import { DEFAULT_LIMIT, EventError, MAX_LIMIT, getEvents, parseEventLimit } from './events'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-events-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
return { db: await openGameDb(file), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Appends `count` synthetic events (turns 1..count) on top of the turn-0 new_game event.
|
||||||
|
async function seedEvents(db: GameDb, count: number): Promise<void> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
for (let i = 1; i <= count; i++) {
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: i, type: 'test', message: `event ${i}`, createdAt: new Date(Date.UTC(2026, 8, 29, 0, i)).toISOString() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseEventLimit', () => {
|
||||||
|
it('defaults to DEFAULT_LIMIT when the param is absent', () => {
|
||||||
|
expect(parseEventLimit(null)).toBe(DEFAULT_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts integers in [1, MAX_LIMIT]', () => {
|
||||||
|
expect(parseEventLimit('1')).toBe(1)
|
||||||
|
expect(parseEventLimit(String(MAX_LIMIT))).toBe(MAX_LIMIT)
|
||||||
|
expect(parseEventLimit('25')).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-integers and out-of-range values with a 400', () => {
|
||||||
|
for (const bad of ['abc', '', '0', '-3', String(MAX_LIMIT + 1), '2.5']) {
|
||||||
|
try {
|
||||||
|
parseEventLimit(bad)
|
||||||
|
expect.unreachable(`limit ${JSON.stringify(bad)} should have been rejected`)
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(EventError)
|
||||||
|
expect((err as EventError).status).toBe(400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getEvents', () => {
|
||||||
|
it('returns the latest events newest-first, sliced to limit', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // auto-initializes the save (turn-0 new_game event)
|
||||||
|
await seedEvents(db, 5)
|
||||||
|
|
||||||
|
const all = await getEvents(db, DEFAULT_LIMIT)
|
||||||
|
expect(all.map((e) => e.turn)).toEqual([5, 4, 3, 2, 1, 0]) // newest first, turn-0 line included
|
||||||
|
expect(all[all.length - 1].type).toBe('new_game')
|
||||||
|
|
||||||
|
const two = await getEvents(db, 2)
|
||||||
|
expect(two.map((e) => [e.turn, e.message])).toEqual([[5, 'event 5'], [4, 'event 4']])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { desc } from 'drizzle-orm'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import type { GameDb } from '@/lib/db/client'
|
||||||
|
|
||||||
|
export interface EventDto {
|
||||||
|
id: number
|
||||||
|
turn: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LIMIT = 50
|
||||||
|
export const MAX_LIMIT = 200
|
||||||
|
|
||||||
|
export class EventError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses the optional ?limit= query param. Absent → default; anything that is not an integer in
|
||||||
|
// [1, MAX_LIMIT] is a 400 (the route maps EventError to its status).
|
||||||
|
export function parseEventLimit(raw: string | null): number {
|
||||||
|
if (raw === null) return DEFAULT_LIMIT
|
||||||
|
const limit = Number(raw)
|
||||||
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
||||||
|
throw new EventError(400, `limit must be an integer between 1 and ${MAX_LIMIT}`)
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest N events of the save, newest first. Assumes a save exists (the route calls ensureSave).
|
||||||
|
export async function getEvents(db: GameDb, limit: number): Promise<EventDto[]> {
|
||||||
|
const rows = await db.db.select().from(schema.events).orderBy(desc(schema.events.id)).limit(limit)
|
||||||
|
return rows.map((e) => ({ id: e.id, turn: e.turn, type: e.type, message: e.message, createdAt: e.createdAt }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { explorationRng, mulberry32, rollExploration } from './world'
|
||||||
|
import { SCHEMA_VERSION, explorePlanet, getWorldState, jumpToSystem } from './save'
|
||||||
|
import { acceptMission, MIN_AVAILABLE, REWARD_ITEM_NAME, generateMission, missionRng, pickMissionType, topUpMissionBoard, type MissionDef, type MissionSystem } from './missions'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-missions-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
return { db: await openGameDb(file), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// A snapshot with one discovered non-current system to deliver to.
|
||||||
|
const SYSTEMS: MissionSystem[] = [
|
||||||
|
{ id: 1, name: 'Start', discovered: true, isCurrent: true },
|
||||||
|
{ id: 2, name: 'Alpha', discovered: true, isCurrent: false },
|
||||||
|
{ id: 3, name: 'Beta', discovered: false, isCurrent: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('generateMission (pure)', () => {
|
||||||
|
it('DELIVER targets a discovered non-current system and returns null when none exists', () => {
|
||||||
|
const def = generateMission('DELIVER', mulberry32(7), SYSTEMS)!
|
||||||
|
expect(def.type).toBe('DELIVER')
|
||||||
|
expect(def.targetSystemId).toBe(2) // the only candidate
|
||||||
|
expect(def.description).toContain('Alpha')
|
||||||
|
|
||||||
|
expect(generateMission('DELIVER', mulberry32(7), [SYSTEMS[0]])).toBeNull() // only the current system is discovered
|
||||||
|
})
|
||||||
|
|
||||||
|
it('EXPLORE targets 2–4 scans, FIND one item kind; rewards are always 1–3', () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const e = generateMission('EXPLORE', mulberry32(i), SYSTEMS)!
|
||||||
|
expect(e.progressTarget).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(e.progressTarget).toBeLessThanOrEqual(4)
|
||||||
|
expect(e.description).toMatch(/^Scan [2-4] bodies$/)
|
||||||
|
|
||||||
|
const f = generateMission('FIND', mulberry32(i), SYSTEMS)!
|
||||||
|
expect(['resource', 'part']).toContain(f.conditionKind)
|
||||||
|
expect(f.description).toContain(f.conditionKind!)
|
||||||
|
|
||||||
|
for (const d of [e, f]) {
|
||||||
|
expect(d.rewardQty).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(d.rewardQty).toBeLessThanOrEqual(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic — the same (seed, turn) stream generates identical missions', () => {
|
||||||
|
const a = generateMission('DELIVER', missionRng('42', 5), SYSTEMS)
|
||||||
|
const b = generateMission('DELIVER', missionRng('42', 5), SYSTEMS)
|
||||||
|
expect(a).toEqual(b)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pickMissionType (pure)', () => {
|
||||||
|
it('never picks DELIVER without a target, and does pick it when one exists', () => {
|
||||||
|
for (let i = 0; i < 200; i++) expect(pickMissionType(mulberry32(i), false)).not.toBe('DELIVER')
|
||||||
|
|
||||||
|
const types = new Set(Array.from({ length: 200 }, (_, i) => pickMissionType(mulberry32(i), true)))
|
||||||
|
expect(types.has('DELIVER')).toBe(true)
|
||||||
|
for (const t of types) expect(['DELIVER', 'EXPLORE', 'FIND']).toContain(t)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('topUpMissionBoard', () => {
|
||||||
|
it('seeds a fresh game with MIN_AVAILABLE AVAILABLE non-DELIVER missions and is idempotent', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game — createNewGame already topped the board up
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows).toHaveLength(MIN_AVAILABLE)
|
||||||
|
for (const r of rows) {
|
||||||
|
expect(r.state).toBe('AVAILABLE')
|
||||||
|
expect(r.type).not.toBe('DELIVER') // only the start system is discovered yet — no targets exist
|
||||||
|
}
|
||||||
|
|
||||||
|
await topUpMissionBoard(db) // a second pass inserts nothing
|
||||||
|
expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates deterministically from (seed, turn) on a hand-crafted save', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
// A v6 save with start (current), one discovered neighbour and one undiscovered.
|
||||||
|
await db.db.insert(schema.saves).values({ seed: '42', turn: 5, createdAt: new Date().toISOString(), schemaVersion: SCHEMA_VERSION })
|
||||||
|
const [start] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Start', x: 50, y: 50, discovered: true, isCurrent: true }).returning()
|
||||||
|
const [alpha] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Alpha', x: 60, y: 50, discovered: true, isCurrent: false }).returning()
|
||||||
|
const [beta] = await db.db.insert(schema.solarSystems).values({ saveId: 1, name: 'Beta', x: 40, y: 50 }).returning()
|
||||||
|
|
||||||
|
await topUpMissionBoard(db)
|
||||||
|
|
||||||
|
// Replay the same pure pipeline with the same (seed, turn) stream — must match row for row.
|
||||||
|
const snapshot: MissionSystem[] = [
|
||||||
|
{ id: start.id, name: 'Start', discovered: true, isCurrent: true },
|
||||||
|
{ id: alpha.id, name: 'Alpha', discovered: true, isCurrent: false },
|
||||||
|
{ id: beta.id, name: 'Beta', discovered: false, isCurrent: false },
|
||||||
|
]
|
||||||
|
const rng = missionRng('42', 5)
|
||||||
|
const expected: MissionDef[] = []
|
||||||
|
while (expected.length < MIN_AVAILABLE) {
|
||||||
|
const type = pickMissionType(rng, snapshot.some((s) => s.discovered && !s.isCurrent))
|
||||||
|
expected.push(generateMission(type, rng, snapshot)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows.map((r) => ({ type: r.type, description: r.description, targetSystemId: r.targetSystemId, conditionKind: r.conditionKind, progressTarget: r.progressTarget, rewardKind: r.rewardKind, rewardQty: r.rewardQty }))).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inserts a mission row directly so tests can control exactly what the board holds.
|
||||||
|
async function insertMission(db: GameDb, fields: { type: string; description: string; targetSystemId?: number | null; conditionKind?: string | null; progressTarget?: number; rewardKind?: string; rewardQty?: number }): Promise<number> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db.insert(schema.missions).values({ saveId: save.id, type: fields.type, description: fields.description, targetSystemId: fields.targetSystemId ?? null, conditionKind: fields.conditionKind ?? null, progressTarget: fields.progressTarget ?? 1, rewardKind: fields.rewardKind ?? 'resource', rewardQty: fields.rewardQty ?? 1 }).returning()
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('acceptMission', () => {
|
||||||
|
it('rejects unknown ids with 404 and a second accept while one is active with 409 naming the active mission', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game — board seeded
|
||||||
|
|
||||||
|
await expect(acceptMission(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||||
|
|
||||||
|
const [a, b] = (await db.db.select().from(schema.missions))
|
||||||
|
await acceptMission(db, a.id)
|
||||||
|
await expect(acceptMission(db, a.id)).rejects.toMatchObject({ status: 409, message: /already active/i }) // re-accepting the active one
|
||||||
|
await expect(acceptMission(db, b.id)).rejects.toMatchObject({ status: 409, message: /One mission at a time/ })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates an AVAILABLE contract, costs a turn, writes an event and keeps the board topped up', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [a] = await db.db.select().from(schema.missions)
|
||||||
|
|
||||||
|
const board = await acceptMission(db, a.id)
|
||||||
|
|
||||||
|
expect(board.find((m) => m.id === a.id)!.state).toBe('ACTIVE')
|
||||||
|
expect(board.filter((m) => m.state === 'AVAILABLE').length).toBeGreaterThanOrEqual(MIN_AVAILABLE) // topped back up
|
||||||
|
const [after] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
expect(after.turn).toBe(save.turn + 1)
|
||||||
|
|
||||||
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission')!
|
||||||
|
expect(event.message).toContain('Accepted mission')
|
||||||
|
expect(event.turn).toBe(save.turn + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('completion', () => {
|
||||||
|
it('EXPLORE counts scans, completes at the target and grants the reward into the hold', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game — empty inventory, all bodies unexplored
|
||||||
|
const id = await insertMission(db, { type: 'EXPLORE', description: 'Scan 3 bodies', progressTarget: 3, rewardKind: 'part', rewardQty: 2 })
|
||||||
|
|
||||||
|
await acceptMission(db, id)
|
||||||
|
const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null)
|
||||||
|
await explorePlanet(db, planets[0].id)
|
||||||
|
let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('ACTIVE')
|
||||||
|
expect(mission.progress).toBe(1)
|
||||||
|
|
||||||
|
await explorePlanet(db, planets[1].id)
|
||||||
|
mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('ACTIVE')
|
||||||
|
expect(mission.progress).toBe(2)
|
||||||
|
|
||||||
|
await explorePlanet(db, planets[2].id) // third scan — completes
|
||||||
|
mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('DONE')
|
||||||
|
expect(mission.progress).toBe(3)
|
||||||
|
|
||||||
|
const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)!
|
||||||
|
expect(reward.kind).toBe('part')
|
||||||
|
expect(reward.qty).toBe(2)
|
||||||
|
|
||||||
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))!
|
||||||
|
expect(event.turn).toBe(4) // accept (1) + three scans (2–4); completion is written by the third scan
|
||||||
|
expect(event.message).toContain('Scan 3 bodies')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FIND completes when a haul contains its kind and ignores scans that do not', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const id = await insertMission(db, { type: 'FIND', description: 'Find at least one part while scanning', conditionKind: 'part', rewardKind: 'resource', rewardQty: 3 })
|
||||||
|
|
||||||
|
// Pick bodies by their deterministic per-planet rolls — one without parts first (no progress),
|
||||||
|
// then one with (completes).
|
||||||
|
const planets = await db.db.select().from(schema.planets)
|
||||||
|
const haulOf = (p: { id: number }) => rollExploration(explorationRng(save.seed, p.id))
|
||||||
|
const noPart = planets.find((p) => !haulOf(p).some((i) => i.kind === 'part'))!
|
||||||
|
expect(noPart).toBeDefined()
|
||||||
|
const withPart = planets.find((p) => haulOf(p).some((i) => i.kind === 'part'))!
|
||||||
|
expect(withPart).toBeDefined()
|
||||||
|
|
||||||
|
await acceptMission(db, id)
|
||||||
|
await explorePlanet(db, noPart.id) // haul has no parts — the mission does not advance
|
||||||
|
let mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('ACTIVE')
|
||||||
|
expect(mission.progress).toBe(0)
|
||||||
|
|
||||||
|
await explorePlanet(db, withPart.id) // haul has parts — completes
|
||||||
|
mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('DONE')
|
||||||
|
|
||||||
|
const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)!
|
||||||
|
expect(reward.kind).toBe('resource')
|
||||||
|
expect(reward.qty).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELIVER completes on arrival at its target system', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const world = await getWorldState(db) // new game — start is current, six undiscovered neighbours
|
||||||
|
const [start] = await db.db.select().from(schema.solarSystems).where(eq(schema.solarSystems.isCurrent, true))
|
||||||
|
const neighbor = world.systems.find((s) => !s.discovered)!
|
||||||
|
|
||||||
|
await jumpToSystem(db, neighbor.id) // start is now discovered + non-current — a valid target
|
||||||
|
const id = await insertMission(db, { type: 'DELIVER', description: `Jump to ${start.name}`, targetSystemId: start.id, rewardKind: 'resource', rewardQty: 1 })
|
||||||
|
|
||||||
|
await acceptMission(db, id)
|
||||||
|
|
||||||
|
// Wrong destination: one of the ring neighbours generated around `neighbor` on the first jump.
|
||||||
|
const world2 = await getWorldState(db)
|
||||||
|
const wrongEdge = world2.edges.find(([a, b]) => (a === neighbor.id || b === neighbor.id) && a !== start.id && b !== start.id)!
|
||||||
|
const wrongId = wrongEdge[0] === neighbor.id ? wrongEdge[1] : wrongEdge[0]
|
||||||
|
await jumpToSystem(db, wrongId) // not the target — no completion
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === id)!.state).toBe('ACTIVE')
|
||||||
|
|
||||||
|
await jumpToSystem(db, neighbor.id) // back through `neighbor`…
|
||||||
|
const result = await jumpToSystem(db, start.id) // …to the target — completes
|
||||||
|
const mission = (await db.db.select().from(schema.missions)).find((m) => m.id === id)!
|
||||||
|
expect(mission.state).toBe('DONE')
|
||||||
|
expect(result.systems.find((s) => s.current)?.id).toBe(start.id)
|
||||||
|
|
||||||
|
const reward = (await db.db.select().from(schema.inventoryItems)).find((i) => i.name === REWARD_ITEM_NAME)!
|
||||||
|
expect(reward.kind).toBe('resource')
|
||||||
|
expect(reward.qty).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewards of the same kind accumulate into one hold row across missions', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db)
|
||||||
|
const a = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 2 })
|
||||||
|
const b = await insertMission(db, { type: 'EXPLORE', description: 'Scan 2 more bodies', progressTarget: 2, rewardKind: 'resource', rewardQty: 3 })
|
||||||
|
const planets = (await db.db.select().from(schema.planets)).filter((p) => p.parentId === null)
|
||||||
|
|
||||||
|
await acceptMission(db, a)
|
||||||
|
await explorePlanet(db, planets[0].id)
|
||||||
|
await explorePlanet(db, planets[1].id) // completes A — 2× resource reward
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === a)!.state).toBe('DONE')
|
||||||
|
|
||||||
|
await acceptMission(db, b)
|
||||||
|
await explorePlanet(db, planets[2].id)
|
||||||
|
await explorePlanet(db, planets[3].id) // completes B — 3× more of the same kind+name
|
||||||
|
expect((await db.db.select().from(schema.missions)).find((m) => m.id === b)!.state).toBe('DONE')
|
||||||
|
|
||||||
|
const rewards = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.name === REWARD_ITEM_NAME)
|
||||||
|
expect(rewards).toHaveLength(1) // accumulated, not a second row
|
||||||
|
expect(rewards[0].qty).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('backfill v6', () => {
|
||||||
|
it('seeds the board on a pre-#10 save when it opens — deterministic and idempotent', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // new game at the current version
|
||||||
|
|
||||||
|
// Simulate a save created by pre-v6 code: no mission rows, stamped v5.
|
||||||
|
await db.db.delete(schema.missions)
|
||||||
|
await db.db.update(schema.saves).set({ schemaVersion: 5 })
|
||||||
|
|
||||||
|
await getWorldState(db) // ensureSave → backfill runs the v6 step
|
||||||
|
|
||||||
|
const rows = await db.db.select().from(schema.missions)
|
||||||
|
expect(rows).toHaveLength(MIN_AVAILABLE)
|
||||||
|
for (const r of rows) expect(r.state).toBe('AVAILABLE')
|
||||||
|
const [save] = await db.db.select().from(schema.saves)
|
||||||
|
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
|
||||||
|
// Idempotent — a second pass changes nothing.
|
||||||
|
await getWorldState(db)
|
||||||
|
expect((await db.db.select().from(schema.missions)).length).toBe(MIN_AVAILABLE)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { persist, type GameDb } from '@/lib/db/client'
|
||||||
|
import { mulberry32, type FoundItem } from './world'
|
||||||
|
|
||||||
|
// Mission board (#10): contracts generated deterministically from the save seed. Three types —
|
||||||
|
// DELIVER (jump to a target system), EXPLORE (scan N bodies), FIND (haul in an item of one kind).
|
||||||
|
// One mission is ACTIVE at a time; the board always holds MIN_AVAILABLE AVAILABLE contracts.
|
||||||
|
// The pure helpers below are DB-free so generation/completion rules stay unit-testable alone.
|
||||||
|
|
||||||
|
export type MissionType = 'DELIVER' | 'EXPLORE' | 'FIND'
|
||||||
|
export type ItemKind = 'resource' | 'part'
|
||||||
|
export type MissionStateName = 'AVAILABLE' | 'ACTIVE' | 'DONE'
|
||||||
|
|
||||||
|
// The board never drops below this many AVAILABLE contracts (topped up after every action).
|
||||||
|
export const MIN_AVAILABLE = 3
|
||||||
|
|
||||||
|
// What generation produces and the DB stores — one row per mission.
|
||||||
|
export interface MissionDef {
|
||||||
|
type: MissionType
|
||||||
|
description: string
|
||||||
|
targetSystemId: number | null // DELIVER only
|
||||||
|
conditionKind: ItemKind | null // FIND only
|
||||||
|
progressTarget: number // EXPLORE: scans required; 1 for the others
|
||||||
|
rewardKind: ItemKind
|
||||||
|
rewardQty: number // 1–3, consistent with exploration hauls
|
||||||
|
}
|
||||||
|
|
||||||
|
// A system as far as generation cares about.
|
||||||
|
export interface MissionSystem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
discovered: boolean
|
||||||
|
isCurrent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic per (seed, turn) — same anchoring style as world expansion in save.ts. Every
|
||||||
|
// action costs exactly one turn, so the stream never repeats for two different top-ups.
|
||||||
|
export function missionRng(seed: string, turn: number): () => number {
|
||||||
|
return mulberry32((Number(seed) + Math.imul(turn, 0x9e3779b1)) >>> 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which type to generate next: DELIVER when a target exists and the coin says so, else EXPLORE/FIND.
|
||||||
|
export function pickMissionType(rng: () => number, canDeliver: boolean): MissionType {
|
||||||
|
if (canDeliver && rng() < 0.5) return 'DELIVER'
|
||||||
|
return rng() < 0.5 ? 'EXPLORE' : 'FIND'
|
||||||
|
}
|
||||||
|
|
||||||
|
// One mission of the given type from the current world snapshot. DELIVER returns null when no
|
||||||
|
// discovered non-current system exists to target (e.g. a brand-new game).
|
||||||
|
export function generateMission(type: MissionType, rng: () => number, systems: MissionSystem[]): MissionDef | null {
|
||||||
|
const rewardKind: ItemKind = rng() < 0.5 ? 'resource' : 'part'
|
||||||
|
const rewardQty = 1 + Math.floor(rng() * 3) // 1–3
|
||||||
|
|
||||||
|
if (type === 'DELIVER') {
|
||||||
|
const candidates = systems.filter((s) => s.discovered && !s.isCurrent)
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
const target = candidates[Math.floor(rng() * candidates.length)]
|
||||||
|
return { type, description: `Jump to ${target.name}`, targetSystemId: target.id, conditionKind: null, progressTarget: 1, rewardKind, rewardQty }
|
||||||
|
}
|
||||||
|
if (type === 'EXPLORE') {
|
||||||
|
const scans = 2 + Math.floor(rng() * 3) // 2–4
|
||||||
|
return { type, description: `Scan ${scans} bodies`, targetSystemId: null, conditionKind: null, progressTarget: scans, rewardKind, rewardQty }
|
||||||
|
}
|
||||||
|
const kind: ItemKind = rng() < 0.5 ? 'resource' : 'part'
|
||||||
|
return { type, description: `Find at least one ${kind} while scanning`, targetSystemId: null, conditionKind: kind, progressTarget: 1, rewardKind, rewardQty }
|
||||||
|
}
|
||||||
|
|
||||||
|
// What an action did to the world — what completion checks run against.
|
||||||
|
export interface MissionAction {
|
||||||
|
jumpedTo?: number // system id reached by a jump
|
||||||
|
outcome?: FoundItem[] // haul from a scan
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far an action advances a mission: new progress, or null when the action doesn't touch it.
|
||||||
|
// DELIVER completes on arrival at its target; EXPLORE counts every scan (capped); FIND completes
|
||||||
|
// when a haul contains its kind.
|
||||||
|
export function advanceMission(
|
||||||
|
mission: { type: MissionType; targetSystemId: number | null; conditionKind: ItemKind | null; progressTarget: number; progress: number },
|
||||||
|
action: MissionAction,
|
||||||
|
): number | null {
|
||||||
|
if (action.jumpedTo !== undefined) {
|
||||||
|
return mission.type === 'DELIVER' && mission.targetSystemId === action.jumpedTo ? 1 : null
|
||||||
|
}
|
||||||
|
if (mission.type === 'EXPLORE') return Math.min(mission.progressTarget, mission.progress + 1)
|
||||||
|
if (mission.type === 'FIND' && mission.conditionKind !== null && (action.outcome ?? []).some((i) => i.kind === mission.conditionKind)) return 1
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DB layer -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MissionDto extends MissionDef {
|
||||||
|
id: number
|
||||||
|
progress: number
|
||||||
|
state: MissionStateName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||||
|
export class MissionError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMissions(db: GameDb): Promise<MissionDto[]> {
|
||||||
|
return (await db.db.select().from(schema.missions)).map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
type: m.type as MissionType,
|
||||||
|
description: m.description,
|
||||||
|
targetSystemId: m.targetSystemId,
|
||||||
|
conditionKind: m.conditionKind as ItemKind | null,
|
||||||
|
progressTarget: m.progressTarget,
|
||||||
|
progress: m.progress,
|
||||||
|
rewardKind: m.rewardKind as ItemKind,
|
||||||
|
rewardQty: m.rewardQty,
|
||||||
|
state: m.state as MissionStateName,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The board for the MISSIONS tab. Assumes a save exists (routes call ensureSave first) and tops
|
||||||
|
// the AVAILABLE list up before reading so the tab always has contracts to offer.
|
||||||
|
export async function getMissions(db: GameDb): Promise<MissionDto[]> {
|
||||||
|
await topUpMissionBoard(db)
|
||||||
|
return readMissions(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps at least MIN_AVAILABLE AVAILABLE missions on the board, generating deterministically from
|
||||||
|
// (seed, turn). Idempotent — a second call with a full list inserts nothing.
|
||||||
|
export async function topUpMissionBoard(db: GameDb): Promise<void> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
if (!save) return
|
||||||
|
const systems = (await db.db.select().from(schema.solarSystems)).map((s) => ({ id: s.id, name: s.name, discovered: s.discovered, isCurrent: s.isCurrent }))
|
||||||
|
|
||||||
|
let available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length
|
||||||
|
if (available >= MIN_AVAILABLE) return
|
||||||
|
|
||||||
|
const rng = missionRng(save.seed, save.turn)
|
||||||
|
while (available < MIN_AVAILABLE) {
|
||||||
|
const type = pickMissionType(rng, systems.some((s) => s.discovered && !s.isCurrent))
|
||||||
|
const def = generateMission(type, rng, systems)
|
||||||
|
if (!def) break // no DELIVER target — the other types always generate
|
||||||
|
await db.db.insert(schema.missions).values({ saveId: save.id, type: def.type, description: def.description, targetSystemId: def.targetSystemId, conditionKind: def.conditionKind, progressTarget: def.progressTarget, rewardKind: def.rewardKind, rewardQty: def.rewardQty })
|
||||||
|
available += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mission rewards are granted under one neutral name — spenders (ship actions, research) consume by kind only.
|
||||||
|
export const REWARD_ITEM_NAME = 'MISSION REWARD'
|
||||||
|
|
||||||
|
const kindLabel = (kind: ItemKind): string => (kind === 'resource' ? 'RESOURCES' : 'PARTS')
|
||||||
|
|
||||||
|
// Accepts an AVAILABLE contract: it becomes THE active mission (one at a time), costs a turn like
|
||||||
|
// every other action and writes an event. Returns the fresh board.
|
||||||
|
export async function acceptMission(db: GameDb, missionId: number): Promise<MissionDto[]> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
if (!save) throw new MissionError(404, 'No save yet')
|
||||||
|
const rows = await readMissions(db)
|
||||||
|
const target = rows.find((m) => m.id === missionId)
|
||||||
|
if (!target) throw new MissionError(404, `Unknown mission ${missionId}`)
|
||||||
|
if (target.state !== 'AVAILABLE') throw new MissionError(409, `${target.description} is already ${target.state.toLowerCase()}`)
|
||||||
|
const active = rows.find((m) => m.state === 'ACTIVE')
|
||||||
|
if (active) throw new MissionError(409, `One mission at a time — finish "${active.description}" first`)
|
||||||
|
|
||||||
|
const turn = save.turn + 1
|
||||||
|
await db.db.update(schema.missions).set({ state: 'ACTIVE' }).where(eq(schema.missions.id, target.id))
|
||||||
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'mission', message: `Accepted mission — ${target.description}`, createdAt: new Date().toISOString() })
|
||||||
|
await topUpMissionBoard(db) // one contract left the AVAILABLE list — keep the board topped up
|
||||||
|
await persist(db)
|
||||||
|
|
||||||
|
return readMissions(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs after a jump or scan: advances the ACTIVE mission (if any) and, on completion, marks it
|
||||||
|
// DONE, grants its reward into the cargo hold and writes an event. Called from save.ts inside the
|
||||||
|
// action that triggered it, before persist — so everything lands in one atomic write.
|
||||||
|
export async function checkMissionCompletion(db: GameDb, saveId: number, turn: number, action: MissionAction): Promise<void> {
|
||||||
|
const row = (await db.db.select().from(schema.missions)).find((m) => m.state === 'ACTIVE')
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
const active = { type: row.type as MissionType, targetSystemId: row.targetSystemId, conditionKind: row.conditionKind as ItemKind | null, progressTarget: row.progressTarget, progress: row.progress }
|
||||||
|
const newProgress = advanceMission(active, action)
|
||||||
|
if (newProgress === null) return
|
||||||
|
|
||||||
|
if (newProgress >= active.progressTarget) {
|
||||||
|
await db.db.update(schema.missions).set({ state: 'DONE', progress: newProgress }).where(eq(schema.missions.id, row.id))
|
||||||
|
// The reward lands in the hold like any haul — same kind+name accumulates into one row.
|
||||||
|
const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === row.rewardKind && i.name === REWARD_ITEM_NAME)
|
||||||
|
if (existing) {
|
||||||
|
await db.db.update(schema.inventoryItems).set({ qty: existing.qty + row.rewardQty }).where(eq(schema.inventoryItems.id, existing.id))
|
||||||
|
} else {
|
||||||
|
await db.db.insert(schema.inventoryItems).values({ saveId, kind: row.rewardKind, name: REWARD_ITEM_NAME, qty: row.rewardQty })
|
||||||
|
}
|
||||||
|
await db.db.insert(schema.events).values({ saveId, turn, type: 'mission', message: `Mission complete — ${row.description} (+${row.rewardQty}× ${kindLabel(row.rewardKind as ItemKind)})`, createdAt: new Date().toISOString() })
|
||||||
|
} else {
|
||||||
|
await db.db.update(schema.missions).set({ progress: newProgress }).where(eq(schema.missions.id, row.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-3
@@ -4,6 +4,7 @@ import { persist } from '@/lib/db/client'
|
|||||||
import * as schema from '@/lib/db/schema'
|
import * as schema from '@/lib/db/schema'
|
||||||
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
||||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
|
import { checkMissionCompletion, topUpMissionBoard } from './missions'
|
||||||
import { TECHS } from './tech'
|
import { TECHS } from './tech'
|
||||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
||||||
|
|
||||||
@@ -95,9 +96,8 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi
|
|||||||
|
|
||||||
// What each schema version added to a save. Saves created by older code lack the data newer
|
// What each schema version added to a save. Saves created by older code lack the data newer
|
||||||
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
|
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
|
||||||
// current — each step is idempotent (a no-op when its data already exists). When #10 adds
|
// current — each step is idempotent (a no-op when its data already exists).
|
||||||
// seeding, bump SCHEMA_VERSION and append a step here.
|
export const SCHEMA_VERSION = 6 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board
|
||||||
export const SCHEMA_VERSION = 5 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree
|
|
||||||
|
|
||||||
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
||||||
// unbuilt (level/capacity 0) until a BUILD action.
|
// unbuilt (level/capacity 0) until a BUILD action.
|
||||||
@@ -142,6 +142,7 @@ async function createNewGame(db: GameDb): Promise<void> {
|
|||||||
await seedStartingCrew(db, save.id, seed)
|
await seedStartingCrew(db, save.id, seed)
|
||||||
await seedStartingShip(db, save.id)
|
await seedStartingShip(db, save.id)
|
||||||
await seedStartingTech(db, save.id)
|
await seedStartingTech(db, save.id)
|
||||||
|
await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game
|
||||||
|
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
||||||
await persist(db)
|
await persist(db)
|
||||||
@@ -168,6 +169,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema
|
|||||||
if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id)
|
if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id)
|
||||||
version = 5
|
version = 5
|
||||||
}
|
}
|
||||||
|
if (version < 6) {
|
||||||
|
await topUpMissionBoard(db) // idempotent — only inserts when fewer than MIN_AVAILABLE AVAILABLE contracts exist
|
||||||
|
version = 6
|
||||||
|
}
|
||||||
|
|
||||||
if (from !== SCHEMA_VERSION) {
|
if (from !== SCHEMA_VERSION) {
|
||||||
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
||||||
@@ -233,6 +238,8 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
|
|||||||
|
|
||||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
|
||||||
|
await checkMissionCompletion(db, save.id, turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
|
||||||
|
await topUpMissionBoard(db)
|
||||||
await persist(db)
|
await persist(db)
|
||||||
|
|
||||||
return getWorldState(db)
|
return getWorldState(db)
|
||||||
@@ -357,6 +364,8 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
|
|||||||
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
|
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
|
||||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||||
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
|
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
|
||||||
|
await checkMissionCompletion(db, save.id, turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
|
||||||
|
await topUpMissionBoard(db)
|
||||||
await persist(db)
|
await persist(db)
|
||||||
|
|
||||||
return { ...(await readSystemState(db)), outcome, crewFound }
|
return { ...(await readSystemState(db)), outcome, crewFound }
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ describe('expandSystem', () => {
|
|||||||
expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1)
|
expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1)
|
||||||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]])
|
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]])
|
||||||
|
|
||||||
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'expand')!
|
||||||
|
expect(event.turn).toBe(1)
|
||||||
|
expect(event.message).toContain('HULL')
|
||||||
|
|
||||||
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
|
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
|
||||||
await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 })
|
await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 })
|
||||||
})
|
})
|
||||||
@@ -144,6 +148,7 @@ describe('buildSystem', () => {
|
|||||||
expect(state.turn).toBe(1)
|
expect(state.turn).toBe(1)
|
||||||
|
|
||||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
|
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
|
||||||
|
expect(event.turn).toBe(1)
|
||||||
expect(event.message).toContain('WEAPONS')
|
expect(event.message).toContain('WEAPONS')
|
||||||
|
|
||||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
|
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ describe('generateRing (expansion)', () => {
|
|||||||
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
for (let i = 0; i < 40; i++) makeName(rng, taken)
|
||||||
expect(taken.size).toBe(40)
|
expect(taken.size).toBe(40)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('terminates with numbered variants once all 128 base names are taken', () => {
|
||||||
|
const rng = mulberry32(9)
|
||||||
|
const taken = new Set<string>()
|
||||||
|
for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool
|
||||||
|
expect(taken.size).toBe(128)
|
||||||
|
|
||||||
|
const a = makeName(rng, taken) // must return instead of spinning forever
|
||||||
|
const b = makeName(rng, taken)
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
expect(taken.has(a)).toBe(true)
|
||||||
|
expect(taken.has(b)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('generatePlanets', () => {
|
describe('generatePlanets', () => {
|
||||||
|
|||||||
+17
-5
@@ -36,13 +36,25 @@ function title(word: string): string {
|
|||||||
return word.charAt(0) + word.slice(1).toLowerCase()
|
return word.charAt(0) + word.slice(1).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one.
|
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one. The base
|
||||||
|
// pool is finite (16×8=128), so once every base is taken the loop would spin forever — which
|
||||||
|
// froze the whole server on a save that had grown past 128 systems (synchronous sql.js runs on
|
||||||
|
// Node's single thread). Numbered variants keep names unique and terminate.
|
||||||
export function makeName(rng: () => number, taken: Set<string>): string {
|
export function makeName(rng: () => number, taken: Set<string>): string {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
const base = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
||||||
if (!taken.has(name)) {
|
if (!taken.has(base)) {
|
||||||
taken.add(name)
|
taken.add(base)
|
||||||
return name
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ describe('OpenAPI spec', () => {
|
|||||||
it('documents every public endpoint', () => {
|
it('documents every public endpoint', () => {
|
||||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||||
'/api/crew',
|
'/api/crew',
|
||||||
|
'/api/events',
|
||||||
'/api/explore',
|
'/api/explore',
|
||||||
'/api/jump',
|
'/api/jump',
|
||||||
|
'/api/missions',
|
||||||
|
'/api/missions/{id}/accept',
|
||||||
'/api/research',
|
'/api/research',
|
||||||
'/api/ship',
|
'/api/ship',
|
||||||
'/api/ship/build',
|
'/api/ship/build',
|
||||||
@@ -58,6 +61,39 @@ describe('OpenAPI spec', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('describes the mission endpoints: GET /api/missions → Mission[], POST accept with error responses', () => {
|
||||||
|
const missions = openApiSpec.paths['/api/missions'].get.responses[200].content['application/json'].schema;
|
||||||
|
expect(missions.type).toBe('array');
|
||||||
|
expect((missions.items as { $ref: string }).$ref).toBe('#/components/schemas/Mission');
|
||||||
|
|
||||||
|
const props = openApiSpec.components.schemas.Mission.properties;
|
||||||
|
expect(Object.keys(props)).toEqual(['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state']);
|
||||||
|
expect((props.type as { enum: string[] }).enum).toEqual(['DELIVER', 'EXPLORE', 'FIND']);
|
||||||
|
expect((props.state as { enum: string[] }).enum).toEqual(['AVAILABLE', 'ACTIVE', 'DONE']);
|
||||||
|
|
||||||
|
const post = openApiSpec.paths['/api/missions/{id}/accept'].post;
|
||||||
|
expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Mission');
|
||||||
|
for (const status of ['400', '404', '409'] as const) {
|
||||||
|
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes GET /api/events → Event[] with a limit query param and a 400 error response', () => {
|
||||||
|
const events = openApiSpec.paths['/api/events'].get.responses[200].content['application/json'].schema;
|
||||||
|
expect(events.type).toBe('array');
|
||||||
|
expect((events.items as { $ref: string }).$ref).toBe('#/components/schemas/Event');
|
||||||
|
|
||||||
|
const props = openApiSpec.components.schemas.Event.properties;
|
||||||
|
expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']);
|
||||||
|
expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission']);
|
||||||
|
|
||||||
|
const get = openApiSpec.paths['/api/events'].get;
|
||||||
|
const limit = (get.parameters ?? []).find((p) => p.name === 'limit');
|
||||||
|
expect(limit?.in).toBe('query');
|
||||||
|
expect(limit?.required).toBe(false);
|
||||||
|
expect(get.responses['400'].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
|
});
|
||||||
|
|
||||||
it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => {
|
it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => {
|
||||||
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
||||||
expect(crew.type).toBe('array');
|
expect(crew.type).toBe('array');
|
||||||
|
|||||||
+130
@@ -454,6 +454,109 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/api/missions': {
|
||||||
|
get: {
|
||||||
|
summary: 'Mission board',
|
||||||
|
description:
|
||||||
|
"The save's mission contracts — generated deterministically from the seed, always topped up to at least three AVAILABLE. One mission is ACTIVE at a time; completion is checked after jumps and scans (DELIVER on arrival at its target system, EXPLORE after N scans, FIND when a haul contains its kind) and grants the reward into the cargo hold. Auto-initializes a new game when no save exists yet.",
|
||||||
|
operationId: 'getMissions',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'The mission board',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Mission' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/missions/{id}/accept': {
|
||||||
|
post: {
|
||||||
|
summary: 'Accept a mission',
|
||||||
|
description:
|
||||||
|
"Makes an AVAILABLE contract the single ACTIVE one (409 while another is active, or when it isn't AVAILABLE). Costs a turn and writes a mission event. Returns the fresh board.",
|
||||||
|
operationId: 'acceptMission',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
schema: { type: 'integer' },
|
||||||
|
description: 'missions.id of the contract to accept',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Fresh mission board after the acceptance',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Mission' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'Non-integer id in the path',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown mission id',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: "Mission isn't AVAILABLE, or another mission is already ACTIVE (one at a time)",
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/events': {
|
||||||
|
get: {
|
||||||
|
summary: 'Event log',
|
||||||
|
description: "The save's game events, newest first — every action (new game, jump, scan, research, ship work, mission) appends one. Auto-initializes a new game when no save exists yet.",
|
||||||
|
operationId: 'getEvents',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'limit',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
schema: { type: 'integer', minimum: 1, maximum: 200 },
|
||||||
|
description: 'How many of the latest events to return (default 50)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Latest events, newest first',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Event' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'limit is not an integer in [1, 200]',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -597,6 +700,33 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['techId'],
|
required: ['techId'],
|
||||||
},
|
},
|
||||||
|
Mission: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
type: { type: 'string', enum: ['DELIVER', 'EXPLORE', 'FIND'] },
|
||||||
|
description: { type: 'string', description: "Human-readable objective, e.g. 'Jump to Kepler Drift'" },
|
||||||
|
targetSystemId: { type: ['integer', 'null'], nullable: true, description: 'DELIVER only — destination system (NULL otherwise)' },
|
||||||
|
conditionKind: { type: ['string', 'null'], enum: ['resource', 'part', null], description: 'FIND only — item kind to haul in (NULL otherwise)' },
|
||||||
|
progressTarget: { type: 'integer', description: 'EXPLORE: scans required; 1 for the others' },
|
||||||
|
progress: { type: 'integer', description: 'Scans counted since acceptance; 0/1 for DELIVER/FIND' },
|
||||||
|
rewardKind: { type: 'string', enum: ['resource', 'part'] },
|
||||||
|
rewardQty: { type: 'integer', description: 'Reward quantity (1–3), granted into the cargo hold on completion' },
|
||||||
|
state: { type: 'string', enum: ['AVAILABLE', 'ACTIVE', 'DONE'] },
|
||||||
|
},
|
||||||
|
required: ['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state'],
|
||||||
|
},
|
||||||
|
Event: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
turn: { type: 'integer', description: 'The save turn the action cost (new_game is 0)' },
|
||||||
|
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] },
|
||||||
|
message: { type: 'string' },
|
||||||
|
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
|
||||||
|
},
|
||||||
|
required: ['id', 'turn', 'type', 'message', 'createdAt'],
|
||||||
|
},
|
||||||
UpgradeRequest: {
|
UpgradeRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.7.0",
|
"version": "0.9.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user