36 KiB
Skill Progression Implementation Plan (PR B — Gitea #45, v0.11.0)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Crews gain skills by doing jobs; power bands make experienced teams faster; role names unchanged.
Architecture: A pure lib/game/skills.ts plus the crew_skills table (schema v8) feed power bands into the existing queue/tick engine defined by the PR A plan.
Tech Stack: Next.js 16 App Router, React 19, TypeScript strict, bun, SQLite via Drizzle + sql.js (WASM), Vitest unit tests, Playwright e2e (CI-only)
Spec: docs/superpowers/specs/2026-10-01-crew-driven-actions-design.md (§6)
Upstream: PR A plan: docs/superpowers/plans/2026-10-01-crew-driven-actions.md — Tasks 3/4a/4b define queueTask / runTurn / computeDuration / Task; this plan assumes the post-v0.10.0 merge and starts a fresh feature branch.
Global Constraints
- AGENTS.md change process per PR: feature branch off master, no direct pushes to master; version bump in package.json + CHANGELOG entry at top (
## v<version> — YYYY-MM-DD HH:MM); local verify ONLYnpx tsc --noEmit,npx vitest run,npx playwright test --list(e2e is CI-only, never run locally); push → CI auto-merge on green (verify merged_at) → tag v on master → manual deploydocker compose up -d --build+ verify /api/version at https://space-wanderer.dev.kkhost.pl. - DDL in lib/db/client.ts must stay in sync with lib/db/schema.ts; schema changes only via idempotent backfill steps in save.ts ensureSave (SCHEMA_VERSION 6→7→8); no DB wipes ever.
- OpenAPI spec in lib/openapi.ts updated whenever an endpoint changes (and lib/openapi.test.ts pins, which must be updated with it).
- Single-player by design: NO auth, accounts, or multiplayer, ever.
- Determinism: every game outcome reproducible from seed + player actions; no wall-clock randomness.
- New pure modules (crew-costs.ts, tasks.ts, skills.ts) must not import lib/db/* — shared server+client like the tech.ts precedent (required for live duration preview in the UI).
Review Focus
- Existing save loaded after deploy has no tasks/crew_skills tables → backfill must create them without touching turn, crew, or inventory state, and be idempotent across restarts.
Task numbering continues from the PR A plan (1–11).
Task 12: crew_skills table, schema v8 (PR B — Gitea issue #45)
Files:
- Modify:
lib/db/client.ts(addcrew_skillsto theDDLtemplate, aftertasks),lib/db/schema.ts(drizzle tablecrewSkills+ unique index),lib/game/save.ts(SCHEMA_VERSION7→8; idempotentseedCrewSkillshelper; the v8 step inbackfillSave; acreateNewGamehook — JC-21),lib/db/admin.ts(registercrew_skillsinADMIN_TABLES) - Test: extend the
backfill (old saves)describe inlib/game/save.test.ts(Task 2's v7-fixture pattern:freshFile()+getWorldState+ stubschemaVersion+ reopen viaopenGameDb(file));lib/db/admin.test.ts's registry test picks up the table automatically (it derives its expected list fromschema.ts)
Interfaces:
-
Consumes:
SkillName+STARTING_LEVELS(Record<string, Record<SkillName, number>>) from Task 1'slib/game/crew-costs.ts;schema.crew/schema.saves;backfillSave/ensureSaveinlib/game/save.ts(nowSCHEMA_VERSION = 7after Task 2) -
Produces (for Task 13+): the
crew_skillstable —saveId(FK saves),crewId(FK crew),skill(SkillName),level(0–5),xp— seeded to exactly 4 rows per member (one per skill) withlevel = STARTING_LEVELS[role][skill],xp = 0, for every save (fresh + upgraded). Task 13 (XP-on-completion, level-ups, power bands, UI) reads/writes this table; level→power stays increw-costs.ts(POWER_BANDS) -
Create the branch (PR B, off
masterpost-v0.10.0 merge):git checkout -b feat/skill-progression master. Every commit in this PR references(#45). -
Write the failing test — extend the
backfill (old saves)describe inlib/game/save.test.ts; addimport { STARTING_LEVELS } from './crew-costs'at the top. Pins Review Focus 1 (v8): v7→v8 seeds 4 rows per member without touching turn/crew/inventory, idempotent across restart:
// Insert one member directly (skill seeding depends only on role — traits can be dummies).
const pinCrew = (db: GameDb, saveId: number, name: string, role: string) =>
db.db.insert(schema.crew).values({ saveId, name, role, traitGood: 'STEADY UNDER PRESSURE', traitBad: 'IMPULSIVE', origin: 'STARTING CREW' }).returning().then((r) => r[0])
// A member's 4 skill rows as { [skill]: { level, xp } }; the toHaveLength(4) asserts one row per skill.
const skillMap = async (db: GameDb, crewId: number) => {
const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, crewId))
expect(rows).toHaveLength(4)
return Object.fromEntries(rows.map((r) => [r.skill, { level: r.level, xp: r.xp }]))
}
it('v8: a v7-stamped save (crew present, no skills) seeds 4 role-based rows per member, xp 0, state untouched; idempotent across restart', async () => {
const { db, file } = await freshFile()
await getWorldState(db) // fresh v8 save (crew_skills already seeded by createNewGame — JC-21)
const save = (await db.db.select().from(schema.saves))[0]
await db.db.delete(schema.crewSkills) // simulate a v0.10.0 save: crew present, no skills yet
await db.db.update(schema.saves).set({ schemaVersion: 7 })
const pilot = await pinCrew(db, save.id, 'P. VANCE', 'PILOT')
const scientist = await pinCrew(db, save.id, 'S. OKAFOR', 'SCIENTIST')
const gunner = await pinCrew(db, save.id, 'G. REYES', 'GUNNER')
const crewBefore = await db.db.select().from(schema.crew)
const invBefore = await db.db.select().from(schema.inventoryItems)
const turnBefore = (await db.db.select().from(schema.saves))[0].turn
await getWorldState(db) // ensureSave → backfill v7 → v8 → seedCrewSkills
expect((await db.db.select().from(schema.saves))[0].schemaVersion).toBe(8)
expect(await skillMap(db, pilot.id)).toEqual({ NAVIGATION: { level: 2, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // PILOT
expect(await skillMap(db, scientist.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 2, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 2, xp: 0 } }) // SCIENTIST
expect(await skillMap(db, gunner.id)).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 0 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) // GUNNER (all L0)
for (const c of crewBefore) { // full invariant: EVERY member (incl. the random starting crew) matches its role map, xp 0
const expected = Object.fromEntries(Object.keys(STARTING_LEVELS[c.role]).map((s) => [s, { level: STARTING_LEVELS[c.role][s], xp: 0 }]))
expect(await skillMap(db, c.id)).toEqual(expected)
}
expect((await db.db.select().from(schema.saves))[0].turn).toBe(turnBefore) // Review Focus 1 (v8): turn / crew / inventory
expect(await db.db.select().from(schema.crew)).toEqual(crewBefore) // byte-identical
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) // byte-identical
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(8)
expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM crew_skills')[0]?.values[0][0] ?? 0)).toBe(4 * (await reopened.db.select().from(schema.crew)).length) // still 4 rows per member
})
-
Run — expect red:
npx vitest run lib/game/save.test.ts→ red:schema.crewSkillsis not exported (the test can't compile) andSCHEMA_VERSIONis still 7. The other describes stay green (they don't referencecrewSkills). -
Implement, each in the existing style of its file:
lib/db/client.ts — add to the DDL template after the tasks table (keep in sync with schema.ts):
CREATE TABLE IF NOT EXISTS crew_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
crew_id INTEGER NOT NULL REFERENCES crew(id),
skill TEXT NOT NULL, -- 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' (SkillName)
level INTEGER NOT NULL DEFAULT 0, -- 0–5
xp INTEGER NOT NULL DEFAULT 0, -- cumulative completions in this skill (Task 13 grants)
UNIQUE (crew_id, skill)
);
lib/db/schema.ts — append after tasks (mirrors the DDL; uniqueIndex is already imported):
// Per-member skill progression (PR B, spec §6): one row per (crew member, skill); seeded to
// STARTING_LEVELS[role][skill] by createNewGame / the v8 backfill; Task 13 adds xp + level-ups.
export const crewSkills = sqliteTable('crew_skills', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
crewId: integer('crew_id').notNull().references(() => crew.id),
skill: text('skill').notNull(), // SkillName
level: integer('level').notNull().default(0), // 0–5
xp: integer('xp').notNull().default(0),
}, (t) => [uniqueIndex('crew_skills_crew_skill_unique').on(t.crewId, t.skill)])
lib/game/save.ts — add import { STARTING_LEVELS, type SkillName } from './crew-costs'; bump the constant; add the idempotent seeder (called by createNewGame and the v8 backfill):
export const SCHEMA_VERSION = 8 // v1: save + start · v2: bodies · v3: crew · v4: ship · v5: tech · v6: missions · v7: tasks · v8: crew_skills (role-based starting levels)
// Idempotent: for each crew member with no crew_skills rows yet, seed one row per skill
// (level = STARTING_LEVELS[role][skill], xp = 0). Called by createNewGame and the v8 backfill.
async function seedCrewSkills(db: GameDb, saveId: number) {
const crew = await db.db.select().from(schema.crew).where(eq(schema.crew.saveId, saveId))
const have = new Set((await db.db.select({ crewId: schema.crewSkills.crewId }).from(schema.crewSkills)).map((r) => r.crewId))
for (const m of crew) {
if (have.has(m.id)) continue // already seeded — idempotent
for (const skill of Object.keys(STARTING_LEVELS[m.role]) as SkillName[]) {
await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: STARTING_LEVELS[m.role][skill], xp: 0 })
}
}
}
In createNewGame, add after await seedStartingCrew(db, save.id, seed): await seedCrewSkills(db, save.id). In backfillSave, add after the v7 step:
if (version < 8) {
await seedCrewSkills(db, save.id) // DDL creates the table at open; seed each member's 4 rows
version = 8
}
lib/db/admin.ts — register in ADMIN_TABLES after tasks: crew_skills: schema.crewSkills,
-
Run — expect green:
npx vitest run lib/game/save.test.ts lib/db/admin.test.ts→ green (new v8 test passes; the admin registry test picks upcrew_skillsautomatically);npx tsc --noEmitclean. -
Commit (PR B):
git add -A && git commit -m "feat(db): crew_skills table + role-based starting levels — schema v8 (#45)".
Closeout (PR B): version bump v0.11.0 + CHANGELOG.md entry + open/auto-merge PR + tag v0.11.0 + manual deploy + close Gitea issue #45 follow the standard per-PR loop (mirroring the v0.10.0 / #44 closeout at this plan's tail). Gitea numbering here is the plan's convention (spec §10's pre-fix numbering lists this work as #44; the actual Gitea issue is #45 (the table is corrected in PR A's Task 11 before the spec is committed)).
Judgment calls
- JC-21 —
ensureSaverunsbackfillSaveonly for pre-existing saves; a fresh save is built bycreateNewGame, which never backfills. So the v8 seeding is also called fromcreateNewGame(the same idempotentseedCrewSkills) — otherwise fresh v8 saves would have nocrew_skillsrows at all. Mirrors howseedStartingCrew/seedStartingShip/seedStartingTechare invoked from bothcreateNewGameand their backfill steps.
Task 13: skills.ts — XP, levels, power bands, titles (PR B)
Files:
- Create:
lib/game/skills.ts(pure — nolib/dbimports; operates on plainSkillRowdata from Task 12'screw_skillstable; the frontend imports it for the CREW-tab skill display + titles, same server+client precedent ascrew-costs.ts) and testlib/game/skills.test.ts(colocated, pure — no DB)
Interfaces:
-
Consumes: from
./crew-costs—SkillName,XP_THRESHOLDS(level thresholds [5,10,15,20,25]),POWER_BANDS([1,1,1,2,2,3]); thecrew_skillsrows (Task 12) as plain{ saveId, crewId, skill, level, xp } -
Produces (for Task 14+):
SkillRowinterface,grantXp(skillRows, crewIds, skill, members) → { rows: SkillRow[]; events: string[] },levelFromXp(xp) → number,powerForLevel(level) → number,titleFor(member, skillRows) → string | null -
Steps: TDD — write failing tests FIRST in
lib/game/skills.test.tsusing the spec's EXACT values (runnpx vitest run lib/game/skills.test.ts→ red, the module does not exist yet):
import { describe, expect, it } from 'vitest'
import type { SkillName } from './crew-costs'
import { grantXp, levelFromXp, powerForLevel, titleFor, type SkillRow } from './skills'
const pilot = { name: 'BEN OKONKWO', role: 'PILOT' }
const row = (crewId: string, skill: SkillName, level: number, xp: number): SkillRow =>
({ saveId: 's1', crewId, skill, level, xp })
const XP: [number, number][] = [[4, 0], [5, 1], [10, 2], [15, 3], [25, 5], [100, 5]]
describe('levelFromXp (thresholds [5,10,15,20,25], cap 5)', () => {
it('maps cumulative xp → highest level reached', () => {
for (const [xp, lvl] of XP) expect(levelFromXp(xp), `xp ${xp}`).toBe(lvl)
})
})
const POWER: [number, number][] = [[0, 1], [2, 1], [3, 2], [4, 2], [5, 3]]
describe('powerForLevel (bands [1,1,1,2,2,3])', () => {
it('L0–2 → 1 · L3–4 → 2 · L5 → 3', () => {
for (const [lvl, p] of POWER) expect(powerForLevel(lvl), `L${lvl}`).toBe(p)
})
})
describe('grantXp — +1 XP to every listed member; event only on a titled-band crossing', () => {
const pilotRows = (): SkillRow[] => [
row('c1', 'NAVIGATION', 2, 14), row('c1', 'SCANNING', 0, 0),
row('c1', 'ENGINEERING', 0, 0), row('c1', 'RESEARCH', 0, 0),
]
it('L0 → L1: xp 4 +1 → 5 (L1), no event (no event for plain L1/L2)', () => {
const { rows, events } = grantXp([row('c1', 'NAVIGATION', 0, 4)], ['c1'], 'NAVIGATION', { c1: pilot })
expect(rows[0]).toEqual({ saveId: 's1', crewId: 'c1', skill: 'NAVIGATION', level: 1, xp: 5 })
expect(events).toEqual([])
})
it('L2 → L3: xp 14 +1 → 15, SENIOR event in the exact spec §6 text', () => {
const { rows, events } = grantXp(pilotRows(), ['c1'], 'NAVIGATION', { c1: pilot })
expect(rows.find((r) => r.skill === 'NAVIGATION')!.level).toBe(3)
expect(events).toEqual(['BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3'])
})
it('L4 → L5: xp 24 +1 → 25, MASTER event', () => {
const { events } = grantXp([row('c1', 'NAVIGATION', 4, 24), row('c1', 'SCANNING', 0, 0)], ['c1'], 'NAVIGATION', { c1: pilot })
expect(events).toEqual(['BEN OKONKWO is now a MASTER PILOT — NAVIGATION L5'])
})
})
describe('titleFor — from the member's HIGHEST skill level', () => {
it('SCIENTIST with SCANNING L3 + RESEARCH L5 → MASTER SCIENTIST (highest wins)', () => {
const scientist = { name: 'AVA VANCE', role: 'SCIENTIST' }
const rows = [row('c2', 'SCANNING', 3, 15), row('c2', 'RESEARCH', 5, 25), row('c2', 'NAVIGATION', 0, 0), row('c2', 'ENGINEERING', 0, 0)]
expect(titleFor(scientist, rows)).toBe('MASTER SCIENTIST')
})
it('GUNNER with all L0 → null', () => {
const gunner = { name: 'RIG HOLLOWAY', role: 'GUNNER' }
const rows = [row('c3', 'NAVIGATION', 0, 0), row('c3', 'SCANNING', 0, 0), row('c3', 'ENGINEERING', 0, 0), row('c3', 'RESEARCH', 0, 0)]
expect(titleFor(gunner, rows)).toBe(null)
})
})
- Implement
lib/game/skills.ts(pure — nolib/dbimports; frontend imports it for the CREW-tab skill display + titles, same precedent ascrew-costs.ts):
// Pure skill-progression module (spec §6): XP accrual, level→power, titles. No lib/db imports — operates on plain SkillRow data (Task 12's crew_skills table); the frontend imports it for the CREW-tab display.
import type { SkillName } from './crew-costs'
import { POWER_BANDS, XP_THRESHOLDS } from './crew-costs'
export interface SkillRow {
saveId: string
crewId: string
skill: SkillName
level: number
xp: number
}
export type CrewIdentity = { name: string; role: string }
// Highest level 0–5 with xp ≥ XP_THRESHOLDS[level−1]; thresholds [5,10,15,20,25], cap 5.
export function levelFromXp(xp: number): number {
let level = 0
for (let l = 1; l <= XP_THRESHOLDS.length; l++) if (xp >= XP_THRESHOLDS[l - 1]) level = l
return level
}
// L0–2 → 1 · L3–4 → 2 · L5 → 3 (direct lookup into crew-costs POWER_BANDS).
export function powerForLevel(level: number): number {
return POWER_BANDS[level]
}
// From the member's HIGHEST skill level: ≥5 → "MASTER <ROLE>", ≥3 → "SENIOR <ROLE>", below → null.
export function titleFor(member: CrewIdentity, skillRows: SkillRow[]): string | null {
const highest = skillRows.reduce((max, r) => Math.max(max, r.level), 0)
if (highest >= 5) return `MASTER ${member.role}`
if (highest >= 3) return `SENIOR ${member.role}`
return null
}
// +1 XP to every listed member's row for `skill`; returns updated rows + any level-up events. An event
// fires when the gained skill steps into a titled band (3 → SENIOR, 5 → MASTER), e.g. "BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3" (title from the member's HIGHEST skill).
export function grantXp(
skillRows: SkillRow[],
crewIds: string[],
skill: SkillName,
members: Record<string, CrewIdentity>,
): { rows: SkillRow[]; events: string[] } {
const rows = skillRows.map((r) =>
r.skill === skill && crewIds.includes(r.crewId)
? { ...r, xp: r.xp + 1, level: levelFromXp(r.xp + 1) }
: r,
)
const events: string[] = []
for (const crewId of crewIds) {
const member = members[crewId]
if (!member) continue
const newLevel = rows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
if (newLevel === 3 || newLevel === 5) {
const title = titleFor(member, rows.filter((r) => r.crewId === crewId))
if (title) events.push(`${member.name} is now a ${title} — ${skill} L${newLevel}`)
}
}
return { rows, events }
}
-
Run — expect green:
npx vitest run lib/game/skills.test.ts→ green (both levelFromXp/powerForLevel tables, the three grantXp crossings, the two titleFor cases);npx tsc --noEmitclean. -
Commit (PR B, same branch as Task 12):
git add -A && git commit -m "feat(game): skills.ts — XP, levels, power bands, titles (#45)".
Judgment calls
- JC-22 — spec §6 shows one level-up event (the SENIOR PILOT example) and none for plain L1/L2/L4; so
grantXpemits an event only when the gained skill steps into a titled band (new level 3 → SENIOR, 5 → MASTER). The event's title is computed from the member's HIGHEST skill level (viatitleForover all their rows); if a member is already MASTER from one skill and a second then crosses L3, this would emit a MASTER-titled event naming the L3 skill — a rare edge left as-is (per-member dedup can be added in Task 14's orchestrator if wanted).
Task 14: Wire skill XP into the tick + power bands into duration (PR B)
Files:
- Modify:
lib/game/skills.ts(addmakePowerOf— the power-band closure);lib/game/save.ts(runTurn: grant XP on each resolved task, fold the level-up events into the tick's event batch, persistcrew_skillsin the final write; newmakePowerOfForroute helper; importsgrantXp/makePowerOf/type SkillRow/ACTION_SKILL);lib/game/tasks.ts(queueTaskgains an optionalpowerOfparam, forwarded tocomputeDuration); and the five queue paths —app/api/jump/route.ts,app/api/explore/route.ts,app/api/ship/{upgrade,expand,build}/route.ts,app/api/research/route.ts(each passesmakePowerOfFor(db, save, type)intoqueueTask) - Test: extend
lib/game/save.test.ts(therunTurn (tick on the DB)describe) andlib/game/tasks.test.ts(thequeueTaskblock). No new files.
Interfaces:
-
Consumes: Task 4b's
runTurn(db)orchestrator insave.ts— readssaves/crew/tasks/inventory/shipSystems/techinto aQueueState, runstickTurn, writessaves.turnbefore resolving, dispatchesresult.completedthroughTASK_RESOLVERS, and ends in one whole-DBpersist(db); Task 13'sgrantXp(skillRows, crewIds, skill, members) → { rows, events },powerForLevel(level),levelFromXp(xp),titleFor(member, rows),SkillRow,CrewIdentity; Task 1'sACTION_SKILL: Record<TaskType, SkillName>, the unchangedcomputeDuration(type, hopCount, lead, helpers, powerOf?), andCrewMember; Task 12'sschema.crewSkillstable; Task 3'squeueTask(state, req)/QueueState. -
Produces:
makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType) → (m: CrewMember) => numberinskills.ts— the exactpowerOfclosure Task 15's live UI preview calls (Task 15's crew endpoint must expose each member's skill rows for the UI to build it); andmakePowerOfFor(db, save, type): Promise<(m: CrewMember) => number>insave.ts— the route-side builder the five queue paths call. -
Write the failing tests — extend the two existing describes (no new files):
lib/game/tasks.test.ts(queueTaskblock) — a power-band duration case: a solo member with apowerOfreturning 2 →queueTask(state, { type: 'EXPLORE', ... }, (m) => 2)storesremaining 2(ceil(4/2)), while the no-powerOfcall (default flat 1) still stores 4 — pins the new param AND its backward-compatible default.lib/game/save.test.ts(runTurndescribe) — three cases over the 4abuildWorldFixture/freshFilepattern (patch a member'screw_skillsrow directly; a fresh save already has 4 rows per member from Task 12's seed):- XP accrual — a 3-person EXPLORE (lead + 2 helpers) completes in one tick → all three members'
SCANNINGrows each gain exactly +1 XP (lead and helpers equally). - Level-up event — a SCIENTIST whose
SCANNINGrow is at xp 14 (L2) completes an EXPLORE → the tick writes the §6 text intoschema.events:AVA VANCE is now a SENIOR SCIENTIST — SCANNING L3(xp 15 → L3 → SENIOR band). - §6 duration-shift — a member at
SCANNINGL0/xp 0 queues an EXPLORE solo → storedduration 4; after 15 completed EXPLOREs (xp 15 → L3 → power 2) the next queued EXPLORE → storedduration 2(ceil(4/2)). The lead is a SCIENTIST (the preferred EXPLORE role) so the §6 "4 → 2" holds without a mismatch penalty — see JC-23.
- XP accrual — a 3-person EXPLORE (lead + 2 helpers) completes in one tick → all three members'
-
Run — expect red:
npx vitest run lib/game/tasks.test.ts lib/game/save.test.ts→ the newits red (queueTaskhas nopowerOfparam;runTurndoesn't readcrew_skills, so no XP accrual, no level-up event, and the queued duration stays 4 at L3); every pre-existing test (Task 3'squeueTaskblock, 4b'srunTurnblock, the 4a A/B + guard blocks) stays green. -
Implement — one action per file:
lib/game/skills.ts— addimport { ACTION_SKILL } from './crew-costs'andimport type { CrewDto } from './save'(type-only, so no runtime cycle — thetasks.tsprecedent), thenmakePowerOf: closes overMap<name, crewId>(names are unique per single-player save) +Map<crewId, level-in-ACTION_SKILL[type]>; returns(m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0)— an unknown member / no row falls back to L0 → power 1 (Task 1's default).lib/game/tasks.ts—queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1); the one lineconst duration = computeDuration(req.type, hops, lead, helpers)becomescomputeDuration(req.type, hops, lead, helpers, powerOf).computeDurationitself is unchanged.lib/game/save.ts— importgrantXp,makePowerOf,type SkillRowfrom./skillsandACTION_SKILLfrom./crew-costs. InrunTurn: addcrewSkillsto the topPromise.allread; map the rows toSkillRow[](stringifysaveId/crewId) and buildmembers: Record<string, CrewIdentity>keyed byString(crew.id); in theresult.completedloop, after eachTASK_RESOLVERS[task.type](db, task), callgrantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members)and reassignskillRows(so overlapping crew across several completions in one tick accumulate), pushing itseventsinto a tick-level array; after the loop, insert the level-up events as oneschema.eventsbatch atresult.turnand update every changedcrew_skillsrow — both inside the existing finalpersist(db). AddmakePowerOfFor(db, save, type)(readscrew+crew_skills, returnsmakePowerOf(roster, rows, type)).- The five queue-path routes — pass
await makePowerOfFor(db, save, type)as the thirdqueueTaskarg so the stored duration uses power bands.
-
Run — expect green:
npx vitest run→ the whole unit suite passes (the three newrunTurncases, the newqueueTaskpower-band case, and every pre-existing block);npx tsc --noEmitclean. -
Commit (PR B, same branch as Tasks 12–13):
git add -A && git commit -m "feat(game): skill XP on task completion + power bands in duration (#45)".
Judgment calls
- JC-23 — the locked design's "e.g. a COMMANDER" for the §6 duration-shift would in fact incur EXPLORE's mismatch penalty (COMMANDER ≠ the preferred SCIENTIST → +1 → 5, not 4), contradicting the cited "duration 4 → 2 (
ceil(4/2))". The test therefore leads the EXPLORE with a SCIENTIST (the preferred role, no penalty) seeded atSCANNINGL0 so the exact §6 "rookie 4 → 2" numbers hold; the power-band effect (the point of the test) is identical either way. - JC-24 —
SkillRow.crewId/saveIdare strings (Task 13) butcrew_skills.crew_id/save_idandtasks.leadCrewId/helpersare numeric;runTurnstringifiescrew.id/save.idwhen mapping rows toSkillRow[]and when buildingmembersand thegrantXpcrewIds, keeping one string-id domain end to end sograntXp'screwIds.includes(r.crewId)matches. - JC-25 — 4a's resolvers each insert their own resolution event and
persist; to keep the level-up events "in the same event batch" and thecrew_skillsupdates "in the same single write" as the resolution,runTurnwrites the level-up events as one batch insert and thecrew_skillsupdates inside its existing finalpersist(db)(sameresult.turn, one atomic commit) rather than refactoring every 4a resolver to return its event.
Task 15: CREW tab skill levels + titles, team-builder contributions (PR B)
Files:
- Modify:
lib/game/save.ts— the crew-state readergetCrewState(post-PR-A it returns theCrewState { crew, tasks }wrapper; this is the only lib change in the task) attaches per-member skill data to eachCrewDto:skills(the member's fourcrew_skillsrows as{ skill, level }in the fixed order NAVIGATION → SCANNING → ENGINEERING → RESEARCH) andtitle(Task 13'stitleFor({ name, role }, memberRows)—nullwhen the member's highest skill is below L3); theCrewDtointerface gains those two fields; importstitleFor/type SkillRowfrom./skillsandtype SkillNamefrom./crew-costs(type-only, no runtime cycle); thecrew_skillsselect joins the existing crew read in the samePromise.all. - Modify:
components/crew-panel.tsx— per member, render the four skill levels on one·-separated line (e.g.NAVIGATION L3 · SCANNING L0 · ENGINEERING L0 · RESEARCH L2) frommember.skills, and, only whenmember.titleis non-null, a title line (e.g.SENIOR PILOT). Display-only — no new interaction. Also label the three STANDBY traits (SHARP SHOOTER, SHORT FUSE, JINXED IN A DOGFIGHT) as "STANDBY — encounter-ready" in each member's trait line (spec §8). - Modify:
components/team-builder.tsx(PR A's component) — extend each member row to show that member's power contribution for the armed action's skill (e.g.BEN OKONKWO (power 2)), computed with Task 14'smakePowerOf; the existing live duration preview and the dispatch control stay unchanged. - Test: extend
lib/game/save.test.ts— one newitin thegetCrewStatesnapshot block for per-memberskills+title. No component tests (the repo has no jsdom/@testing-library — the unit suite is pure-TS only), so the rendered UI is covered by e2e (CI-only), per the standard skip note.
Interfaces:
-
Consumes: the post-PR-A crew-state shape —
getCrewState(db): Promise<CrewState>withCrewState { crew: CrewDto[]; tasks: QueueDto[] }fromGET /api/crew(PR A Task 6 / JC-7); Task 13'stitleFor(member: CrewIdentity, rows: SkillRow[]) → string | nullandSkillRow; Task 14'smakePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType) → (m: CrewMember) => number; Task 12'screw_skillstable; Task 1'sACTION_SKILL/SkillName. -
Produces:
CrewDto.skills: { skill: SkillName; level: number }[](the four rows, fixed order) andCrewDto.title: string | nullon theGET /api/crewresponse — asserted by the new unit test and by e2e (the CREW-tab skill/title lines and the team-builder(power N)rows). No new endpoint, no schema/table change. -
Write the failing test — in
lib/game/save.test.ts'sgetCrewStatesnapshot block, over a fresh save (which Task 12 already seeds with fourcrew_skillsrows per member): pin one member (e.g. a PILOT) toNAVIGATIONL3 (xp 15) and its other three skills L0, and leave a second member all-L0. Assert the pinned member'sgetCrewState(db).crewentry carriesskills=[{ NAVIGATION, 3 }, { SCANNING, 0 }, { ENGINEERING, 0 }, { RESEARCH, 0 }](all four, fixed order) andtitle="SENIOR PILOT", and the all-L0 member'stitle=null. -
Run — expect red:
npx vitest run lib/game/save.test.ts→ the newitis red (getCrewStatestill returnsCrewDtoentries withoutskills/title); every pre-existing block (4b, Task 14's XP/event/duration, the PR-A snapshot-queue block) stays green. -
Implement — one action per file:
lib/game/save.ts— ingetCrewState, addcrew_skillsto the read and group rows bycrewId; for eachCrewDtosetskillsto the four fixed-order{ skill, level }rows andtitletotitleFor({ name, role }, memberRows); add the two fields to theCrewDtointerface and the type-only imports.components/crew-panel.tsx— under each member's role line, render the·-separated skill-level line frommember.skillsand, only when set, amember.titleline; purely presentational.components/team-builder.tsx— importmakePowerOf(andtype SkillRow) from@/lib/game/skills; buildskillRows: SkillRow[]once from the roster (roster.flatMap((c) => c.skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 }))));const powerOf = makePowerOf(roster, skillRows, type); append(power N)to each member row's name for that member's contribution in the armed action's skill. Leave the livedurationpreview and dispatch as-is.
-
Run — expect green:
npx vitest run→ the whole unit suite passes (the newgetCrewStateskills/titleitplus every pre-existing block);npx tsc --noEmitclean. The rendered CREW-tab lines and team-builder(power N)rows are e2e-covered (CI-only, per AGENTS.md). -
Commit (PR B, same branch as Tasks 12–14):
git add -A && git commit -m "feat(ui): skill levels + titles on CREW tab and team-builder (#45)".
Judgment calls
- JC-26 —
getCrewStatereturns the lightweight{ skill, level }per member (not the fullSkillRowwithxp/saveId) to keep theGET /api/crewpayload display-oriented; the team builder reconstructs just enough of aSkillRow[](crewId+skill+level) to feedmakePowerOf, which reads only those fields, so the full rows are not shipped to the client twice. - JC-27 — skill levels render in the fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (the spec §6 skill list) rather than DB row order, so the CREW-tab line and any e2e text assertion are stable; the same order is pinned in the new unit test.
- JC-28 — the
GET /api/crewresponse object gains two additive display-only fields, but per the locked design this task keeps the OpenAPICrewMemberschema (and itsopenapi.test.tskey pin) at its six documented keys, leaving the spec slightly under-documented; a trivial follow-up may mirrorskills/titleinto the schema + pin later. CI stays green as-is because the pin is unchanged.
Task 16: Ship v0.11.0 (PR B — Gitea issue #45)
Files touched (this ship step only — no app code, no failing-test step):
docs/superpowers/plans/2026-10-01-skill-progression.md— this plan (untracked, committed here; the spec doc was already committed + approved in PR A, so no spec edits in this PR).package.json(version bump) andCHANGELOG.md(new top entry).
This is a process task that ships the PR B already built by Tasks 12–15 (all code + tests already live on the same branch). It has no failing-test step and ends in a verification gate.
-
- Commit the PR B plan document on the PR B branch (the branch created in Task 12,
feat/skill-progression) — the plan doc is untracked:git add docs/superpowers/plans/2026-10-01-skill-progression.md && git commit -m "docs: skill-progression implementation plan (#45)". The spec doc was already committed in PR A — no spec edits in this PR.
- Commit the PR B plan document on the PR B branch (the branch created in Task 12,
-
- Version bump —
package.json→"version": "0.11.0"(from the current0.10.0).CHANGELOG.md: new entry at the very top in the repo's format## v0.11.0 — YYYY-MM-DD HH:MM(fill the date/time at execution) with a bullet summary of the PR: thecrew_skillstable (schema v8) with role-based starting specialisation; +1 XP per completed task for all assigned crew; power bands in task duration (L0–2→1, L3–4→2, L5→3); SENIOR/MASTER titles; the CREW tab shows skill levels + title and the team-builder shows per-member power contribution.
- Version bump —
-
- Local verify — ONLY these three (e2e is CI-only, never run locally):
npx tsc --noEmit(clean),npx vitest run(all pass),npx playwright test --list(every spec compiles + lists). All three must pass. Do NOT run the e2e suite (npx playwright test) — CI-only per AGENTS.md.
- Local verify — ONLY these three (e2e is CI-only, never run locally):
-
- Push + open the PR to master —
git push -u origin feat/skill-progression, then open a pull request tomasteron Gitea (repoOpenCode/SpaceWandererat git.kkhost.pl). Gitea API access per Task 11's JC-20: prefer the pre-configured gitea MCP tools to create the PR; if you must use raw REST/curl,sourcethe repo-root.env(GITEA_TOKEN, gitignored).
- Push + open the PR to master —
-
- Auto-merge on green — trigger Gitea's auto-merge:
POST /repos/OpenCode/SpaceWanderer/pulls/<n>/mergewith body{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}. The PR merges automatically once the required status checkCI / build + unit + e2e (pull request)passes (branch protection; CI runs the full e2e suite in an isolated dind stack, ~7 min). Poll the PR untilmerged_atis set (max ~15 min; poll the API every ~30s). If CI is red, STOP and report the failure — never force-merge.
- Auto-merge on green — trigger Gitea's auto-merge:
-
- Tag on master — after pulling the merge on
master:git tag v0.11.0 && git push origin v0.11.0.
- Tag on master — after pulling the merge on
-
- Manual deploy (CI never deploys) —
docker compose up -d --buildin the repo root, then verifycurl -s https://space-wanderer.dev.kkhost.pl/api/versionreturns the 0.11.0 payload.
- Manual deploy (CI never deploys) —
-
- Close the issue — Gitea API: close issue #45 with a comment summarizing the shipped state (version 0.11.0, tag v0.11.0, live deploy verified).
Verification gate (whole task): /api/version on the live URL (https://space-wanderer.dev.kkhost.pl) reports 0.11.0 AND Gitea issue #45 is closed — only then is this PR done.