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
+18
View File
@@ -108,6 +108,24 @@ export const tech = sqliteTable(
(t) => [uniqueIndex('tech_save_slug_unique').on(t.saveId, t.slug)],
)
// The mission board (#10): contracts generated deterministically from the save seed, one ACTIVE
// at a time. `type` drives completion semantics (see advanceMission in lib/game/missions.ts):
// DELIVER jumps to target_system_id, EXPLORE counts scans up to progress_target, FIND completes
// when a scan's haul contains condition_kind. The reward lands in inventory_items on completion.
export const missions = sqliteTable('missions', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
type: text('type').notNull(), // 'DELIVER' | 'EXPLORE' | 'FIND'
description: text('description').notNull(), // human-readable objective, e.g. "Jump to Kepler Drift"
targetSystemId: integer('target_system_id'), // DELIVER only — destination system (NULL otherwise)
conditionKind: text('condition_kind'), // FIND only — 'resource' | 'part' (NULL otherwise)
progressTarget: integer('progress_target').notNull().default(1), // EXPLORE: scans required; 1 for the others
progress: integer('progress').notNull().default(0), // scans counted since acceptance; 0/1 for DELIVER/FIND
rewardKind: text('reward_kind').notNull(), // 'resource' | 'part'
rewardQty: integer('reward_qty').notNull().default(1),
state: text('state').notNull().default('AVAILABLE'), // 'AVAILABLE' | 'ACTIVE' | 'DONE'
})
// Append-only game log. Writers so far: new-game + jump events; later issues add more
// writers and a read endpoint.
export const events = sqliteTable('events', {