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