feat: crew-driven actions — queue + tick engine, NEXT TURN, team builder, task strip (#44, v0.10.0) #46
@@ -18,6 +18,7 @@ export const ADMIN_TABLES = {
|
||||
tech: schema.tech,
|
||||
missions: schema.missions,
|
||||
events: schema.events,
|
||||
tasks: schema.tasks,
|
||||
} as const satisfies Record<string, SQLiteTable>
|
||||
|
||||
const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
+6
-1
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user