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

416 lines
19 KiB
TypeScript
Raw Permalink 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 { eq } from 'drizzle-orm'
import { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { TECHS } from './tech'
import { SCHEMA_VERSION, 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)
})
})
describe('backfill (old saves)', () => {
// Planets/moons of one system in insertion order, nested by parent — ids are excluded so a
// regenerated set compares equal to the original even though autoincrement moved on.
function bodyShape(rows: { id: number; name: string; type: string; hazard: boolean; parentId: number | null }[]) {
return rows
.filter((r) => r.parentId === null)
.map((p) => ({ name: p.name, type: p.type, hazard: p.hazard, moons: rows.filter((m) => m.parentId === p.id).map((m) => [m.name, m.type, m.hazard]) }))
}
it('upgrades a v1 save (no bodies, no crew) in place — deterministic and idempotent', async () => {
const { db } = await freshFile()
await getWorldState(db) // new game: everything seeded at the current version
const systems = await db.db.select().from(schema.solarSystems)
const rowsFor = (systemId: number) => db.db.select().from(schema.planets).where(eq(schema.planets.systemId, systemId))
const beforeBodies: Record<number, unknown> = {}
for (const s of systems) {
const rows = await rowsFor(s.id)
beforeBodies[s.id] = bodyShape(rows.sort((a, b) => a.id - b.id))
}
const crewOf = (rows: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }[]) =>
rows.map((c) => ({ name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) // ids excluded — re-seed moves autoincrement on
const beforeCrew = crewOf(await db.db.select().from(schema.crew))
const shipOf = (rows: { id: number; type: string; level: number; capacity: number }[]) =>
rows.map((s) => ({ type: s.type, level: s.level, capacity: s.capacity })).sort((a, b) => a.type.localeCompare(b.type)) // ids excluded — re-seed moves autoincrement on
const beforeShip = shipOf(await db.db.select().from(schema.shipSystems))
const techShape = (rows: { id: number; slug: string; name: string; costKind: string; costQty: number; prerequisites: string; effect: string; researched: boolean }[]) =>
rows.map((t) => ({ slug: t.slug, name: t.name, costKind: t.costKind, costQty: t.costQty, prerequisites: t.prerequisites, effect: t.effect, researched: t.researched })).sort((a, b) => a.slug.localeCompare(b.slug))
const beforeTech = techShape(await db.db.select().from(schema.tech))
// Simulate a save created by pre-v0.5 code: no bodies, no crew, no ship, no tech, no version stamp (NULL).
await db.db.delete(schema.planets)
await db.db.delete(schema.crew)
await db.db.delete(schema.shipSystems)
await db.db.delete(schema.tech)
await db.db.update(schema.saves).set({ schemaVersion: null })
const world = await getWorldState(db) // ensureSave → backfill runs
expect(world.systems).toHaveLength(7)
for (const s of systems) {
const rows = await rowsFor(s.id)
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id]) // identical to fresh generation
}
expect(crewOf(await db.db.select().from(schema.crew))).toEqual(beforeCrew)
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
const [save] = await db.db.select().from(schema.saves)
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
// Idempotent: a second pass changes nothing.
const planetCount = (await db.db.select().from(schema.planets)).length
await getWorldState(db)
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
expect((await db.db.select().from(schema.crew)).length).toBe(beforeCrew.length)
expect(shipOf(await db.db.select().from(schema.shipSystems))).toEqual(beforeShip)
expect(techShape(await db.db.select().from(schema.tech))).toEqual(beforeTech)
for (const s of systems) {
const rows = await rowsFor(s.id)
expect(bodyShape(rows.sort((a, b) => a.id - b.id))).toEqual(beforeBodies[s.id])
}
})
it('leaves an up-to-date save untouched', async () => {
const { db } = await freshFile()
await getWorldState(db)
const planetCount = (await db.db.select().from(schema.planets)).length
const [save] = await db.db.select().from(schema.saves)
expect(save.schemaVersion).toBe(SCHEMA_VERSION)
await getWorldState(db) // second read: no backfill work to do
expect((await db.db.select().from(schema.planets)).length).toBe(planetCount)
})
it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
const { db } = await freshFile()
await getWorldState(db)
const rows = (await db.db.select().from(schema.shipSystems)).sort((a, b) => a.type.localeCompare(b.type))
expect(rows).toHaveLength(SHIP_TYPES.length)
for (const def of SHIP_TYPES) {
const row = rows.find((r) => r.type === def.type)!
if (def.builtAtStart) {
expect({ level: row.level, capacity: row.capacity }).toEqual({ level: BASE_LEVEL, capacity: BASE_CAPACITY })
} else {
expect(row.level).toBe(0) // unbuilt until a BUILD action
expect(row.capacity).toBe(0)
}
}
})
it('seeds the tech tree on a new game: one row per known tech, none researched', async () => {
const { db } = await freshFile()
await getWorldState(db)
const rows = (await db.db.select().from(schema.tech)).sort((a, b) => a.slug.localeCompare(b.slug))
expect(rows).toHaveLength(TECHS.length)
for (const def of TECHS) {
const row = rows.find((r) => r.slug === def.slug)!
expect({ name: row.name, costKind: row.costKind, costQty: row.costQty, prerequisites: row.prerequisites, effect: row.effect }).toEqual({
name: def.name,
costKind: def.costKind,
costQty: def.costQty,
prerequisites: def.prereqs.join(','),
effect: def.effect,
})
expect(row.researched).toBe(false) // nothing is researched at the start
}
})
})