952 lines
55 KiB
TypeScript
952 lines
55 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 { and, eq } from 'drizzle-orm'
|
||
import { openGameDb, type GameDb } from '@/lib/db/client'
|
||
import * as schema from '@/lib/db/schema'
|
||
import { ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
|
||
import { STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||
import { MIN_AVAILABLE } from './missions'
|
||
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
|
||
import { TECHS } from './tech'
|
||
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, makePowerOfFor, queueStateFromSave, resolveJumpTask, resolveExploreTask, runTurn, saveTaskRow, type WorldState } from './save'
|
||
import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures'
|
||
import { queueTask, type Task } from './tasks'
|
||
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)).crew // ensureSave runs inside, so this also seeds the crew (the roster moved under .crew — JC-7)
|
||
|
||
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]))
|
||
})
|
||
|
||
it('attaches per-member skill levels + title: the four crew_skills rows in fixed order, SENIOR from L3, null below', async () => {
|
||
const { db } = await freshFile()
|
||
const { crew: seeded } = await getCrewState(db) // ensureSave seeds the starting crew + their crew_skills rows (v8)
|
||
expect(seeded.length).toBeGreaterThanOrEqual(2)
|
||
const [pinned, other] = [seeded[0], seeded[1]]
|
||
|
||
// Pin the first member to NAVIGATION L3 (xp 15 — the L3 threshold, so levelFromXp round-trips)
|
||
// with the other three skills L0, and reset the second member to all-L0 (a specialist role
|
||
// would otherwise seed L2).
|
||
for (const skill of ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH'] as const) {
|
||
const level = skill === 'NAVIGATION' ? 3 : 0
|
||
await db.db.update(schema.crewSkills).set({ level, xp: level > 0 ? XP_THRESHOLDS[level - 1] : 0 }).where(and(eq(schema.crewSkills.crewId, pinned.id), eq(schema.crewSkills.skill, skill)))
|
||
}
|
||
for (const r of (await db.db.select().from(schema.crewSkills)).filter((r) => r.crewId === other.id)) {
|
||
await db.db.update(schema.crewSkills).set({ level: 0, xp: 0 }).where(eq(schema.crewSkills.id, r.id))
|
||
}
|
||
|
||
const { crew } = await getCrewState(db)
|
||
const p = crew.find((c) => c.id === pinned.id)!
|
||
expect(p.skills).toEqual([
|
||
{ skill: 'NAVIGATION', level: 3 },
|
||
{ skill: 'SCANNING', level: 0 },
|
||
{ skill: 'ENGINEERING', level: 0 },
|
||
{ skill: 'RESEARCH', level: 0 },
|
||
])
|
||
expect(p.title).toBe(`SENIOR ${p.role}`) // the pinned skill is the member's highest — L3 → SENIOR
|
||
|
||
const o = crew.find((c) => c.id === other.id)!
|
||
expect(o.skills).toEqual([
|
||
{ skill: 'NAVIGATION', level: 0 },
|
||
{ skill: 'SCANNING', level: 0 },
|
||
{ skill: 'ENGINEERING', level: 0 },
|
||
{ skill: 'RESEARCH', level: 0 },
|
||
])
|
||
expect(o.title).toBeNull()
|
||
})
|
||
})
|
||
|
||
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)).crew // 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)).crew
|
||
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)).crew.length).toBe(before.length + 1)
|
||
})
|
||
})
|
||
|
||
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
|
||
//
|
||
// Each test builds the identical deterministic fixture (buildWorldFixture — the shared
|
||
// fixture suite in ./test-fixtures: seed '42', stamped save → no backfill; ids line up
|
||
// across worlds) in two fresh files and compares their full state:
|
||
// A = the legacy immediate action (validates, costs a turn, applies the effect);
|
||
// B = the queued shape — the tick has advanced the turn (Task 4b) and the resolver
|
||
// applies the effect (ship/research rows deduct the cost up front, as queueTask did at
|
||
// queue time — Task 3). snapshot() normalizes every game table (wall-clock columns
|
||
// excluded), so the two end states must be byte-identical.
|
||
|
||
// The resolvers only read `type` + `payload` — tests mint stand-in rows with zeroed
|
||
// identity fields, the same way the public functions' delegation does.
|
||
const task = (type: 'JUMP' | 'EXPLORE', payload: object): Task =>
|
||
({ id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 })
|
||
|
||
async function snapshot(db: GameDb): Promise<string> {
|
||
const [saves, systems, routes, planets, inventory, crew, ship, tech, missions, events] = await Promise.all([
|
||
db.db.select({ id: schema.saves.id, seed: schema.saves.seed, turn: schema.saves.turn, schemaVersion: schema.saves.schemaVersion }).from(schema.saves),
|
||
db.db.select().from(schema.solarSystems),
|
||
db.db.select().from(schema.jumpRoutes),
|
||
db.db.select().from(schema.planets),
|
||
db.db.select().from(schema.inventoryItems),
|
||
db.db.select().from(schema.crew),
|
||
db.db.select().from(schema.shipSystems),
|
||
db.db.select().from(schema.tech),
|
||
db.db.select().from(schema.missions),
|
||
db.db.select({ id: schema.events.id, turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events),
|
||
])
|
||
return JSON.stringify({ saves, systems, routes, planets, inventory, crew, ship, tech, missions, events })
|
||
}
|
||
|
||
describe('resolveJumpTask (A/B against jumpToSystem)', () => {
|
||
it('undiscovered target: lands exactly what jumpToSystem lands — move, discover, ring, event', async () => {
|
||
const { db: a } = await freshFile()
|
||
const { db: b } = await freshFile()
|
||
const fa = await buildWorldFixture(a)
|
||
const fb = await buildWorldFixture(b)
|
||
|
||
await jumpToSystem(a, fa.sys1) // legacy: validates, +1 turn, effect, event at turn 1
|
||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1)) // the tick's job (Task 4b)
|
||
await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
|
||
|
||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||
})
|
||
|
||
it('discovered target: no ring — still byte-identical to jumpToSystem', async () => {
|
||
const { db: a } = await freshFile()
|
||
const { db: b } = await freshFile()
|
||
const fa = await buildWorldFixture(a, true)
|
||
const fb = await buildWorldFixture(b, true)
|
||
|
||
await jumpToSystem(a, fa.sys1)
|
||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||
await resolveJumpTask(b, task('JUMP', { systemId: fb.sys1, hops: 1 }))
|
||
|
||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||
expect(await b.db.select().from(schema.solarSystems)).toHaveLength(3) // the fixture's three systems, no ring added
|
||
})
|
||
|
||
it('guards: 404 unknown system, 409 already-current', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||
|
||
await expect(resolveJumpTask(db, task('JUMP', { systemId: 999, hops: 1 }))).rejects.toMatchObject({ status: 404, message: 'Unknown system 999' })
|
||
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, 1))
|
||
await db.db.update(schema.solarSystems).set({ isCurrent: true }).where(eq(schema.solarSystems.id, f.sys1))
|
||
await expect(resolveJumpTask(db, task('JUMP', { systemId: f.sys1, hops: 1 }))).rejects.toMatchObject({ status: 409, message: 'Already at this system' })
|
||
})
|
||
})
|
||
|
||
describe('resolveExploreTask (A/B against explorePlanet)', () => {
|
||
it('planet in the current system: lands exactly what explorePlanet lands — haul, explored, event', async () => {
|
||
const { db: a } = await freshFile()
|
||
const { db: b } = await freshFile()
|
||
const fa = await buildWorldFixture(a)
|
||
const fb = await buildWorldFixture(b)
|
||
|
||
await explorePlanet(a, fa.p0)
|
||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||
await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p0 }))
|
||
|
||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||
})
|
||
|
||
it('payload planet wins over the current system — the ship stays put (elsewhere semantics)', async () => {
|
||
const { db: a } = await freshFile()
|
||
const { db: b } = await freshFile()
|
||
const fa = await buildWorldFixture(a)
|
||
const fb = await buildWorldFixture(b)
|
||
|
||
await explorePlanet(a, fa.p1) // the current function already scans any planet, not just the current system's
|
||
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||
await resolveExploreTask(b, task('EXPLORE', { planetId: fb.p1 }))
|
||
|
||
expect(await snapshot(b)).toEqual(await snapshot(a))
|
||
|
||
const sysB = await b.db.select().from(schema.solarSystems)
|
||
expect(sysB.filter((s) => s.isCurrent).map((s) => s.name)).toEqual(['Origin']) // the ship never moved
|
||
expect(sysB.find((s) => s.name === 'Outpost')!.discovered).toBe(false) // a scan never discovers a system
|
||
|
||
const ev = (await b.db.select({ turn: schema.events.turn, type: schema.events.type, message: schema.events.message }).from(schema.events)).find((e) => e.type === 'explore')!
|
||
expect(ev.turn).toBe(1)
|
||
// Exact existing format, recomputed the way save.ts computes it: haul first, crew roll on the same stream.
|
||
const rng = explorationRng('42', fb.p1)
|
||
const haul = rollExploration(rng)
|
||
const member = rollCrewMember(rng, new Set<string>())
|
||
const foundText = haul.length > 0 ? haul.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
|
||
expect(ev.message).toBe(`Explored Basalt — found ${foundText}${member ? ` — ${member.name} joined the crew` : ''}`)
|
||
})
|
||
|
||
it('guards: 404 unknown planet, 409 already explored (double-resolution safe)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
|
||
|
||
await expect(resolveExploreTask(db, task('EXPLORE', { planetId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown planet 999' })
|
||
await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))
|
||
await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' })
|
||
})
|
||
})
|
||
|
||
// --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) -----------------
|
||
//
|
||
// The route→save pattern: ensureSave first, unscoped single-player reads, persist last.
|
||
// The tick (tickTurn) owns the turn increment and the queue math; the 4a resolvers own the
|
||
// effects and the mission hooks (their contract — invoked here, at the tick's new turn,
|
||
// before the final persist); this function owns the table writes.
|
||
describe('runTurn (tick on the DB)', () => {
|
||
// buildWorldFixture (the shared suite in ./test-fixtures) seeds the save + Origin /
|
||
// Outpost / Farpoint + Alder / Basalt + the route chain — but no crew or tasks; the tick
|
||
// needs both, so each test here mints its own rows via addCrew / insertTaskRow.
|
||
it('free skip: an empty queue costs exactly one turn and touches nothing else (Review Focus 4)', async () => {
|
||
const { db } = await freshFile()
|
||
await buildWorldFixture(db)
|
||
const count = (t: typeof schema.solarSystems | typeof schema.planets | typeof schema.inventoryItems | typeof schema.missions | typeof schema.events | typeof schema.tasks) => db.db.select().from(t).then((rows) => rows.length)
|
||
const before = {
|
||
systems: await count(schema.solarSystems),
|
||
planets: await count(schema.planets),
|
||
inventory: await count(schema.inventoryItems),
|
||
missions: await count(schema.missions),
|
||
events: await count(schema.events),
|
||
}
|
||
|
||
const r = await runTurn(db)
|
||
expect(r.turn).toBe(1)
|
||
expect(r.tasks).toEqual([])
|
||
expect(r.completed).toEqual([])
|
||
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
expect(save.turn).toBe(1) // the tick is the increment — the only write of the call
|
||
expect(await count(schema.solarSystems)).toBe(before.systems)
|
||
expect(await count(schema.planets)).toBe(before.planets)
|
||
expect(await count(schema.inventoryItems)).toBe(before.inventory)
|
||
expect(await count(schema.missions)).toBe(before.missions) // no top-up without a resolution
|
||
expect(await count(schema.events)).toBe(before.events) // no event for a skip
|
||
expect(await count(schema.tasks)).toBe(0)
|
||
})
|
||
|
||
it('a resolved JUMP completes the matching DELIVER mission, grants the reward, and tops the board up — the 4a hooks, at the tick\'s new turn', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db) // s0 current; s1 'Outpost' undiscovered
|
||
const [c1] = await addCrew(db, 1)
|
||
const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 4 })
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
await db.db.update(schema.saves).set({ turn: 5 }).where(eq(schema.saves.id, save.id))
|
||
const [mission] = await db.db.insert(schema.missions).values({ saveId: save.id, type: 'DELIVER', description: 'Jump to Outpost', targetSystemId: f.sys1, progressTarget: 1, rewardKind: 'resource', rewardQty: 2, state: 'ACTIVE' }).returning({ id: schema.missions.id })
|
||
|
||
const r = await runTurn(db)
|
||
expect(r.turn).toBe(6)
|
||
expect(r.completed.map((t) => t.id)).toEqual([jumpId])
|
||
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === jumpId)!.status).toBe('DONE')
|
||
|
||
// the mission resolved at the new turn: DONE + reward in the hold + both events at turn 6
|
||
expect((await db.db.select().from(schema.missions)).find((m) => m.id === mission.id)).toMatchObject({ state: 'DONE', progress: 1 })
|
||
expect(await db.db.select().from(schema.inventoryItems)).toEqual([{ id: expect.any(Number), saveId: save.id, kind: 'resource', name: 'MISSION REWARD', qty: 2 }])
|
||
const events = (await db.db.select().from(schema.events)).map((e) => [e.turn, e.type] as const)
|
||
expect(events).toHaveLength(2)
|
||
expect(events).toContainEqual([6, 'jump'])
|
||
expect(events).toContainEqual([6, 'mission'])
|
||
|
||
// the board is topped up after the arrival (the 4a jump-resolver hook) to exactly the floor
|
||
const available = (await db.db.select({ state: schema.missions.state }).from(schema.missions)).filter((m) => m.state === 'AVAILABLE').length
|
||
expect(available).toBe(MIN_AVAILABLE)
|
||
|
||
// the effect half: the ship is at the payload system
|
||
expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true)
|
||
})
|
||
|
||
it('a JUMP and an EXPLORE both resolving on one tick: the explore effect lands on its payload planet, not the newly arrived system (Review Focus 3)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db) // p0 'Alder' in s0 (Origin); p1 'Basalt' in s1 (Outpost)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id))
|
||
|
||
const r = await runTurn(db)
|
||
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE', 'JUMP']) // both this tick — the resolvers are payload-targeted (4a), so resolution order cannot change what lands
|
||
|
||
const planets = await db.db.select().from(schema.planets)
|
||
expect(planets.find((p) => p.id === f.p0)!.explored).toBe(true) // the payload planet — in the OLD system
|
||
expect(planets.find((p) => p.id === f.p1)!.explored).toBe(false) // nothing in the new system
|
||
expect((await db.db.select().from(schema.solarSystems)).find((s) => s.id === f.sys1)!.isCurrent).toBe(true) // the jump still landed
|
||
// the haul is the payload planet's deterministic stream (seed '42', p0) — never the new system's
|
||
expect((await db.db.select().from(schema.inventoryItems)).map((i) => ({ kind: i.kind, name: i.name, qty: i.qty }))).toEqual(rollExploration(explorationRng('42', f.p0)))
|
||
// the ~18% crew roll rides the same stream — at most one member joins (PR C pins the stream)
|
||
expect((await db.db.select().from(schema.crew)).length).toBeGreaterThanOrEqual(2)
|
||
})
|
||
|
||
it('a resolver guard failure fails just that task — the turn still lands, the sibling still resolves, no event for the failure', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
const badJumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: 999, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
await db.db.update(schema.saves).set({ turn: 4 }).where(eq(schema.saves.id, save.id))
|
||
|
||
const r = await runTurn(db) // must not throw — the 4a guard throw is a failure signal, not an error
|
||
expect(r.turn).toBe(5)
|
||
expect(r.completed.map((t) => t.id)).toEqual([badJumpId, exploreId]) // both reported; only the sibling's effect lands
|
||
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === badJumpId)!.status).toBe('DONE') // failed task: done — the turn is spent, the crew freed
|
||
expect((await db.db.select().from(schema.planets)).find((p) => p.id === f.p0)!.explored).toBe(true) // the sibling resolved
|
||
const types = (await db.db.select().from(schema.events)).map((e) => e.type)
|
||
expect(types.filter((t) => t === 'jump')).toHaveLength(0) // the 404 guard threw before the jump effect/event
|
||
expect(types.filter((t) => t === 'explore')).toHaveLength(1)
|
||
expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move
|
||
})
|
||
|
||
// PR B (#45): the fixture's crew rows never ran through createNewGame's seeder, so the
|
||
// tests here establish the crew_skills rows they need directly (insert / patch).
|
||
const seedRows = async (db: GameDb, saveId: number, crewId: number, rows: [skill: string, level: number, xp: number][]) => {
|
||
for (const [skill, level, xp] of rows) {
|
||
await db.db.insert(schema.crewSkills).values({ saveId, crewId, skill, level, xp })
|
||
}
|
||
}
|
||
// addCrew always mints PILOTs — insert a SCIENTIST directly (the pinCrew pattern from the
|
||
// v8 backfill test above) with duration-neutral traits.
|
||
const insertScientist = async (db: GameDb, saveId: number) =>
|
||
db.db.insert(schema.crew).values({ saveId, name: 'AVA VANCE', role: 'SCIENTIST', traitGood: 'FINDS HIDDEN THINGS', traitBad: 'JINXED IN A DOGFIGHT', origin: 'STARTING CREW' }).returning().then((r) => r[0])
|
||
|
||
it('a resolved EXPLORE grants +1 SCANNING XP to every assigned member — lead and helpers alike — and no level-up event below L3', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2, c3] = await addCrew(db, 3)
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
for (const id of [c1, c2, c3]) await seedRows(db, save.id, id, [['NAVIGATION', 0, 0], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]])
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [c2, c3], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||
|
||
const r = await runTurn(db)
|
||
expect(r.turn).toBe(1)
|
||
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||
|
||
for (const id of [c1, c2, c3]) { // every assigned member's SCANNING row gained exactly +1 XP (xp 1 < the L1 threshold 5 — still L0); the other skills are untouched
|
||
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, id))
|
||
expect(Object.fromEntries(rows.map((x) => [x.skill, { level: x.level, xp: x.xp }] as const))).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 1 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } })
|
||
}
|
||
expect((await db.db.select().from(schema.events)).filter((e) => e.type === 'level_up')).toHaveLength(0) // L1 is a plain level — no event
|
||
})
|
||
|
||
it('a SCIENTIST at SCANNING xp 14 (L2) completing an EXPLORE writes the §6 level-up event at the tick\'s new turn', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
const sc = await insertScientist(db, save.id)
|
||
await seedRows(db, save.id, sc.id, [['NAVIGATION', 0, 0], ['SCANNING', 2, 14], ['ENGINEERING', 0, 0], ['RESEARCH', 2, 10]]) // one completion from L3
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: sc.id, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||
|
||
const r = await runTurn(db)
|
||
expect(r.turn).toBe(1)
|
||
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||
|
||
const levelUps = (await db.db.select().from(schema.events)).filter((e) => e.type === 'level_up')
|
||
expect(levelUps.map((e) => [e.turn, e.message] as const)).toEqual([[1, 'AVA VANCE is now a SENIOR SCIENTIST — SCANNING L3']]) // §6 text, at the tick's new turn
|
||
const scanning = (await db.db.select().from(schema.crewSkills).where(and(eq(schema.crewSkills.crewId, sc.id), eq(schema.crewSkills.skill, 'SCANNING'))))[0]
|
||
expect({ level: scanning.level, xp: scanning.xp }).toEqual({ level: 3, xp: 15 })
|
||
})
|
||
|
||
it('§6 duration shift: a SCANNING L0 member queues a solo EXPLORE at stored duration 4; at L3 (xp 15) the next queued EXPLORE stores duration 2 (ceil(4/2))', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
const sc = await insertScientist(db, save.id)
|
||
await seedRows(db, save.id, sc.id, [['NAVIGATION', 0, 0], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) // rookie — JC-23: SCIENTIST leads, no mismatch penalty
|
||
|
||
// Rookie (SCANNING L0 → power 1): the queued EXPLORE stores the full 4 turns.
|
||
const first = await saveTaskRow(db, queueTask(await queueStateFromSave(db), { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: sc.id, helperCrewIds: [] }, await makePowerOfFor(db, save, 'EXPLORE')).task)
|
||
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === first)!).toMatchObject({ status: 'ACTIVE', remaining: 4 })
|
||
|
||
// After 15 completed EXPLOREs the member is SCANNING L3 (power 2) — the next queued EXPLORE halves to 2.
|
||
await db.db.update(schema.crewSkills).set({ level: 3, xp: 15 }).where(and(eq(schema.crewSkills.crewId, sc.id), eq(schema.crewSkills.skill, 'SCANNING')))
|
||
const second = await saveTaskRow(db, queueTask(await queueStateFromSave(db), { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: sc.id, helperCrewIds: [] }, await makePowerOfFor(db, save, 'EXPLORE')).task)
|
||
expect((await db.db.select().from(schema.tasks)).find((t) => t.id === second)!).toMatchObject({ status: 'PENDING', remaining: 2 }) // PENDING — the lead chains behind its own ACTIVE
|
||
})
|
||
|
||
it('a recruit found on a resolved EXPLORE is seeded with role-based starting skills — the 4-rows-per-member invariant holds (R-P6)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
const [c1] = await addCrew(db, 1) // a crew id (addCrew returns ids, not rows)
|
||
await seedRows(db, save.id, c1, [['NAVIGATION', 2, 10], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) // PILOT profile
|
||
// Seed '3' + planet 1 (Alder) is the fixture's known recruit stream — the crew roll is
|
||
// recomputed here, mirroring save.ts: the item haul draws first, then the crew roll on the same stream.
|
||
await db.db.update(schema.saves).set({ seed: '3' })
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 })
|
||
|
||
const r = await runTurn(db)
|
||
expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE'])
|
||
|
||
const crew = await db.db.select().from(schema.crew)
|
||
expect(crew).toHaveLength(2) // exactly one join
|
||
const recruit = crew.find((c) => c.id !== c1)!
|
||
const expectedRng = explorationRng('3', f.p0)
|
||
rollExploration(expectedRng) // consume the haul draws first, exactly as save.ts does
|
||
const expectedMember = rollCrewMember(expectedRng, new Set(['CREW 1']))!
|
||
expect(recruit.name).toBe(expectedMember.name)
|
||
const levels = STARTING_LEVELS[expectedMember.role]
|
||
// The recruit gets the same profile as starting crew: role-based levels, xp = the level's threshold (levelFromXp round-trips).
|
||
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, recruit.id))
|
||
expect(Object.fromEntries(rows.map((x) => [x.skill, { level: x.level, xp: x.xp }] as const))).toEqual(
|
||
Object.fromEntries((Object.keys(levels) as SkillName[]).map((s) => [s, { level: levels[s], xp: levels[s] > 0 ? XP_THRESHOLDS[levels[s] - 1] : 0 }] as const)),
|
||
)
|
||
// The seeder is idempotent — the pre-seeded member is not re-seeded: still exactly 4 rows per member.
|
||
expect((await db.db.select().from(schema.crewSkills)).length).toBe(4 * crew.length)
|
||
})
|
||
})
|
||
|
||
// --- The GET snapshots' task queue (Task 6) ---------------------------------------------
|
||
//
|
||
// Every GET snapshot (world / system / crew) carries the active + pending queue: ACTIVE rows
|
||
// first, then PENDING, each block ordered by (queuedAtTurn, id); DONE rows never appear. A
|
||
// pure read — `remaining` is the stored value, never recomputed; crew names resolve from the
|
||
// roster by id.
|
||
describe('snapshot task queue (world / system / crew)', () => {
|
||
// buildWorldFixture (4a): a save (seed '42') + Origin (s0, current) / Outpost (s1) +
|
||
// Alder (p0) / Basalt (p1) + the route pair — no crew, no tasks; this block adds its own.
|
||
|
||
// insertTaskRow JSON-encodes its arguments, so the corruption tests seed raw strings straight
|
||
// into the tasks table (same row shape, no encoding) to reach states the encoder can't.
|
||
async function insertRawTaskRow(
|
||
db: GameDb,
|
||
over: { type: string; payload: string; helpers: string; leadCrewId: number; duration: number; remaining: number; status: string; queuedAtTurn: number },
|
||
): Promise<number> {
|
||
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||
const [row] = await db.db
|
||
.insert(schema.tasks)
|
||
.values({ saveId: save.id, ...over })
|
||
.returning({ id: schema.tasks.id })
|
||
return row.id
|
||
}
|
||
|
||
it('empty queue → every GET snapshot carries tasks: []', async () => {
|
||
const { db } = await freshFile()
|
||
await buildWorldFixture(db)
|
||
expect((await getWorldState(db)).tasks).toEqual([])
|
||
expect((await getSystemState(db)).tasks).toEqual([])
|
||
const crew = await getCrewState(db)
|
||
expect(crew.tasks).toEqual([])
|
||
expect(crew.crew).toEqual([]) // the new wrapper: the roster moves under .crew (JC-7)
|
||
})
|
||
|
||
it('one ACTIVE + one PENDING row → exactly those two, ACTIVE first, in all three snapshots', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2, c3] = await addCrew(db, 3)
|
||
const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [c3], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 3 })
|
||
const expected = [
|
||
{ id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
|
||
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4, status: 'PENDING', leadCrewId: c2, helperCrewIds: [c3] },
|
||
]
|
||
expect((await getWorldState(db)).tasks).toEqual(expected)
|
||
expect((await getSystemState(db)).tasks).toEqual(expected)
|
||
expect((await getCrewState(db)).tasks).toEqual(expected)
|
||
})
|
||
|
||
it('DONE rows never appear — even when they sort first by (queuedAtTurn, id)', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1] = await addCrew(db, 1)
|
||
await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 0, status: 'DONE', queuedAtTurn: 1 }) // oldest, lowest id in the queue
|
||
await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 2 })
|
||
const snap = await getWorldState(db)
|
||
expect(snap.tasks).toHaveLength(1)
|
||
expect(snap.tasks[0].type).toBe('EXPLORE') // the DONE row is filtered out, not reordered to the back
|
||
})
|
||
|
||
it('a corrupted payload degrades to targetLabel `unknown` — the snapshot still builds', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1, c2] = await addCrew(db, 2)
|
||
const badId = await insertRawTaskRow(db, { type: 'EXPLORE', payload: 'not-json', helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 2 })
|
||
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
const snap = await getWorldState(db) // must not throw out of the read
|
||
expect(snap.tasks).toEqual([
|
||
{ id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
|
||
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c2, helperCrewIds: [] }, // the well-formed sibling still resolves
|
||
])
|
||
})
|
||
|
||
it('a corrupted helpers blob degrades to helperNames [] — no throw', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1] = await addCrew(db, 1)
|
||
const badId = await insertRawTaskRow(db, { type: 'JUMP', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[not-json', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
const snap = await getWorldState(db)
|
||
expect(snap.tasks).toEqual([
|
||
{ id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
|
||
])
|
||
})
|
||
|
||
it('a corrupted type string degrades to targetLabel `unknown` — no throw', async () => {
|
||
const { db } = await freshFile()
|
||
const f = await buildWorldFixture(db)
|
||
const [c1] = await addCrew(db, 1)
|
||
const badId = await insertRawTaskRow(db, { type: 'TELEPORT', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||
const snap = await getWorldState(db)
|
||
expect(snap.tasks).toEqual([
|
||
{ id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
|
||
])
|
||
})
|
||
})
|
||
|
||
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('v7: a v6-stamped save gets an empty tasks table without touching state; idempotent across restart', async () => {
|
||
const { db, file } = await freshFile()
|
||
const world = await getWorldState(db) // fresh game, stamped at the current version
|
||
const crewBefore = await db.db.select().from(schema.crew)
|
||
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||
|
||
await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12
|
||
const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7 → v8
|
||
|
||
expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1)
|
||
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore)
|
||
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
|
||
const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||
expect(tables[0].values.map((v) => v[0])).toContain('tasks')
|
||
expect(Number(db.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) // empty queue after upgrade
|
||
|
||
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||
await getWorldState(reopened)
|
||
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0)
|
||
})
|
||
|
||
// Insert one member directly (skill seeding depends only on role — traits can be dummies).
|
||
const pinCrew = (db: GameDb, saveId: number, name: string, role: string) =>
|
||
db.db.insert(schema.crew).values({ saveId, name, role, traitGood: 'STEADY UNDER PRESSURE', traitBad: 'IMPULSIVE', origin: 'STARTING CREW' }).returning().then((r) => r[0])
|
||
// A member's 4 skill rows as { [skill]: { level, xp } }; the toHaveLength(4) asserts one row per skill.
|
||
const skillMap = async (db: GameDb, crewId: number) => {
|
||
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, crewId))
|
||
expect(rows).toHaveLength(4)
|
||
return Object.fromEntries(rows.map((r) => [r.skill, { level: r.level, xp: r.xp }]))
|
||
}
|
||
|
||
it('v8: a v7-stamped save (crew present, no skills) seeds 4 role-based rows per member, xp consistent with level, state untouched; idempotent across restart', async () => {
|
||
const { db, file } = await freshFile()
|
||
await getWorldState(db) // fresh v8 save (crew_skills already seeded by createNewGame — JC-21)
|
||
const save = (await db.db.select().from(schema.saves))[0]
|
||
await db.db.delete(schema.crewSkills) // simulate a v0.10.0 save: crew present, no skills yet
|
||
await db.db.update(schema.saves).set({ schemaVersion: 7 })
|
||
const pilot = await pinCrew(db, save.id, 'P. VANCE', 'PILOT')
|
||
const scientist = await pinCrew(db, save.id, 'S. OKAFOR', 'SCIENTIST')
|
||
const gunner = await pinCrew(db, save.id, 'G. REYES', 'GUNNER')
|
||
const crewBefore = await db.db.select().from(schema.crew)
|
||
const invBefore = await db.db.select().from(schema.inventoryItems)
|
||
const turnBefore = (await db.db.select().from(schema.saves))[0].turn
|
||
await getWorldState(db) // ensureSave → backfill v7 → v8 → seedCrewSkills
|
||
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||
expect(await skillMap(db, pilot.id)).toEqual({ NAVIGATION: { level: 2, xp: 10 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // PILOT
|
||
expect(await skillMap(db, scientist.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 2, xp: 10 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 2, xp: 10 } }) // SCIENTIST
|
||
expect(await skillMap(db, gunner.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // GUNNER (all L0)
|
||
for (const c of crewBefore) { // full invariant: EVERY member (incl. the random starting crew) matches its role map, xp = the level's threshold (levelFromXp round-trips)
|
||
const expected = Object.fromEntries((Object.keys(STARTING_LEVELS[c.role]) as SkillName[]).map((s) => [s, { level: STARTING_LEVELS[c.role][s], xp: STARTING_LEVELS[c.role][s] > 0 ? XP_THRESHOLDS[STARTING_LEVELS[c.role][s] - 1] : 0 }]))
|
||
expect(await skillMap(db, c.id)).toEqual(expected)
|
||
}
|
||
expect((await db.db.select().from(schema.saves))[0].turn).toBe(turnBefore) // Review Focus 1 (v8): turn / crew / inventory
|
||
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore) // byte-identical
|
||
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) // byte-identical
|
||
const reopened = await openGameDb(file) // restart: the backfill must be a no-op
|
||
await getWorldState(reopened)
|
||
expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION)
|
||
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM crew_skills')[0]?.values[0][0] ?? 0)).toBe(4 * (await reopened.db.select().from(schema.crew)).length) // still 4 rows per member
|
||
})
|
||
|
||
it('pins the role universe: every role in crew.ts is a key of STARTING_LEVELS, so the v8 seeder never hits an unknown role', () => {
|
||
for (const role of ROLES) {
|
||
expect(STARTING_LEVELS[role], `STARTING_LEVELS[${role}]`).toBeDefined()
|
||
}
|
||
})
|
||
|
||
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
|
||
}
|
||
})
|
||
})
|