From 7a9ff110f354fa5a6eba01416956b3e9259ae472 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:45:55 +0000 Subject: [PATCH] game: starting crew seeded on new game; exploration rolls for a joining crew member (same per-planet stream) --- lib/game/save.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++++- lib/game/save.ts | 50 ++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 1f3a7df..3b0129e 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -4,7 +4,8 @@ import path from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' -import { getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' +import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew' +import { getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' const dirs: string[] = [] @@ -228,3 +229,74 @@ describe('explorePlanet', () => { expect(state.turn).toBe(planetIds.length) // one turn per scan }) }) + +describe('getCrewState', () => { + it('seeds a starting crew of 2–3 members derived from the save seed', async () => { + const { db } = await freshFile() + const crew = await getCrewState(db) // ensureSave runs inside, so this also seeds the crew + + expect(crew.length).toBeGreaterThanOrEqual(2) + expect(crew.length).toBeLessThanOrEqual(3) + for (const member of crew) { + expect(member.origin).toBe('STARTING CREW') + expect(member.name.trim()).not.toBe('') + expect(member.role.trim()).not.toBe('') + expect(member.traitGood.trim()).not.toBe('') + expect(member.traitBad.trim()).not.toBe('') + } + + // The roster is exactly the pure generation for this save's seed. + const [save] = await db.db.select().from(schema.saves) + const expected = generateStartingCrew(startingCrewRng(save.seed)) + expect(crew.map((c) => c.name)).toEqual(expected.map((m) => m.name)) + expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role)) + expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad])) + }) +}) + +describe('explorePlanet crew finds', () => { + it('lands a found member in the roster with the planet+system origin and never joins twice (known seed)', async () => { + // Hand-built save with a known seed so the crew roll 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 }) + + // Find a small planet id whose exploration roll finds crew (deterministic — recomputed here, + // mirroring save.ts: the item haul draws first, then the crew roll on the same stream). + let crewPlanetId = -1 + for (let id = 1; id <= 50 && crewPlanetId === -1; id++) { + const rng = explorationRng('42', id) + rollExploration(rng) + if (rollCrewMember(rng, new Set())) crewPlanetId = id + } + expect(crewPlanetId).toBeGreaterThan(0) + + const planetName = `P${crewPlanetId}` + for (let i = 1; i <= crewPlanetId; i++) { + await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false }) + } + + const before = await getCrewState(db) // empty — this save was hand-built, no starting crew seeded + const result = await explorePlanet(db, crewPlanetId) + + expect(result.crewFound).not.toBeNull() + const expectedRng = explorationRng('42', crewPlanetId) + rollExploration(expectedRng) // consume the item haul draws first, exactly as save.ts does + const expectedMember = rollCrewMember(expectedRng, new Set())! // same save+planet → same member + expect(result.crewFound!.name).toBe(expectedMember.name) + expect(result.crewFound!.role).toBe(expectedMember.role) + expect([result.crewFound!.traitGood, result.crewFound!.traitBad]).toEqual([expectedMember.traitGood, expectedMember.traitBad]) + expect(result.crewFound!.origin).toBe(`${planetName}, Test System`) + + const after = await getCrewState(db) + expect(after.length).toBe(before.length + 1) // exactly one join + expect(after.find((c) => c.id === result.crewFound!.id)).toMatchObject({ name: expectedMember.name, origin: `${planetName}, Test System` }) + + // The body is already explored → a second scan is rejected and no duplicate joins. + await expect(explorePlanet(db, crewPlanetId)).rejects.toMatchObject({ status: 409 }) + expect((await getCrewState(db)).length).toBe(before.length + 1) + }) +}) diff --git a/lib/game/save.ts b/lib/game/save.ts index a7ea44b..4922e3e 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -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 { + 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 { @@ -86,6 +94,7 @@ export async function ensureSave(db: GameDb): Promise { 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 { return readSystemState(db) } +// --- Crew roster -------------------------------------------------------------- + +export interface CrewDto { + id: number + name: string + role: string + traitGood: string + traitBad: string + origin: string // 'STARTING CREW' or ', ' 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 { + 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 { 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 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 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 } }