game: bodies generated with each system; explore action — deterministic haul, turn cost, inventory accumulation
This commit is contained in:
+113
-12
@@ -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<Map<string, number>> {
|
||||
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<number> {
|
||||
const [row] = await db.db.insert(schema.solarSystems).values({ saveId, name: ws.name, x: ws.x, y: ws.y }).returning({ id: schema.solarSystems.id })
|
||||
for (const body of generatePlanets(bodyRng(seed, row.id))) {
|
||||
const [planet] = await db.db.insert(schema.planets).values({ systemId: row.id, parentId: null, name: body.name, type: body.type, hazard: body.hazard }).returning({ id: schema.planets.id })
|
||||
for (const moon of body.moons) {
|
||||
await db.db.insert(schema.planets).values({ systemId: row.id, parentId: planet.id, name: moon.name, type: moon.type, hazard: moon.hazard })
|
||||
}
|
||||
}
|
||||
return row.id
|
||||
}
|
||||
|
||||
async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (index: number) => number): Promise<void> {
|
||||
@@ -73,11 +79,12 @@ export async function ensureSave(db: GameDb): Promise<void> {
|
||||
|
||||
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<WorldS
|
||||
const rng = expansionRng(save.seed, turn)
|
||||
const takenNames = new Set(systems.map((s) => 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<WorldS
|
||||
|
||||
return getWorldState(db)
|
||||
}
|
||||
|
||||
// --- Local system + exploration ---------------------------------------------
|
||||
|
||||
export interface PlanetDto {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
hazard: boolean
|
||||
explored: boolean
|
||||
parent: number | null // NULL for a planet, the parent planet's id for a moon
|
||||
}
|
||||
|
||||
export interface InventoryItemDto {
|
||||
kind: 'resource' | 'part'
|
||||
name: string
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface SystemState {
|
||||
systemId: number
|
||||
systemName: string
|
||||
planets: PlanetDto[] // insertion order = display order (a moon follows its parent row)
|
||||
inventory: InventoryItemDto[]
|
||||
turn: number
|
||||
}
|
||||
|
||||
// Carries an HTTP status so route handlers can map failures to responses without re-parsing.
|
||||
export class ExploreError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function readSystemState(db: GameDb): Promise<SystemState> {
|
||||
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<SystemState> {
|
||||
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<ExploreResult> {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user