game: starting crew seeded on new game; exploration rolls for a joining crew member (same per-planet stream)

This commit is contained in:
2026-09-28 22:45:55 +00:00
parent ccd156b7c6
commit 7a9ff110f3
2 changed files with 118 additions and 6 deletions
+45 -5
View File
@@ -2,6 +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 { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
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.
@@ -67,6 +68,13 @@ async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (ind
}
}
// Seeds the starting crew for a fresh save: 2–3 members derived from the seed alone.
async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promise<void> {
for (const member of generateStartingCrew(startingCrewRng(seed))) {
await db.db.insert(schema.crew).values({ saveId, name: member.name, role: member.role, traitGood: member.traitGood, traitBad: member.traitBad, origin: 'STARTING CREW' })
}
}
// Creates the save + starting region when the DB has none. No-op otherwise — safe to call
// on every request (single player, one process: check-then-insert cannot race in practice).
export async function ensureSave(db: GameDb): Promise<void> {
@@ -86,6 +94,7 @@ export async function ensureSave(db: GameDb): Promise<void> {
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
await insertRoutes(db, region.edges, resolve)
await seedStartingCrew(db, save.id, seed)
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)
@@ -210,13 +219,32 @@ export async function getSystemState(db: GameDb): Promise<SystemState> {
return readSystemState(db)
}
// --- Crew roster --------------------------------------------------------------
export interface CrewDto {
id: number
name: string
role: string
traitGood: string
traitBad: string
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring
}
// The full roster in join order (starting crew first, then finds). Auto-initializes like the other reads.
export async function getCrewState(db: GameDb): Promise<CrewDto[]> {
await ensureSave(db)
return (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
}
export interface ExploreResult extends SystemState {
outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing)
crewFound: CrewDto | null // a member who joined the ship on this scan (null when none)
}
// 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).
// costs a turn, accumulates finds into inventory and writes an event. The same per-planet
// rng stream also rolls for a crew member (~18% of scans) who joins the roster. 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)
@@ -225,7 +253,10 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
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 rng = explorationRng(save.seed, target.id)
const outcome = rollExploration(rng) // resource/part haul first…
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream
const turn = save.turn + 1
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
@@ -239,10 +270,19 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
}
}
let crewFound: CrewDto | null = null
if (foundMember) {
const systems = await db.db.select().from(schema.solarSystems)
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system'
const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning()
crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
}
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
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 db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
await persist(db)
return { ...(await readSystemState(db)), outcome }
return { ...(await readSystemState(db)), outcome, crewFound }
}