Files
SpaceWanderer/lib/game/save.test.ts
T
kamilkrzywda ab22a7e68b save/e2e: #51 — wire generateRing attach into resolveJumpTask + spread-limit e2e
- resolveJumpTask: build existingEdgeKeys from all jump routes (both
  endpoints in the save's system set), pass all systems except the
  target as attachCandidates, insert closing routes for each attach.
- save.test.ts: jump test now expects 1–4 fresh systems (was pinned
  to exactly 4); A/B snapshot test stays green (both paths share the
  same resolveJumpTask code).
- e2e/helpers.ts: update stale comment ('4 fresh neighbours' →
  '1–4 new systems').
- e2e/smoke.spec.ts: new test verifying 1–4 mints, all undiscovered,
  each routed to the target, and BFS connectivity of the full graph.
2026-10-06 12:18:33 +00:00

1114 lines
65 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 { and, eq } from 'drizzle-orm'
import { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { CREW_CHANCE, ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
import { RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, 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, T0 } 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)
// #51: 1–4 new undiscovered systems minted around the target, each routed to it.
const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id))
expect(fresh.length).toBeGreaterThanOrEqual(1)
expect(fresh.length).toBeLessThanOrEqual(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' })
})
it('no trait-bearing assigned crew ⇒ identical haul and recruit gate to the un-assigned path (regression pin)', async () => {
// World A: un-assigned (syntheticTask, leadCrewId: 0, no crew rows → taken = Set())
const { db: a } = await freshFile()
const fa = await buildWorldFixture(a)
const resultA = await explorePlanet(a, fa.p0)
// World B: one assigned crew member with non-FHT/non-TALKER traits
const { db: b } = await freshFile()
const fb = await buildWorldFixture(b)
const [saveB] = await b.db.select().from(schema.saves).limit(1)
const [c1] = await b.db
.insert(schema.crew)
.values({ saveId: saveB.id, name: 'QUICK GUY', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' })
.returning({ id: schema.crew.id })
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, saveB.id))
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fb.p0 }), leadCrewId: c1.id, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultB = await resolveExploreTask(b, taskB)
// The haul is draw-order-stable (drawn before any crew interaction) — must be identical.
expect(resultB.outcome).toEqual(resultA.outcome)
// The recruit gate (first draw after the haul) uses the same rng value → same found/null result.
// (The recruit NAME can differ because the taken set differs: world A has no crew rows,
// world B has QUICK GUY. We assert gate equality, then recompute the recruit for
// world B's taken set to prove determinism.)
expect(resultB.crewFound === null).toBe(resultA.crewFound === null)
if (resultB.crewFound !== null) {
// Recompute: same rng stream, same taken set as world B.
const rng = explorationRng('42', fb.p0)
rollExploration(rng) // consume the haul draws first, exactly as save.ts does
const expected = rollCrewMember(rng, new Set(['QUICK GUY']))!
expect(resultB.crewFound!.name).toBe(expected.name)
expect(resultB.crewFound!.role).toBe(expected.role)
expect([resultB.crewFound!.traitGood, resultB.crewFound!.traitBad]).toEqual([expected.traitGood, expected.traitBad])
}
})
it('FINDS HIDDEN THINGS: the FHT team gets exactly one extra haul item — the no-FHT haul is a prefix (Review Focus 5)', async () => {
// Find a planet id whose SECOND rollExploration draw yields exactly 1 item (so the
// FHT delta is concretely +1 item). Mirrors the explorePlanet crew-finds id-scanning pattern.
let fhtPlanetId = -1
for (let id = 1; id <= 50 && fhtPlanetId === -1; id++) {
const rng = explorationRng('42', id)
rollExploration(rng) // first haul (consumes its draws)
const second = rollExploration(rng) // second haul (the FHT extra)
if (second.length === 1) fhtPlanetId = id
}
expect(fhtPlanetId).toBeGreaterThan(0)
// Hand-built world (same pattern as the crew-finds test): seed '42', one system,
// planets P1..P{fhtPlanetId} so the target planet has id = fhtPlanetId.
const setupWorld = async (db: GameDb, withFht: boolean) => {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).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 })
for (let i = 1; i <= fhtPlanetId; i++) {
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
}
// Always one non-FHT lead; add an FHT helper when withFht.
const [lead] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'LEAD', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
const helperIds: number[] = []
if (withFht) {
const [helper] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'FINDS', role: 'SCIENTIST', traitGood: 'FINDS HIDDEN THINGS', traitBad: 'JINXED IN A DOGFIGHT', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
helperIds.push(helper.id)
}
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, save.id))
return { leadId: lead.id, helperIds }
}
// World A: no FHT in the team → one haul roll.
const { db: a } = await freshFile()
const worldA = await setupWorld(a, false)
const taskA: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldA.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultA = await resolveExploreTask(a, taskA)
// World B: FHT in the team (as helper) → two haul rolls.
const { db: b } = await freshFile()
const worldB = await setupWorld(b, true)
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldB.leadId, helpers: worldB.helperIds, duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultB = await resolveExploreTask(b, taskB)
// The no-FHT haul is a prefix of the FHT haul.
const noFhtHaul = resultA.outcome
const fhtHaul = resultB.outcome
expect(fhtHaul.slice(0, noFhtHaul.length)).toEqual(noFhtHaul)
// The FHT run has EXACTLY ONE more haul item (the second rollExploration yielded 1 item).
expect(fhtHaul.length).toBe(noFhtHaul.length + 1)
// Reproducibility: a second independent fresh file with the same inserts produces
// the identical outcome (haul + recruit).
const { db: c } = await freshFile()
const worldC = await setupWorld(c, true)
const taskC: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldC.leadId, helpers: worldC.helperIds, duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultC = await resolveExploreTask(c, taskC)
expect(resultC.outcome).toEqual(resultB.outcome)
if (resultB.crewFound !== null) {
expect(resultC.crewFound).not.toBeNull()
expect(resultC.crewFound!.name).toBe(resultB.crewFound!.name)
expect(resultC.crewFound!.role).toBe(resultB.crewFound!.role)
expect([resultC.crewFound!.traitGood, resultC.crewFound!.traitBad]).toEqual([resultB.crewFound!.traitGood, resultB.crewFound!.traitBad])
} else {
expect(resultC.crewFound).toBeNull()
}
})
it('TALKS TO ANYONE: recruit chance raised 18% → 30% — constant consumed by rollCrewMember, end-to-end on a discriminating planet', async () => {
// 1. Pin the constants.
expect(RECRUIT_CHANCE_BASE).toBe(0.18)
expect(RECRUIT_CHANCE_TALKER).toBe(0.30)
// Guard the two separately-declared 0.18 constants: the no-trait recruit path passes
// RECRUIT_CHANCE_BASE explicitly while rollCrewMember's default uses CREW_CHANCE — if
// they ever diverge the no-trait outcome would silently drift from its bit-identity pin.
expect(RECRUIT_CHANCE_BASE).toBe(CREW_CHANCE)
// 2. Prove the constant is consumed by rollCrewMember: a fixed rng in [0.18, 0.30)
// (a member is found iff rng() < chance).
const rng024 = () => 0.24
expect(rollCrewMember(rng024, new Set<string>(), RECRUIT_CHANCE_TALKER)).not.toBeNull() // 0.24 < 0.30 → found
expect(rollCrewMember(rng024, new Set<string>(), RECRUIT_CHANCE_BASE)).toBeNull() // 0.24 >= 0.18 → null
expect(rollCrewMember(rng024, new Set<string>())).toBeNull() // default = CREW_CHANCE = 0.18 → null
// 3. End-to-end: find a planet whose recruit gate value (the rng draw after the haul)
// falls in [0.18, 0.30) — TALKER finds a member, non-talker does not.
let talkerPlanetId = -1
for (let id = 1; id <= 100 && talkerPlanetId === -1; id++) {
const rng = explorationRng('42', id)
rollExploration(rng) // consume the haul draws
const gate = rng() // the recruit gate draw
if (gate >= RECRUIT_CHANCE_BASE && gate < RECRUIT_CHANCE_TALKER) talkerPlanetId = id
}
expect(talkerPlanetId).toBeGreaterThan(0)
// Hand-built world (same pattern as the FHT test above).
const setupWorld = async (db: GameDb, talker: boolean) => {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).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 })
for (let i = 1; i <= talkerPlanetId; i++) {
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
}
const traitGood = talker ? 'TALKS TO ANYONE' : 'QUICK HANDS'
const [lead] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'LEAD', role: 'PILOT', traitGood, traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, save.id))
return { leadId: lead.id }
}
// World A: non-talker crew → gate >= 0.18 → crewFound == null.
const { db: a } = await freshFile()
const worldA = await setupWorld(a, false)
const taskA: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: talkerPlanetId }), leadCrewId: worldA.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultA = await resolveExploreTask(a, taskA)
expect(resultA.crewFound).toBeNull()
// World B: talker crew → gate < 0.30 → crewFound != null.
const { db: b } = await freshFile()
const worldB = await setupWorld(b, true)
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: talkerPlanetId }), leadCrewId: worldB.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultB = await resolveExploreTask(b, taskB)
expect(resultB.crewFound).not.toBeNull()
})
})
// --- 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
}
})
})