From 3aa27ffd898ae2b958de7fe7e5e1b0b7bfc5ea70 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:07:53 +0000 Subject: [PATCH] =?UTF-8?q?game:=20bodies=20generated=20with=20each=20syst?= =?UTF-8?q?em;=20explore=20action=20=E2=80=94=20deterministic=20haul,=20tu?= =?UTF-8?q?rn=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 } +}