From 172f167a154666f407d04f1f4c5aa113d70f6743 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:01:41 +0000 Subject: [PATCH 1/7] db: planets (planets+moons via parent_id) and inventory_items tables --- lib/db/client.ts | 18 ++++++++++++++++++ lib/db/schema.ts | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/lib/db/client.ts b/lib/db/client.ts index 6c5bb10..70ff305 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -43,6 +43,24 @@ CREATE TABLE IF NOT EXISTS jump_routes ( PRIMARY KEY (from_system, to_system) ); +CREATE TABLE IF NOT EXISTS planets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + system_id INTEGER NOT NULL REFERENCES solar_systems(id), + parent_id INTEGER, -- NULL for a planet, set to the parent planet's id for a moon + name TEXT NOT NULL, + type TEXT NOT NULL, + hazard INTEGER NOT NULL DEFAULT 0, + explored INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS inventory_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + save_id INTEGER NOT NULL REFERENCES saves(id), + kind TEXT NOT NULL, -- 'resource' | 'part' + name TEXT NOT NULL, + qty INTEGER NOT NULL DEFAULT 1 +); + CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 201e40c..ef42d28 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -35,6 +35,29 @@ export const jumpRoutes = sqliteTable( (t) => [primaryKey({ columns: [t.fromSystem, t.toSystem] })], ) +// Bodies of a solar system: planets (parent_id NULL) and moons (parent_id = their planet's +// id). Generated when the system is generated, so every system has 2–5 planets, some with +// 1–3 moons. Insertion order is display order — a moon always follows its parent row. +export const planets = sqliteTable('planets', { + id: integer('id').primaryKey({ autoIncrement: true }), + systemId: integer('system_id').notNull().references(() => solarSystems.id), + parentId: integer('parent_id'), // NULL for a planet, set for a moon + name: text('name').notNull(), + type: text('type').notNull(), + hazard: integer('hazard', { mode: 'boolean' }).notNull().default(false), + explored: integer('explored', { mode: 'boolean' }).notNull().default(false), +}) + +// Dumb-simple cargo hold on the save: what exploration has found so far. SHIP SYSTEMS (#8) +// will spend parts from here later; nothing consumes it yet. +export const inventoryItems = sqliteTable('inventory_items', { + id: integer('id').primaryKey({ autoIncrement: true }), + saveId: integer('save_id').notNull().references(() => saves.id), + kind: text('kind').notNull(), // 'resource' | 'part' + name: text('name').notNull(), + qty: integer('qty').notNull().default(1), +}) + // 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', { From cbab17047b50e37dee668f1a1d7127d6e4cc26f6 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:04:23 +0000 Subject: [PATCH 2/7] game: seeded planet/moon generation per system + deterministic exploration roll --- lib/game/world.test.ts | 57 ++++++++++++++++++++++- lib/game/world.ts | 102 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/lib/game/world.test.ts b/lib/game/world.test.ts index 4987da1..0c37d78 100644 --- a/lib/game/world.test.ts +++ b/lib/game/world.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { generateRing, generateStartRegion, makeName, mulberry32 } from './world' +import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world' describe('generateStartRegion', () => { const region = generateStartRegion(mulberry32(42)) @@ -77,3 +77,58 @@ describe('generateRing (expansion)', () => { expect(taken.size).toBe(40) }) }) + +describe('generatePlanets', () => { + it('gives every system 2–5 planets and keeps body names unique within the system', () => { + for (const seed of [1, 7, 42, 999]) { + const planets = generatePlanets(bodyRng(String(seed), seed)) + expect(planets.length).toBeGreaterThanOrEqual(2) + expect(planets.length).toBeLessThanOrEqual(5) + + const names: string[] = [] + for (const p of planets) { + names.push(p.name, ...p.moons.map((m) => m.name)) + expect(['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']).toContain(p.type) + for (const m of p.moons) { + expect(['MOON', 'ICE MOON', 'VOLCANIC MOON']).toContain(m.type) + } + } + expect(new Set(names).size).toBe(names.length) // planets + moons share one name pool per system + } + }) + + it('is deterministic per (seed, system id) and differs across systems', () => { + const a = generatePlanets(bodyRng('42', 7)) + expect(generatePlanets(bodyRng('42', 7))).toEqual(a) // same save+system → same bodies + const b = generatePlanets(bodyRng('43', 7)) + expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different world + }) +}) + +describe('rollExploration', () => { + it('is deterministic for the same save+planet and yields valid items', () => { + const first = rollExploration(explorationRng('42', 13)) + expect(rollExploration(explorationRng('42', 13))).toEqual(first) + + for (const item of first) { + expect(['resource', 'part']).toContain(item.kind) + if (item.kind === 'resource') { + expect(item.qty).toBeGreaterThanOrEqual(1) + expect(item.qty).toBeLessThanOrEqual(4) + } else { + expect(item.qty).toBe(1) + } + } + }) + + it('across many planets produces every outcome shape: resource, part, both, or nothing', () => { + const shapes = new Set() + for (let planetId = 1; planetId <= 200; planetId++) { + const found = rollExploration(explorationRng('42', planetId)) + const hasResource = found.some((f) => f.kind === 'resource') + const hasPart = found.some((f) => f.kind === 'part') + shapes.add(`${hasResource ? 'r' : '-'}${hasPart ? 'p' : '-'}`) + } + expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--'])) + }) +}) diff --git a/lib/game/world.ts b/lib/game/world.ts index 24fba68..0807953 100644 --- a/lib/game/world.ts +++ b/lib/game/world.ts @@ -123,3 +123,105 @@ export function generateRing( const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions) return { systems, edges: ringEdges(systems.length) } } + +// --- Bodies (planets + moons) and exploration ------------------------------- + +export interface WorldMoon { + name: string + type: string + hazard: boolean +} + +export interface WorldPlanet { + name: string + type: string + hazard: boolean + moons: WorldMoon[] +} + +// Derive an independent PRNG stream for one entity from the save seed, so a system's bodies +// and a planet's exploration haul are reproducible offline (tests recompute them directly). +export function bodyRng(seed: string, systemId: number): () => number { + return mulberry32((Number(seed) + Math.imul(systemId, 0x9e3779b1)) >>> 0) +} + +export function explorationRng(seed: string, planetId: number): () => number { + return mulberry32((Number(seed) + Math.imul(planetId, 0x85ebca6b)) >>> 0) +} + +const BODY_NAMES = [ + 'AURELLE', 'BRIMSTONE', 'CINDERROCK', 'DRIFTBAND', 'PALEMOON', 'ASHFALL', 'VESPERINE', 'KORR', + 'MIRELLA', 'SABLET', 'EMBERLING', 'FROSTHOLD', 'GALEN', 'IRONVEIN', 'JUNO', 'KESTREL', + 'LUMEN', 'NADIR', 'OPALINE', 'PYRE', 'QUILL', 'RUSTBARK', 'SIROCCO', 'TARN', + 'UMBRA', 'VELVETINE', 'WYRM', 'ZEPHYR', 'HALCYON', 'MORROW', 'ONYX', 'THALASSA', +] + +const PLANET_TYPES = ['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD'] +const MOON_TYPES = ['MOON', 'ICE MOON', 'VOLCANIC MOON'] + +const HAZARD_CHANCE = 0.25 +const MOON_CHANCE = 0.6 // a planet has any moons at all +const PLANET_COUNT_MIN = 2 +const PLANET_COUNT_SPREAD = 4 // 2..5 planets per system + +// A body name unique within its system (planets and moons share one pool). Max bodies per +// system is 5 + 5*3 = 20 < BODY_NAMES.length, so the loop always terminates. +function makeBodyName(rng: () => number, taken: Set): string { + for (;;) { + const name = title(BODY_NAMES[Math.floor(rng() * BODY_NAMES.length)]) + if (!taken.has(name)) { + taken.add(name) + return name + } + } +} + +function pick(rng: () => number, arr: T[]): T { + return arr[Math.floor(rng() * arr.length)] +} + +// The bodies of one system: 2–5 planets, some with 1–3 moons. Pure — the caller derives the +// rng from (save seed, system id) so a given system always gets the same bodies. +export function generatePlanets(rng: () => number): WorldPlanet[] { + const taken = new Set() + const planets: WorldPlanet[] = [] + for (let i = 0; i < PLANET_COUNT_MIN + Math.floor(rng() * PLANET_COUNT_SPREAD); i++) { + const name = makeBodyName(rng, taken) + const type = pick(rng, PLANET_TYPES) + const hazard = rng() < HAZARD_CHANCE + const moons: WorldMoon[] = [] + if (rng() < MOON_CHANCE) { + for (let m = 0; m < 1 + Math.floor(rng() * 3); m++) { + moons.push({ name: makeBodyName(rng, taken), type: pick(rng, MOON_TYPES), hazard: rng() < HAZARD_CHANCE }) + } + } + planets.push({ name, type, hazard, moons }) + } + return planets +} + +export interface FoundItem { + kind: 'resource' | 'part' + name: string + qty: number +} + +const RESOURCE_NAMES = ['IRON ORE', 'CRYSTAL SHARDS', 'GAS CELLS', 'WATER ICE', 'RARE EARTH'] +const PART_NAMES = ['NAV MODULE', 'HULL PLATE', 'REACTOR CORE', 'COMMS ARRAY', 'ENGINE TUNING'] + +const RESOURCE_CHANCE = 0.7 +const PART_CHANCE = 0.3 // parts are rarer than resources + +// The haul from scanning one body: a resource and/or a part, or nothing (~21% of scans). +// Pure — the caller derives the rng from (save seed, planet id), so the same save+planet +// always yields the same outcome. +export function rollExploration(rng: () => number): FoundItem[] { + const found: FoundItem[] = [] + if (rng() < RESOURCE_CHANCE) { + found.push({ kind: 'resource', name: pick(rng, RESOURCE_NAMES), qty: 1 + Math.floor(rng() * 4) }) // 1..4 + } + if (rng() < PART_CHANCE) { + found.push({ kind: 'part', name: pick(rng, PART_NAMES), qty: 1 }) + } + return found +} From 3aa27ffd898ae2b958de7fe7e5e1b0b7bfc5ea70 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:07:53 +0000 Subject: [PATCH 3/7] =?UTF-8?q?game:=20bodies=20generated=20with=20each=20?= =?UTF-8?q?system;=20explore=20action=20=E2=80=94=20deterministic=20haul,?= =?UTF-8?q?=20turn=20cost,=20inventory=20accumulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/game/save.test.ts | 100 ++++++++++++++++++++++++++++++++- lib/game/save.ts | 125 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 212 insertions(+), 13 deletions(-) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 011ef9a..1f3a7df 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -3,7 +3,9 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { openGameDb, type GameDb } from '@/lib/db/client' -import { getWorldState, jumpToSystem, type WorldState } from './save' +import * as schema from '@/lib/db/schema' +import { getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' +import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' const dirs: string[] = [] @@ -130,3 +132,99 @@ describe('jumpToSystem success path', () => { expect(again.systems).toHaveLength(after.systems.length) }) }) + +describe('getSystemState', () => { + it('generates bodies for every system on new game; moons nest under a parent planet only', async () => { + const { db } = await freshFile() + const state = await getSystemState(db) + + // Current (start) system: 2–5 planets, all unexplored yet. + const planetRows = state.planets.filter((p) => p.parent === null) + expect(planetRows.length).toBeGreaterThanOrEqual(2) + expect(planetRows.length).toBeLessThanOrEqual(5) + const planetIds = new Set(planetRows.map((p) => p.id)) + for (const row of state.planets) { + if (row.parent !== null) expect(planetIds.has(row.parent)).toBe(true) // moons hang off a planet in the same system + expect(row.explored).toBe(false) + } + + // Every one of the 7 fresh-save systems got bodies. + const counts = db.raw.exec('SELECT system_id, COUNT(*) FROM planets GROUP BY system_id') + expect(counts[0].values).toHaveLength(7) + for (const [, n] of counts[0].values) expect(Number(n)).toBeGreaterThanOrEqual(2) + + expect(state.inventory).toEqual([]) // nothing found before any scan + expect(state.turn).toBe(0) + }) +}) + +describe('explorePlanet', () => { + it('rejects unknown planets with 404 and already-explored ones with 409, mutating nothing on failure', async () => { + const { db } = await freshFile() + const state = await getSystemState(db) + const target = state.planets[0] + + await expect(explorePlanet(db, 999_999)).rejects.toMatchObject({ status: 404 }) + await explorePlanet(db, target.id) + await expect(explorePlanet(db, target.id)).rejects.toMatchObject({ status: 409 }) + + const after = await getSystemState(db) + expect(after.turn).toBe(1) // only the successful scan costs a turn + expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true) + }) + + it('marks explored, bumps the turn, lands finds in inventory and writes an event', async () => { + const { db } = await freshFile() + const state = await getSystemState(db) + const target = state.planets[0] + + const result = await explorePlanet(db, target.id) + + expect(result.turn).toBe(1) + // Fresh save: inventory is exactly this scan's haul. + for (const item of result.outcome) { + expect(result.inventory.find((i) => i.kind === item.kind && i.name === item.name)?.qty).toBe(item.qty) + } + expect(result.inventory.length).toBe(result.outcome.length) + + const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id') + expect(rows[0].values).toEqual([['new_game', 0], ['explore', 1]]) + }) + + it('is deterministic per save+planet and accumulates qty across scans (known seed)', async () => { + // Hand-built save with a known seed so every outcome is recomputable offline. + const { db } = await freshFile() + const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: new Date().toISOString() }).returning() + const [sys] = await db.db + .insert(schema.solarSystems) + .values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true }) + .returning({ id: schema.solarSystems.id }) + + const planetIds: number[] = [] + for (const body of generatePlanets(bodyRng('42', sys.id))) { + const [p] = await db.db + .insert(schema.planets) + .values({ systemId: sys.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard }) + .returning({ id: schema.planets.id }) + planetIds.push(p.id) + } + + const expected = planetIds.map((id) => rollExploration(explorationRng('42', id))) + for (let i = 0; i < planetIds.length; i++) { + const result = await explorePlanet(db, planetIds[i]) + expect(result.outcome).toEqual(expected[i]) // same save+planet → same haul + } + + // Inventory accumulates: qty per kind+name equals the sum over all scans. + const totals = new Map() + for (const items of expected) { + for (const it of items) totals.set(`${it.kind}|${it.name}`, (totals.get(`${it.kind}|${it.name}`) ?? 0) + it.qty) + } + const state = await getSystemState(db) + expect(state.inventory.length).toBe(totals.size) + for (const item of state.inventory) { + expect(item.qty).toBe(totals.get(`${item.kind}|${item.name}`)) + } + expect(state.turn).toBe(planetIds.length) // one turn per scan + }) +}) diff --git a/lib/game/save.ts b/lib/game/save.ts index d0d57cc..a7ea44b 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -2,7 +2,7 @@ import { eq } from 'drizzle-orm' import type { GameDb } from '@/lib/db/client' import { persist } from '@/lib/db/client' import * as schema from '@/lib/db/schema' -import { generateRing, generateStartRegion, mulberry32 } 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. // Single-player by design — exactly one save row ever exists, so every query below is @@ -44,12 +44,18 @@ function expansionRng(seed: string, turn: number): () => number { return mulberry32((Number(seed) + turn * 1_000_003) >>> 0) } -async function insertSystems(db: GameDb, saveId: number, systems: { name: string; x: number; y: number }[], discovered: boolean): Promise> { - const rows = await db.db - .insert(schema.solarSystems) - .values(systems.map((s) => ({ saveId, name: s.name, x: s.x, y: s.y, discovered }))) - .returning({ id: schema.solarSystems.id, name: schema.solarSystems.name }) - return new Map(rows.map((r) => [r.name, r.id])) +// Inserts one system together with its generated bodies (planets + moons), so a system is +// never left without worlds to explore. Bodies derive from (seed, system id) via bodyRng — +// independent of generation order and every other system. +async function insertSystemWithBodies(db: GameDb, saveId: number, ws: { name: string; x: number; y: number }, seed: string): Promise { + const [row] = await db.db.insert(schema.solarSystems).values({ saveId, name: ws.name, x: ws.x, y: ws.y }).returning({ id: schema.solarSystems.id }) + for (const body of generatePlanets(bodyRng(seed, row.id))) { + const [planet] = await db.db.insert(schema.planets).values({ systemId: row.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id }) + for (const moon of body.moons) { + await db.db.insert(schema.planets).values({ systemId: row.id, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard }) + } + } + return row.id } async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise { @@ -73,11 +79,12 @@ export async function ensureSave(db: GameDb): Promise { const [save] = await db.db.insert(schema.saves).values({ seed, turn: 0, createdAt: nowIso }).returning() // All inserted as undiscovered first; the start is then marked discovered + current. - const ids = await insertSystems(db, save.id, [region.start, ...region.neighbors], false) - const startId = ids.get(region.start.name)! + const startId = await insertSystemWithBodies(db, save.id, region.start, seed) + const neighborIds: number[] = [] + for (const n of region.neighbors) neighborIds.push(await insertSystemWithBodies(db, save.id, n, seed)) await db.db.update(schema.solarSystems).set({ discovered: true, isCurrent: true }).where(eq(schema.solarSystems.id, startId)) - const resolve = (index: number) => (index === 0 ? startId : ids.get(region.neighbors[index - 1].name)!) + const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1]) await insertRoutes(db, region.edges, resolve) 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 }) @@ -134,8 +141,9 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise s.name)) const ring = generateRing(rng, target.x, target.y, takenNames, systems) - const ids = await insertSystems(db, save.id, ring.systems, false) - const resolve = (index: number) => (index === 0 ? target.id : ids.get(ring.systems[index - 1].name)!) + const ringIds: number[] = [] + for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed)) + const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1]) await insertRoutes(db, ring.edges, resolve) } @@ -145,3 +153,96 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + const systems = await db.db.select().from(schema.solarSystems) + const current = systems.find((s) => s.isCurrent)! + const planets = (await db.db.select().from(schema.planets).where(eq(schema.planets.systemId, current.id))).map((p) => ({ + id: p.id, + name: p.name, + type: p.type, + hazard: p.hazard, + explored: p.explored, + parent: p.parentId, + })) + const inventory = (await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })) + return { systemId: current.id, systemName: current.name, planets, inventory, turn: save.turn } +} + +export async function getSystemState(db: GameDb): Promise { + await ensureSave(db) + return readSystemState(db) +} + +export interface ExploreResult extends SystemState { + outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing) +} + +// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored, +// costs a turn, accumulates finds into inventory and writes an event. Any existing planet +// may be scanned (the UI only offers the current system's rows). +export async function explorePlanet(db: GameDb, planetId: number): Promise { + await ensureSave(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const planets = await db.db.select().from(schema.planets) + const target = planets.find((p) => p.id === planetId) + if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`) + if (target.explored) throw new ExploreError(409, `${target.name} was already explored`) + + const outcome = rollExploration(explorationRng(save.seed, target.id)) + const turn = save.turn + 1 + + await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id)) + for (const item of outcome) { + // Accumulate: the same kind+name on this save bumps qty instead of adding a row. + const existing = (await db.db.select().from(schema.inventoryItems)).find((i) => i.kind === item.kind && i.name === item.name) + if (existing) { + await db.db.update(schema.inventoryItems).set({ qty: existing.qty + item.qty }).where(eq(schema.inventoryItems.id, existing.id)) + } else { + await db.db.insert(schema.inventoryItems).values({ saveId: save.id, kind: item.kind, name: item.name, qty: item.qty }) + } + } + + const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' + await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) + await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}`, createdAt: new Date().toISOString() }) + await persist(db) + + return { ...(await readSystemState(db)), outcome } +} From 8153007668cc600f80a9c08838d51ed0b95f61f4 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:11:01 +0000 Subject: [PATCH 4/7] api: GET /api/system + POST /api/explore, OpenAPI spec updated in code --- app/api/explore/route.ts | 28 ++++++++ app/api/system/route.ts | 8 +++ lib/openapi.test.ts | 23 ++++++- lib/openapi.ts | 136 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 app/api/explore/route.ts create mode 100644 app/api/system/route.ts diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts new file mode 100644 index 0000000..a8e644c --- /dev/null +++ b/app/api/explore/route.ts @@ -0,0 +1,28 @@ +import { getGameDb } from '@/lib/db/client' +import { ExploreError, explorePlanet } from '@/lib/game/save' + +// Scan one body of the current system. Body: { "planetId": number }. Returns the fresh +// system state plus what this scan found. Costs a turn; each body can only be scanned once. +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 planetId = (body as { planetId?: unknown }).planetId + if (typeof planetId !== 'number' || !Number.isInteger(planetId)) { + return Response.json({ error: 'planetId must be an integer' }, { status: 400 }) + } + + try { + const db = await getGameDb() + return Response.json(await explorePlanet(db, planetId)) + } catch (err) { + if (err instanceof ExploreError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } +} diff --git a/app/api/system/route.ts b/app/api/system/route.ts new file mode 100644 index 0000000..4ab46f2 --- /dev/null +++ b/app/api/system/route.ts @@ -0,0 +1,8 @@ +import { getGameDb } from '@/lib/db/client' +import { getSystemState } from '@/lib/game/save' + +// The current system's bodies (planets + moons) and the save's inventory; auto-initializes on first hit. +export async function GET() { + const db = await getGameDb() + return Response.json(await getSystemState(db)) +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 07cc610..4da6060 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -4,7 +4,28 @@ import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { it('documents every public endpoint', () => { - expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/jump', '/api/version', '/api/world']); + expect(Object.keys(openApiSpec.paths).sort()).toEqual([ + '/api/explore', + '/api/jump', + '/api/system', + '/api/version', + '/api/world', + ]); + }); + + it('describes the system endpoints as SystemState in and out', () => { + const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema; + expect(system.$ref).toBe('#/components/schemas/SystemState'); + + const explore = openApiSpec.paths['/api/explore'].post; + expect(explore.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreRequest'); + expect(explore.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreResult'); + // ExploreResult extends SystemState with the scan outcome. + const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: { $ref?: string }[] }; + expect(exploreResult.allOf[0].$ref).toBe('#/components/schemas/SystemState'); + for (const status of ['400', '404', '409'] as const) { + expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); + } }); it('describes the world endpoints as WorldState in and out', () => { diff --git a/lib/openapi.ts b/lib/openapi.ts index 3c7450e..cb22c07 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -100,6 +100,74 @@ export const openApiSpec = { }, }, }, + '/api/system': { + get: { + summary: 'Current system state', + description: + 'The bodies of the current solar system — planets and their moons (nested via parent) with hazard/explored flags — plus the save inventory and turn counter. Auto-initializes a new game when no save exists yet.', + operationId: 'getSystem', + responses: { + 200: { + description: 'Current system state', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SystemState' }, + }, + }, + }, + }, + }, + }, + '/api/explore': { + post: { + summary: 'Explore a planet or moon', + description: + 'Scans one body of the save with a deterministic haul (a resource and/or a part, or nothing), marks it explored, increments the turn counter, accumulates finds into inventory and writes an explore event. Returns the fresh system state plus this scan outcome.', + operationId: 'explorePlanet', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ExploreRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Fresh system state plus the scan outcome', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ExploreResult' }, + }, + }, + }, + 400: { + description: 'Malformed body or non-integer planetId', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 404: { + description: 'Unknown planet id', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + 409: { + description: 'Body was already explored (each can only be scanned once)', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -147,6 +215,74 @@ export const openApiSpec = { }, required: ['systemId'], }, + Planet: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + type: { type: 'string', description: 'Body class, e.g. TERRESTRIAL or GAS GIANT' }, + hazard: { type: 'boolean' }, + explored: { type: 'boolean', description: 'True once scanned — each body can only be explored once' }, + parent: { type: ['integer', 'null'], nullable: true, description: 'NULL for a planet, the parent planet id for a moon' }, + }, + required: ['id', 'name', 'type', 'hazard', 'explored', 'parent'], + }, + InventoryItem: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['resource', 'part'] }, + name: { type: 'string' }, + qty: { type: 'integer' }, + }, + required: ['kind', 'name', 'qty'], + }, + FoundItem: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['resource', 'part'] }, + name: { type: 'string' }, + qty: { type: 'integer' }, + }, + required: ['kind', 'name', 'qty'], + }, + SystemState: { + type: 'object', + properties: { + systemId: { type: 'integer' }, + systemName: { type: 'string' }, + planets: { + type: 'array', + description: 'Planets and moons in display order — a moon always follows its parent row', + items: { $ref: '#/components/schemas/Planet' }, + }, + inventory: { type: 'array', items: { $ref: '#/components/schemas/InventoryItem' } }, + turn: { type: 'integer', description: 'Turn counter — one per jump or exploration' }, + }, + required: ['systemId', 'systemName', 'planets', 'inventory', 'turn'], + }, + ExploreRequest: { + type: 'object', + properties: { + planetId: { type: 'integer', description: 'planets.id of the body to scan (planet or moon)' }, + }, + required: ['planetId'], + }, + ExploreResult: { + allOf: [ + { $ref: '#/components/schemas/SystemState' }, + { + type: 'object', + properties: { + outcome: { + type: 'array', + description: 'What this scan found — may be empty (some scans find nothing)', + items: { $ref: '#/components/schemas/FoundItem' }, + }, + }, + required: ['outcome'], + }, + ], + }, Error: { type: 'object', properties: { From 82d41b87be07abbb8f9fc639f4c0fe02ca2daac1 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:13:19 +0000 Subject: [PATCH 5/7] ui: LOCAL SYSTEM tab renders real bodies with explore buttons + cargo hold (real data from /api/system) --- components/local-system.tsx | 187 +++++++++++++++++++++++++++++++++ components/mission-control.tsx | 101 +----------------- 2 files changed, 192 insertions(+), 96 deletions(-) create mode 100644 components/local-system.tsx diff --git a/components/local-system.tsx b/components/local-system.tsx new file mode 100644 index 0000000..d1c413a --- /dev/null +++ b/components/local-system.tsx @@ -0,0 +1,187 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import type { ExploreResult, SystemState } from '@/lib/game/save' +import type { FoundItem } from '@/lib/game/world' + +// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an +// EXPLORE button per unexplored row. Fetches /api/system on mount; explore responses are +// applied directly (no polling). Turn-based: one scan costs a turn, each body once. + +export function LocalSystemPanel() { + const [state, setState] = useState(null) + const [lastOutcome, setLastOutcome] = useState(null) + const [error, setError] = useState(null) + const [exploringId, setExploringId] = useState(null) + + useEffect(() => { + let cancelled = false + fetch('/api/system') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as SystemState + }) + .then((s) => { + if (!cancelled) setState(s) + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, []) + + const explore = useCallback(async (planetId: number) => { + setExploringId(planetId) + try { + const res = await fetch('/api/explore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ planetId }), + }) + const body = (await res.json()) as ExploreResult & { error?: string } + if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`) + setState(body) // fresh state straight from the response — no refetch needed + setLastOutcome(body.outcome) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setExploringId(null) + } + }, []) + + if (error && !state) { + return ( +
+ + SYSTEM SCAN OFFLINE // {error.toUpperCase()} + +
+ ) + } + + if (!state) { + return ( +
+ SCANNING SYSTEM… +
+ ) + } + + const badgeFor = (explored: boolean, hazard: boolean) => + explored ? { label: 'SCANNED', variant: 'ACTIVE' as const } : hazard ? { label: 'HAZARD', variant: 'WARNING' as const } : { label: 'UNEXPLORED', variant: 'SCANNING' as const } + + return ( +
+
+
+ EXPLORABLE WORLDS // {state.systemName.toUpperCase()} — TURN {state.turn} +
+ {state.planets.map((row, i) => { + const depth = row.parent !== null ? 1 : 0 + const badge = badgeFor(row.explored, row.hazard) + return ( +
+
+
+ {row.name} +
+
+ {depth ? `MOON OF ${state.planets.find((p) => p.id === row.parent)?.name ?? '?'} // ${row.type}` : row.type} +
+
+
+ {badge.label} + {!row.explored && ( + + )} +
+
+ ) + })} +
+ + {lastOutcome !== null && ( +
+ + SCAN RESULT //{' '} + {lastOutcome.length > 0 ? lastOutcome.map((i) => `${i.qty}× ${i.name.toUpperCase()}`).join(', ') : 'NOTHING FOUND'} + +
+ )} + +
+
+ CARGO HOLD // INVENTORY +
+ {state.inventory.length === 0 ? ( +
+ EMPTY — EXPLORE WORLDS TO FIND THINGS +
+ ) : ( + state.inventory.map((item) => ( +
+ + {item.name} // {item.kind} + + ×{item.qty} +
+ )) + )} +
+ + {error && ( +
+ SCAN FAILED // {error.toUpperCase()} +
+ )} +
+ ) +} diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 605cc5a..55d8f34 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -11,12 +11,13 @@ import { StatCard } from '@/components/ui/stat-card' import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Terminal, type TerminalLine } from '@/components/ui/terminal' +import { LocalSystemPanel } from '@/components/local-system' import { WorldMapPanel } from '@/components/world-map' // 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 tab renders live data from /api/world; the other tabs keep placeholder content -// until their own issues land. Single player, no auth, by design. +// WORLD MAP and LOCAL SYSTEM tabs render live data (/api/world, /api/system); the other +// tabs keep placeholder content until their own issues land. Single player, no auth, by design. function useNarrow(threshold = 900): boolean { const [narrow, setNarrow] = useState( @@ -44,20 +45,6 @@ const shipSystems: SystemEntry[] = [ { name: 'COMMS', status: 'ACTIVE' }, ] -// LOCAL SYSTEM — explorable worlds of the current system (regenerated per save after each jump, later). -const explorablePlanets = [ - { - name: 'AURELLE', type: 'TERRESTRIAL // OCEAN CONTINENTS', state: 'UNEXPLORED' as const, - moons: [{ name: 'PALEMOON', type: 'ICE MOON // FROST PLAINS', state: 'UNEXPLORED' as const }], - }, - { - name: 'BRIMSTONE', type: 'GAS GIANT // CLOUD DECKS', state: 'SCANNED' as const, - moons: [{ name: 'ASHFALL', type: 'VOLCANIC MOON // SULFUR VEINS', state: 'UNEXPLORED' as const }], - }, - { name: 'CINDERROCK', type: 'ROCKY // IRON RICH', state: 'UNEXPLORED' as const, moons: [] }, - { name: 'DRIFTBAND', type: 'ASTEROID FIELD // HAZARD', state: 'HAZARD' as const, moons: [] }, -] - const crew = [ { name: 'CMDR. E. VANCE', role: 'COMMANDER', good: 'STEADY UNDER PRESSURE', bad: 'RIGID — SLOW TO ADAPT' }, { name: 'LT. M. OKONKWO', role: 'PILOT', good: 'RECKLESS COURAGE', bad: 'SHORT FUSE' }, @@ -339,87 +326,9 @@ export function MissionControl() { - {/* LOCAL SYSTEM TAB */} + {/* LOCAL SYSTEM TAB — live data from /api/system */} -
-
- EXPLORABLE WORLDS // FIND THINGS HERE (PLACEHOLDER) -
- {explorablePlanets.flatMap((planet) => [ - { name: planet.name, type: planet.type, state: planet.state, depth: 0 }, - ...planet.moons.map((moon) => ({ - name: moon.name, - type: moon.type, - state: moon.state, - depth: 1, - })), - ]).map((row, i, rows) => ( -
-
-
- {row.name} -
-
- {row.type} -
-
- - {row.state} - -
- ))} -
+
{/* CREW TAB */} From 1a50ffb2f95689547a5dddca82dd5c4de7637580 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:17:03 +0000 Subject: [PATCH 6/7] e2e: local system smoke tests (staggered bodies, explore row state, inventory accumulation) + explored-state persistence across restart --- e2e/smoke.spec.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++++- e2e/state.spec.ts | 32 +++++++++++++++++---- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index a6d22fa..2a0e819 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,8 +1,13 @@ import { expect, test } from '@playwright/test'; -// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained. +// 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 } +interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } +interface InventoryItemDto { kind: string; name: string; qty: number } +interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number } +interface FoundItem { kind: string; name: string; qty: number } +interface ExploreResult extends SystemState { outcome: FoundItem[] } test('landing page loads with the SpaceWanderer title', async ({ page }) => { await page.goto('/'); @@ -49,6 +54,71 @@ test('jumping to an adjacent system updates the world state and turn counter', a expect(after.systems.find((s) => s.current)?.id).toBe(targetId); }); +test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => { + const sys = (await (await page.request.get('/api/system')).json()) as SystemState; + + await page.goto('/'); + await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click(); + + // Header carries the real system name + turn counter. + await expect(page.getByText(`EXPLORABLE WORLDS // ${sys.systemName.toUpperCase()} — TURN ${sys.turn}`)).toBeVisible(); + // One EXPLORE button per unexplored body (planets and moons alike). + const exploreButtons = page.getByRole('button', { name: /\/\/ EXPLORE$/ }); + await expect(exploreButtons.first()).toBeVisible(); + await expect(exploreButtons).toHaveCount(sys.planets.filter((p) => !p.explored).length); +}); + +test('exploring a body flips its row state, bumps the turn and shows the scan result', async ({ page }) => { + const before = (await (await page.request.get('/api/system')).json()) as SystemState; + const target = before.planets.find((p) => !p.explored)!; + + await page.goto('/'); + await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click(); + await page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` }).click(); + + const after = (await (await page.request.get('/api/system')).json()) as SystemState; + expect(after.turn).toBe(before.turn + 1); + expect(after.planets.find((p) => p.id === target.id)?.explored).toBe(true); + + // UI reflects it: the row badge flipped to SCANNED and its EXPLORE button is gone. + await expect(page.getByRole('button', { name: `${target.name.toUpperCase()} // EXPLORE` })).toHaveCount(0); + await expect(page.getByText(/SCAN RESULT \/\/ /)).toBeVisible(); +}); + +test('exploration yields finds that accumulate in the inventory', async ({ page }) => { + // Scan bodies until one yields something (most scans do; jumping keeps it going if a whole + // system is exhausted). P(no find across this many scans) ≈ 0.21^30 — negligible. + const found = new Map(); // kind|name -> qty + let iterations = 0; + while (found.size === 0 && 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) { + const res = await page.request.post('/api/explore', { data: { planetId: unexplored[0].id } }); + expect(res.status()).toBe(200); + for (const item of ((await res.json()) as ExploreResult).outcome) { + found.set(`${item.kind}|${item.name}`, item.qty); + } + } else { + // Current system fully scanned — jump somewhere and keep scanning. + 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); + } + } + expect(found.size).toBeGreaterThan(0); + + // Every find landed in the inventory with at least that qty. + const sys = (await (await page.request.get('/api/system')).json()) as SystemState; + for (const [key, qty] of found) { + const [kind, name] = key.split('|'); + expect(sys.inventory.find((i) => i.kind === kind && i.name === name)?.qty).toBeGreaterThanOrEqual(qty); + } +}); + 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); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index 3e22db7..4958300 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -8,9 +8,11 @@ import { expect, test } from '@playwright/test'; const repoRoot = path.join(__dirname, '..'); const composeArgs = process.env.E2E_COMPOSE_ARGS ?? ''; -// Mirrors the /api/world response shape (lib/game/save.ts) — kept local so the spec stays self-contained. +// 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 } +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 } test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); @@ -28,7 +30,22 @@ test('app survives a container restart (stack + volume persistence)', async ({ p 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); - const worldJumped = (await jumpRes.json()) as WorldState; + + // 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); + sysBefore = (await (await page.request.get('/api/system')).json()) as SystemState; + } + const targetPlanet = sysBefore.planets.find((p) => !p.explored)!; + const exploreRes = await page.request.post('/api/explore', { data: { planetId: targetPlanet.id } }); + expect(exploreRes.status()).toBe(200); + const sysExplored = (await exploreRes.json()) as SystemState; // Restart the app container mid-run — the named DB volume must survive it. execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); @@ -50,10 +67,15 @@ test('app survives a container restart (stack + volume persistence)', async ({ p expect(after).toBeDefined(); expect(after!.version).toBe(before.version); // same build, not a re-seeded stack - // Game state survived: same turn counter and jumped-to current system as before the restart. + // 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; - expect(worldAfter.turn).toBe(worldJumped.turn); - expect(worldAfter.systems.find((s) => s.current)?.id).toBe(targetId); + expect(worldAfter.turn).toBe(sysExplored.turn); + expect(worldAfter.systems.find((s) => s.current)?.id).toBe(sysExplored.systemId); + + // Exploration state survived too: the scanned body is still explored. + const sysAfter = (await (await page.request.get('/api/system')).json()) as SystemState; + expect(sysAfter.turn).toBe(sysExplored.turn); + expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true); // The page itself serves again with the landing title. await page.goto('/'); From 0ddba27333a840c7310aeeb45090d0745f15d269 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:17:56 +0000 Subject: [PATCH 7/7] =?UTF-8?q?release:=20v0.4.0=20=E2=80=94=20LOCAL=20SYS?= =?UTF-8?q?TEM=20planet=20list=20+=20exploration=20to=20find=20things=20(i?= =?UTF-8?q?ssue=20#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ ORCHESTRATOR.md | 4 ++-- package.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c562c9..6d128f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.4.0 — 2026-09-28 22:17 +- LOCAL SYSTEM tab — planet list + exploration to find things (issue #6): the first in-system game loop on top of issue #5's save/world foundation. Single-player, no auth, by design. +- New tables (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `planets` (id/system_id/parent_id/name/type/hazard/explored — planets have parent_id NULL, moons point at their planet) and `inventory_items` (id/save_id/kind[name]/qty — the dumb-simple cargo hold SHIP SYSTEMS (#8) will spend from later). +- Planet generation hooks into world gen (`lib/game/world.ts`, pure + seeded): each system gets 2–5 planets, some with 1–3 moons, generated when the system is inserted via `bodyRng(seed, systemId)` — so a given system always has the same bodies regardless of order. Bodies are stored in display order (a moon follows its parent row). +- Exploration (`lib/game/save.ts`): `explorePlanet` rolls a deterministic haul from `explorationRng(seed, planetId)` — a resource and/or a part (qty), or nothing (~21% of scans) — marks the body explored (each once), costs a turn, accumulates finds into inventory (same kind+name bumps qty) and writes an explore event. +- New endpoints: `GET /api/system` → `{ systemId, systemName, planets[{id,name,type,hazard,explored,parent}], inventory, turn }`; `POST /api/explore {planetId}` → fresh system state + `outcome` (400 malformed body, 404 unknown planet, 409 already explored). OpenAPI spec in code (`lib/openapi.ts`) updated for both. +- LOCAL SYSTEM tab now renders REAL data: fetches `/api/system` on mount, draws the staggered body list (planets base level, moons indented + dimmed with their parent's name), badges reflect real state (UNEXPLORED / SCANNED / HAZARD) and an EXPLORE button per unexplored row; explore responses apply directly (no polling). A CARGO HOLD panel shows the live inventory. Placeholder constants removed for this tab only — other tabs keep theirs until their issues land. + ## v0.3.0 — 2026-09-28 21:35 - World generation + jump between solar systems (issue #5): the game save, DB schema, turn counter and event writing that all later tabs build on. Single-player by design — one save row, no auth/accounts. - New tables (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `saves` (id/seed/turn/created_at), `solar_systems` (name, x/y chart position, discovered, is_current), `jump_routes` (directed pairs, both directions stored for single-row adjacency lookups), `events` (append-only log: new-game + jump events so far). diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md index be2f8a7..1f69b5d 100644 --- a/ORCHESTRATOR.md +++ b/ORCHESTRATOR.md @@ -46,8 +46,8 @@ Credentials are deliberately NOT stored in this file — see §1 for where they ## 7. App architecture quick map - **Scaffolded (v0.1.0)** — stack proof only, no game systems yet; the rest of this section is what's built + what lands next. - Next.js 16 App Router + React 19 + TypeScript strict, bun package manager (bun.lock), Node 22 in CI/Docker. -- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart, NodeGraph, Progress), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. As of v0.2.0 the operations panel is a 7-tab visual template (WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE, MISSIONS, EVENT LOG) built on NodeGraph + Card widgets. As of v0.3.0 the WORLD MAP tab renders live data from `GET /api/world` (seeded world generation in `lib/game/`, save/jump/events via Drizzle/sql.js — single player, one save, no auth); the other tabs keep local placeholder constants until their own issues land. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase. -- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Game tables (`saves`, `solar_systems`, `jump_routes`, `events`) live since v0.3.0 — single-player save with seeded world generation + turn counter; DDL in `lib/db/client.ts` must stay in sync with `lib/db/schema.ts`. +- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart, NodeGraph, Progress), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. As of v0.2.0 the operations panel is a 7-tab visual template (WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE, MISSIONS, EVENT LOG) built on NodeGraph + Card widgets. As of v0.3.0 the WORLD MAP tab renders live data from `GET /api/world` (seeded world generation in `lib/game/`, save/jump/events via Drizzle/sql.js — single player, one save, no auth); as of v0.4.0 the LOCAL SYSTEM tab also renders live data (`GET /api/system` + `POST /api/explore` — per-system planet/moon generation and deterministic exploration into inventory). The other tabs keep local placeholder constants until their own issues land. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase. +- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Game tables (`saves`, `solar_systems`, `jump_routes`, `planets`, `inventory_items`, `events`) live since v0.3.0/v0.4.0 — single-player save with seeded world + per-system body generation, turn counter and exploration; DDL in `lib/db/client.ts` must stay in sync with `lib/db/schema.ts`. - Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only. - Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer). - Plain React hooks for client state, no external store. **NO auth/accounts/multiplayer by design** (single player only) — there is no users/sessions table and no session headers; a "session" means the local single-player save slot in the DB, not an identity system. Do not copy Flight-Ops's lib/auth.ts / SignInScreen / admin patterns. diff --git a/package.json b/package.json index 7df96b6..0e9fbc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.3.0", + "version": "0.4.0", "private": true, "scripts": { "dev": "next dev -p 3000",