feat: crew-driven actions — queue + tick engine, NEXT TURN, team builder, task strip (#44, v0.10.0) #46

Merged
kamilkrzywda merged 20 commits from feat/crew-driven-actions into master 2026-10-03 03:07:42 +02:00
6 changed files with 521 additions and 60 deletions
Showing only changes of commit bbcd77c41b - Show all commits
+75 -2
View File
@@ -6,9 +6,11 @@ import { eq } from 'drizzle-orm'
import { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { BUILD_PART_COST, MAX_LEVEL, upgradeCost } from './ship-costs'
import { buildSystem, getShipState, upgradeSystem, type ShipState } from './ship'
import { buildSystem, getShipState, spendItems, upgradeSystem, type ShipState } from './ship'
import { TECHS } from './tech'
import { getTechState, researchTech } from './research'
import { SCHEMA_VERSION } from './save'
import { getTechState, researchTech, resolveResearchTask } from './research'
import type { Task } from './tasks'
const dirs: string[] = []
@@ -145,3 +147,74 @@ describe('gating of ship actions', () => {
expect(systemOf(state, 'REACTOR').level).toBe(MAX_LEVEL + 1)
})
})
// --- A/B: resolver vs the legacy immediate action (Task 4a) ---------------------------
const T0 = '2026-10-01T00:00:00.000Z'
// Stamped save (no backfill), the full tech tree, nothing researched yet. Identical build
// per world → ids line up.
async function buildResearchFixture(db: GameDb): Promise<void> {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
for (const def of TECHS) {
await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect })
}
}
const task = (payload: object): Task =>
({ id: 0, saveId: 0, type: 'RESEARCH', 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('resolveResearchTask (A/B against researchTech)', () => {
// A = legacy immediate action (validates, deducts the cost, +1 turn, marks researched,
// event). B = the queued shape: the cost was deducted at queue time (Task 3), the tick
// advanced the turn (Task 4b), then the resolver marks the tech. Same starting world →
// byte-identical end states.
it('lands exactly what researchTech lands', async () => {
const { db: a } = await freshFile()
const { db: b } = await freshFile()
await buildResearchFixture(a)
await buildResearchFixture(b)
await addItem(a, 'resource', 'IRON ORE', 2) // NAVIGATION AI costs 2 resources
await addItem(b, 'resource', 'IRON ORE', 2)
const [navA] = await a.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
const [navB] = await b.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
await researchTech(a, navA.id)
await spendItems(b, 1, 'resource', 2) // the queue-time deduction
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await resolveResearchTask(b, task({ techId: navB.id }))
expect(await snapshot(b)).toEqual(await snapshot(a))
expect((await b.db.select().from(schema.tech)).find((t) => t.id === navB.id)?.researched).toBe(true)
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 === 'research')!
expect(ev.turn).toBe(1)
expect(ev.message).toBe('Researched NAVIGATION AI (cost: 2× resources)')
})
it('guards: 404 unknown tech, 409 already researched (prereqs are monotone — no guard needed)', async () => {
const { db } = await freshFile()
await buildResearchFixture(db)
const [nav] = await db.db.select().from(schema.tech).where(eq(schema.tech.slug, 'NAV_AI'))
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await expect(resolveResearchTask(db, task({ techId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown tech 999' })
await resolveResearchTask(db, task({ techId: nav.id }))
await expect(resolveResearchTask(db, task({ techId: nav.id }))).rejects.toMatchObject({ status: 409, message: 'NAVIGATION AI is already researched' })
})
})
+26 -4
View File
@@ -4,6 +4,7 @@ import { persist, type GameDb } from '@/lib/db/client'
import { EFFECTS, TECHS, techStatus, type TechDto } from './tech'
import { ensureSave } from './save'
import { ShipError, spendItems } from './ship'
import type { Task, TaskType } from './tasks'
// Technology research: the SKILL TREE tab's read + action. Researching a tech costs inventory
// items (oldest row first, like ship actions) plus a turn, and its unlock effect then gates or
@@ -46,8 +47,31 @@ export async function getTechState(db: GameDb): Promise<TechDto[]> {
})
}
function syntheticTask(type: TaskType, payload: object): Task {
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
}
// RESEARCH — mark the payload's tech researched at the current turn. No cost here (deducted
// at queue time — Task 3 — or by researchTech before delegating); no mission hooks (the
// current action has none).
export async function resolveResearchTask(db: GameDb, task: Task): Promise<void> {
await ensureSave(db)
const { techId } = JSON.parse(task.payload) as { techId: number }
const [save] = await db.db.select().from(schema.saves).limit(1)
const rows = await db.db.select().from(schema.tech)
const target = rows.find((t) => t.id === techId)
if (!target) throw new ResearchError(404, `Unknown tech ${techId}`)
const def = TECHS.find((t) => t.slug === target.slug)!
if (target.researched) throw new ResearchError(409, `${def.name} is already researched`)
await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() })
await persist(db)
}
// Research one tech: validates prerequisites (409, naming what is missing) and the cost (422),
// spends it from the cargo hold, marks it researched, costs a turn and writes an event.
// spends it from the cargo hold, then delegates the unlock to the resolver at the new turn.
export async function researchTech(db: GameDb, techId: number): Promise<TechDto[]> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
@@ -70,10 +94,8 @@ export async function researchTech(db: GameDb, techId: number): Promise<TechDto[
}
const turn = save.turn + 1
await db.db.update(schema.tech).set({ researched: true }).where(eq(schema.tech.id, target.id))
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'research', message: `Researched ${def.name} (cost: ${def.costQty}× ${def.costKind === 'resource' ? 'resources' : 'parts'})`, createdAt: new Date().toISOString() })
await persist(db)
await resolveResearchTask(db, syntheticTask('RESEARCH', { techId }))
return getTechState(db)
}
+136 -1
View File
@@ -8,7 +8,8 @@ import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { TECHS } from './tech'
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save'
import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, type WorldState } from './save'
import type { Task } from './tasks'
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
const dirs: string[] = []
@@ -304,6 +305,140 @@ describe('explorePlanet crew finds', () => {
})
})
// --- A/B: resolvers vs the legacy immediate actions (Task 4a) -------------------------
//
// Each test builds the identical deterministic fixture in two fresh files (seed '42',
// stamped save → no backfill; ids line up across worlds) 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.
const T0 = '2026-10-01T00:00:00.000Z'
async function buildWorldFixture(db: GameDb, outpostDiscovered = false): Promise<{ sys1: number; p0: number; p1: number }> {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
const [s0] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Origin', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
const [s1] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Outpost', x: 55, y: 50, discovered: outpostDiscovered }).returning({ id: schema.solarSystems.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s0.id, toSystem: s1.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: s1.id, toSystem: s0.id })
const [p0] = await db.db.insert(schema.planets).values({ systemId: s0.id, parentId: null, name: 'Alder', type: 'ROCKY', hazard: false }).returning({ id: schema.planets.id })
const [p1] = await db.db.insert(schema.planets).values({ systemId: s1.id, parentId: null, name: 'Basalt', type: 'GAS', hazard: false }).returning({ id: schema.planets.id })
return { sys1: s1.id, p0: p0.id, p1: p1.id }
}
// 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(2) // 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' })
})
})
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.
+87 -30
View File
@@ -5,6 +5,7 @@ import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions'
import type { Task, TaskType } from './tasks'
import { TECHS } from './tech'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
@@ -211,6 +212,58 @@ export async function getWorldState(db: GameDb): Promise<WorldState> {
}
}
// --- Per-type resolvers (PR A, spec §3) -------------------------------------------
//
// The effect bodies of the instant actions, extracted so the turn tick (Task 4b) can
// resolve a completed task row against whatever the world looks like at resolution time.
// Contract (all six resolvers across save.ts / ship.ts / research.ts — see the Interfaces
// block of this plan): effects land on the PAYLOAD target, never on the current location;
// no turn advance (the caller owns the turn); no cost deduction (queueTask deducted at
// queue time — Task 3; the immediate public functions deduct before delegating); a minimal
// guard against a stale or double-resolved target (existing error class + exact message —
// a throw is the signal for the 4b tick's re-validation); the existing event text formats
// at the current turn; persist at the end.
// A stand-in task row for the immediate public functions delegating to a resolver: the
// resolvers only read `type` + `payload`, so the identity fields are zeroed.
function syntheticTask(type: TaskType, payload: object): Task {
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
}
// JUMP — move the ship to the payload's system: flip isCurrent, discover the target, and
// when it was undiscovered grow the chart ring off the same (seed, turn) stream as before.
export async function resolveJumpTask(db: GameDb, task: Task): Promise<void> {
await ensureSave(db)
const { systemId } = JSON.parse(task.payload) as { systemId: number }
const [save] = await db.db.select().from(schema.saves).limit(1)
const systems = await db.db.select().from(schema.solarSystems)
const target = systems.find((s) => s.id === systemId)
if (!target) throw new JumpError(404, `Unknown system ${systemId}`)
if (target.isCurrent) throw new JumpError(409, 'Already at this system')
const current = systems.find((s) => s.isCurrent)!
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
if (!target.discovered) {
// Growing the chart: a ring of new (undiscovered) neighbours around the newly reached
// system, so there is always somewhere to jump next and a route back.
const rng = expansionRng(save.seed, save.turn)
const takenNames = new Set(systems.map((s) => s.name))
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
const ringIds: number[] = []
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
await insertRoutes(db, ring.edges, resolve)
}
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, save.turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
await topUpMissionBoard(db)
await persist(db)
}
export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
@@ -222,33 +275,18 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
if (current.id === target.id) throw new JumpError(409, 'Already at this system')
const routes = await db.db.select().from(schema.jumpRoutes)
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop jump
// costs one turn per hop, so single-hop jumps keep their existing cost.
// #35 — NAVIGATION AI extends the jump range from adjacent (1 hop) to 2 hops; a multi-hop
// jump costs one turn per hop, so single-hop jumps keep their existing cost.
const navAi = (await db.db.select().from(schema.tech)).some((t) => t.slug === 'NAV_AI' && t.researched)
const hopCount = reachableWithin(routes.map((r) => [r.fromSystem, r.toSystem]), current.id, navAi ? 2 : 1).get(target.id)
if (hopCount === undefined) throw new JumpError(409, `${target.name} is not reachable from ${current.name}`)
// The immediate jump keeps its own turn cost (the tick owns turn advances for queued
// tasks): advance it before delegating so the resolver's event lands at exactly the
// turn the old code wrote.
const turn = save.turn + hopCount
await db.db.update(schema.solarSystems).set({ isCurrent: false }).where(eq(schema.solarSystems.id, current.id))
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
if (!target.discovered) {
// Growing the chart: a ring of new (undiscovered) neighbours around the newly reached
// system, so there is always somewhere to jump next and a route back.
const rng = expansionRng(save.seed, turn)
const takenNames = new Set(systems.map((s) => s.name))
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
const ringIds: number[] = []
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
await insertRoutes(db, ring.edges, resolve)
}
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, turn, { jumpedTo: target.id }) // an ACTIVE DELIVER may have arrived at its destination
await topUpMissionBoard(db)
await persist(db)
await resolveJumpTask(db, syntheticTask('JUMP', { systemId, hops: hopCount }))
return getWorldState(db)
}
@@ -331,12 +369,13 @@ export interface ExploreResult extends SystemState {
crewFound: CrewDto | null // a member who joined the ship on this scan (null when none)
}
// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored,
// costs a turn, accumulates finds into inventory and writes an event. The same per-planet
// rng stream also rolls for a crew member (~18% of scans) who joins the roster. Any existing
// planet may be scanned (the UI only offers the current system's rows).
export async function explorePlanet(db: GameDb, planetId: number): Promise<ExploreResult> {
// EXPLORE — scan the payload's planet (any system — the UI only offered the current
// system's rows; a queued scan may resolve while the ship is elsewhere): deterministic
// haul into the inventory, planet marked explored, crew-join roll on the same
// (seed, planet) stream.
export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outcome: FoundItem[]; crewFound: CrewDto | null }> {
await ensureSave(db)
const { planetId } = JSON.parse(task.payload) as { planetId: number }
const [save] = await db.db.select().from(schema.saves).limit(1)
const planets = await db.db.select().from(schema.planets)
const target = planets.find((p) => p.id === planetId)
@@ -347,7 +386,6 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
const outcome = rollExploration(rng) // resource/part haul first…
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream
const turn = save.turn + 1
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
for (const item of outcome) {
@@ -370,11 +408,30 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : ''
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() })
await checkMissionCompletion(db, save.id, save.turn, { outcome }) // an ACTIVE EXPLORE/FIND may have advanced or completed
await topUpMissionBoard(db)
await persist(db)
return { outcome, crewFound }
}
// Scan one body of the save: deterministic haul from (seed, planet id), marks it explored,
// costs a turn, accumulates finds into inventory and writes an event. The same per-planet
// rng stream also rolls for a crew member (~18% of scans) who joins the roster. Any existing
// planet may be scanned (the UI only offers the current system's rows).
export async function explorePlanet(db: GameDb, planetId: number): Promise<ExploreResult> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
const planets = await db.db.select().from(schema.planets)
const target = planets.find((p) => p.id === planetId)
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
// The immediate scan keeps its one-turn cost; the resolver applies everything else at that turn.
const turn = save.turn + 1
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
const { outcome, crewFound } = await resolveExploreTask(db, syntheticTask('EXPLORE', { planetId }))
return { ...(await readSystemState(db)), outcome, crewFound }
}
+128 -1
View File
@@ -6,7 +6,10 @@ import { eq } from 'drizzle-orm'
import { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { BASE_CAPACITY, BASE_LEVEL, BUILD_PART_COST, MAX_CAPACITY, MAX_LEVEL, SHIP_TYPES } from './ship-costs'
import { buildSystem, expandSystem, getShipState, upgradeSystem, type ShipState } from './ship'
import { buildSystem, expandSystem, getShipState, resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, spendItems, upgradeSystem, type ShipState } from './ship'
import { SCHEMA_VERSION } from './save'
import { TECHS } from './tech'
import type { Task } from './tasks'
const dirs: string[] = []
@@ -155,3 +158,127 @@ describe('buildSystem', () => {
await expect(buildSystem(db, 'SHIELDS')).rejects.toMatchObject({ status: 404 }) // not a known type
})
})
// --- A/B: resolvers vs the legacy immediate ship actions (Task 4a) ---------------------
const T0 = '2026-10-01T00:00:00.000Z'
// Stamped save (no backfill), the full ship per SHIP_TYPES, the full tech tree with
// WEAPONS_SYSTEMS researched (unlocks the BUILD row). Identical build per world → ids line up.
async function buildShipFixture(db: GameDb): Promise<void> {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
for (const def of SHIP_TYPES) {
await db.db.insert(schema.shipSystems).values({ saveId: save.id, type: def.type, level: def.builtAtStart ? BASE_LEVEL : 0, capacity: def.builtAtStart ? BASE_CAPACITY : 0 })
}
for (const def of TECHS) {
await db.db.insert(schema.tech).values({ saveId: save.id, slug: def.slug, name: def.name, costKind: def.costKind, costQty: def.costQty, prerequisites: def.prereqs.join(','), effect: def.effect, researched: def.slug === 'WEAPONS_SYSTEMS' })
}
}
const task = (type: 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD', 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('resolveShip* (A/B against the immediate actions)', () => {
// A = legacy immediate action (validates, deducts the cost, +1 turn, effect, event).
// B = the queued shape: the cost is deducted up front (queueTask did it at queue time —
// Task 3), the tick advanced the turn (Task 4b), then the resolver applies the effect.
// Same starting world → byte-identical end states.
it('UPGRADE: lands exactly what upgradeSystem lands', async () => {
const { db: a } = await freshFile()
const { db: b } = await freshFile()
await buildShipFixture(a)
await buildShipFixture(b)
await addItem(a, 'resource', 'IRON ORE', 1) // level 1 → cost 1
await addItem(b, 'resource', 'IRON ORE', 1)
const reactorId = systemOf(await getShipState(a), 'REACTOR').id
await upgradeSystem(a, reactorId)
await spendItems(b, 1, 'resource', 1) // the queue-time deduction
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await resolveShipUpgradeTask(b, task('SHIP_UPGRADE', { systemId: reactorId }))
expect(await snapshot(b)).toEqual(await snapshot(a))
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 === 'upgrade')!
expect(ev.turn).toBe(1)
expect(ev.message).toBe('Upgraded REACTOR to level 2 (cost: 1× resources)')
})
it('EXPAND: lands exactly what expandSystem lands', async () => {
const { db: a } = await freshFile()
const { db: b } = await freshFile()
await buildShipFixture(a)
await buildShipFixture(b)
await addItem(a, 'resource', 'CRYSTAL DUST', 2) // capacity 2 → cost 2
await addItem(b, 'resource', 'CRYSTAL DUST', 2)
const hullId = systemOf(await getShipState(a), 'HULL').id
await expandSystem(a, hullId)
await spendItems(b, 1, 'resource', 2)
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await resolveShipExpandTask(b, task('SHIP_EXPAND', { systemId: hullId }))
expect(await snapshot(b)).toEqual(await snapshot(a))
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 === 'expand')!
expect(ev.turn).toBe(1)
expect(ev.message).toBe('Expanded HULL capacity to 3 (cost: 2× resources)')
})
it('BUILD: lands exactly what buildSystem lands', async () => {
const { db: a } = await freshFile()
const { db: b } = await freshFile()
await buildShipFixture(a)
await buildShipFixture(b)
await addItem(a, 'part', 'SERVO ACTUATOR', 3)
await addItem(b, 'part', 'SERVO ACTUATOR', 3)
await buildSystem(a, 'WEAPONS')
await spendItems(b, 1, 'part', 3)
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await resolveShipBuildTask(b, task('SHIP_BUILD', { type: 'WEAPONS' }))
expect(await snapshot(b)).toEqual(await snapshot(a))
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 === 'build')!
expect(ev.turn).toBe(1)
expect(ev.message).toBe('Built WEAPONS at base values (cost: 3× parts)')
})
it('guards: 404 unknown, 409 unbuilt / max level / max capacity / gate / already built', async () => {
const { db } = await freshFile()
await buildShipFixture(db)
const state = await getShipState(db)
const reactorId = systemOf(state, 'REACTOR').id
const hullId = systemOf(state, 'HULL').id
const weaponsId = systemOf(state, 'WEAPONS').id
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, 1))
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' })
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: weaponsId }))).rejects.toMatchObject({ status: 409, message: 'WEAPONS is not built' })
await db.db.update(schema.shipSystems).set({ level: MAX_LEVEL }).where(eq(schema.shipSystems.id, reactorId))
await expect(resolveShipUpgradeTask(db, task('SHIP_UPGRADE', { systemId: reactorId }))).rejects.toMatchObject({ status: 409, message: `REACTOR is at max level (${MAX_LEVEL})` })
await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: 999 }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system 999' })
await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId))
await expect(resolveShipExpandTask(db, task('SHIP_EXPAND', { systemId: hullId }))).rejects.toMatchObject({ status: 409, message: `HULL is at max capacity (${MAX_CAPACITY})` })
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'SHIELDS' }))).rejects.toMatchObject({ status: 404, message: 'Unknown ship system type SHIELDS' })
await db.db.update(schema.tech).set({ researched: false }).where(eq(schema.tech.slug, 'WEAPONS_SYSTEMS'))
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'WEAPONS' }))).rejects.toMatchObject({ status: 409, message: 'Building WEAPONS requires the WEAPONS SYSTEMS tech' })
await expect(resolveShipBuildTask(db, task('SHIP_BUILD', { type: 'HULL' }))).rejects.toMatchObject({ status: 409, message: 'HULL is already built' })
})
})
+69 -22
View File
@@ -4,6 +4,7 @@ import { persist, type GameDb } from '@/lib/db/client'
import { BUILD_PART_COST, BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES, expandCost, upgradeCost } from './ship-costs'
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from './tech'
import { ensureSave, type InventoryItemDto } from './save'
import type { Task, TaskType } from './tasks'
export interface ShipSystemDto {
id: number
@@ -68,17 +69,69 @@ export async function spendItems(db: GameDb, saveId: number, kind: 'resource' |
}
}
async function bumpTurnAndEvent(db: GameDb, saveId: number, turn: number, type: string, message: string): Promise<void> {
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, saveId))
await db.db.insert(schema.events).values({ saveId, turn, type, message, createdAt: new Date().toISOString() })
}
// Slugs of the researched techs on this save — drives the effective caps and build gates below.
async function researchedTechSlugs(db: GameDb): Promise<Set<string>> {
return new Set((await db.db.select().from(schema.tech)).filter((t) => t.researched).map((t) => t.slug))
}
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs). Cost: `level` units of any resource.
function syntheticTask(type: TaskType, payload: object): Task {
return { id: 0, saveId: 0, type, payload: JSON.stringify(payload), leadCrewId: 0, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
}
// SHIP_UPGRADE — level a built system by one, at whatever level it sits at resolution
// time. No cost here (deducted at queue time — Task 3 — or by upgradeSystem before
// delegating); no mission hooks (the current action has none).
export async function resolveShipUpgradeTask(db: GameDb, task: Task): Promise<void> {
await ensureSave(db)
const { systemId } = JSON.parse(task.payload) as { systemId: number }
const [save] = await db.db.select().from(schema.saves).limit(1)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db))
if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`)
const cost = upgradeCost(target.level)
await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'upgrade', message: `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() })
await persist(db)
}
// SHIP_EXPAND — same shape for capacity (cost: `capacity` units of any resource, deducted).
export async function resolveShipExpandTask(db: GameDb, task: Task): Promise<void> {
await ensureSave(db)
const { systemId } = JSON.parse(task.payload) as { systemId: number }
const [save] = await db.db.select().from(schema.saves).limit(1)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.id === systemId)
if (!target) throw new ShipError(404, `Unknown ship system ${systemId}`)
if (target.level <= 0) throw new ShipError(409, `${target.type} is not built`)
const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db))
if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`)
const cost = expandCost(target.capacity)
await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'expand', message: `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`, createdAt: new Date().toISOString() })
await persist(db)
}
// SHIP_BUILD — build an unbuilt type at base values. The flat parts cost is already
// deducted; some types are gated behind a researched tech (see EFFECTS in lib/game/tech.ts).
export async function resolveShipBuildTask(db: GameDb, task: Task): Promise<void> {
await ensureSave(db)
const { type } = JSON.parse(task.payload) as { type: string }
const [save] = await db.db.select().from(schema.saves).limit(1)
const target = (await db.db.select().from(schema.shipSystems)).find((s) => s.type === type)
if (!target) throw new ShipError(404, `Unknown ship system type ${type}`)
if (target.level > 0) throw new ShipError(409, `${target.type} is already built`)
const gate = buildGate(type, await researchedTechSlugs(db))
if (gate) throw new ShipError(409, `Building ${type} requires the ${gate} tech`)
await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id))
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'build', message: `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`, createdAt: new Date().toISOString() })
await persist(db)
}
// UPGRADE — level up a built system by one (capped at the effective max level, raised by techs).
export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
@@ -88,16 +141,14 @@ export async function upgradeSystem(db: GameDb, systemId: number): Promise<ShipS
const maxLevel = effectiveLevelCap(target.type, await researchedTechSlugs(db))
if (target.level >= maxLevel) throw new ShipError(409, `${target.type} is at max level (${maxLevel})`)
const cost = upgradeCost(target.level)
await spendItems(db, save.id, 'resource', cost)
await spendItems(db, save.id, 'resource', upgradeCost(target.level))
const turn = save.turn + 1
await db.db.update(schema.shipSystems).set({ level: target.level + 1 }).where(eq(schema.shipSystems.id, target.id))
await bumpTurnAndEvent(db, save.id, turn, 'upgrade', `Upgraded ${target.type} to level ${target.level + 1} (cost: ${cost}× resources)`)
await persist(db)
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await resolveShipUpgradeTask(db, syntheticTask('SHIP_UPGRADE', { systemId }))
return readShipState(db)
}
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs). Cost: `capacity` units of any resource.
// EXPAND — raise a built system's capacity by one (capped at the effective max capacity, raised by techs).
export async function expandSystem(db: GameDb, systemId: number): Promise<ShipState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
@@ -107,17 +158,14 @@ export async function expandSystem(db: GameDb, systemId: number): Promise<ShipSt
const maxCapacity = effectiveCapacityCap(target.type, await researchedTechSlugs(db))
if (target.capacity >= maxCapacity) throw new ShipError(409, `${target.type} is at max capacity (${maxCapacity})`)
const cost = expandCost(target.capacity)
await spendItems(db, save.id, 'resource', cost)
await spendItems(db, save.id, 'resource', expandCost(target.capacity))
const turn = save.turn + 1
await db.db.update(schema.shipSystems).set({ capacity: target.capacity + 1 }).where(eq(schema.shipSystems.id, target.id))
await bumpTurnAndEvent(db, save.id, turn, 'expand', `Expanded ${target.type} capacity to ${target.capacity + 1} (cost: ${cost}× resources)`)
await persist(db)
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await resolveShipExpandTask(db, syntheticTask('SHIP_EXPAND', { systemId }))
return readShipState(db)
}
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts. Some types are
// gated behind a researched tech (see EFFECTS in lib/game/tech.ts).
// BUILD — build an unbuilt type at base values. Cost: a flat amount of parts.
export async function buildSystem(db: GameDb, type: string): Promise<ShipState> {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
@@ -129,8 +177,7 @@ export async function buildSystem(db: GameDb, type: string): Promise<ShipState>
await spendItems(db, save.id, 'part', BUILD_PART_COST)
const turn = save.turn + 1
await db.db.update(schema.shipSystems).set({ level: BASE_LEVEL, capacity: BASE_CAPACITY }).where(eq(schema.shipSystems.id, target.id))
await bumpTurnAndEvent(db, save.id, turn, 'build', `Built ${target.type} at base values (cost: ${BUILD_PART_COST}× parts)`)
await persist(db)
await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id))
await resolveShipBuildTask(db, syntheticTask('SHIP_BUILD', { type }))
return readShipState(db)
}