231 lines
9.4 KiB
TypeScript
231 lines
9.4 KiB
TypeScript
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 { 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
|
||
})
|
||
})
|