feat(game): per-type resolvers — jump/explore/ship/research effect extraction for task resolution (#44)
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user