Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcaeb4dded | ||
|
|
7e975566ab | ||
|
|
de0cb45b09 | ||
|
|
fce27ff6ca | ||
|
|
5fe306d64d | ||
|
|
0910891995 | ||
|
|
c7580d92bd |
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||||
import { CrewPanel } from '@/components/crew-panel'
|
import { CrewPanel } from '@/components/crew-panel'
|
||||||
import { LocalSystemPanel } from '@/components/local-system'
|
import { LocalSystemPanel } from '@/components/local-system'
|
||||||
|
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||||
import { WorldMapPanel } from '@/components/world-map'
|
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
|
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
||||||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
||||||
// WORLD MAP, LOCAL SYSTEM and CREW tabs render live data (/api/world, /api/system, /api/crew);
|
// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS 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.
|
// /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 {
|
function useNarrow(threshold = 900): boolean {
|
||||||
const [narrow, setNarrow] = useState(
|
const [narrow, setNarrow] = useState(
|
||||||
@@ -38,22 +43,6 @@ function now(): string {
|
|||||||
|
|
||||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
// ── 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 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 = [
|
const skillCards = [
|
||||||
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
||||||
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
||||||
@@ -89,6 +78,36 @@ const logLines: TerminalLine[] = [
|
|||||||
export function MissionControl() {
|
export function MissionControl() {
|
||||||
const narrow = useNarrow()
|
const narrow = useNarrow()
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
|
// Ship state is owned here (not in the tab panel) so the left column mirrors ship actions
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||||||
@@ -257,11 +276,25 @@ export function MissionControl() {
|
|||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
<Badge variant="ACTIVE">4 ONLINE</Badge>
|
<Badge variant={ship && ship.systems.some((s) => s.built) ? 'ACTIVE' : 'OFFLINE'}>
|
||||||
|
{ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<StatusGrid systems={shipSystems} />
|
{ship ? (
|
||||||
|
<StatusGrid
|
||||||
|
systems={ship.systems.map((s) => ({
|
||||||
|
name: s.type,
|
||||||
|
status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const),
|
||||||
|
detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem' }}>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel notch="sm">
|
<Panel notch="sm">
|
||||||
@@ -269,11 +302,17 @@ export function MissionControl() {
|
|||||||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<PanelContent>
|
<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' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||||||
<Progress label="HULL INTEGRITY" value={87} />
|
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
|
||||||
<Progress label="REACTOR OUTPUT" value={94} />
|
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
|
||||||
<Progress label="CREW ROSTER / 6" value={50} />
|
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||||||
|
)}
|
||||||
</PanelContent>
|
</PanelContent>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,40 +369,9 @@ export function MissionControl() {
|
|||||||
<CrewPanel narrow={narrow} />
|
<CrewPanel narrow={narrow} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* SHIP SYSTEMS TAB */}
|
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
||||||
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} />
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{shipSystemCards.map((system) => (
|
|
||||||
<Card key={system.name}>
|
|
||||||
<CardHeader>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
||||||
<CardTitle>{system.name}</CardTitle>
|
|
||||||
<Badge variant={system.status} className="ml-auto">
|
|
||||||
{system.level}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
|
||||||
MODULE BAY // PLACEHOLDER READOUT
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="gap-2">
|
|
||||||
{system.actions.map((action) => (
|
|
||||||
<Button key={action} variant="OUTLINE" size="SM">
|
|
||||||
{action}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* SKILL TREE TAB */}
|
{/* SKILL TREE TAB */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ interface SystemState { systemId: number; systemName: string; planets: PlanetDto
|
|||||||
interface FoundItem { kind: string; name: string; qty: number }
|
interface FoundItem { kind: string; name: string; qty: number }
|
||||||
interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown }
|
interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown }
|
||||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||||
|
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||||
|
interface 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 }) => {
|
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -139,6 +143,67 @@ test('CREW tab renders real roster cards, each showing both traits and an origin
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 }) => {
|
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||||
const res = await page.request.get('/api/version');
|
const res = await page.request.get('/api/version');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; tur
|
|||||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
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 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 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('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
@@ -52,6 +54,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
||||||
expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded
|
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.
|
// Restart the app container mid-run — the named DB volume must survive it.
|
||||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
||||||
|
|
||||||
@@ -86,6 +92,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
|||||||
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[];
|
||||||
expect(crewAfter).toEqual(crewBefore);
|
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.
|
// The page itself serves again with the landing title.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||||
|
|||||||
+19
-1
@@ -24,7 +24,8 @@ CREATE TABLE IF NOT EXISTS saves (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
seed TEXT NOT NULL,
|
seed TEXT NOT NULL,
|
||||||
turn INTEGER NOT NULL DEFAULT 0,
|
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 (
|
CREATE TABLE IF NOT EXISTS solar_systems (
|
||||||
@@ -71,6 +72,15 @@ CREATE TABLE IF NOT EXISTS crew (
|
|||||||
origin TEXT NOT NULL -- 'STARTING CREW' or '<planet>, <system>' for members found while exploring
|
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 (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||||
@@ -98,6 +108,14 @@ async function open(file: string): Promise<GameDb> {
|
|||||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||||
raw.exec(DDL); // no-op for tables that already exist in an older file
|
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||||
|
|
||||||
|
// Idempotent evolution for files created by older code: CREATE TABLE IF NOT EXISTS above cannot
|
||||||
|
// add columns, so check and add any that are missing. This is what makes DB wipes unnecessary —
|
||||||
|
// old saves open fine and get backfilled by ensureSave (see lib/game/save.ts).
|
||||||
|
const saveCols = raw.exec('PRAGMA table_info(saves)')[0]?.values.map((v) => v[1]) ?? [];
|
||||||
|
if (!saveCols.includes('schema_version')) {
|
||||||
|
raw.exec('ALTER TABLE saves ADD COLUMN schema_version INTEGER');
|
||||||
|
}
|
||||||
|
|
||||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||||
|
|
||||||
// First run: stamp when this DB was created and land the file on disk so the ./db volume
|
// First run: stamp when this DB was created and land the file on disk so the ./db volume
|
||||||
|
|||||||
+18
-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
|
// 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.
|
// (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(),
|
seed: text('seed').notNull(),
|
||||||
turn: integer('turn').notNull().default(0),
|
turn: integer('turn').notNull().default(0),
|
||||||
createdAt: text('created_at').notNull(),
|
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', {
|
export const solarSystems = sqliteTable('solar_systems', {
|
||||||
@@ -71,6 +74,20 @@ export const crew = sqliteTable('crew', {
|
|||||||
origin: text('origin').notNull(),
|
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
|
// Append-only game log. Writers so far: new-game + jump events; later issues add more
|
||||||
// writers and a read endpoint.
|
// writers and a read endpoint.
|
||||||
export const events = sqliteTable('events', {
|
export const events = sqliteTable('events', {
|
||||||
|
|||||||
+89
-1
@@ -2,10 +2,12 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { afterAll, describe, expect, it } from 'vitest'
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||||
import * as schema from '@/lib/db/schema'
|
import * as schema from '@/lib/db/schema'
|
||||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||||
import { getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
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'
|
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||||
|
|
||||||
const dirs: string[] = []
|
const dirs: string[] = []
|
||||||
@@ -300,3 +302,89 @@ describe('explorePlanet crew finds', () => {
|
|||||||
expect((await getCrewState(db)).length).toBe(before.length + 1)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+75
-13
@@ -3,6 +3,7 @@ import type { GameDb } from '@/lib/db/client'
|
|||||||
import { persist } from '@/lib/db/client'
|
import { persist } from '@/lib/db/client'
|
||||||
import * as schema from '@/lib/db/schema'
|
import * as schema from '@/lib/db/schema'
|
||||||
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
|
||||||
|
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||||
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
|
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.
|
// Save lifecycle: auto-init on first hit, world state reads, and the jump action.
|
||||||
@@ -45,20 +46,36 @@ function expansionRng(seed: string, turn: number): () => number {
|
|||||||
return mulberry32((Number(seed) + turn * 1_000_003) >>> 0)
|
return mulberry32((Number(seed) + turn * 1_000_003) >>> 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inserts one system together with its generated bodies (planets + moons), so a system is
|
// The bodies of one system (planets + moons), derived from (seed, system id) via bodyRng —
|
||||||
// never left without worlds to explore. Bodies derive from (seed, system id) via bodyRng —
|
// independent of generation order and every other system. Shared by new-game creation and the
|
||||||
// independent of generation order and every other system.
|
// 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> {
|
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 })
|
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))) {
|
await insertBodiesForSystem(db, row.id, seed)
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return row.id
|
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> {
|
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
|
||||||
for (const [a, b] of edges) {
|
for (const [a, b] of edges) {
|
||||||
const from = resolve(a)
|
const from = resolve(a)
|
||||||
@@ -75,17 +92,37 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates the save + starting region when the DB has none. No-op otherwise — safe to call
|
// What each schema version added to a save. Saves created by older code lack the data newer
|
||||||
// on every request (single player, one process: check-then-insert cannot race in practice).
|
// 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> {
|
export async function ensureSave(db: GameDb): Promise<void> {
|
||||||
const existing = await db.db.select().from(schema.saves).limit(1)
|
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 seed = newSeed()
|
||||||
const region = generateStartRegion(mulberry32(Number(seed)))
|
const region = generateStartRegion(mulberry32(Number(seed)))
|
||||||
const nowIso = new Date().toISOString()
|
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.
|
// All inserted as undiscovered first; the start is then marked discovered + current.
|
||||||
const startId = await insertSystemWithBodies(db, save.id, region.start, seed)
|
const startId = await insertSystemWithBodies(db, save.id, region.start, seed)
|
||||||
const neighborIds: number[] = []
|
const neighborIds: number[] = []
|
||||||
@@ -95,11 +132,36 @@ export async function ensureSave(db: GameDb): Promise<void> {
|
|||||||
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
|
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
|
||||||
await insertRoutes(db, region.edges, resolve)
|
await insertRoutes(db, region.edges, resolve)
|
||||||
await seedStartingCrew(db, save.id, seed)
|
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 db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
|
||||||
await persist(db)
|
await persist(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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> {
|
export async function getWorldState(db: GameDb): Promise<WorldState> {
|
||||||
await ensureSave(db)
|
await ensureSave(db)
|
||||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -8,12 +8,37 @@ describe('OpenAPI spec', () => {
|
|||||||
'/api/crew',
|
'/api/crew',
|
||||||
'/api/explore',
|
'/api/explore',
|
||||||
'/api/jump',
|
'/api/jump',
|
||||||
|
'/api/ship',
|
||||||
|
'/api/ship/build',
|
||||||
|
'/api/ship/expand',
|
||||||
|
'/api/ship/upgrade',
|
||||||
'/api/system',
|
'/api/system',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
'/api/world',
|
'/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', () => {
|
it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => {
|
||||||
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
|
||||||
expect(crew.type).toBe('array');
|
expect(crew.type).toBe('array');
|
||||||
|
|||||||
+237
@@ -186,6 +186,198 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/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: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -275,6 +467,51 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'],
|
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: {
|
SystemState: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user