From 6c51c3cec8dac55e855abf2ad40ff53c83c44a10 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 13:08:53 +0000 Subject: [PATCH] feat(game): tasks table + schema v7 backfill, admin registry (#44) --- lib/db/admin.ts | 1 + lib/db/client.ts | 13 +++++++++++++ lib/db/schema.ts | 17 +++++++++++++++++ lib/game/save.test.ts | 23 +++++++++++++++++++++++ lib/game/save.ts | 7 ++++++- lib/game/tasks.ts | 18 ++++++++++++++++++ 6 files changed, 78 insertions(+), 1 deletion(-) diff --git a/lib/db/admin.ts b/lib/db/admin.ts index 78dfe5b..6f449d2 100644 --- a/lib/db/admin.ts +++ b/lib/db/admin.ts @@ -18,6 +18,7 @@ export const ADMIN_TABLES = { tech: schema.tech, missions: schema.missions, events: schema.events, + tasks: schema.tasks, } as const satisfies Record const TABLE_NAMES: Set = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t))) diff --git a/lib/db/client.ts b/lib/db/client.ts index 5158809..ccdc603 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -116,6 +116,19 @@ CREATE TABLE IF NOT EXISTS events ( message TEXT NOT NULL, created_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + save_id INTEGER NOT NULL REFERENCES saves(id), + type TEXT NOT NULL, -- 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH' + payload TEXT NOT NULL, -- JSON per type: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · RESEARCH {techId} · EXPLORE {planetId} + lead_crew_id INTEGER NOT NULL REFERENCES crew(id), + helpers TEXT NOT NULL DEFAULT '[]', -- JSON array of crew ids + duration INTEGER NOT NULL, + remaining INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE' + queued_at_turn INTEGER NOT NULL DEFAULT 0 +); `; // SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 9e63ab6..cb204a5 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -136,3 +136,20 @@ export const events = sqliteTable('events', { message: text('message').notNull(), createdAt: text('created_at').notNull(), }) + +// Queued actions (PR A, spec §3): one row per task assigned to a team. `payload` is a JSON +// string whose shape depends on `type` (see the Task interface in lib/game/tasks.ts); +// `helpers` is a JSON array of crew ids. Upgraded saves start with an empty queue — the v7 +// backfill stamps the version, the DDL in client.ts creates the table. +export const tasks = sqliteTable('tasks', { + id: integer('id').primaryKey({ autoIncrement: true }), + saveId: integer('save_id').notNull().references(() => saves.id), + type: text('type').notNull(), // TaskType + payload: text('payload').notNull(), + leadCrewId: integer('lead_crew_id').notNull().references(() => crew.id), + helpers: text('helpers').notNull().default('[]'), // JSON array of crew ids + duration: integer('duration').notNull(), + remaining: integer('remaining').notNull(), + status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE' + queuedAtTurn: integer('queued_at_turn').notNull().default(0), +}) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index fee3b3f..cc1e7f2 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -379,6 +379,29 @@ describe('backfill (old saves)', () => { expect((await db.db.select().from(schema.planets)).length).toBe(planetCount) }) + it('v7: a v6-stamped save gets an empty tasks table without touching state; idempotent across restart', async () => { + const { db, file } = await freshFile() + const world = await getWorldState(db) // fresh game, stamped at the current version + const crewBefore = await db.db.select().from(schema.crew) + const invBefore = await db.db.select().from(schema.inventoryItems) + + await db.db.update(schema.saves).set({ schemaVersion: 6 }) // simulate a save stamped by v0.9.12 + const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7 + + expect(world2).toEqual(world) // turn, systems, edges — untouched (Review Focus 1) + expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(7) + expect(await db.db.select().from(schema.crew)).toEqual(crewBefore) + expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) + const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'") + expect(tables[0].values.map((v) => v[0])).toContain('tasks') + expect(Number(db.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) // empty queue after upgrade + + const reopened = await openGameDb(file) // restart: the backfill must be a no-op + await getWorldState(reopened) + expect((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(7) + expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) + }) + it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => { const { db } = await freshFile() await getWorldState(db) diff --git a/lib/game/save.ts b/lib/game/save.ts index 8725970..88115cc 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -97,7 +97,7 @@ 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). -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 +export const SCHEMA_VERSION = 7 // v1: save + start region only · v2: bodies for every system · v3: starting crew · v4: starting ship · v5: tech tree · v6: mission board · v7: tasks table // 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. @@ -173,6 +173,11 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema await topUpMissionBoard(db) // idempotent — only inserts when fewer than MIN_AVAILABLE AVAILABLE contracts exist version = 6 } + if (version < 7) { + // The tasks table itself is created by the DDL at open (CREATE TABLE IF NOT EXISTS) for + // every file — new or old — so an upgraded save simply starts with an empty queue; stamp. + version = 7 + } if (from !== SCHEMA_VERSION) { await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id)) diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts index e36501b..61e6d2d 100644 --- a/lib/game/tasks.ts +++ b/lib/game/tasks.ts @@ -6,6 +6,24 @@ import type { CrewMember } from './crew' export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH' +export type TaskStatus = 'ACTIVE' | 'PENDING' | 'DONE' + +// One queued/active/done action (spec §3). `payload` is a JSON string whose shape depends on +// the TaskType: JUMP {systemId, hops} · SHIP_UPGRADE/EXPAND {systemId} · SHIP_BUILD {type} · +// RESEARCH {techId} · EXPLORE {planetId}. +export interface Task { + id: number + saveId: number + type: TaskType + payload: string + leadCrewId: number + helpers: number[] + duration: number + remaining: number + status: TaskStatus + queuedAtTurn: number +} + // Work points per action (spec §4): JUMP scales with hop count, the rest are flat. function workPoints(type: TaskType, hopCount: number): number { switch (type) {