Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd16d3fc47 | ||
|
|
dcaeb4dded | ||
|
|
7e975566ab | ||
|
|
de0cb45b09 | ||
|
|
fce27ff6ca | ||
|
|
5fe306d64d | ||
|
|
0910891995 | ||
|
|
c7580d92bd | ||
|
|
f5cba45b42 | ||
|
|
59ccf5674e | ||
|
|
60947d3cda | ||
|
|
08a15a09cf | ||
|
|
a2c8586618 | ||
|
|
7a9ff110f3 | ||
|
|
ccd156b7c6 |
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.0 — 2026-09-28 23:46
|
||||
- SHIP SYSTEMS tab — expand / upgrade / build ship systems + save backfill plumbing (issue #8): the first spend-side game loop on top of issue #7's crew, plus a root-cause fix for saves created by older code lacking data newer code seeds. Single-player, no auth, by design.
|
||||
- Backfill plumbing: `schema_version` INTEGER added to `saves` (NULL = v1). `ensureSave` now runs ordered IDEMPOTENT backfill steps for anything below current — v2: every system without planets gets its generated bodies (same `(seed, systemId)` derivation as fresh generation), v3: starting crew if no rows. New games get the current version directly. `lib/db/client.ts` open() carries an idempotent `ALTER TABLE ... ADD COLUMN` guard for files created before the column existed — old saves open fine and backfill on first read, so DB wipes are no longer needed when deploying new versions.
|
||||
- New table (`lib/db/schema.ts` + DDL): `ship_systems` (id/save_id/type/level/capacity, UNIQUE(save_id,type)). `built` is derived, not stored — level > 0 means built; unbuilt rows sit at level/capacity 0. Starting ship seeded on new game AND as backfill step v4: core types (HULL, REACTOR, LIFE SUPPORT, NAV COMPUTER, COMMS ARRAY) built at base values, WEAPONS unbuilt until a build action.
|
||||
- Cost model (`lib/game/ship-costs.ts`, pure — imported by both the API and the UI so affordability can't drift): UPGRADE costs `level` units of any resource (capped at MAX_LEVEL 5), EXPAND costs `capacity` units of any resource (capped at MAX_CAPACITY 10), BUILD costs a flat 3 parts. Consumption takes from the oldest cargo row of the required kind first, deleting rows that hit zero; an unaffordable action mutates nothing.
|
||||
- Ship actions (`lib/game/ship.ts`): `upgradeSystem` / `expandSystem` / `buildSystem` — validate (404 unknown id/type, 409 not built / already built / at cap, 422 insufficient inventory), spend the cost, bump level/capacity or build at base values, cost one turn and write an event each.
|
||||
- New endpoints: `GET /api/ship` → `{systems:[{id,type,level,capacity,built}],inventory,turn}`; `POST /api/ship/upgrade {systemId}`, `POST /api/ship/expand {systemId}`, `POST /api/ship/build {type}` — each returns the fresh ship state. OpenAPI spec in code updated (10 paths).
|
||||
- SHIP SYSTEMS tab now renders REAL data: cards show type + level/capacity with UPGRADE / EXPAND buttons per built card and BUILD NEW for unbuilt types, disabled when unaffordable or capped; each action's response is applied directly (no polling). Left column mirrors live state too — the SHIP SYSTEMS panel lists every system with its status/level/capacity + an N ONLINE badge, and VESSEL METRICS bars are driven by real values (hull/reactor from system levels, crew roster vs a cap of 6). Placeholder constants for both removed; other tabs keep theirs until their issues land.
|
||||
- Tests: unit — backfill upgrades a hand-built v1 save in place (deterministic vs fresh generation, idempotent), starting ship seeding, cost validation with no mutation on failure, exact oldest-first inventory deduction, level/capacity caps, build-once, turn + event per action; e2e — SHIP SYSTEMS tab performs one affordable UPGRADE/BUILD via the UI after a bounded scan loop until something is affordable (asserting card + left column reflect it), and ship state survives the mid-run container restart.
|
||||
|
||||
## v0.5.0 — 2026-09-28 22:51
|
||||
- CREW tab — crew roster with good/bad traits (issue #7): the ship's people on top of issue #6's exploration loop. Single-player, no auth, by design. Display + arrival only — no dismiss/assign actions yet.
|
||||
- New table (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `crew` (id/save_id/name/role/trait_good/trait_bad/origin). Origin is 'STARTING CREW' for the seeded crew or '<planet>, <system>' for members found while exploring.
|
||||
- Pure seeded crew generation (`lib/game/crew.ts`, same pattern as world.ts): name pools (16 first × 22 last, unique per roster), role pool of 8, good/bad trait pools of 10 each. `generateStartingCrew` gives a new game 2–3 members with unique names and roles; `rollCrewMember` finds a joining member on ~18% of scans (`CREW_CHANCE = 0.18`).
|
||||
- Starting crew is seeded in `ensureSave` from the save seed via `startingCrewRng(seed)` — same per-save derivation pattern as bodies/exploration, so a given seed always starts with the same crew.
|
||||
- Exploration extended: the crew roll draws from the SAME per-planet rng stream as the resource/part haul (called after it in `explorePlanet`), so outcomes stay deterministic per save+planet and existing hauls are unchanged. Found members join the roster with origin = '<planet>, <system>'; each body can only be scanned once, so no duplicate joins.
|
||||
- New endpoint: `GET /api/crew` → `[{id,name,role,trait_good,trait_bad,origin}]` in join order (auto-initializes like the others). `POST /api/explore` responses now also carry `crewFound` (the joining member or null). OpenAPI spec in code (`lib/openapi.ts`) updated for both.
|
||||
- CREW tab now renders REAL data: fetches `/api/crew` on mount (Radix unmounts inactive tabs, so opening the tab always refetches — no polling), cards show name + role, the good (+) and bad (−) traits and an ORIGIN line. Placeholder crew constants removed for this tab only — other tabs keep theirs until their issues land.
|
||||
|
||||
## v0.4.0 — 2026-09-28 22:17
|
||||
- LOCAL SYSTEM tab — planet list + exploration to find things (issue #6): the first in-system game loop on top of issue #5's save/world foundation. Single-player, no auth, by design.
|
||||
- New tables (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `planets` (id/system_id/parent_id/name/type/hazard/explored — planets have parent_id NULL, moons point at their planet) and `inventory_items` (id/save_id/kind[name]/qty — the dumb-simple cargo hold SHIP SYSTEMS (#8) will spend from later).
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { getCrewState } from '@/lib/game/save'
|
||||
|
||||
// The ship's crew roster: starting crew plus members found while exploring. Auto-initializes on first hit.
|
||||
export async function GET() {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await getCrewState(db))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, buildSystem } from '@/lib/game/ship'
|
||||
|
||||
// Build an unbuilt ship system type at base values. Body: { "type": string }.
|
||||
// Costs inventory items + a turn; returns the fresh ship state.
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const type = (body as { type?: unknown }).type
|
||||
if (typeof type !== 'string' || type.length === 0) {
|
||||
return Response.json({ error: 'type must be a non-empty string' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await buildSystem(db, type))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, expandSystem } from '@/lib/game/ship'
|
||||
|
||||
// Raise a built ship system's capacity by one (capped at max capacity). Body: { "systemId": number }.
|
||||
// Costs inventory items + a turn; returns the fresh ship state.
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const systemId = (body as { systemId?: unknown }).systemId
|
||||
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await expandSystem(db, systemId))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { getShipState } from '@/lib/game/ship'
|
||||
|
||||
// The ship's systems + cargo hold + turn; auto-initializes on first hit.
|
||||
export async function GET() {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await getShipState(db))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ShipError, upgradeSystem } from '@/lib/game/ship'
|
||||
|
||||
// Level up a built ship system by one (capped at max level). Body: { "systemId": number }.
|
||||
// Costs inventory items + a turn; returns the fresh ship state.
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const systemId = (body as { systemId?: unknown }).systemId
|
||||
if (typeof systemId !== 'number' || !Number.isInteger(systemId)) {
|
||||
return Response.json({ error: 'systemId must be an integer' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await upgradeSystem(db, systemId))
|
||||
} catch (err) {
|
||||
if (err instanceof ShipError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { CrewDto } from '@/lib/game/save'
|
||||
|
||||
// CREW tab — the ship's roster: starting crew plus members found while exploring. Fetches
|
||||
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
|
||||
// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet.
|
||||
|
||||
interface CrewPanelProps {
|
||||
narrow?: boolean
|
||||
}
|
||||
|
||||
export function CrewPanel({ narrow }: CrewPanelProps) {
|
||||
const [roster, setRoster] = useState<CrewDto[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as CrewDto[]
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setRoster(r)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error && !roster) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||
CREW MANIFEST OFFLINE // {error.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!roster) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING CREW MANIFEST…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{roster.map((member) => (
|
||||
<Card key={member.id}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{member.name}</CardTitle>
|
||||
<Badge variant="ACTIVE" className="ml-auto">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{member.role}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.traitGood}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.traitBad}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
ORIGIN // {member.origin.toUpperCase()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+67
-104
@@ -11,13 +11,19 @@ import { StatCard } from '@/components/ui/stat-card'
|
||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||
import { CrewPanel } from '@/components/crew-panel'
|
||||
import { LocalSystemPanel } from '@/components/local-system'
|
||||
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||
import { WorldMapPanel } from '@/components/world-map'
|
||||
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
||||
import type { CrewDto } from '@/lib/game/save'
|
||||
import type { ShipState } from '@/lib/game/ship'
|
||||
|
||||
// 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
|
||||
// WORLD MAP and LOCAL SYSTEM tabs render live data (/api/world, /api/system); the other
|
||||
// tabs keep placeholder content until their own issues land. Single player, no auth, by design.
|
||||
// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system,
|
||||
// /api/crew, /api/ship); the other tabs keep placeholder content until their own issues land.
|
||||
// Single player, no auth, by design.
|
||||
|
||||
function useNarrow(threshold = 900): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
@@ -37,29 +43,6 @@ function now(): string {
|
||||
|
||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
||||
|
||||
const shipSystems: SystemEntry[] = [
|
||||
{ name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
|
||||
{ name: 'LIFE SUPPORT', status: 'ACTIVE' },
|
||||
{ name: 'NAV COMPUTER', status: 'SCANNING' },
|
||||
{ name: 'WEAPONS', status: 'OFFLINE' },
|
||||
{ name: 'COMMS', status: 'ACTIVE' },
|
||||
]
|
||||
|
||||
const crew = [
|
||||
{ name: 'CMDR. E. VANCE', role: 'COMMANDER', good: 'STEADY UNDER PRESSURE', bad: 'RIGID — SLOW TO ADAPT' },
|
||||
{ name: 'LT. M. OKONKWO', role: 'PILOT', good: 'RECKLESS COURAGE', bad: 'SHORT FUSE' },
|
||||
{ name: 'DR. I. SORENSEN', role: 'MEDIC', good: 'CALM IN A CRISIS', bad: 'OVERCAUTIOUS' },
|
||||
{ name: 'R. "SPARK" DELACRUZ', role: 'ENGINEER', good: 'FIXES ANYTHING', bad: 'CARELESS WITH FUSES' },
|
||||
]
|
||||
|
||||
const shipSystemCards = [
|
||||
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
{ name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
{ name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] },
|
||||
{ name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
|
||||
]
|
||||
|
||||
const skillCards = [
|
||||
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
||||
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
||||
@@ -95,6 +78,36 @@ const logLines: TerminalLine[] = [
|
||||
export function MissionControl() {
|
||||
const narrow = useNarrow()
|
||||
const [tick, setTick] = useState(0)
|
||||
// Ship state is owned here (not in the tab panel) so the left column mirrors ship actions
|
||||
// immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count
|
||||
// feeds the roster bar — refetched on page load, like the other panels.
|
||||
const [ship, setShip] = useState<ShipState | null>(null)
|
||||
const [crewCount, setCrewCount] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/ship')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as ShipState
|
||||
})
|
||||
.then((s) => {
|
||||
if (!cancelled) setShip(s)
|
||||
})
|
||||
.catch(() => {}) // left column + tab keep their loading state on failure
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as CrewDto[]
|
||||
})
|
||||
.then((c) => {
|
||||
if (!cancelled) setCrewCount(c.length)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
@@ -263,11 +276,25 @@ export function MissionControl() {
|
||||
<PanelHeader>
|
||||
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Badge variant="ACTIVE">4 ONLINE</Badge>
|
||||
<Badge variant={ship && ship.systems.some((s) => s.built) ? 'ACTIVE' : 'OFFLINE'}>
|
||||
{ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<StatusGrid systems={shipSystems} />
|
||||
{ship ? (
|
||||
<StatusGrid
|
||||
systems={ship.systems.map((s) => ({
|
||||
name: s.type,
|
||||
status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const),
|
||||
detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '0.5rem 0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel notch="sm">
|
||||
@@ -275,11 +302,17 @@ export function MissionControl() {
|
||||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||
</PanelHeader>
|
||||
<PanelContent>
|
||||
{ship ? (
|
||||
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
||||
// crew roster vs the display cap.
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||
<Progress label="HULL INTEGRITY" value={87} />
|
||||
<Progress label="REACTOR OUTPUT" value={94} />
|
||||
<Progress label="CREW ROSTER / 6" value={50} />
|
||||
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
|
||||
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
|
||||
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||
)}
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
</div>
|
||||
@@ -331,84 +364,14 @@ export function MissionControl() {
|
||||
<LocalSystemPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* CREW TAB */}
|
||||
{/* CREW TAB — live data from /api/crew */}
|
||||
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{crew.map((member) => (
|
||||
<Card key={member.name}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{member.name}</CardTitle>
|
||||
<Badge variant="ACTIVE" className="ml-auto">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{member.role}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.good}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.bad}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<CrewPanel narrow={narrow} />
|
||||
</TabsContent>
|
||||
|
||||
{/* SHIP SYSTEMS TAB */}
|
||||
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
||||
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{shipSystemCards.map((system) => (
|
||||
<Card key={system.name}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{system.name}</CardTitle>
|
||||
<Badge variant={system.status} className="ml-auto">
|
||||
{system.level}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
MODULE BAY // PLACEHOLDER READOUT
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-2">
|
||||
{system.actions.map((action) => (
|
||||
<Button key={action} variant="OUTLINE" size="SM">
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} />
|
||||
</TabsContent>
|
||||
|
||||
{/* SKILL TREE TAB */}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
|
||||
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
|
||||
|
||||
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left
|
||||
// column mirrors actions immediately; this panel applies each action's response directly via
|
||||
// onChanged (no polling). Buttons are disabled when unaffordable or capped, using the same
|
||||
// cost constants the API enforces (lib/game/ship-costs.ts).
|
||||
|
||||
interface ShipSystemsPanelProps {
|
||||
ship: ShipState | null
|
||||
narrow?: boolean
|
||||
onChanged: (next: ShipState) => void
|
||||
}
|
||||
|
||||
export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
||||
|
||||
if (!ship) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING SHIP SYSTEMS…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const resources = totalQty(ship.inventory, 'resource')
|
||||
const parts = totalQty(ship.inventory, 'part')
|
||||
|
||||
async function act(url: string, body: unknown) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
const data = (await res.json()) as ShipState & { error?: string }
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||
onChanged(data) // response is the fresh ship state — apply directly
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{error && (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
|
||||
{ship.systems.map((system) => (
|
||||
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} onAct={act} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemCard({
|
||||
system,
|
||||
resources,
|
||||
parts,
|
||||
busy,
|
||||
onAct,
|
||||
}: {
|
||||
system: ShipSystemDto
|
||||
resources: number
|
||||
parts: number
|
||||
busy: boolean
|
||||
onAct: (url: string, body: unknown) => void
|
||||
}) {
|
||||
const upgradeDisabled = busy || !system.built || system.level >= MAX_LEVEL || resources < upgradeCost(system.level)
|
||||
const expandDisabled = busy || !system.built || system.capacity >= MAX_CAPACITY || resources < expandCost(system.capacity)
|
||||
const buildDisabled = busy || system.built || parts < BUILD_PART_COST
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{system.type}</CardTitle>
|
||||
<Badge variant={system.built ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
|
||||
{system.built ? `LV ${system.level}` : 'OFFLINE'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
|
||||
{system.built ? `LEVEL ${system.level} · CAPACITY ${system.capacity}` : 'NOT INSTALLED'}
|
||||
</p>
|
||||
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
{system.built
|
||||
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
|
||||
: `BUILD ${BUILD_PART_COST}× PARTS`}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-2">
|
||||
{system.built ? (
|
||||
<>
|
||||
<Button variant="OUTLINE" size="SM" disabled={upgradeDisabled} onClick={() => onAct('/api/ship/upgrade', { systemId: system.id })}>
|
||||
UPGRADE
|
||||
</Button>
|
||||
<Button variant="OUTLINE" size="SM" disabled={expandDisabled} onClick={() => onAct('/api/ship/expand', { systemId: system.id })}>
|
||||
EXPAND
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="OUTLINE" size="SM" disabled={buildDisabled} onClick={() => onAct('/api/ship/build', { type: system.type })}>
|
||||
BUILD NEW
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
+86
-1
@@ -7,7 +7,12 @@ interface PlanetDto { id: number; name: string; type: string; hazard: boolean; e
|
||||
interface InventoryItemDto { kind: string; name: string; qty: number }
|
||||
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number }
|
||||
interface FoundItem { kind: string; name: string; qty: number }
|
||||
interface ExploreResult extends SystemState { outcome: FoundItem[] }
|
||||
interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown }
|
||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||
interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number }
|
||||
|
||||
const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0);
|
||||
|
||||
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -119,6 +124,86 @@ test('exploration yields finds that accumulate in the inventory', async ({ page
|
||||
}
|
||||
});
|
||||
|
||||
test('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => {
|
||||
const roster = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
||||
expect(roster.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'CREW' }).click();
|
||||
|
||||
for (const member of roster) {
|
||||
const heading = page.getByRole('heading', { name: member.name });
|
||||
await expect(heading).toBeVisible();
|
||||
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
|
||||
const card = heading.locator('xpath=ancestor::div[3]');
|
||||
await expect(card.getByText(member.role)).toBeVisible();
|
||||
await expect(card.getByText(member.traitGood)).toBeVisible();
|
||||
await expect(card.getByText(member.traitBad)).toBeVisible();
|
||||
await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card and left column', async ({ page }) => {
|
||||
// Scan bodies until something is affordable: an upgrade costs `level` resources (at least one),
|
||||
// a build costs three parts. Jumping keeps it going if a whole system is exhausted.
|
||||
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
const upgradeTarget = () => ship.systems.find((s) => s.built && s.level < 5 && totalQty(ship.inventory, 'resource') >= s.level);
|
||||
const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built) : undefined);
|
||||
|
||||
let iterations = 0;
|
||||
while (!upgradeTarget() && !buildTarget() && iterations < 60) {
|
||||
iterations++;
|
||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
const unexplored = sys.planets.filter((p) => !p.explored);
|
||||
if (unexplored.length > 0) {
|
||||
expect((await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } })).status()).toBe(200);
|
||||
} else {
|
||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const currentId = world.systems.find((s) => s.current)!.id;
|
||||
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||
expect((await page.request.post('/api/jump', { data: { systemId: targetId } })).status()).toBe(200);
|
||||
}
|
||||
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
}
|
||||
|
||||
const upgrade = upgradeTarget();
|
||||
const build = !upgrade ? buildTarget() : undefined;
|
||||
expect(upgrade ?? build).toBeDefined(); // the bounded scan always finds something to spend on
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||
|
||||
if (upgrade) {
|
||||
const card = page.getByRole('heading', { name: upgrade.type }).locator('xpath=ancestor::div[3]');
|
||||
await expect(card.getByRole('button', { name: 'UPGRADE' })).toBeEnabled();
|
||||
await card.getByRole('button', { name: 'UPGRADE' }).click();
|
||||
|
||||
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
const row = after.systems.find((s) => s.id === upgrade.id)!;
|
||||
expect(row.level).toBe(upgrade.level + 1);
|
||||
expect(after.turn).toBe(ship.turn + 1);
|
||||
|
||||
// The card badge flipped to the new level and the left column mirrors it.
|
||||
await expect(card.getByText(`LV ${upgrade.level + 1}`)).toBeVisible();
|
||||
await expect(page.getByText(`LV ${row.level} · CAP ${row.capacity}`)).toBeVisible();
|
||||
} else {
|
||||
const target = build!;
|
||||
const card = page.getByRole('heading', { name: target.type }).locator('xpath=ancestor::div[3]');
|
||||
await expect(card.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
||||
await card.getByRole('button', { name: 'BUILD NEW' }).click();
|
||||
|
||||
const after = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
const row = after.systems.find((s) => s.id === target.id)!;
|
||||
expect(row.built).toBe(true);
|
||||
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: 1, capacity: 2 });
|
||||
|
||||
// The card badge flipped OFFLINE → LV 1 and the left column's online count went up.
|
||||
await expect(card.getByText('LV 1')).toBeVisible();
|
||||
await expect(page.getByText(`${after.systems.filter((s) => s.built).length} ONLINE`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||
const res = await page.request.get('/api/version');
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
@@ -13,6 +13,9 @@ interface WorldSystemDto { id: number; name: string; x: number; y: number; disco
|
||||
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
||||
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||
|
||||
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
@@ -47,6 +50,14 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
expect(exploreRes.status()).toBe(200);
|
||||
const sysExplored = (await exploreRes.json()) as SystemState;
|
||||
|
||||
// Crew baseline: the roster (starting crew + any finds so far) must survive too.
|
||||
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
||||
expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
||||
|
||||
// Ship baseline: the systems (levels/capacities) + cargo hold must survive too.
|
||||
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship is always seeded
|
||||
|
||||
// Restart the app container mid-run — the named DB volume must survive it.
|
||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
||||
|
||||
@@ -77,6 +88,14 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
expect(sysAfter.turn).toBe(sysExplored.turn);
|
||||
expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true);
|
||||
|
||||
// Crew roster survived too: same members, traits and origins (no re-seed on restart).
|
||||
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
||||
expect(crewAfter).toEqual(crewBefore);
|
||||
|
||||
// Ship state survived too: same systems, levels/capacities and cargo hold.
|
||||
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
expect(shipAfter).toEqual(shipBefore);
|
||||
|
||||
// The page itself serves again with the landing title.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||
|
||||
+29
-1
@@ -24,7 +24,8 @@ CREATE TABLE IF NOT EXISTS saves (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
seed TEXT NOT NULL,
|
||||
turn INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
schema_version INTEGER -- which save-schema version this row was backfilled up to (NULL = v1)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS solar_systems (
|
||||
@@ -61,6 +62,25 @@ CREATE TABLE IF NOT EXISTS inventory_items (
|
||||
qty INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crew (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
trait_good TEXT NOT NULL,
|
||||
trait_bad TEXT NOT NULL,
|
||||
origin TEXT NOT NULL -- 'STARTING CREW' or '<planet>, <system>' for members found while exploring
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ship_systems (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
type TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0, -- 0 = not built yet ('built' is derived: level > 0)
|
||||
capacity INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (save_id, type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
@@ -88,6 +108,14 @@ async function open(file: string): Promise<GameDb> {
|
||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||
|
||||
// Idempotent evolution for files created by older code: CREATE TABLE IF NOT EXISTS above cannot
|
||||
// add columns, so check and add any that are missing. This is what makes DB wipes unnecessary —
|
||||
// old saves open fine and get backfilled by ensureSave (see lib/game/save.ts).
|
||||
const saveCols = raw.exec('PRAGMA table_info(saves)')[0]?.values.map((v) => v[1]) ?? [];
|
||||
if (!saveCols.includes('schema_version')) {
|
||||
raw.exec('ALTER TABLE saves ADD COLUMN schema_version INTEGER');
|
||||
}
|
||||
|
||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||
|
||||
// First run: stamp when this DB was created and land the file on disk so the ./db volume
|
||||
|
||||
+31
-1
@@ -1,4 +1,4 @@
|
||||
import { integer, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { integer, primaryKey, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
// Drizzle schema for game state. Single-player by design: exactly ONE save row ever exists
|
||||
// (no auth/accounts), so every table hangs off saves.id but only one save is in play.
|
||||
@@ -11,6 +11,9 @@ export const saves = sqliteTable('saves', {
|
||||
seed: text('seed').notNull(),
|
||||
turn: integer('turn').notNull().default(0),
|
||||
createdAt: text('created_at').notNull(),
|
||||
// Which schema version this save was last brought up to (NULL on saves from before the
|
||||
// column existed = v1). ensureSave runs ordered idempotent backfill steps for anything below.
|
||||
schemaVersion: integer('schema_version'),
|
||||
})
|
||||
|
||||
export const solarSystems = sqliteTable('solar_systems', {
|
||||
@@ -58,6 +61,33 @@ export const inventoryItems = sqliteTable('inventory_items', {
|
||||
qty: integer('qty').notNull().default(1),
|
||||
})
|
||||
|
||||
// The ship's crew roster: the starting crew (seeded on new game) plus members found while
|
||||
// exploring planets/moons. Display-only for now — no dismiss/assign actions yet (#7 scope).
|
||||
export const crew = sqliteTable('crew', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||
name: text('name').notNull(),
|
||||
role: text('role').notNull(),
|
||||
traitGood: text('trait_good').notNull(),
|
||||
traitBad: text('trait_bad').notNull(),
|
||||
// Where the member came from: 'STARTING CREW' or '<planet>, <system>' for found members.
|
||||
origin: text('origin').notNull(),
|
||||
})
|
||||
|
||||
// The ship's systems, one row per known type (see SHIP_TYPES in lib/game/ship-costs.ts).
|
||||
// `built` is derived, not stored: level > 0 means built — unbuilt rows sit at level/capacity 0.
|
||||
export const shipSystems = sqliteTable(
|
||||
'ship_systems',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||
type: text('type').notNull(),
|
||||
level: integer('level').notNull().default(0), // 0 = not built yet
|
||||
capacity: integer('capacity').notNull().default(0),
|
||||
},
|
||||
(t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)],
|
||||
)
|
||||
|
||||
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
||||
// writers and a read endpoint.
|
||||
export const events = sqliteTable('events', {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CREW_CHANCE, generateStartingCrew, makeCrewName, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { explorationRng } from './world'
|
||||
|
||||
describe('generateStartingCrew', () => {
|
||||
it('gives a new game 2–3 members, each with exactly one good and one bad trait', () => {
|
||||
for (const seed of [1, 7, 42, 999]) {
|
||||
const crew = generateStartingCrew(startingCrewRng(String(seed)))
|
||||
expect(crew.length).toBeGreaterThanOrEqual(2)
|
||||
expect(crew.length).toBeLessThanOrEqual(3)
|
||||
|
||||
for (const member of crew) {
|
||||
expect(member.name.trim().length).toBeGreaterThan(0)
|
||||
expect(member.role.trim().length).toBeGreaterThan(0)
|
||||
expect(member.traitGood.trim().length).toBeGreaterThan(0)
|
||||
expect(member.traitBad.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
// Names are unique within the crew; roles too (drawn without replacement).
|
||||
expect(new Set(crew.map((m) => m.name)).size).toBe(crew.length)
|
||||
expect(new Set(crew.map((m) => m.role)).size).toBe(crew.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('is deterministic under a fixed seed and differs across seeds', () => {
|
||||
const a = generateStartingCrew(startingCrewRng('42'))
|
||||
expect(generateStartingCrew(startingCrewRng('42'))).toEqual(a) // same save → same crew
|
||||
const b = generateStartingCrew(startingCrewRng('43'))
|
||||
expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different crew
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollCrewMember', () => {
|
||||
it('finds a member with the documented probability and never reuses a taken name', () => {
|
||||
const rng = startingCrewRng('42')
|
||||
let found = 0
|
||||
for (let i = 0; i < 1000; i++) found += rollCrewMember(rng, new Set<string>()) ? 1 : 0
|
||||
// 18% ± a comfortable band — catches an inverted or missing chance without flaking.
|
||||
expect(found).toBeGreaterThan(90)
|
||||
expect(found).toBeLessThan(270)
|
||||
|
||||
const taken = new Set(['Ava Vance'])
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const member = rollCrewMember(startingCrewRng(String(i)), taken)
|
||||
if (member) expect(member.name).not.toBe('Ava Vance') // never reuses a roster name
|
||||
}
|
||||
})
|
||||
|
||||
it('is deterministic per save+planet when drawn from the exploration stream', () => {
|
||||
const first = rollCrewMember(explorationRng('42', 13), new Set<string>())
|
||||
expect(rollCrewMember(explorationRng('42', 13), new Set<string>())).toEqual(first) // same save+planet → same result
|
||||
|
||||
// Across many planets both outcomes occur (someone is found, someone isn't).
|
||||
let found = 0
|
||||
for (let planetId = 1; planetId <= 200; planetId++) {
|
||||
if (rollCrewMember(explorationRng('42', planetId), new Set<string>())) found++
|
||||
}
|
||||
expect(found).toBeGreaterThan(0)
|
||||
expect(found).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('keeps the crew chance documented and sane', () => {
|
||||
expect(CREW_CHANCE).toBeGreaterThanOrEqual(0.15)
|
||||
expect(CREW_CHANCE).toBeLessThanOrEqual(0.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeCrewName', () => {
|
||||
it('never reuses a taken name and always terminates', () => {
|
||||
const rng = startingCrewRng('7')
|
||||
const taken = new Set<string>()
|
||||
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
|
||||
expect(taken.size).toBe(40)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// Pure, seeded crew generation — no DB, no Date, no Math.random. Same seed → same starting
|
||||
// crew; the exploration crew roll draws from the caller's per-planet rng stream so a found
|
||||
// member is deterministic per save+planet (same derivation as the resource/part haul).
|
||||
|
||||
import { mulberry32 } from './world'
|
||||
|
||||
export interface CrewMember {
|
||||
name: string
|
||||
role: string
|
||||
traitGood: string
|
||||
traitBad: string
|
||||
}
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'AVA', 'BEN', 'CASS', 'DANA', 'ELI', 'FERN', 'GIDEON', 'HALYA',
|
||||
'IVAR', 'JUNO', 'KAI', 'LORE', 'MARA', 'NICO', 'ODESSA', 'PETRA',
|
||||
]
|
||||
|
||||
const LAST_NAMES = [
|
||||
'VANCE', 'OKONKWO', 'SORENSEN', 'DELACRUZ', 'HALLOWAY', 'IBARRA', 'KOVAC', 'LINDQVIST',
|
||||
'MERCER', 'NADEAU', 'ORLOV', 'QUINTANA', 'REYES', 'STALKE', 'TANAKA', 'UNDERWOOD',
|
||||
'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL',
|
||||
]
|
||||
|
||||
const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN']
|
||||
|
||||
const GOOD_TRAITS = [
|
||||
'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING',
|
||||
'SHARP SHOOTER', 'READS STARS BLIND', 'QUICK HANDS', 'IRON WILL',
|
||||
'FINDS HIDDEN THINGS', 'TALKS TO ANYONE',
|
||||
]
|
||||
|
||||
const BAD_TRAITS = [
|
||||
'RIGID — SLOW TO ADAPT', 'SHORT FUSE', 'OVERCAUTIOUS', 'CARELESS WITH FUSES',
|
||||
'SLOWS THE CREW DOWN', 'ARGUES WITH EVERYONE', 'FORGETS TO EAT', 'JINXED IN A DOGFIGHT',
|
||||
'HOARDS RATIONS', 'DOUBTS THE CAPTAIN',
|
||||
]
|
||||
|
||||
const STARTING_CREW_MIN = 2
|
||||
const STARTING_CREW_SPREAD = 2 // 2..3 starting members
|
||||
export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans)
|
||||
|
||||
function title(word: string): string {
|
||||
return word.charAt(0) + word.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, arr: T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]
|
||||
}
|
||||
|
||||
// A name unique against `taken` (the current roster), claimed into the set on success.
|
||||
// 16×22 = 352 combinations — a roster realistically never approaches that size.
|
||||
export function makeCrewName(rng: () => number, taken: Set<string>): string {
|
||||
for (;;) {
|
||||
const name = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
|
||||
if (!taken.has(name)) {
|
||||
taken.add(name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The crew a new game starts with: 2–3 members, unique names and roles (roles are drawn
|
||||
// without replacement). Pure — the caller derives the rng from the save seed.
|
||||
export function generateStartingCrew(rng: () => number): CrewMember[] {
|
||||
const taken = new Set<string>()
|
||||
const roles = [...ROLES]
|
||||
const crew: CrewMember[] = []
|
||||
for (let i = 0; i < STARTING_CREW_MIN + Math.floor(rng() * STARTING_CREW_SPREAD); i++) {
|
||||
const role = roles.splice(Math.floor(rng() * roles.length), 1)[0]
|
||||
crew.push({ name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) })
|
||||
}
|
||||
return crew
|
||||
}
|
||||
|
||||
// The exploration crew roll: with CREW_CHANCE a member is found and joins the ship. Draws
|
||||
// from the SAME per-planet rng stream as the resource/part haul (called after it in
|
||||
// save.ts), so the outcome stays deterministic per save+planet. `taken` is the current
|
||||
// roster's names, keeping every name on the ship unique.
|
||||
export function rollCrewMember(rng: () => number, taken: Set<string>): CrewMember | null {
|
||||
if (rng() >= CREW_CHANCE) return null
|
||||
const role = pick(rng, ROLES)
|
||||
return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) }
|
||||
}
|
||||
|
||||
// Independent PRNG stream for the starting crew of one save (same pattern as bodyRng /
|
||||
// explorationRng in world.ts — a fixed offset keeps it away from every other derivation).
|
||||
export function startingCrewRng(seed: string): () => number {
|
||||
return mulberry32((Number(seed) + 0x51ed270b) >>> 0)
|
||||
}
|
||||
+161
-1
@@ -2,9 +2,12 @@ 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 { getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -228,3 +231,160 @@ describe('explorePlanet', () => {
|
||||
expect(state.turn).toBe(planetIds.length) // one turn per scan
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCrewState', () => {
|
||||
it('seeds a starting crew of 2–3 members derived from the save seed', async () => {
|
||||
const { db } = await freshFile()
|
||||
const crew = await getCrewState(db) // ensureSave runs inside, so this also seeds the crew
|
||||
|
||||
expect(crew.length).toBeGreaterThanOrEqual(2)
|
||||
expect(crew.length).toBeLessThanOrEqual(3)
|
||||
for (const member of crew) {
|
||||
expect(member.origin).toBe('STARTING CREW')
|
||||
expect(member.name.trim()).not.toBe('')
|
||||
expect(member.role.trim()).not.toBe('')
|
||||
expect(member.traitGood.trim()).not.toBe('')
|
||||
expect(member.traitBad.trim()).not.toBe('')
|
||||
}
|
||||
|
||||
// The roster is exactly the pure generation for this save's seed.
|
||||
const [save] = await db.db.select().from(schema.saves)
|
||||
const expected = generateStartingCrew(startingCrewRng(save.seed))
|
||||
expect(crew.map((c) => c.name)).toEqual(expected.map((m) => m.name))
|
||||
expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role))
|
||||
expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('explorePlanet crew finds', () => {
|
||||
it('lands a found member in the roster with the planet+system origin and never joins twice (known seed)', async () => {
|
||||
// Hand-built save with a known seed so the crew roll is recomputable offline.
|
||||
const { db } = await freshFile()
|
||||
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: new Date().toISOString() }).returning()
|
||||
const [sys] = await db.db
|
||||
.insert(schema.solarSystems)
|
||||
.values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true })
|
||||
.returning({ id: schema.solarSystems.id })
|
||||
|
||||
// Find a small planet id whose exploration roll finds crew (deterministic — recomputed here,
|
||||
// mirroring save.ts: the item haul draws first, then the crew roll on the same stream).
|
||||
let crewPlanetId = -1
|
||||
for (let id = 1; id <= 50 && crewPlanetId === -1; id++) {
|
||||
const rng = explorationRng('42', id)
|
||||
rollExploration(rng)
|
||||
if (rollCrewMember(rng, new Set<string>())) crewPlanetId = id
|
||||
}
|
||||
expect(crewPlanetId).toBeGreaterThan(0)
|
||||
|
||||
const planetName = `P${crewPlanetId}`
|
||||
for (let i = 1; i <= crewPlanetId; i++) {
|
||||
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
|
||||
}
|
||||
|
||||
const before = await getCrewState(db) // empty — this save was hand-built, no starting crew seeded
|
||||
const result = await explorePlanet(db, crewPlanetId)
|
||||
|
||||
expect(result.crewFound).not.toBeNull()
|
||||
const expectedRng = explorationRng('42', crewPlanetId)
|
||||
rollExploration(expectedRng) // consume the item haul draws first, exactly as save.ts does
|
||||
const expectedMember = rollCrewMember(expectedRng, new Set<string>())! // same save+planet → same member
|
||||
expect(result.crewFound!.name).toBe(expectedMember.name)
|
||||
expect(result.crewFound!.role).toBe(expectedMember.role)
|
||||
expect([result.crewFound!.traitGood, result.crewFound!.traitBad]).toEqual([expectedMember.traitGood, expectedMember.traitBad])
|
||||
expect(result.crewFound!.origin).toBe(`${planetName}, Test System`)
|
||||
|
||||
const after = await getCrewState(db)
|
||||
expect(after.length).toBe(before.length + 1) // exactly one join
|
||||
expect(after.find((c) => c.id === result.crewFound!.id)).toMatchObject({ name: expectedMember.name, origin: `${planetName}, Test System` })
|
||||
|
||||
// The body is already explored → a second scan is rejected and no duplicate joins.
|
||||
await expect(explorePlanet(db, crewPlanetId)).rejects.toMatchObject({ status: 409 })
|
||||
expect((await getCrewState(db)).length).toBe(before.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('backfill (old saves)', () => {
|
||||
// Planets/moons of one system in insertion order, nested by parent — ids are excluded so a
|
||||
// regenerated set compares equal to the original even though autoincrement moved on.
|
||||
function bodyShape(rows: { id: number; name: string; type: string; hazard: boolean; parentId: number | null }[]) {
|
||||
return rows
|
||||
.filter((r) => r.parentId === null)
|
||||
.map((p) => ({ name: p.name, type: p.type, hazard: p.hazard, moons: rows.filter((m) => m.parentId === p.id).map((m) => [m.name, m.type, m.hazard]) }))
|
||||
}
|
||||
|
||||
it('upgrades a v1 save (no bodies, no crew) in place — deterministic and idempotent', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db) // new game: everything seeded at the current version
|
||||
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
const rowsFor = (systemId: number) => db.db.select().from(schema.planets).where(eq(schema.planets.systemId, systemId))
|
||||
const beforeBodies: Record<number, unknown> = {}
|
||||
for (const s of systems) {
|
||||
const rows = await rowsFor(s.id)
|
||||
beforeBodies[s.id] = bodyShape(rows.sort((a, b) => a.id - b.id))
|
||||
}
|
||||
const crewOf = (rows: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }[]) =>
|
||||
rows.map((c) => ({ name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) // ids excluded — re-seed moves autoincrement on
|
||||
const beforeCrew = crewOf(await db.db.select().from(schema.crew))
|
||||
const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) =>
|
||||
rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on
|
||||
const beforeShip = shipOf(await db.db.select().from(schema.shipSystems))
|
||||
|
||||
// Simulate a save created by pre-v0.4 code: no bodies, no crew, no ship, no version stamp (NULL).
|
||||
await db.db.delete(schema.planets)
|
||||
await db.db.delete(schema.crew)
|
||||
await db.db.delete(schema.shipSystems)
|
||||
await db.db.update(schema.saves).set({ schemaVersion: null })
|
||||
|
||||
const world = await getWorldState(db) // ensureSave → backfill runs
|
||||
expect(world.systems).toHaveLength(7)
|
||||
|
||||
for (const s of systems) {
|
||||
const rows = await rowsFor(s.id)
|
||||
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id]) // identical to fresh generation
|
||||
}
|
||||
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
|
||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||
|
||||
const [save] = await db.db.select().from(schema.saves)
|
||||
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||
|
||||
// Idempotent: a second pass changes nothing.
|
||||
const planetCount = (await db.db.select().from(schema.planets)).length
|
||||
await getWorldState(db)
|
||||
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
|
||||
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
|
||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||
for (const s of systems) {
|
||||
const rows = await rowsFor(s.id)
|
||||
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves an up-to-date save untouched', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db)
|
||||
const planetCount = (await db.db.select().from(schema.planets)).length
|
||||
const [save] = await db.db.select().from(schema.saves)
|
||||
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||
|
||||
await getWorldState(db) // second read: no backfill work to do
|
||||
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
|
||||
})
|
||||
|
||||
it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db)
|
||||
const rows = (await db.db.select().from(schema.shipSystems)).sort((a, b) => a.type.localeCompare(b.type))
|
||||
expect(rows).toHaveLength(SHIP_TYPES.length)
|
||||
for (const def of SHIP_TYPES) {
|
||||
const row = rows.find((r) => r.type === def.type)!
|
||||
if (def.builtAtStart) {
|
||||
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
|
||||
} else {
|
||||
expect(row.level).toBe(0) // unbuilt until a BUILD action
|
||||
expect(row.capacity).toBe(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+120
-18
@@ -2,6 +2,8 @@ import { eq } from 'drizzle-orm'
|
||||
import type { GameDb } from '@/lib/db/client'
|
||||
import { persist } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
||||
|
||||
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
||||
@@ -44,20 +46,36 @@ function expansionRng(seed: string, turn: number): () => number {
|
||||
return mulberry32((Number(seed) + turn * 1_000_003) >>> 0)
|
||||
}
|
||||
|
||||
// Inserts one system together with its generated bodies (planets + moons), so a system is
|
||||
// never left without worlds to explore. Bodies derive from (seed, system id) via bodyRng —
|
||||
// independent of generation order and every other system.
|
||||
// The bodies of one system (planets + moons), derived from (seed, system id) via bodyRng —
|
||||
// independent of generation order and every other system. Shared by new-game creation and the
|
||||
// v2 backfill so both produce identical rows for a given save+system.
|
||||
async function insertBodiesForSystem(db: GameDb, systemId: number, seed: string): Promise<void> {
|
||||
for (const body of generatePlanets(bodyRng(seed, systemId))) {
|
||||
const [planet] = await db.db.insert(schema.planets).values({ systemId, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id })
|
||||
for (const moon of body.moons) {
|
||||
await db.db.insert(schema.planets).values({ systemId, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inserts one system together with its generated bodies, so a system is never left without
|
||||
// worlds to explore.
|
||||
async function insertSystemWithBodies(db: GameDb, saveId: number, ws: { name: string; x: number; y: number }, seed: string): Promise<number> {
|
||||
const [row] = await db.db.insert(schema.solarSystems).values({ saveId, name: ws.name, x: ws.x, y: ws.y }).returning({ id: schema.solarSystems.id })
|
||||
for (const body of generatePlanets(bodyRng(seed, row.id))) {
|
||||
const [planet] = await db.db.insert(schema.planets).values({ systemId: row.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id })
|
||||
for (const moon of body.moons) {
|
||||
await db.db.insert(schema.planets).values({ systemId: row.id, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
|
||||
}
|
||||
}
|
||||
await insertBodiesForSystem(db, row.id, seed)
|
||||
return row.id
|
||||
}
|
||||
|
||||
// v2 backfill: every system without planets gets its generated bodies. Idempotent — systems
|
||||
// that already have rows are skipped, so a second run changes nothing.
|
||||
async function ensureBodiesForAllSystems(db: GameDb, saveId: number, seed: string): Promise<void> {
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
for (const system of systems) {
|
||||
const rows = await db.db.select({ id: schema.planets.id }).from(schema.planets).where(eq(schema.planets.systemId, system.id))
|
||||
if (rows.length === 0) await insertBodiesForSystem(db, system.id, seed)
|
||||
}
|
||||
}
|
||||
|
||||
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
|
||||
for (const [a, b] of edges) {
|
||||
const from = resolve(a)
|
||||
@@ -67,17 +85,44 @@ async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (ind
|
||||
}
|
||||
}
|
||||
|
||||
// Creates the save + starting region when the DB has none. No-op otherwise — safe to call
|
||||
// on every request (single player, one process: check-then-insert cannot race in practice).
|
||||
// Seeds the starting crew for a fresh save: 2–3 members derived from the seed alone.
|
||||
async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promise<void> {
|
||||
for (const member of generateStartingCrew(startingCrewRng(seed))) {
|
||||
await db.db.insert(schema.crew).values({ saveId, name: member.name, role: member.role, traitGood: member.traitGood, traitBad: member.traitBad, origin: 'STARTING CREW' })
|
||||
}
|
||||
}
|
||||
|
||||
// What each schema version added to a save. Saves created by older code lack the data newer
|
||||
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
|
||||
// current — each step is idempotent (a no-op when its data already exists). When #9/#10 add
|
||||
// seeding, bump SCHEMA_VERSION and append a step here.
|
||||
export const SCHEMA_VERSION = 4 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship
|
||||
|
||||
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
||||
// unbuilt (level/capacity 0) until a BUILD action.
|
||||
async function seedStartingShip(db: GameDb, saveId: number): Promise<void> {
|
||||
for (const def of SHIP_TYPES) {
|
||||
await db.db.insert(schema.shipSystems).values({ saveId, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Creates the save + starting region when the DB has none; backfills older saves otherwise.
|
||||
// Safe to call on every request (single player, one process: check-then-insert cannot race).
|
||||
export async function ensureSave(db: GameDb): Promise<void> {
|
||||
const existing = await db.db.select().from(schema.saves).limit(1)
|
||||
if (existing.length > 0) return
|
||||
if (existing.length === 0) {
|
||||
await createNewGame(db)
|
||||
return
|
||||
}
|
||||
await backfillSave(db, existing[0])
|
||||
}
|
||||
|
||||
async function createNewGame(db: GameDb): Promise<void> {
|
||||
const seed = newSeed()
|
||||
const region = generateStartRegion(mulberry32(Number(seed)))
|
||||
const nowIso = new Date().toISOString()
|
||||
|
||||
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso }).returning()
|
||||
const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso, schemaVersion: SCHEMA_VERSION }).returning()
|
||||
// All inserted as undiscovered first; the start is then marked discovered + current.
|
||||
const startId = await insertSystemWithBodies(db, save.id, region.start, seed)
|
||||
const neighborIds: number[] = []
|
||||
@@ -86,11 +131,37 @@ export async function ensureSave(db: GameDb): Promise<void> {
|
||||
|
||||
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
|
||||
await insertRoutes(db, region.edges, resolve)
|
||||
await seedStartingCrew(db, save.id, seed)
|
||||
await seedStartingShip(db, save.id)
|
||||
|
||||
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
||||
await persist(db)
|
||||
}
|
||||
|
||||
// Ordered idempotent backfill for a save created by older code. A NULL version means v1.
|
||||
async function backfillSave(db: GameDb, save: { id: number; seed: string; schemaVersion: number | null }): Promise<void> {
|
||||
const from = save.schemaVersion ?? 1
|
||||
let version = from
|
||||
|
||||
if (version < 2) {
|
||||
await ensureBodiesForAllSystems(db, save.id, save.seed) // bodies derive from (seed, system id) — identical to fresh generation
|
||||
version = 2
|
||||
}
|
||||
if (version < 3) {
|
||||
if ((await db.db.select().from(schema.crew)).length === 0) await seedStartingCrew(db, save.id, save.seed)
|
||||
version = 3
|
||||
}
|
||||
if (version < 4) {
|
||||
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
|
||||
version = 4
|
||||
}
|
||||
|
||||
if (from !== SCHEMA_VERSION) {
|
||||
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
||||
await persist(db)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorldState(db: GameDb): Promise<WorldState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
@@ -210,13 +281,32 @@ export async function getSystemState(db: GameDb): Promise<SystemState> {
|
||||
return readSystemState(db)
|
||||
}
|
||||
|
||||
// --- Crew roster --------------------------------------------------------------
|
||||
|
||||
export interface CrewDto {
|
||||
id: number
|
||||
name: string
|
||||
role: string
|
||||
traitGood: string
|
||||
traitBad: string
|
||||
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring
|
||||
}
|
||||
|
||||
// The full roster in join order (starting crew first, then finds). Auto-initializes like the other reads.
|
||||
export async function getCrewState(db: GameDb): Promise<CrewDto[]> {
|
||||
await ensureSave(db)
|
||||
return (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
|
||||
}
|
||||
|
||||
export interface ExploreResult extends SystemState {
|
||||
outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing)
|
||||
crewFound: CrewDto | null // a member who joined the ship on this scan (null when none)
|
||||
}
|
||||
|
||||
// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored,
|
||||
// costs a turn, accumulates finds into inventory and writes an event. Any existing planet
|
||||
// may be scanned (the UI only offers the current system's rows).
|
||||
// costs a turn, accumulates finds into inventory and writes an event. The same per-planet
|
||||
// rng stream also rolls for a crew member (~18% of scans) who joins the roster. Any existing
|
||||
// planet may be scanned (the UI only offers the current system's rows).
|
||||
export async function explorePlanet(db: GameDb, planetId: number): Promise<ExploreResult> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
@@ -225,7 +315,10 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
|
||||
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
|
||||
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
|
||||
|
||||
const outcome = rollExploration(explorationRng(save.seed, target.id))
|
||||
const rng = explorationRng(save.seed, target.id)
|
||||
const outcome = rollExploration(rng) // resource/part haul first…
|
||||
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
|
||||
const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream
|
||||
const turn = save.turn + 1
|
||||
|
||||
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
|
||||
@@ -239,10 +332,19 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
|
||||
}
|
||||
}
|
||||
|
||||
let crewFound: CrewDto | null = null
|
||||
if (foundMember) {
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
|
||||
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
|
||||
crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
|
||||
}
|
||||
|
||||
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
|
||||
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
|
||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}`, 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 persist(db)
|
||||
|
||||
return { ...(await readSystemState(db)), outcome }
|
||||
return { ...(await readSystemState(db)), outcome, crewFound }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Pure ship-system model: the known system types, base values and the action cost model.
|
||||
// No DB imports — both lib/game/ship.ts (server) and the frontend import from here so the
|
||||
// UI's affordability/cap logic can never drift from what the API enforces.
|
||||
|
||||
export interface ShipTypeDef {
|
||||
type: string
|
||||
builtAtStart: boolean // part of the starting ship? (otherwise unbuilt until BUILD)
|
||||
}
|
||||
|
||||
// The known system types — display order. Reuses the placeholder SHIP SYSTEMS tab card names
|
||||
// for continuity, plus HULL so the vessel-metrics hull bar can be driven by a real level.
|
||||
export const SHIP_TYPES: ShipTypeDef[] = [
|
||||
{ type: 'HULL', builtAtStart: true },
|
||||
{ type: 'REACTOR', builtAtStart: true },
|
||||
{ type: 'LIFE SUPPORT', builtAtStart: true },
|
||||
{ type: 'NAV COMPUTER', builtAtStart: true },
|
||||
{ type: 'COMMS ARRAY', builtAtStart: true },
|
||||
{ type: 'WEAPONS', builtAtStart: false },
|
||||
]
|
||||
|
||||
export const BASE_LEVEL = 1 // what a system starts at when built (starting ship or BUILD)
|
||||
export const BASE_CAPACITY = 2
|
||||
export const MAX_LEVEL = 5 // UPGRADE cap
|
||||
export const MAX_CAPACITY = 10 // EXPAND cap
|
||||
|
||||
// Display-only cap for the VESSEL METRICS crew roster bar (no gameplay effect yet).
|
||||
export const CREW_CAPACITY = 6
|
||||
|
||||
// Cost model — all costs are in inventory items, consumed from the oldest cargo row of the
|
||||
// required kind first (see spendItems in lib/game/ship.ts):
|
||||
// UPGRADE {systemId} — `level` units of any resource (scales with current level)
|
||||
// EXPAND {systemId} — `capacity` units of any resource (scales with current capacity)
|
||||
// BUILD {type} — a flat amount of parts (parts are rarer than resources, so builds
|
||||
// are the bigger investment)
|
||||
export function upgradeCost(level: number): number {
|
||||
return level
|
||||
}
|
||||
|
||||
export function expandCost(capacity: number): number {
|
||||
return capacity
|
||||
}
|
||||
|
||||
export const BUILD_PART_COST = 3
|
||||
|
||||
// Total qty of one kind across the cargo hold — used for affordability checks.
|
||||
export function totalQty(items: { kind: string; qty: number }[], kind: 'resource' | 'part'): number {
|
||||
return items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { buildSystem, expandSystem, getShipState, upgradeSystem, type ShipState } from './ship'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'sw-ship-test-'))
|
||||
dirs.push(dir)
|
||||
const file = path.join(dir, 'test.db')
|
||||
return { db: await openGameDb(file), file }
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function addItem(db: GameDb, kind: 'resource' | 'part', name: string, qty: number): Promise<void> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind, name, qty })
|
||||
}
|
||||
|
||||
const systemOf = (state: ShipState, type: string): ShipState['systems'][number] => state.systems.find((s) => s.type === type)!
|
||||
|
||||
describe('getShipState', () => {
|
||||
it('new game: starting ship in display order — core types built at base values, WEAPONS unbuilt', async () => {
|
||||
const { db } = await freshFile()
|
||||
const state = await getShipState(db)
|
||||
|
||||
expect(state.systems.map((s) => s.type)).toEqual(SHIP_TYPES.map((t) => t.type)) // display order follows SHIP_TYPES
|
||||
for (const def of SHIP_TYPES) {
|
||||
const row = systemOf(state, def.type)
|
||||
if (def.builtAtStart) {
|
||||
expect(row.built).toBe(true)
|
||||
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
|
||||
} else {
|
||||
expect(row.built).toBe(false) // unbuilt until a BUILD action
|
||||
expect(row.level).toBe(0)
|
||||
expect(row.capacity).toBe(0)
|
||||
}
|
||||
}
|
||||
expect(state.inventory).toEqual([])
|
||||
expect(state.turn).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upgradeSystem', () => {
|
||||
it('rejects with 422 and mutates nothing when the hold is empty', async () => {
|
||||
const { db } = await freshFile()
|
||||
const before = await getShipState(db)
|
||||
const reactorId = systemOf(before, 'REACTOR').id
|
||||
|
||||
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 422 })
|
||||
|
||||
const after = await getShipState(db)
|
||||
expect(systemOf(after, 'REACTOR').level).toBe(BASE_LEVEL) // no level change…
|
||||
expect(after.turn).toBe(before.turn) // …and no turn spent on a failed action
|
||||
})
|
||||
|
||||
it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await expect(upgradeSystem(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||
const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id
|
||||
await expect(upgradeSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
|
||||
it('consumes the cost from the oldest resource row first (deleting rows that hit zero), costs a turn and writes an event', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await addItem(db, 'resource', 'IRON ORE', 1) // oldest — fully consumed by the cost of 1
|
||||
await addItem(db, 'resource', 'CRYSTAL DUST', 5)
|
||||
|
||||
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
|
||||
const state = await upgradeSystem(db, reactorId) // level 1 → cost 1 resource
|
||||
|
||||
expect(systemOf(state, 'REACTOR').level).toBe(BASE_LEVEL + 1)
|
||||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 5]]) // IRON ORE row deleted
|
||||
expect(state.turn).toBe(1)
|
||||
|
||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'upgrade')!
|
||||
expect(event.turn).toBe(1)
|
||||
expect(event.message).toContain('REACTOR')
|
||||
})
|
||||
|
||||
it('caps at MAX_LEVEL with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
|
||||
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) // cost path covered above
|
||||
|
||||
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 })
|
||||
expect((await getShipState(db)).turn).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandSystem', () => {
|
||||
it('raises capacity by one for `capacity` resources; caps at MAX_CAPACITY with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await addItem(db, 'resource', 'IRON ORE', BASE_CAPACITY + 1) // cost is the current capacity (2), one left over
|
||||
|
||||
const hullId = systemOf(await getShipState(db), 'HULL').id
|
||||
const state = await expandSystem(db, hullId)
|
||||
expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1)
|
||||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]])
|
||||
|
||||
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
|
||||
await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
|
||||
it('rejects unknown systemId with 404 and unbuilt systems with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await expect(expandSystem(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||
const weaponsId = systemOf(await getShipState(db), 'WEAPONS').id
|
||||
await expect(expandSystem(db, weaponsId)).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSystem', () => {
|
||||
it('builds an unbuilt type at base values for a flat parts cost; a second build is 409 and unknown types are 404', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
|
||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 422 }) // no parts in the hold yet
|
||||
|
||||
await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST)
|
||||
const state = await buildSystem(db, 'WEAPONS')
|
||||
const weapons = systemOf(state, 'WEAPONS')
|
||||
expect(weapons.built).toBe(true)
|
||||
expect({ level: weapons.level, capacity: weapons.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
|
||||
expect(await db.db.select().from(schema.inventoryItems)).toHaveLength(0) // parts row deleted
|
||||
expect(state.turn).toBe(1)
|
||||
|
||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')!
|
||||
expect(event.message).toContain('WEAPONS')
|
||||
|
||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built
|
||||
await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { persist, type GameDb } from '@/lib/db/client'
|
||||
import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs'
|
||||
import { ensureSave, type InventoryItemDto } from './save'
|
||||
|
||||
export interface ShipSystemDto {
|
||||
id: number
|
||||
type: string
|
||||
level: number
|
||||
capacity: number
|
||||
built: boolean // derived — level > 0 (unbuilt rows sit at level/capacity 0)
|
||||
}
|
||||
|
||||
// The ship's full state for the SHIP SYSTEMS tab and left column.
|
||||
export interface ShipState {
|
||||
systems: ShipSystemDto[] // display order follows SHIP_TYPES
|
||||
inventory: InventoryItemDto[]
|
||||
turn: number
|
||||
}
|
||||
|
||||
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||
export class ShipError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function readShipState(db: GameDb): Promise<ShipState> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const rows = await db.db.select().from(schema.shipSystems)
|
||||
const orderOf = (type: string) => {
|
||||
const i = SHIP_TYPES.findIndex((t) => t.type === type)
|
||||
return i === -1 ? 99 : i // unknown types (shouldn't exist) sort last
|
||||
}
|
||||
const systems = [...rows]
|
||||
.sort((a, b) => orderOf(a.type) - orderOf(b.type))
|
||||
.map((r) => ({ id: r.id, type: r.type, level: r.level, capacity: r.capacity, built: r.level > 0 }))
|
||||
const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty }))
|
||||
return { systems, inventory, turn: save.turn }
|
||||
}
|
||||
|
||||
// The ship's systems + cargo hold + turn. Auto-initializes like the other reads.
|
||||
export async function getShipState(db: GameDb): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// Consume `amount` units of one kind from the oldest cargo rows first (id order); rows that hit
|
||||
// zero are deleted. The total is checked up front, so an insufficient hold mutates nothing.
|
||||
async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise<void> {
|
||||
const rows = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.kind === kind).sort((a, b) => a.id - b.id)
|
||||
if (rows.reduce((sum, r) => sum + r.qty, 0) < amount) {
|
||||
throw new ShipError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
|
||||
}
|
||||
let remaining = amount
|
||||
for (const row of rows) {
|
||||
if (remaining <= 0) break
|
||||
const take = Math.min(row.qty, remaining)
|
||||
remaining -= take
|
||||
if (take >= row.qty) await db.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.id, row.id))
|
||||
else await db.db.update(schema.inventoryItems).set({ qty: row.qty - take }).where(eq(schema.inventoryItems.id, row.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type: string, message: string): Promise<void> {
|
||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, saveId))
|
||||
await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() })
|
||||
}
|
||||
|
||||
// UPGRADE — level up a built system by one (capped at MAX_LEVEL). Cost: `level` units of any resource.
|
||||
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
||||
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
||||
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
||||
if (target.level >= MAX_LEVEL) throw new ShipError(409, `${target.type} is at max level (${MAX_LEVEL})`)
|
||||
|
||||
const cost = upgradeCost(target.level)
|
||||
await spendItems(db, save.id, 'resource', cost)
|
||||
const turn = save.turn + 1
|
||||
await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id))
|
||||
await bumpTurnAndEvent(db, save.id, turn, 'upgrade', `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`)
|
||||
await persist(db)
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// EXPAND — raise a built system's capacity by one (capped at MAX_CAPACITY). Cost: `capacity` units of any resource.
|
||||
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
|
||||
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
|
||||
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
|
||||
if (target.capacity >= MAX_CAPACITY) throw new ShipError(409, `${target.type} is at max capacity (${MAX_CAPACITY})`)
|
||||
|
||||
const cost = expandCost(target.capacity)
|
||||
await spendItems(db, save.id, 'resource', cost)
|
||||
const turn = save.turn + 1
|
||||
await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id))
|
||||
await bumpTurnAndEvent(db, save.id, turn, 'expand', `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`)
|
||||
await persist(db)
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
|
||||
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
|
||||
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
|
||||
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
|
||||
|
||||
await spendItems(db, save.id, 'part', BUILD_PART_COST)
|
||||
const turn = save.turn + 1
|
||||
await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id))
|
||||
await bumpTurnAndEvent(db, save.id, turn, 'build', `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`)
|
||||
await persist(db)
|
||||
return readShipState(db)
|
||||
}
|
||||
@@ -5,14 +5,53 @@ import { openApiSpec } from './openapi';
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents every public endpoint', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/crew',
|
||||
'/api/explore',
|
||||
'/api/jump',
|
||||
'/api/ship',
|
||||
'/api/ship/build',
|
||||
'/api/ship/expand',
|
||||
'/api/ship/upgrade',
|
||||
'/api/system',
|
||||
'/api/version',
|
||||
'/api/world',
|
||||
]);
|
||||
});
|
||||
|
||||
it('describes the ship endpoints as ShipState in and out, with error responses on the actions', () => {
|
||||
const ship = openApiSpec.paths['/api/ship'].get.responses[200].content['application/json'].schema;
|
||||
expect(ship.$ref).toBe('#/components/schemas/ShipState');
|
||||
|
||||
const system = openApiSpec.components.schemas.ShipSystem.properties;
|
||||
expect(Object.keys(system)).toEqual(['id', 'type', 'level', 'capacity', 'built']);
|
||||
|
||||
for (const [path, request] of [
|
||||
['/api/ship/upgrade', '#/components/schemas/UpgradeRequest'],
|
||||
['/api/ship/expand', '#/components/schemas/ExpandRequest'],
|
||||
['/api/ship/build', '#/components/schemas/BuildRequest'],
|
||||
] as const) {
|
||||
const post = openApiSpec.paths[path].post;
|
||||
expect(post.requestBody.content['application/json'].schema.$ref).toBe(request);
|
||||
expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ShipState');
|
||||
for (const status of ['400', '404', '409', '422'] as const) {
|
||||
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => {
|
||||
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
||||
expect(crew.type).toBe('array');
|
||||
expect((crew.items as { $ref: string }).$ref).toBe('#/components/schemas/CrewMember');
|
||||
|
||||
const member = openApiSpec.components.schemas.CrewMember.properties;
|
||||
expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']);
|
||||
|
||||
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] };
|
||||
const extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).properties;
|
||||
expect(Object.keys(extra)).toEqual(['outcome', 'crewFound']);
|
||||
});
|
||||
|
||||
it('describes the system endpoints as SystemState in and out', () => {
|
||||
const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema;
|
||||
expect(system.$ref).toBe('#/components/schemas/SystemState');
|
||||
|
||||
+272
-1
@@ -168,6 +168,216 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/crew': {
|
||||
get: {
|
||||
summary: 'Crew roster',
|
||||
description:
|
||||
'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring planets/moons. Each member has one good and one bad trait and an origin. Auto-initializes a new game when no save exists yet.',
|
||||
operationId: 'getCrew',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Crew roster',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/CrewMember' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/ship': {
|
||||
get: {
|
||||
summary: 'Ship state',
|
||||
description:
|
||||
"The ship's systems (built flag derived from level), the cargo hold and the turn counter. The starting ship is seeded on new game — core types built at base values, the rest unbuilt until a build action. Auto-initializes a new game when no save exists yet.",
|
||||
operationId: 'getShip',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current ship state',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ShipState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/ship/upgrade': {
|
||||
post: {
|
||||
summary: 'Upgrade a ship system',
|
||||
description:
|
||||
"Level up a built ship system by one (capped at max level). Costs `level` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.",
|
||||
operationId: 'upgradeShipSystem',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/UpgradeRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Fresh ship state after the upgrade',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ShipState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Malformed body or non-integer systemId',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown ship system id',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: 'System is not built or already at max level',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: {
|
||||
description: 'Not enough resources in the cargo hold for the cost',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/ship/expand': {
|
||||
post: {
|
||||
summary: 'Expand a ship system',
|
||||
description:
|
||||
"Raise a built ship system's capacity by one (capped at max capacity). Costs `capacity` units of any resource, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.",
|
||||
operationId: 'expandShipSystem',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ExpandRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Fresh ship state after the expansion',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ShipState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Malformed body or non-integer systemId',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown ship system id',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: 'System is not built or already at max capacity',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: {
|
||||
description: 'Not enough resources in the cargo hold for the cost',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/ship/build': {
|
||||
post: {
|
||||
summary: 'Build a ship system',
|
||||
description:
|
||||
'Build an unbuilt ship system type at base values. Costs a flat amount of parts, consumed from the oldest cargo row first, plus a turn. Returns the fresh ship state.',
|
||||
operationId: 'buildShipSystem',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/BuildRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Fresh ship state after the build',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ShipState' },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Malformed body or empty type',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown ship system type',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: 'System is already built',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: {
|
||||
description: 'Not enough parts in the cargo hold for the cost',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
@@ -245,6 +455,63 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['kind', 'name', 'qty'],
|
||||
},
|
||||
CrewMember: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
name: { type: 'string' },
|
||||
role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' },
|
||||
trait_good: { type: 'string', description: 'The member good trait' },
|
||||
trait_bad: { type: 'string', description: 'The member bad trait' },
|
||||
origin: { type: 'string', description: "'STARTING CREW' or '<planet>, <system>' for members found while exploring" },
|
||||
},
|
||||
required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'],
|
||||
},
|
||||
ShipSystem: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
type: { type: 'string', description: "System type, e.g. REACTOR or WEAPONS" },
|
||||
level: { type: 'integer', description: '0 when not built yet' },
|
||||
capacity: { type: 'integer', description: '0 when not built yet' },
|
||||
built: { type: 'boolean', description: 'Derived — true when level > 0' },
|
||||
},
|
||||
required: ['id', 'type', 'level', 'capacity', 'built'],
|
||||
},
|
||||
ShipState: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
systems: {
|
||||
type: 'array',
|
||||
description: "The ship's systems in display order",
|
||||
items: { $ref: '#/components/schemas/ShipSystem' },
|
||||
},
|
||||
inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } },
|
||||
turn: { type: 'integer', description: 'Turn counter — one per jump, exploration or ship action' },
|
||||
},
|
||||
required: ['systems', 'inventory', 'turn'],
|
||||
},
|
||||
UpgradeRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
systemId: { type: 'integer', description: 'ship_systems.id of the built system to level up' },
|
||||
},
|
||||
required: ['systemId'],
|
||||
},
|
||||
ExpandRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
systemId: { type: 'integer', description: 'ship_systems.id of the built system to expand' },
|
||||
},
|
||||
required: ['systemId'],
|
||||
},
|
||||
BuildRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: "Ship system type to build, e.g. WEAPONS (see ShipSystem.type)" },
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
SystemState: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -278,8 +545,12 @@ export const openApiSpec = {
|
||||
description: 'What this scan found — may be empty (some scans find nothing)',
|
||||
items: { $ref: '#/components/schemas/FoundItem' },
|
||||
},
|
||||
crewFound: {
|
||||
description: 'A crew member who joined the ship on this scan (~18% of scans), null when none',
|
||||
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
|
||||
},
|
||||
required: ['outcome'],
|
||||
},
|
||||
required: ['outcome', 'crewFound'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space-wanderer",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user