Compare commits
7
Commits
v0.6.0
...
skill-tree
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80885c8ad7 | ||
|
|
a62ed2cf95 | ||
|
|
672e99bd96 | ||
|
|
94e9fc728c | ||
|
|
7b0227cde9 | ||
|
|
8818f0928d | ||
|
|
4ef924edd6 |
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## v0.7.0 — 2026-09-29 00:38
|
||||
- SKILL TREE tab — technology research unlocks (issue #9): the second spend-side loop on top of issue #8's ship systems. Researching a tech costs inventory items + a turn and its unlock effect then gates or raises ship actions, so the two tabs interlock: researching WEAPONS SYSTEMS is what makes building WEAPONS possible at all.
|
||||
- New table (`lib/db/schema.ts` + DDL): `tech` (id/save_id/slug/name/cost_kind/cost_qty/prerequisites/effect/researched, UNIQUE(save_id,slug)). Definitions are seeded from the pure module on new game AND as backfill step v5 — same pattern as ship_systems; saves created by older code get the tree on first read. `SCHEMA_VERSION` 4 → 5.
|
||||
- Pure tech model (`lib/game/tech.ts`, no DB imports — imported by both server and UI so research/cap/gate logic can't drift): six techs in two short chains (WEAPONS SYSTEMS → PLASMA CANNON, REACTOR OVERCLOCK → FUSION CORE) plus four roots. Effects: WEAPONS SYSTEMS gates building WEAPONS; the other five raise one system type's UPGRADE level cap or EXPAND capacity cap by 2–3 over the base caps. Helpers compute per-tech state (RESEARCHED / AVAILABLE / LOCKED + missing prereq names), effective caps and build gates from a set of researched slugs.
|
||||
- Research action (`lib/game/research.ts`): `getTechState` → the full tree with per-save state; `researchTech` validates prerequisites (409, naming what is missing) and the cost against the cargo hold (422), spends it through the same oldest-row-first path as ship actions, marks the tech researched, costs a turn and writes an event.
|
||||
- Ship-action gating (`lib/game/ship.ts`): `buildSystem('WEAPONS')` now 409s "requires the WEAPONS SYSTEMS tech" until it is researched; UPGRADE/EXPAND use the effective caps from researched techs, so a raised cap re-enables buttons that were capped.
|
||||
- New endpoints: `GET /api/tech` → `[{id,slug,name,costKind,costQty,prereqs,effect,state,missingPrereqs}]`; `POST /api/research {techId}` → fresh tech tree state (400 malformed body, 404 unknown id, 409 already researched / prereq missing, 422 insufficient hold). OpenAPI spec in code updated (12 paths).
|
||||
- SKILL TREE tab now renders REAL data: one card per tech with a state badge, cost line, UNLOCKS line and — when locked — the blocking prerequisite(s); RESEARCH is enabled only for AVAILABLE techs whose cost the cargo hold can pay, and each response is applied directly (no polling). SHIP SYSTEMS cards pick up the same pure helpers: effective caps drive UPGRADE/EXPAND disabling and gated builds show a red `REQUIRES // <TECH>` line with BUILD NEW disabled. Placeholder skill cards removed; MissionControl owns both states so the tabs stay in sync.
|
||||
- Tests: unit — tree seeding on new game + v5 backfill (deterministic, idempotent), per-tech state incl. locked-with-missing-prereq names, research cost validation with no mutation on failure, exact oldest-first deduction + turn + event + persistence, prereq chain unlock, WEAPONS build gated 409 → succeeds after research, REACTOR upgrade cap raised by REACTOR OVERCLOCK; e2e — SKILL TREE tab researches WEAPONS SYSTEMS via the UI (card flips to RESEARCHED) and the previously-disabled BUILD NEW on WEAPONS becomes enabled and builds, plus tech state survives the mid-run container restart.
|
||||
|
||||
## v0.6.0 — 2026-09-28 23:46
|
||||
- SHIP SYSTEMS tab — expand / upgrade / build ship systems + save backfill plumbing (issue #8): the first spend-side game loop on top of issue #7's crew, plus a root-cause fix for saves created by older code lacking data newer code seeds. Single-player, no auth, by design.
|
||||
- Backfill plumbing: `schema_version` INTEGER added to `saves` (NULL = v1). `ensureSave` now runs ordered IDEMPOTENT backfill steps for anything below current — v2: every system without planets gets its generated bodies (same `(seed, systemId)` derivation as fresh generation), v3: starting crew if no rows. New games get the current version directly. `lib/db/client.ts` open() carries an idempotent `ALTER TABLE ... ADD COLUMN` guard for files created before the column existed — old saves open fine and backfill on first read, so DB wipes are no longer needed when deploying new versions.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { ResearchError, researchTech } from '@/lib/game/research'
|
||||
|
||||
// Research one tech. Body: { "techId": number }. Costs inventory items + a turn; returns the fresh tech tree 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 techId = (body as { techId?: unknown }).techId
|
||||
if (typeof techId !== 'number' || !Number.isInteger(techId)) {
|
||||
return Response.json({ error: 'techId must be an integer' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await researchTech(db, techId))
|
||||
} catch (err) {
|
||||
if (err instanceof ResearchError) {
|
||||
return Response.json({ error: err.message }, { status: err.status })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getGameDb } from '@/lib/db/client'
|
||||
import { getTechState } from '@/lib/game/research'
|
||||
|
||||
// The full tech tree with per-save state (RESEARCHED / AVAILABLE / LOCKED + missing prereqs). Auto-initializes on first hit.
|
||||
export async function GET() {
|
||||
const db = await getGameDb()
|
||||
return Response.json(await getTechState(db))
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { StatCard } from '@/components/ui/stat-card'
|
||||
@@ -14,16 +12,18 @@ 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 { SkillTreePanel } from '@/components/skill-tree'
|
||||
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'
|
||||
import type { TechDto } from '@/lib/game/tech'
|
||||
|
||||
// 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, 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.
|
||||
// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS and SKILL TREE tabs render live data (/api/world,
|
||||
// /api/system, /api/crew, /api/ship, /api/tech); 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(
|
||||
@@ -43,13 +43,6 @@ function now(): string {
|
||||
|
||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
||||
|
||||
const skillCards = [
|
||||
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
|
||||
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
|
||||
{ name: 'CREW TRAINING PROTOCOLS', unlocks: 'RETRAIN CREW TRAITS', state: 'LOCKED' as const },
|
||||
{ name: 'JUMP DRIVE TUNING', unlocks: 'SHORTER JUMP COOLDOWN', state: 'LOCKED' as const },
|
||||
]
|
||||
|
||||
const encounters = [
|
||||
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
||||
{ name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
|
||||
@@ -82,6 +75,7 @@ export function MissionControl() {
|
||||
// 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 [techs, setTechs] = useState<TechDto[] | null>(null)
|
||||
const [crewCount, setCrewCount] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,6 +89,15 @@ export function MissionControl() {
|
||||
if (!cancelled) setShip(s)
|
||||
})
|
||||
.catch(() => {}) // left column + tab keep their loading state on failure
|
||||
fetch('/api/tech')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return (await res.json()) as TechDto[]
|
||||
})
|
||||
.then((t) => {
|
||||
if (!cancelled) setTechs(t)
|
||||
})
|
||||
.catch(() => {})
|
||||
fetch('/api/crew')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -116,6 +119,10 @@ export function MissionControl() {
|
||||
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||
|
||||
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
|
||||
// tab via the same pure helpers the API enforces (lib/game/tech.ts).
|
||||
const researchedSlugs = new Set((techs ?? []).filter((t) => t.state === 'RESEARCHED').map((t) => t.slug))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -371,41 +378,12 @@ export function MissionControl() {
|
||||
|
||||
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
||||
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||||
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} />
|
||||
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} researchedSlugs={researchedSlugs} />
|
||||
</TabsContent>
|
||||
|
||||
{/* SKILL TREE TAB */}
|
||||
{/* SKILL TREE TAB — live data from /api/tech (state owned by MissionControl) */}
|
||||
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{skillCards.map((skill) => (
|
||||
<Card key={skill.name}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{skill.name}</CardTitle>
|
||||
<Badge variant={skill.state === 'AVAILABLE' ? 'ACTIVE' : 'OFFLINE'} className="ml-auto">
|
||||
{skill.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
UNLOCKS: {skill.unlocks}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="OUTLINE" size="SM">
|
||||
RESEARCH
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
||||
</TabsContent>
|
||||
|
||||
{/* MISSIONS TAB */}
|
||||
|
||||
@@ -4,21 +4,24 @@ 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 { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
|
||||
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
|
||||
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from '@/lib/game/tech'
|
||||
|
||||
// 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).
|
||||
// onChanged (no polling). Buttons are disabled when unaffordable, capped or gated by an
|
||||
// unresearched tech, using the same pure helpers the API enforces (lib/game/ship-costs.ts +
|
||||
// lib/game/tech.ts).
|
||||
|
||||
interface ShipSystemsPanelProps {
|
||||
ship: ShipState | null
|
||||
narrow?: boolean
|
||||
onChanged: (next: ShipState) => void
|
||||
researchedSlugs: Set<string> // slugs of researched techs — raises caps, lifts build gates
|
||||
}
|
||||
|
||||
export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelProps) {
|
||||
export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: ShipSystemsPanelProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
||||
|
||||
@@ -57,7 +60,7 @@ export function ShipSystemsPanel({ ship, narrow, onChanged }: ShipSystemsPanelPr
|
||||
)}
|
||||
<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} />
|
||||
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} researchedSlugs={researchedSlugs} onAct={act} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,17 +72,23 @@ function SystemCard({
|
||||
resources,
|
||||
parts,
|
||||
busy,
|
||||
researchedSlugs,
|
||||
onAct,
|
||||
}: {
|
||||
system: ShipSystemDto
|
||||
resources: number
|
||||
parts: number
|
||||
busy: boolean
|
||||
researchedSlugs: Set<string>
|
||||
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
|
||||
const maxLevel = effectiveLevelCap(system.type, researchedSlugs)
|
||||
const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs)
|
||||
const gate = buildGate(system.type, researchedSlugs) // name of the required tech, or null when ungated/researched
|
||||
|
||||
const upgradeDisabled = busy || !system.built || system.level >= maxLevel || resources < upgradeCost(system.level)
|
||||
const expandDisabled = busy || !system.built || system.capacity >= maxCapacity || resources < expandCost(system.capacity)
|
||||
const buildDisabled = busy || system.built || gate !== null || parts < BUILD_PART_COST
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -100,6 +109,9 @@ function SystemCard({
|
||||
? `UPGRADE ${upgradeCost(system.level)}× RES · EXPAND ${expandCost(system.capacity)}× RES`
|
||||
: `BUILD ${BUILD_PART_COST}× PARTS`}
|
||||
</p>
|
||||
{!system.built && gate !== null && (
|
||||
<p style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>REQUIRES // {gate}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="gap-2">
|
||||
{system.built ? (
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'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 { totalQty } from '@/lib/game/ship-costs'
|
||||
import type { ShipState } from '@/lib/game/ship'
|
||||
import type { TechDto } from '@/lib/game/tech'
|
||||
|
||||
// SKILL TREE tab — live data. The parent (MissionControl) owns the tech state + ship state; this panel
|
||||
// applies each research response directly via onResearched (no polling). RESEARCH is enabled only when
|
||||
// the tech is AVAILABLE and its cost is payable from the cargo hold, mirroring what POST /api/research enforces.
|
||||
|
||||
interface SkillTreePanelProps {
|
||||
techs: TechDto[] | null
|
||||
ship: ShipState | null
|
||||
narrow?: boolean
|
||||
onResearched: (next: TechDto[]) => void
|
||||
}
|
||||
|
||||
export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreePanelProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
|
||||
|
||||
if (!techs || !ship) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING SKILL TREE…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const resources = totalQty(ship.inventory, 'resource')
|
||||
const parts = totalQty(ship.inventory, 'part')
|
||||
|
||||
async function research(tech: TechDto) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id }) })
|
||||
const data = (await res.json()) as TechDto[] & { error?: string }
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||
onResearched(data) // response is the fresh tech tree state — apply directly
|
||||
} 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' }}>
|
||||
{techs.map((tech) => (
|
||||
<TechCard key={tech.slug} tech={tech} resources={resources} parts={parts} busy={busy} onResearch={research} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TechCard({
|
||||
tech,
|
||||
resources,
|
||||
parts,
|
||||
busy,
|
||||
onResearch,
|
||||
}: {
|
||||
tech: TechDto
|
||||
resources: number
|
||||
parts: number
|
||||
busy: boolean
|
||||
onResearch: (tech: TechDto) => void
|
||||
}) {
|
||||
const affordable = tech.costKind === 'resource' ? resources >= tech.costQty : parts >= tech.costQty
|
||||
const researchDisabled = busy || tech.state !== 'AVAILABLE' || !affordable
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{tech.name}</CardTitle>
|
||||
<Badge variant={tech.state === 'RESEARCHED' ? 'ACTIVE' : tech.state === 'AVAILABLE' ? 'SCANNING' : 'OFFLINE'} className="ml-auto">
|
||||
{tech.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
|
||||
COST {tech.costQty}× {tech.costKind === 'resource' ? 'RESOURCES' : 'PARTS'}
|
||||
</p>
|
||||
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>UNLOCKS // {tech.effect}</p>
|
||||
{tech.state === 'LOCKED' && (
|
||||
<p style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>REQUIRES // {tech.missingPrereqs.join(' + ')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button variant="OUTLINE" size="SM" disabled={researchDisabled} onClick={() => onResearch(tech)}>
|
||||
RESEARCH
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
// Shared e2e helpers — kept out of the specs so both files can reuse them.
|
||||
|
||||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||||
|
||||
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
|
||||
// undiscovered system generates 4 fresh neighbours (lib/game/world.ts), so fresh territory is
|
||||
// always available — picking the FIRST edge instead ping-pongs between exhausted systems, because
|
||||
// edges come back in insertion order and the oldest route out of a system is usually the one that
|
||||
// led here. Falls back to any non-current adjacent system if none is undiscovered.
|
||||
export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
|
||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const currentId = world.systems.find((s) => s.current)!.id;
|
||||
|
||||
const adjacentIds = new Set<number>();
|
||||
for (const [a, b] of world.edges) {
|
||||
if (a === currentId) adjacentIds.add(b);
|
||||
else if (b === currentId) adjacentIds.add(a);
|
||||
}
|
||||
const candidates = [...adjacentIds].map((id) => world.systems.find((s) => s.id === id)!);
|
||||
const target = candidates.find((s) => !s.discovered) ?? candidates[0];
|
||||
expect(target).toBeDefined(); // every system has at least one route out (ring generation)
|
||||
|
||||
const res = await page.request.post('/api/jump', { data: { systemId: target.id } });
|
||||
expect(res.status()).toBe(200);
|
||||
return (await res.json()) as WorldState;
|
||||
}
|
||||
+52
-1
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { jumpToFreshSystem } from './helpers';
|
||||
|
||||
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||
@@ -148,7 +149,9 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card
|
||||
// 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);
|
||||
// WEAPONS is gated behind the unresearched WEAPONS SYSTEMS tech — its build path is covered by
|
||||
// the research test below, so this one only targets ungated types.
|
||||
const buildTarget = () => (totalQty(ship.inventory, 'part') >= 3 ? ship.systems.find((s) => !s.built && s.type !== 'WEAPONS') : undefined);
|
||||
|
||||
let iterations = 0;
|
||||
while (!upgradeTarget() && !buildTarget() && iterations < 60) {
|
||||
@@ -204,6 +207,54 @@ test('SHIP SYSTEMS tab performs an affordable action and reflects it in the card
|
||||
}
|
||||
});
|
||||
|
||||
test('SKILL TREE tab researches WEAPONS SYSTEMS and unlocks the gated WEAPONS build', async ({ page }) => {
|
||||
// Research costs two parts, building WEAPONS three — scan until the hold has five. Parts drop on
|
||||
// ~30% of scans, so P(<5 in 80 scans) is negligible; jumping keeps it going if a system exhausts.
|
||||
let ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
let iterations = 0;
|
||||
while (totalQty(ship.inventory, 'part') < 5 && iterations < 80) {
|
||||
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 {
|
||||
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||||
}
|
||||
ship = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
}
|
||||
expect(totalQty(ship.inventory, 'part')).toBeGreaterThanOrEqual(5);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// SHIP SYSTEMS tab: BUILD NEW on WEAPONS is disabled — gated behind the unresearched tech.
|
||||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||
const weaponsCard = page.getByRole('heading', { name: 'WEAPONS' }).locator('xpath=ancestor::div[3]');
|
||||
await expect(weaponsCard.getByText('REQUIRES // WEAPONS SYSTEMS')).toBeVisible();
|
||||
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeDisabled();
|
||||
|
||||
// SKILL TREE tab: research WEAPONS SYSTEMS (two parts) — the card flips to RESEARCHED.
|
||||
await page.getByRole('tab', { name: 'SKILL TREE' }).click();
|
||||
const techCard = page.getByRole('heading', { name: 'WEAPONS SYSTEMS' }).locator('xpath=ancestor::div[3]');
|
||||
await expect(techCard.getByText('AVAILABLE')).toBeVisible(); // root tech — no prerequisites
|
||||
await expect(techCard.getByRole('button', { name: 'RESEARCH' })).toBeEnabled(); // parts ≥ 2
|
||||
const turnBefore = ship.turn;
|
||||
await techCard.getByRole('button', { name: 'RESEARCH' }).click();
|
||||
await expect(techCard.getByText('RESEARCHED')).toBeVisible();
|
||||
|
||||
const afterResearch = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
expect(afterResearch.turn).toBe(turnBefore + 1); // research costs a turn
|
||||
|
||||
// The gated ship action is now available: BUILD NEW on WEAPONS flips to enabled and builds.
|
||||
await page.getByRole('tab', { name: 'SHIP SYSTEMS' }).click();
|
||||
await expect(weaponsCard.getByRole('button', { name: 'BUILD NEW' })).toBeEnabled();
|
||||
await weaponsCard.getByRole('button', { name: 'BUILD NEW' }).click();
|
||||
|
||||
const afterBuild = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
expect(afterBuild.systems.find((s) => s.type === 'WEAPONS')!.built).toBe(true);
|
||||
await expect(weaponsCard.getByText('LV 1')).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);
|
||||
|
||||
+14
-15
@@ -1,6 +1,7 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { jumpToFreshSystem } from './helpers';
|
||||
|
||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
||||
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
||||
@@ -8,14 +9,13 @@ import { expect, test } from '@playwright/test';
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
||||
|
||||
// Mirrors the /api/world + /api/system response shapes (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||
interface WorldSystemDto { id: number; name: string; x: number; y: number; discovered: boolean; current: boolean }
|
||||
interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number }
|
||||
// Mirrors the /api/system response shape (lib/game/save.ts) — kept local so the spec stays self-contained.
|
||||
interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null }
|
||||
interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||
interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }
|
||||
interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean }
|
||||
interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number }
|
||||
interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] }
|
||||
|
||||
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
@@ -27,22 +27,13 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
|
||||
// Game-state baseline: jump once so there is real progress that must survive the restart.
|
||||
const worldBefore = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const currentId = worldBefore.systems.find((s) => s.current)!.id;
|
||||
const edge = worldBefore.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||
const jumpRes = await page.request.post('/api/jump', { data: { systemId: targetId } });
|
||||
expect(jumpRes.status()).toBe(200);
|
||||
await jumpToFreshSystem(page);
|
||||
|
||||
// Exploration baseline: scan one body so explored state + inventory must survive too.
|
||||
// (Earlier specs may have exhausted the current system, so jump until an unscanned body shows up.)
|
||||
let sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
for (let i = 0; !sysBefore.planets.some((p) => !p.explored) && i < 20; i++) {
|
||||
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const curId = world.systems.find((s) => s.current)!.id;
|
||||
const e = world.edges.find(([a, b]) => a === curId || b === curId)!;
|
||||
const tId = e[0] === curId ? e[1] : e[0];
|
||||
expect((await page.request.post('/api/jump', { data: { systemId: tId } })).status()).toBe(200);
|
||||
await jumpToFreshSystem(page); // prefer undiscovered territory — first-edge ping-pongs between exhausted systems
|
||||
sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||
}
|
||||
const targetPlanet = sysBefore.planets.find((p) => !p.explored)!;
|
||||
@@ -58,6 +49,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
const shipBefore = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
expect(shipBefore.systems.length).toBeGreaterThan(0); // starting ship is always seeded
|
||||
|
||||
// Tech baseline: the researched flags must survive too.
|
||||
const techBefore = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
||||
expect(techBefore.length).toBeGreaterThan(0); // the tech tree is always seeded
|
||||
|
||||
// Restart the app container mid-run — the named DB volume must survive it.
|
||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
||||
|
||||
@@ -79,7 +74,7 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
||||
|
||||
// Game state survived: same turn counter and current system as before the restart.
|
||||
const worldAfter = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||
const worldAfter = (await (await page.request.get('/api/world')).json()) as { turn: number; systems: { id: number; current: boolean }[] };
|
||||
expect(worldAfter.turn).toBe(sysExplored.turn);
|
||||
expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId);
|
||||
|
||||
@@ -96,6 +91,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p
|
||||
const shipAfter = (await (await page.request.get('/api/ship')).json()) as ShipState;
|
||||
expect(shipAfter).toEqual(shipBefore);
|
||||
|
||||
// Tech tree survived too: same researched flags (no re-seed on restart).
|
||||
const techAfter = (await (await page.request.get('/api/tech')).json()) as TechDto[];
|
||||
expect(techAfter).toEqual(techBefore);
|
||||
|
||||
// The page itself serves again with the landing title.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||
|
||||
@@ -81,6 +81,19 @@ CREATE TABLE IF NOT EXISTS ship_systems (
|
||||
UNIQUE (save_id, type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tech (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
slug TEXT NOT NULL, -- stable key, e.g. WEAPONS_SYSTEMS
|
||||
name TEXT NOT NULL, -- display name, e.g. 'WEAPONS SYSTEMS'
|
||||
cost_kind TEXT NOT NULL, -- 'resource' | 'part'
|
||||
cost_qty INTEGER NOT NULL DEFAULT 1,
|
||||
prerequisites TEXT NOT NULL DEFAULT '', -- comma-separated tech slugs, '' when none
|
||||
effect TEXT NOT NULL, -- effect slug — semantics in EFFECTS (lib/game/tech.ts)
|
||||
researched INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (save_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
save_id INTEGER NOT NULL REFERENCES saves(id),
|
||||
|
||||
@@ -88,6 +88,26 @@ export const shipSystems = sqliteTable(
|
||||
(t) => [uniqueIndex('ship_systems_save_type_unique').on(t.saveId, t.type)],
|
||||
)
|
||||
|
||||
// The technology tree, one row per known tech (see TECHS in lib/game/tech.ts). Definitions are
|
||||
// seeded from the pure module on new game / backfill v5 — same pattern as ship_systems.
|
||||
// `researched` is the per-save progress; prerequisites/effect are stored for completeness but
|
||||
// the canonical semantics live in lib/game/tech.ts (EFFECTS).
|
||||
export const tech = sqliteTable(
|
||||
'tech',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
saveId: integer('save_id').notNull().references(() => saves.id),
|
||||
slug: text('slug').notNull(), // stable key, e.g. WEAPONS_SYSTEMS
|
||||
name: text('name').notNull(), // display name, e.g. 'WEAPONS SYSTEMS'
|
||||
costKind: text('cost_kind').notNull(), // 'resource' | 'part'
|
||||
costQty: integer('cost_qty').notNull().default(1),
|
||||
prerequisites: text('prerequisites').notNull().default(''), // comma-separated tech slugs, '' when none
|
||||
effect: text('effect').notNull(), // effect slug — semantics in EFFECTS (lib/game/tech.ts)
|
||||
researched: integer('researched', { mode: 'boolean' }).notNull().default(false),
|
||||
},
|
||||
(t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)],
|
||||
)
|
||||
|
||||
// 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,147 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { BUILD_PART_COST, MAX_LEVEL, upgradeCost } from './ship-costs'
|
||||
import { buildSystem, getShipState, upgradeSystem, type ShipState } from './ship'
|
||||
import { TECHS } from './tech'
|
||||
import { getTechState, researchTech } from './research'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
async function freshFile(): Promise<{ db: GameDb; file: string }> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'sw-research-test-'))
|
||||
dirs.push(dir)
|
||||
const file = path.join(dir, 'test.db')
|
||||
return { db: await openGameDb(file), file }
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const d of dirs) await rm(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function addItem(db: GameDb, kind: 'resource' | 'part', name: string, qty: number): Promise<void> {
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind, name, qty })
|
||||
}
|
||||
|
||||
const systemOf = (state: ShipState, type: string): ShipState['systems'][number] => state.systems.find((s) => s.type === type)!
|
||||
|
||||
describe('getTechState', () => {
|
||||
it('new game: every tech seeded — roots AVAILABLE, chained ones LOCKED naming the missing prereq', async () => {
|
||||
const { db } = await freshFile()
|
||||
const techs = await getTechState(db)
|
||||
|
||||
expect(techs).toHaveLength(TECHS.length)
|
||||
for (const def of TECHS) {
|
||||
const t = techs.find((x) => x.slug === def.slug)!
|
||||
if (def.prereqs.length === 0) {
|
||||
expect(t.state).toBe('AVAILABLE')
|
||||
expect(t.missingPrereqs).toEqual([])
|
||||
} else {
|
||||
expect(t.state).toBe('LOCKED')
|
||||
expect(t.missingPrereqs).toEqual(def.prereqs.map((slug) => TECHS.find((x) => x.slug === slug)!.name))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('researchTech', () => {
|
||||
it('rejects unknown id with 404, locked techs with 409 naming the missing prereq, and re-research with 409', async () => {
|
||||
const { db } = await freshFile()
|
||||
const techs = await getTechState(db)
|
||||
|
||||
await expect(researchTech(db, 999)).rejects.toMatchObject({ status: 404 })
|
||||
|
||||
const plasma = techs.find((t) => t.slug === 'PLASMA_CANNON')! // locked behind WEAPONS SYSTEMS
|
||||
await expect(researchTech(db, plasma.id)).rejects.toMatchObject({ status: 409, message: /WEAPONS SYSTEMS/ })
|
||||
|
||||
const weapons = techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!
|
||||
await addItem(db, 'part', 'SERVO ACTUATOR', weapons.costQty)
|
||||
await researchTech(db, weapons.id)
|
||||
await expect(researchTech(db, weapons.id)).rejects.toMatchObject({ status: 409 }) // already researched
|
||||
})
|
||||
|
||||
it('validates the cost against the hold — 422 with no mutation when short', async () => {
|
||||
const { db } = await freshFile()
|
||||
const techs = await getTechState(db)
|
||||
const nav = techs.find((t) => t.slug === 'NAV_AI')! // resource×2, root
|
||||
const before = await getShipState(db)
|
||||
|
||||
await expect(researchTech(db, nav.id)).rejects.toMatchObject({ status: 422 })
|
||||
|
||||
expect((await getShipState(db)).turn).toBe(before.turn) // no turn spent on a failed research
|
||||
expect((await db.db.select().from(schema.tech)).find((t) => t.slug === 'NAV_AI')!.researched).toBe(false)
|
||||
})
|
||||
|
||||
it('spends the cost from the oldest row first, persists researched state, costs a turn and writes an event', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await addItem(db, 'resource', 'IRON ORE', 1) // oldest — fully consumed by the cost of 2
|
||||
await addItem(db, 'resource', 'CRYSTAL DUST', 5)
|
||||
|
||||
const techs = await getTechState(db)
|
||||
const nav = techs.find((t) => t.slug === 'NAV_AI')!
|
||||
const result = await researchTech(db, nav.id)
|
||||
|
||||
expect(result.find((t) => t.slug === 'NAV_AI')!.state).toBe('RESEARCHED')
|
||||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['CRYSTAL DUST', 4]]) // IRON ORE row deleted
|
||||
expect((await getShipState(db)).turn).toBe(1)
|
||||
|
||||
const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'research')!
|
||||
expect(event.turn).toBe(1)
|
||||
expect(event.message).toContain('NAVIGATION AI')
|
||||
|
||||
// Persisted: a fresh read still shows it researched.
|
||||
expect((await getTechState(db)).find((t) => t.slug === 'NAV_AI')!.state).toBe('RESEARCHED')
|
||||
})
|
||||
|
||||
it('unlocks the next chain step once its prerequisite is researched', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getTechState(db) // ensureSave — the save must exist before items can be added
|
||||
await addItem(db, 'part', 'SERVO ACTUATOR', 5) // WEAPONS SYSTEMS (2) + PLASMA CANNON (3)
|
||||
let techs = await getTechState(db)
|
||||
|
||||
expect(techs.find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('LOCKED')
|
||||
await researchTech(db, techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.id)
|
||||
|
||||
techs = await getTechState(db)
|
||||
expect(techs.find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('AVAILABLE') // prereq met → researchable now
|
||||
await researchTech(db, techs.find((t) => t.slug === 'PLASMA_CANNON')!.id)
|
||||
expect((await getTechState(db)).find((t) => t.slug === 'PLASMA_CANNON')!.state).toBe('RESEARCHED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gating of ship actions', () => {
|
||||
it('WEAPONS build is 409 until the WEAPONS SYSTEMS tech is researched, then succeeds', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST + 2) // research (2) + build (3)
|
||||
|
||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409, message: /requires/ }) // gated — even with parts in the hold
|
||||
const techs = await getTechState(db)
|
||||
await researchTech(db, techs.find((t) => t.slug === 'WEAPONS_SYSTEMS')!.id)
|
||||
|
||||
const state = await buildSystem(db, 'WEAPONS')
|
||||
expect(systemOf(state, 'WEAPONS').built).toBe(true)
|
||||
})
|
||||
|
||||
it('raises the REACTOR upgrade cap once REACTOR OVERCLOCK is researched', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
const reactorId = systemOf(await getShipState(db), 'REACTOR').id
|
||||
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId)) // at the base cap
|
||||
|
||||
await expect(upgradeSystem(db, reactorId)).rejects.toMatchObject({ status: 409 })
|
||||
|
||||
await addItem(db, 'resource', 'IRON ORE', 3 + upgradeCost(MAX_LEVEL)) // research cost (3) + upgrade cost at level 5
|
||||
const techs = await getTechState(db)
|
||||
await researchTech(db, techs.find((t) => t.slug === 'REACTOR_OVERCLOCK')!.id)
|
||||
|
||||
const state = await upgradeSystem(db, reactorId) // the raised cap admits one more level
|
||||
expect(systemOf(state, 'REACTOR').level).toBe(MAX_LEVEL + 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { persist, type GameDb } from '@/lib/db/client'
|
||||
import { EFFECTS, TECHS, techStatus, type TechDto } from './tech'
|
||||
import { ensureSave } from './save'
|
||||
import { ShipError, spendItems } from './ship'
|
||||
|
||||
// Technology research: the SKILL TREE tab's read + action. Researching a tech costs inventory
|
||||
// items (oldest row first, like ship actions) plus a turn, and its unlock effect then gates or
|
||||
// raises ship actions (enforced in lib/game/ship.ts via the same pure helpers).
|
||||
|
||||
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||
export class ResearchError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function researchedSlugs(db: GameDb): Promise<Set<string>> {
|
||||
return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug))
|
||||
}
|
||||
|
||||
// The full tech tree with per-save state — RESEARCHED / AVAILABLE / LOCKED (with the missing
|
||||
// prerequisite names when locked). Auto-initializes like the other reads.
|
||||
export async function getTechState(db: GameDb): Promise<TechDto[]> {
|
||||
await ensureSave(db)
|
||||
const rows = await db.db.select().from(schema.tech)
|
||||
const researched = new Set(rows.filter((t) => t.researched).map((t) => t.slug))
|
||||
return TECHS.map((def) => {
|
||||
const row = rows.find((r) => r.slug === def.slug)! // seeded on new game / backfill v5
|
||||
const { state, missingPrereqs } = techStatus(def, researched)
|
||||
return {
|
||||
id: row.id,
|
||||
slug: def.slug,
|
||||
name: def.name,
|
||||
costKind: def.costKind,
|
||||
costQty: def.costQty,
|
||||
prereqs: def.prereqs.map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug),
|
||||
effect: EFFECTS[def.effect]?.description ?? def.effect,
|
||||
state,
|
||||
missingPrereqs,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Research one tech: validates prerequisites (409, naming what is missing) and the cost (422),
|
||||
// spends it from the cargo hold, marks it researched, costs a turn and writes an event.
|
||||
export async function researchTech(db: GameDb, techId: number): Promise<TechDto[]> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const rows = await db.db.select().from(schema.tech)
|
||||
const target = rows.find((t) => t.id === techId)
|
||||
if (!target) throw new ResearchError(404, `Unknown tech ${techId}`)
|
||||
|
||||
const def = TECHS.find((t) => t.slug === target.slug)!
|
||||
const researched = await researchedSlugs(db)
|
||||
if (researched.has(def.slug)) throw new ResearchError(409, `${def.name} is already researched`)
|
||||
const missing = def.prereqs.filter((p) => !researched.has(p)).map((slug) => TECHS.find((t) => t.slug === slug)?.name ?? slug)
|
||||
if (missing.length > 0) throw new ResearchError(409, `${def.name} requires ${missing.join(' and ')} first`)
|
||||
|
||||
try {
|
||||
await spendItems(db, save.id, def.costKind, def.costQty)
|
||||
} catch (err) {
|
||||
// spendItems throws ShipError(422) on a short hold — re-throw as this domain's error.
|
||||
if (err instanceof ShipError) throw new ResearchError(err.status, err.message)
|
||||
throw err
|
||||
}
|
||||
|
||||
const turn = save.turn + 1
|
||||
await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id))
|
||||
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
|
||||
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() })
|
||||
await persist(db)
|
||||
|
||||
return getTechState(db)
|
||||
}
|
||||
+26
-1
@@ -7,6 +7,7 @@ import { openGameDb, type GameDb } from '@/lib/db/client'
|
||||
import * as schema from '@/lib/db/schema'
|
||||
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||||
import { TECHS } from './tech'
|
||||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
|
||||
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
|
||||
|
||||
@@ -329,11 +330,15 @@ describe('backfill (old saves)', () => {
|
||||
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))
|
||||
const techShape = (rows: { id: number; slug: string; name: string; costKind: string; costQty: number; prerequisites: string; effect: string; researched: boolean }[]) =>
|
||||
rows.map((t) => ({ slug: t.slug, name: t.name, costKind: t.costKind, costQty: t.costQty, prerequisites: t.prerequisites, effect: t.effect, researched: t.researched })).sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
const beforeTech = techShape(await db.db.select().from(schema.tech))
|
||||
|
||||
// Simulate a save created by pre-v0.4 code: no bodies, no crew, no ship, no version stamp (NULL).
|
||||
// Simulate a save created by pre-v0.5 code: no bodies, no crew, no ship, no tech, 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.delete(schema.tech)
|
||||
await db.db.update(schema.saves).set({ schemaVersion: null })
|
||||
|
||||
const world = await getWorldState(db) // ensureSave → backfill runs
|
||||
@@ -345,6 +350,7 @@ describe('backfill (old saves)', () => {
|
||||
}
|
||||
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
|
||||
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
|
||||
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
|
||||
|
||||
const [save] = await db.db.select().from(schema.saves)
|
||||
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
|
||||
@@ -355,6 +361,7 @@ describe('backfill (old saves)', () => {
|
||||
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)
|
||||
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
|
||||
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])
|
||||
@@ -387,4 +394,22 @@ describe('backfill (old saves)', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds the tech tree on a new game: one row per known tech, none researched', async () => {
|
||||
const { db } = await freshFile()
|
||||
await getWorldState(db)
|
||||
const rows = (await db.db.select().from(schema.tech)).sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
expect(rows).toHaveLength(TECHS.length)
|
||||
for (const def of TECHS) {
|
||||
const row = rows.find((r) => r.slug === def.slug)!
|
||||
expect({ name: row.name, costKind: row.costKind, costQty: row.costQty, prerequisites: row.prerequisites, effect: row.effect }).toEqual({
|
||||
name: def.name,
|
||||
costKind: def.costKind,
|
||||
costQty: def.costQty,
|
||||
prerequisites: def.prereqs.join(','),
|
||||
effect: def.effect,
|
||||
})
|
||||
expect(row.researched).toBe(false) // nothing is researched at the start
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+15
-2
@@ -4,6 +4,7 @@ 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 { TECHS } from './tech'
|
||||
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.
|
||||
@@ -94,9 +95,9 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi
|
||||
|
||||
// What each schema version added to a save. Saves created by older code lack the data newer
|
||||
// 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
|
||||
// current — each step is idempotent (a no-op when its data already exists). When #10 adds
|
||||
// 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
|
||||
export const SCHEMA_VERSION = 5 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree
|
||||
|
||||
// Seeds the starting ship: one row per known type — core types built at base values, the rest
|
||||
// unbuilt (level/capacity 0) until a BUILD action.
|
||||
@@ -106,6 +107,13 @@ async function seedStartingShip(db: GameDb, saveId: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Seeds the tech tree: one row per known tech (definitions from lib/game/tech.ts), none researched.
|
||||
async function seedStartingTech(db: GameDb, saveId: number): Promise<void> {
|
||||
for (const def of TECHS) {
|
||||
await db.db.insert(schema.tech).values({ saveId, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
@@ -133,6 +141,7 @@ async function createNewGame(db: GameDb): Promise<void> {
|
||||
await insertRoutes(db, region.edges, resolve)
|
||||
await seedStartingCrew(db, save.id, seed)
|
||||
await seedStartingShip(db, save.id)
|
||||
await seedStartingTech(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)
|
||||
@@ -155,6 +164,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema
|
||||
if ((await db.db.select().from(schema.shipSystems)).length === 0) await seedStartingShip(db, save.id)
|
||||
version = 4
|
||||
}
|
||||
if (version < 5) {
|
||||
if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id)
|
||||
version = 5
|
||||
}
|
||||
|
||||
if (from !== SCHEMA_VERSION) {
|
||||
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
|
||||
|
||||
@@ -129,6 +129,10 @@ describe('buildSystem', () => {
|
||||
const { db } = await freshFile()
|
||||
await getShipState(db)
|
||||
|
||||
// WEAPONS is gated behind the WEAPONS SYSTEMS tech (issue #9) — research it directly so this
|
||||
// test keeps covering the cost path; the gate itself is covered in research.test.ts.
|
||||
await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS'))
|
||||
|
||||
await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 422 }) // no parts in the hold yet
|
||||
|
||||
await addItem(db, 'part', 'SERVO ACTUATOR', BUILD_PART_COST)
|
||||
|
||||
+19
-7
@@ -1,7 +1,8 @@
|
||||
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 { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs'
|
||||
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from './tech'
|
||||
import { ensureSave, type InventoryItemDto } from './save'
|
||||
|
||||
export interface ShipSystemDto {
|
||||
@@ -51,7 +52,8 @@ export async function getShipState(db: GameDb): Promise<ShipState> {
|
||||
|
||||
// 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> {
|
||||
// Exported: research.ts spends tech costs through the same path.
|
||||
export async function spendItems(db: GameDb, saveId: number, kind: 'resource' | 'part', amount: number): Promise<void> {
|
||||
const rows = (await db.db.select().from(schema.inventoryItems)).filter((i) => i.kind === kind).sort((a, b) => a.id - b.id)
|
||||
if (rows.reduce((sum, r) => sum + r.qty, 0) < amount) {
|
||||
throw new ShipError(422, `Insufficient ${kind === 'resource' ? 'resources' : 'parts'} in the cargo hold — need ${amount}`)
|
||||
@@ -71,14 +73,20 @@ async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type:
|
||||
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.
|
||||
// Slugs of the researched techs on this save — drives the effective caps and build gates below.
|
||||
async function researchedTechSlugs(db: GameDb): Promise<Set<string>> {
|
||||
return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug))
|
||||
}
|
||||
|
||||
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). 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 maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db))
|
||||
if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`)
|
||||
|
||||
const cost = upgradeCost(target.level)
|
||||
await spendItems(db, save.id, 'resource', cost)
|
||||
@@ -89,14 +97,15 @@ export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipS
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// EXPAND — raise a built system's capacity by one (capped at MAX_CAPACITY). Cost: `capacity` units of any resource.
|
||||
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). 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 maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db))
|
||||
if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`)
|
||||
|
||||
const cost = expandCost(target.capacity)
|
||||
await spendItems(db, save.id, 'resource', cost)
|
||||
@@ -107,13 +116,16 @@ export async function expandSystem(db: GameDb, systemId: number): Promise<ShipSt
|
||||
return readShipState(db)
|
||||
}
|
||||
|
||||
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
|
||||
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. Some types are
|
||||
// gated behind a researched tech (see EFFECTS in lib/game/tech.ts).
|
||||
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
|
||||
await ensureSave(db)
|
||||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
|
||||
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
|
||||
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
|
||||
const gate = buildGate(type, await researchedTechSlugs(db))
|
||||
if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`)
|
||||
|
||||
await spendItems(db, save.id, 'part', BUILD_PART_COST)
|
||||
const turn = save.turn + 1
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Pure technology model: the known techs, their costs/prerequisites and what each unlock does.
|
||||
// No DB imports — both lib/game/research.ts + lib/game/ship.ts (server) and the frontend import
|
||||
// from here so research/cap/gate logic can never drift from what the API enforces.
|
||||
|
||||
import { MAX_CAPACITY, MAX_LEVEL } from './ship-costs'
|
||||
|
||||
export interface TechDef {
|
||||
slug: string // stable key used in DB + prerequisites
|
||||
name: string // display name
|
||||
costKind: 'resource' | 'part'
|
||||
costQty: number
|
||||
prereqs: string[] // slugs that must be researched first (short chains only)
|
||||
effect: string // effect slug — see EFFECTS below
|
||||
}
|
||||
|
||||
// The technology tree, in display order. Roots have no prerequisites; the two chains are
|
||||
// WEAPONS SYSTEMS → PLASMA CANNON and REACTOR OVERCLOCK → FUSION CORE. Costs stay small so a
|
||||
// handful of scans makes something affordable (the e2e scan loop relies on that).
|
||||
export const TECHS: TechDef[] = [
|
||||
{ slug: 'WEAPONS_SYSTEMS', name: 'WEAPONS SYSTEMS', costKind: 'part', costQty: 2, prereqs: [], effect: 'BUILD_WEAPONS' },
|
||||
{ slug: 'REACTOR_OVERCLOCK', name: 'REACTOR OVERCLOCK', costKind: 'resource', costQty: 3, prereqs: [], effect: 'REACTOR_LEVEL_CAP' },
|
||||
{ slug: 'HULL_PLATING', name: 'HULL PLATING', costKind: 'resource', costQty: 3, prereqs: [], effect: 'HULL_CAPACITY_CAP' },
|
||||
{ slug: 'NAV_AI', name: 'NAVIGATION AI', costKind: 'resource', costQty: 2, prereqs: [], effect: 'NAV_LEVEL_CAP' },
|
||||
{ slug: 'PLASMA_CANNON', name: 'PLASMA CANNON', costKind: 'part', costQty: 3, prereqs: ['WEAPONS_SYSTEMS'], effect: 'WEAPONS_LEVEL_CAP' },
|
||||
{ slug: 'FUSION_CORE', name: 'FUSION CORE', costKind: 'resource', costQty: 4, prereqs: ['REACTOR_OVERCLOCK'], effect: 'REACTOR_CAPACITY_CAP' },
|
||||
]
|
||||
|
||||
// What each researched tech changes on the ship. `unlock_build` gates a BUILD action; the cap
|
||||
// effects raise the UPGRADE/EXPAND ceiling for one system type (stacked over the base caps).
|
||||
export interface TechEffect {
|
||||
kind: 'unlock_build' | 'raise_level_cap' | 'raise_capacity_cap'
|
||||
shipType: string
|
||||
bonus?: number // cap effects only
|
||||
}
|
||||
|
||||
export const EFFECTS: Record<string, TechEffect & { description: string }> = {
|
||||
BUILD_WEAPONS: { kind: 'unlock_build', shipType: 'WEAPONS', description: 'Unlocks building the WEAPONS system' },
|
||||
REACTOR_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'REACTOR', bonus: 2, description: `Raises REACTOR max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` },
|
||||
HULL_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'HULL', bonus: 3, description: `Raises HULL max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
|
||||
NAV_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'NAV COMPUTER', bonus: 2, description: `Raises NAV COMPUTER max level by 2 (${MAX_LEVEL} → ${MAX_LEVEL + 2})` },
|
||||
WEAPONS_LEVEL_CAP: { kind: 'raise_level_cap', shipType: 'WEAPONS', bonus: 2, description: `Raises WEAPONS max level by 2 (once built)` },
|
||||
REACTOR_CAPACITY_CAP: { kind: 'raise_capacity_cap', shipType: 'REACTOR', bonus: 3, description: `Raises REACTOR max capacity by 3 (${MAX_CAPACITY} → ${MAX_CAPACITY + 3})` },
|
||||
}
|
||||
|
||||
export type TechStateName = 'RESEARCHED' | 'AVAILABLE' | 'LOCKED'
|
||||
|
||||
// One tech as served to the UI (GET /api/tech) and consumed by both tabs.
|
||||
export interface TechDto {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
costKind: 'resource' | 'part'
|
||||
costQty: number
|
||||
prereqs: string[] // prerequisite NAMES (for display), in TECHS order
|
||||
effect: string // human-readable unlock description
|
||||
state: TechStateName
|
||||
missingPrereqs: string[] // unmet prerequisite names — non-empty only when LOCKED
|
||||
}
|
||||
|
||||
export function techBySlug(slug: string): TechDef | undefined {
|
||||
return TECHS.find((t) => t.slug === slug)
|
||||
}
|
||||
|
||||
// RESEARCHED if done, AVAILABLE if all prerequisites are researched, LOCKED otherwise (with the
|
||||
// missing prerequisite names so the UI can say which one is blocking it).
|
||||
export function techStatus(def: TechDef, researchedSlugs: Set<string>): { state: TechStateName; missingPrereqs: string[] } {
|
||||
if (researchedSlugs.has(def.slug)) return { state: 'RESEARCHED', missingPrereqs: [] }
|
||||
const missing = def.prereqs.filter((p) => !researchedSlugs.has(p)).map((slug) => techBySlug(slug)?.name ?? slug)
|
||||
return missing.length === 0 ? { state: 'AVAILABLE', missingPrereqs: [] } : { state: 'LOCKED', missingPrereqs: missing }
|
||||
}
|
||||
|
||||
// The UPGRADE ceiling for a system type given what is researched (base cap + bonuses).
|
||||
export function effectiveLevelCap(shipType: string, researchedSlugs: Set<string>): number {
|
||||
let cap = MAX_LEVEL
|
||||
for (const def of TECHS) {
|
||||
if (!researchedSlugs.has(def.slug)) continue
|
||||
const effect = EFFECTS[def.effect]
|
||||
if (effect?.kind === 'raise_level_cap' && effect.shipType === shipType) cap += effect.bonus ?? 0
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
// The EXPAND ceiling for a system type given what is researched.
|
||||
export function effectiveCapacityCap(shipType: string, researchedSlugs: Set<string>): number {
|
||||
let cap = MAX_CAPACITY
|
||||
for (const def of TECHS) {
|
||||
if (!researchedSlugs.has(def.slug)) continue
|
||||
const effect = EFFECTS[def.effect]
|
||||
if (effect?.kind === 'raise_capacity_cap' && effect.shipType === shipType) cap += effect.bonus ?? 0
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
// The name of the tech that must be researched before a system type can be BUILT, or null when
|
||||
// building it is not gated (or the gate is already researched).
|
||||
export function buildGate(shipType: string, researchedSlugs: Set<string>): string | null {
|
||||
for (const def of TECHS) {
|
||||
const effect = EFFECTS[def.effect]
|
||||
if (effect?.kind === 'unlock_build' && effect.shipType === shipType && !researchedSlugs.has(def.slug)) return def.name
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -8,11 +8,13 @@ describe('OpenAPI spec', () => {
|
||||
'/api/crew',
|
||||
'/api/explore',
|
||||
'/api/jump',
|
||||
'/api/research',
|
||||
'/api/ship',
|
||||
'/api/ship/build',
|
||||
'/api/ship/expand',
|
||||
'/api/ship/upgrade',
|
||||
'/api/system',
|
||||
'/api/tech',
|
||||
'/api/version',
|
||||
'/api/world',
|
||||
]);
|
||||
@@ -39,6 +41,23 @@ describe('OpenAPI spec', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('describes the tech endpoints: GET /api/tech → Tech[], POST /api/research with error responses', () => {
|
||||
const tech = openApiSpec.paths['/api/tech'].get.responses[200].content['application/json'].schema;
|
||||
expect(tech.type).toBe('array');
|
||||
expect((tech.items as { $ref: string }).$ref).toBe('#/components/schemas/Tech');
|
||||
|
||||
const techProps = openApiSpec.components.schemas.Tech.properties;
|
||||
expect(Object.keys(techProps)).toEqual(['id', 'slug', 'name', 'costKind', 'costQty', 'prereqs', 'effect', 'state', 'missingPrereqs']);
|
||||
expect((techProps.state as { enum: string[] }).enum).toEqual(['RESEARCHED', 'AVAILABLE', 'LOCKED']);
|
||||
|
||||
const post = openApiSpec.paths['/api/research'].post;
|
||||
expect(post.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ResearchRequest');
|
||||
expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Tech');
|
||||
for (const status of ['400', '404', '409', '422'] as const) {
|
||||
expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
+106
@@ -378,6 +378,82 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/tech': {
|
||||
get: {
|
||||
summary: 'Tech tree state',
|
||||
description:
|
||||
"The full technology tree with per-save state — RESEARCHED if done, AVAILABLE if all prerequisites are researched, LOCKED otherwise (with the missing prerequisite names). Researching a tech gates or raises ship actions (e.g. building WEAPONS requires the WEAPONS SYSTEMS tech). Auto-initializes a new game when no save exists yet.",
|
||||
operationId: 'getTech',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The tech tree with per-save state',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/research': {
|
||||
post: {
|
||||
summary: 'Research a tech',
|
||||
description:
|
||||
"Validates the prerequisites (409, naming what is missing) and the cost against the cargo hold (422), spends the cost from the oldest row first, marks the tech researched, increments the turn counter and writes a research event. Returns the fresh tech tree state.",
|
||||
operationId: 'researchTech',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ResearchRequest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Fresh tech tree state after the research',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array', items: { $ref: '#/components/schemas/Tech' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Malformed body or non-integer techId',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown tech id',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: 'Tech is already researched or a prerequisite is not researched yet (message names the missing prereq)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: {
|
||||
description: 'Not enough of the required kind in the cargo hold for the cost',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
@@ -491,6 +567,36 @@ export const openApiSpec = {
|
||||
},
|
||||
required: ['systems', 'inventory', 'turn'],
|
||||
},
|
||||
Tech: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
slug: { type: 'string', description: "Stable key, e.g. WEAPONS_SYSTEMS" },
|
||||
name: { type: 'string', description: "Display name, e.g. 'WEAPONS SYSTEMS'" },
|
||||
costKind: { type: 'string', enum: ['resource', 'part'] },
|
||||
costQty: { type: 'integer' },
|
||||
prereqs: {
|
||||
type: 'array',
|
||||
description: 'Prerequisite tech names (empty for roots)',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
effect: { type: 'string', description: "Human-readable unlock, e.g. 'Unlocks building the WEAPONS system'" },
|
||||
state: { type: 'string', enum: ['RESEARCHED', 'AVAILABLE', 'LOCKED'] },
|
||||
missingPrereqs: {
|
||||
type: 'array',
|
||||
description: 'Unmet prerequisite names — non-empty only when LOCKED',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
required: ['id', 'slug', 'name', 'costKind', 'costQty', 'prereqs', 'effect', 'state', 'missingPrereqs'],
|
||||
},
|
||||
ResearchRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
techId: { type: 'integer', description: 'tech.id of the tech to research' },
|
||||
},
|
||||
required: ['techId'],
|
||||
},
|
||||
UpgradeRequest: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space-wanderer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user