feat(missions): mission board — schema, deterministic generation, top-up + v6 backfill

New missions table (schema + DDL). Pure model in lib/game/missions.ts:
DELIVER / EXPLORE / FIND contracts generated from missionRng(seed, turn),
board always topped up to >=3 AVAILABLE after every action. save.ts wires
SCHEMA_VERSION 5 -> 6 with a v6 backfill step and completion hooks inside
jumpToSystem/explorePlanet (one atomic persist). Unit tests cover generation
determinism, top-up idempotency, backfill, accept validation and per-type
completion incl. reward accumulation.
This commit is contained in:
2026-09-29 01:55:34 +00:00
parent 5657e8938a
commit f356ffbe93
5 changed files with 539 additions and 3 deletions
+12 -3
View File
@@ -4,6 +4,7 @@ import { persist } from '@/lib/db/client'
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 { TECHS } from './tech'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world'
@@ -95,9 +96,8 @@ async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promi
// What each schema version added to a save. Saves created by older code lack the data newer
// versions seed at new-game time, so ensureSave runs these steps in order for anything below
// current — each step is idempotent (a no-op when its data already exists). When #10 adds
// seeding, bump SCHEMA_VERSION and append a step here.
export const SCHEMA_VERSION = 5 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree
// current — each step is idempotent (a no-op when its data already exists).
export const SCHEMA_VERSION = 6 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board
// Seeds the starting ship: one row per known type — core types built at base values, the rest
// unbuilt (level/capacity 0) until a BUILD action.
@@ -142,6 +142,7 @@ async function createNewGame(db: GameDb): Promise<void> {
await seedStartingCrew(db, save.id, seed)
await seedStartingShip(db, save.id)
await seedStartingTech(db, save.id)
await topUpMissionBoard(db) // only the start system is discovered yet — no DELIVER targets on a fresh game
await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso })
await persist(db)
@@ -168,6 +169,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema
if ((await db.db.select().from(schema.tech)).length === 0) await seedStartingTech(db, save.id)
version = 5
}
if (version < 6) {
await topUpMissionBoard(db) // idempotent — only inserts when fewer than MIN_AVAILABLE AVAILABLE contracts exist
version = 6
}
if (from !== SCHEMA_VERSION) {
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
@@ -233,6 +238,8 @@ export async function jumpToSystem(db: GameDb, systemId: number): Promise<WorldS
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)
return getWorldState(db)
@@ -357,6 +364,8 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise<Explo
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 topUpMissionBoard(db)
await persist(db)
return { ...(await readSystemState(db)), outcome, crewFound }