Files
SpaceWanderer/lib/game/save.test.ts
T

303 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number>()
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<string>())) 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<string>())! // 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)
})
})