import { mkdtemp, rm } from 'node:fs/promises' 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 * as schema from '@/lib/db/schema' 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[] = [] async function freshFile(): Promise<{ db: GameDb; file: string }> { const dir = await mkdtemp(path.join(tmpdir(), 'sw-save-test-')) dirs.push(dir) const file = path.join(dir, 'test.db') return { db: await openGameDb(file), file } } afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }) }) function currentOf(world: WorldState): WorldState['systems'][number] { const current = world.systems.filter((s) => s.current) expect(current).toHaveLength(1) return current[0] } describe('ensureSave / getWorldState', () => { it('auto-initializes a save on first read: start + 6 neighbours, turn 0, new_game event', async () => { const { db } = await freshFile() const world = await getWorldState(db) expect(world.turn).toBe(0) expect(world.systems).toHaveLength(7) expect(currentOf(world).discovered).toBe(true) expect(world.systems.filter((s) => !s.discovered)).toHaveLength(6) expect(world.edges).toHaveLength(12) // start↔each neighbour + ring cycle const ids = new Set(world.systems.map((s) => s.id)) for (const [a, b] of world.edges) { expect(ids.has(a)).toBe(true) expect(ids.has(b)).toBe(true) } const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id') expect(rows[0].values).toEqual([['new_game', 0]]) }) it('is idempotent — a second read does not regenerate the world', async () => { const { db } = await freshFile() const first = await getWorldState(db) const second = await getWorldState(db) expect(second).toEqual(first) }) }) describe('jumpToSystem validation', () => { it('rejects unknown systems with 404 and jumping to the current system with 409', async () => { const { db } = await freshFile() const world = await getWorldState(db) const start = currentOf(world) await expect(jumpToSystem(db, 999_999)).rejects.toMatchObject({ status: 404 }) await expect(jumpToSystem(db, start.id)).rejects.toMatchObject({ status: 409 }) const after = await getWorldState(db) expect(after.turn).toBe(0) // rejected jumps mutate nothing expect(currentOf(after).id).toBe(start.id) }) it('rejects non-adjacent systems with 409 and leaves state untouched', async () => { const { db } = await freshFile() const w0 = await getWorldState(db) const start = currentOf(w0) // Every initial neighbour is adjacent to the start, so jump once first. const n0 = w0.systems.find((s) => !s.current)! await jumpToSystem(db, n0.id) const w1 = await getWorldState(db) expect(currentOf(w1).id).toBe(n0.id) // A system with no route to the current one (ring members two steps away qualify). const adjacent = new Set( w1.edges.filter(([a, b]) => a === n0.id || b === n0.id).flatMap(([a, b]) => [a, b]), ) const far = w1.systems.find((s) => !adjacent.has(s.id))! await expect(jumpToSystem(db, far.id)).rejects.toMatchObject({ status: 409 }) const after = await getWorldState(db) expect(after.turn).toBe(1) expect(currentOf(after).id).toBe(n0.id) }) }) describe('jumpToSystem success path', () => { it('sets current, increments turn, discovers the target and grows the chart', async () => { const { db } = await freshFile() const w0 = await getWorldState(db) const start = currentOf(w0) const target = w0.systems.find((s) => !s.current)! // all initial neighbours are adjacent to the start const after = await jumpToSystem(db, target.id) expect(after.turn).toBe(1) expect(currentOf(after).id).toBe(target.id) expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true) // Expansion: 4 new undiscovered neighbours around the target, each routed to it. const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id)) expect(fresh).toHaveLength(4) expect(fresh.every((s) => !s.discovered)).toBe(true) for (const n of fresh) { expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true) } const rows = db.raw.exec('SELECT type, turn FROM events ORDER BY id') expect(rows[0].values).toEqual([['new_game', 0], ['jump', 1]]) }) it('persists the current system and turn across a DB reopen (container restart)', async () => { const { db, file } = await freshFile() const w0 = await getWorldState(db) const target = w0.systems.find((s) => !s.current)! const after = await jumpToSystem(db, target.id) const reopened = await openGameDb(file) // a new process would see the same file const again = await getWorldState(reopened) expect(again.turn).toBe(after.turn) expect(currentOf(again).id).toBe(target.id) 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 }) }) 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) }) })