Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18ca5eae6c | ||
|
|
b05ac32da6 | ||
|
|
85627201f8 | ||
|
|
fb11a8ef81 | ||
|
|
37b193ffb8 | ||
|
|
760a45fc4b | ||
|
|
0b435a583b | ||
|
|
29e8ac6909 | ||
|
|
21abf8768f | ||
|
|
8d678d73ce | ||
|
|
0d5858695a | ||
|
|
6c828e40a6 |
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- 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.
|
- New table (`lib/db/schema.ts` + DDL): `missions` (id/save_id/type/description/target_system_id/condition_kind/progress_target/progress/reward_kind/reward_qty/state). Seeded on new game AND as backfill step v6 — same pattern as tech; saves created by older code get their board on first read. `SCHEMA_VERSION` 5 → 6.
|
||||||
|
|||||||
@@ -0,0 +1,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,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>
|
||||||
|
|||||||
+73
-151
@@ -7,23 +7,23 @@ 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 { 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'
|
||||||
|
|
||||||
// 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, SKILL TREE and MISSIONS tabs render live data
|
// tabs render live data (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions,
|
||||||
// (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions); the EVENT LOG tab
|
// /api/events); the right column's CARGO HOLD renders from the lifted /api/system state. Single
|
||||||
// keeps placeholder content until its own issue lands. 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,44 +37,15 @@ function useNarrow(threshold = 900): boolean {
|
|||||||
return narrow
|
return narrow
|
||||||
}
|
}
|
||||||
|
|
||||||
function now(): string {
|
|
||||||
return new Date().toLocaleTimeString('en-GB', { hour12: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
|
||||||
|
|
||||||
const encounters = [
|
|
||||||
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
|
||||||
{ name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
|
|
||||||
{ name: 'ANOMALY FIELD', when: 'T-48:12', id: '#E-104' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const shipData = [
|
|
||||||
{ label: 'TOTAL MODULES', value: '34' },
|
|
||||||
{ label: 'HULL INTEGRITY', value: '87%' },
|
|
||||||
{ label: 'REACTOR OUTPUT', value: '94.2%' },
|
|
||||||
{ label: 'CREW ACTIVE', value: '3 / 6' },
|
|
||||||
{ label: 'SECTOR', value: '7G // DRIFT LANE' },
|
|
||||||
{ label: 'DRIFT VELOCITY', value: '0.4 KM/S' },
|
|
||||||
{ label: 'TURN', value: '12' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const logLines: TerminalLine[] = [
|
|
||||||
{ type: 'system', text: 'SPACEWANDERER // INITIALIZING SHIP SYSTEMS...', timestamp: now() },
|
|
||||||
{ type: 'output', text: 'HULL SCAN COMPLETE — INTEGRITY 87%', timestamp: now() },
|
|
||||||
{ type: 'output', text: 'REACTOR OUTPUT STABLE AT 94.2%', timestamp: now() },
|
|
||||||
{ type: 'output', text: 'CREW MANIFEST LOADED — 3 ACTIVE PERSONNEL', timestamp: now() },
|
|
||||||
{ type: 'warn', text: 'WEAPONS OFFLINE — MODULE NOT INSTALLED', timestamp: now() },
|
|
||||||
{ type: 'system', text: 'ALL SYSTEMS NOMINAL. AWAITING FIRST TURN.', timestamp: now() },
|
|
||||||
]
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -89,6 +60,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}`)
|
||||||
@@ -263,7 +243,7 @@ export function MissionControl() {
|
|||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
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 +291,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>
|
||||||
@@ -366,9 +369,9 @@ export function MissionControl() {
|
|||||||
<WorldMapPanel height={380} />
|
<WorldMapPanel height={380} />
|
||||||
</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 */}
|
||||||
@@ -393,19 +396,14 @@ export function MissionControl() {
|
|||||||
|
|
||||||
{/* 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)',
|
||||||
@@ -418,110 +416,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>
|
||||||
|
|||||||
+38
-1
@@ -13,6 +13,7 @@ interface CrewDto { id: number; name: string; role: string; traitGood: string; t
|
|||||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
interface 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 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);
|
||||||
|
|
||||||
@@ -64,7 +65,9 @@ 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('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 }) => {
|
||||||
@@ -362,6 +365,40 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn
|
|||||||
expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty);
|
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);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface ShipSystemDto { id: number; type: string; level: number; capacity: num
|
|||||||
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
interface 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 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);
|
||||||
@@ -59,6 +60,11 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const missionsBefore = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
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
|
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' });
|
||||||
|
|
||||||
@@ -105,6 +111,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const missionsAfter = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
const missionsAfter = (await (await page.request.get('/api/missions')).json()) as MissionDto[];
|
||||||
expect(missionsAfter).toEqual(missionsBefore);
|
expect(missionsAfter).toEqual(missionsBefore);
|
||||||
|
|
||||||
|
// Event log survived too: same latest lines, in the same order.
|
||||||
|
const eventsAfter = (await (await page.request.get('/api/events?limit=5')).json()) as EventDto[];
|
||||||
|
expect(eventsAfter).toEqual(eventsBefore);
|
||||||
|
|
||||||
// The page itself serves again with the landing title.
|
// The page itself serves again with the landing title.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import { getWorldState } from './save'
|
||||||
|
import { DEFAULT_LIMIT, EventError, MAX_LIMIT, getEvents, parseEventLimit } from './events'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'sw-events-test-'))
|
||||||
|
dirs.push(dir)
|
||||||
|
const file = path.join(dir, 'test.db')
|
||||||
|
return { db: await openGameDb(file), file }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Appends `count` synthetic events (turns 1..count) on top of the turn-0 new_game event.
|
||||||
|
async function seedEvents(db: GameDb, count: number): Promise<void> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
for (let i = 1; i <= count; i++) {
|
||||||
|
await db.db.insert(schema.events).values({ saveId: save.id, turn: i, type: 'test', message: `event ${i}`, createdAt: new Date(Date.UTC(2026, 8, 29, 0, i)).toISOString() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseEventLimit', () => {
|
||||||
|
it('defaults to DEFAULT_LIMIT when the param is absent', () => {
|
||||||
|
expect(parseEventLimit(null)).toBe(DEFAULT_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts integers in [1, MAX_LIMIT]', () => {
|
||||||
|
expect(parseEventLimit('1')).toBe(1)
|
||||||
|
expect(parseEventLimit(String(MAX_LIMIT))).toBe(MAX_LIMIT)
|
||||||
|
expect(parseEventLimit('25')).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-integers and out-of-range values with a 400', () => {
|
||||||
|
for (const bad of ['abc', '', '0', '-3', String(MAX_LIMIT + 1), '2.5']) {
|
||||||
|
try {
|
||||||
|
parseEventLimit(bad)
|
||||||
|
expect.unreachable(`limit ${JSON.stringify(bad)} should have been rejected`)
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(EventError)
|
||||||
|
expect((err as EventError).status).toBe(400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getEvents', () => {
|
||||||
|
it('returns the latest events newest-first, sliced to limit', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
await getWorldState(db) // auto-initializes the save (turn-0 new_game event)
|
||||||
|
await seedEvents(db, 5)
|
||||||
|
|
||||||
|
const all = await getEvents(db, DEFAULT_LIMIT)
|
||||||
|
expect(all.map((e) => e.turn)).toEqual([5, 4, 3, 2, 1, 0]) // newest first, turn-0 line included
|
||||||
|
expect(all[all.length - 1].type).toBe('new_game')
|
||||||
|
|
||||||
|
const two = await getEvents(db, 2)
|
||||||
|
expect(two.map((e) => [e.turn, e.message])).toEqual([[5, 'event 5'], [4, 'event 4']])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { desc } from 'drizzle-orm'
|
||||||
|
import * as schema from '@/lib/db/schema'
|
||||||
|
import type { GameDb } from '@/lib/db/client'
|
||||||
|
|
||||||
|
export interface EventDto {
|
||||||
|
id: number
|
||||||
|
turn: number
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LIMIT = 50
|
||||||
|
export const MAX_LIMIT = 200
|
||||||
|
|
||||||
|
export class EventError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses the optional ?limit= query param. Absent → default; anything that is not an integer in
|
||||||
|
// [1, MAX_LIMIT] is a 400 (the route maps EventError to its status).
|
||||||
|
export function parseEventLimit(raw: string | null): number {
|
||||||
|
if (raw === null) return DEFAULT_LIMIT
|
||||||
|
const limit = Number(raw)
|
||||||
|
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
||||||
|
throw new EventError(400, `limit must be an integer between 1 and ${MAX_LIMIT}`)
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest N events of the save, newest first. Assumes a save exists (the route calls ensureSave).
|
||||||
|
export async function getEvents(db: GameDb, limit: number): Promise<EventDto[]> {
|
||||||
|
const rows = await db.db.select().from(schema.events).orderBy(desc(schema.events.id)).limit(limit)
|
||||||
|
return rows.map((e) => ({ id: e.id, turn: e.turn, type: e.type, message: e.message, createdAt: e.createdAt }))
|
||||||
|
}
|
||||||
@@ -185,6 +185,7 @@ describe('completion', () => {
|
|||||||
expect(reward.qty).toBe(2)
|
expect(reward.qty).toBe(2)
|
||||||
|
|
||||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))!
|
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')
|
expect(event.message).toContain('Scan 3 bodies')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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',
|
||||||
@@ -77,6 +78,22 @@ describe('OpenAPI spec', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('describes GET /api/events → Event[] with a limit query param and a 400 error response', () => {
|
||||||
|
const events = openApiSpec.paths['/api/events'].get.responses[200].content['application/json'].schema;
|
||||||
|
expect(events.type).toBe('array');
|
||||||
|
expect((events.items as { $ref: string }).$ref).toBe('#/components/schemas/Event');
|
||||||
|
|
||||||
|
const props = openApiSpec.components.schemas.Event.properties;
|
||||||
|
expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']);
|
||||||
|
expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission']);
|
||||||
|
|
||||||
|
const get = openApiSpec.paths['/api/events'].get;
|
||||||
|
const limit = (get.parameters ?? []).find((p) => p.name === 'limit');
|
||||||
|
expect(limit?.in).toBe('query');
|
||||||
|
expect(limit?.required).toBe(false);
|
||||||
|
expect(get.responses['400'].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||||
|
});
|
||||||
|
|
||||||
it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => {
|
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');
|
||||||
|
|||||||
@@ -523,6 +523,40 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/api/events': {
|
||||||
|
get: {
|
||||||
|
summary: 'Event log',
|
||||||
|
description: "The save's game events, newest first — every action (new game, jump, scan, research, ship work, mission) appends one. Auto-initializes a new game when no save exists yet.",
|
||||||
|
operationId: 'getEvents',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'limit',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
schema: { type: 'integer', minimum: 1, maximum: 200 },
|
||||||
|
description: 'How many of the latest events to return (default 50)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Latest events, newest first',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'array', items: { $ref: '#/components/schemas/Event' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: 'limit is not an integer in [1, 200]',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Error' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -682,6 +716,17 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state'],
|
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.8.0",
|
"version": "0.9.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user