From bbcd77c41ba18b7463538ca90a45d1fbf5c390b2 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 13:55:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(game):=20per-type=20resolvers=20=E2=80=94?= =?UTF-8?q?=20jump/explore/ship/research=20effect=20extraction=20for=20tas?= =?UTF-8?q?k=20resolution=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/game/research.test.ts | 77 ++++++++++++++++++++- lib/game/research.ts | 30 +++++++-- lib/game/save.test.ts | 137 +++++++++++++++++++++++++++++++++++++- lib/game/save.ts | 117 +++++++++++++++++++++++--------- lib/game/ship.test.ts | 129 ++++++++++++++++++++++++++++++++++- lib/game/ship.ts | 91 +++++++++++++++++++------ 6 files changed, 521 insertions(+), 60 deletions(-) diff --git a/lib/game/research.test.ts b/lib/game/research.test.ts index ef91764..a5a5341 100644 --- a/lib/game/research.test.ts +++ b/lib/game/research.test.ts @@ -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 { + 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 { + 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' }) + }) +}) diff --git a/lib/game/research.ts b/lib/game/research.ts index 13cae51..ee9f9be 100644 --- a/lib/game/research.ts +++ b/lib/game/research.ts @@ -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 { }) } +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 { + 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 { 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 { }) }) +// --- 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 { + 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()) + 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. diff --git a/lib/game/save.ts b/lib/game/save.ts index 88115cc..8f1f06c 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -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 { } } +// --- 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 { + 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 { 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 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 { +// 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 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 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 { + 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 } } diff --git a/lib/game/ship.test.ts b/lib/game/ship.test.ts index 1c8f374..3f6352f 100644 --- a/lib/game/ship.test.ts +++ b/lib/game/ship.test.ts @@ -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 { + 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 { + 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' }) + }) +}) diff --git a/lib/game/ship.ts b/lib/game/ship.ts index 969bdb3..0a39baa 100644 --- a/lib/game/ship.ts +++ b/lib/game/ship.ts @@ -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 { - 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> { 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 { + 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 { + 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 { + 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 { 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= 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 { 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= 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 { 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 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) }