diff --git a/CHANGELOG.md b/CHANGELOG.md index a353e5a..642283a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.11.0 — 2026-10-04 03:47 +- Skill progression: crew skills (NAVIGATION, SCANNING, ENGINEERING, RESEARCH) gain XP on every completed action, leveling through 5 bands; higher skill levels reduce task duration via power scaling. +- Role-based starting levels: PILOTs start NAVIGATION L2, SCIENTISTS start SCANNING+RESEARCH L2, etc. Recruits discovered during exploration get the same role-based starting profile. +- Level-up events: when a member's skill steps into the SENIOR (L3) or MASTER (L5) band, a named event is emitted (e.g. "A. OKAFOR is now a SENIOR SCIENTIST — SCANNING L3"). +- CREW tab: each member now shows their four skill levels and a title (SENIOR/MASTER + role) based on their highest skill. +- Team-builder: the duration preview now accounts for crew skill power (same computation as the queue routes). +- Combat traits (SHARP SHOOTER, SHORT FUSE, JINXED IN A DOGFIGHT) now display a "STANDBY — encounter-ready" label, as promised in v0.10.0. +- OpenAPI: `CrewMember` gains `skills` (array of {skill, level}) and `title` fields; fixes pre-existing snake_case `trait_good`/`trait_bad` to camelCase to match the actual response. + ## v0.10.1 — 2026-10-03 18:36 - Fix React 19 hydration mismatch on every production load — the banner's live clock was prerendered at build time in the static page and desynced at hydration (CI-verified: no hydration errors in the job logs). A real fix, but NOT the cause of the red e2e jump specs. - Fix the world-map jump dispatch — the actual cause of the three deterministically red e2e jump specs: TeamBuilder's `blocked` term was `undefined`, not `null`, whenever no jump was queued (`Array.find` returns `undefined`), so the roster rendered (truthiness check) while the DISPATCH button's `blocked !== null` guard read it as blocked and stayed disabled — the JUMP panel's dispatch was permanently dead on an empty queue (the 300 s `waitForResponse` timeout; present since the builder shipped in v0.10.0, so the UI-jump specs were red on every run since, on a fresh DB; non-JUMP panels were unaffected, their branch yielding `null`). diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts index 68e65c4..665b206 100644 --- a/app/api/explore/route.ts +++ b/app/api/explore/route.ts @@ -1,6 +1,7 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' -import { checkExploreTarget, ExploreError, getSystemState, queueStateFromSave, saveTaskRow } from '@/lib/game/save' +import { checkExploreTarget, ExploreError, getSystemState, makePowerOfFor, queueStateFromSave, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Scan one body of the current system: assign the crew team (lead + helpers) and queue the @@ -29,7 +30,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned const state = await queueStateFromSave(db) - const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task } = queueTask(state, { type: 'EXPLORE', payload: { planetId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'EXPLORE')) await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null }) } catch (err) { diff --git a/app/api/jump/route.ts b/app/api/jump/route.ts index f86a812..5d097b8 100644 --- a/app/api/jump/route.ts +++ b/app/api/jump/route.ts @@ -1,6 +1,7 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' -import { checkJumpTarget, getWorldState, JumpError, queueStateFromSave, saveTaskRow } from '@/lib/game/save' +import { checkJumpTarget, getWorldState, JumpError, makePowerOfFor, queueStateFromSave, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP @@ -28,7 +29,8 @@ export async function POST(req: Request) { const db = await getGameDb() const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range const state = await queueStateFromSave(db) - const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task } = queueTask(state, { type: 'JUMP', payload: { systemId, hops }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'JUMP')) await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write return Response.json(await getWorldState(db)) } catch (err) { diff --git a/app/api/research/route.ts b/app/api/research/route.ts index b808940..9e5ff85 100644 --- a/app/api/research/route.ts +++ b/app/api/research/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' import { getTechState, checkResearchPreconditions, ResearchError } from '@/lib/game/research' -import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Research one tech: assign the crew team (lead + helpers) and queue the RESEARCH task — the @@ -30,7 +31,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq const state = await queueStateFromSave(db) - const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task, inventory } = queueTask(state, { type: 'RESEARCH', payload: { techId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'RESEARCH')) await saveTaskRow(db, task) await saveInventory(db, inventory) // the cost was deducted at queue time return Response.json(await getTechState(db)) diff --git a/app/api/ship/build/route.ts b/app/api/ship/build/route.ts index a25d204..706b86e 100644 --- a/app/api/ship/build/route.ts +++ b/app/api/ship/build/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' import { checkBuildPreconditions, getShipState, ShipError } from '@/lib/game/ship' -import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Build an unbuilt ship system type at base values: assign the crew team (lead + helpers) @@ -30,7 +31,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate const state = await queueStateFromSave(db) - const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task, inventory } = queueTask(state, { type: 'SHIP_BUILD', payload: { type }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_BUILD')) await saveTaskRow(db, task) await saveInventory(db, inventory) // the cost was deducted at queue time return Response.json(await getShipState(db)) diff --git a/app/api/ship/expand/route.ts b/app/api/ship/expand/route.ts index bc29815..60ef224 100644 --- a/app/api/ship/expand/route.ts +++ b/app/api/ship/expand/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' import { checkExpandPreconditions, getShipState, ShipError } from '@/lib/game/ship' -import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Raise a built ship system's capacity by one (capped at max capacity): assign the crew team @@ -30,7 +31,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity const state = await queueStateFromSave(db) - const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task, inventory } = queueTask(state, { type: 'SHIP_EXPAND', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_EXPAND')) await saveTaskRow(db, task) await saveInventory(db, inventory) // the cost was deducted at queue time return Response.json(await getShipState(db)) diff --git a/app/api/ship/upgrade/route.ts b/app/api/ship/upgrade/route.ts index f404b07..d22c3de 100644 --- a/app/api/ship/upgrade/route.ts +++ b/app/api/ship/upgrade/route.ts @@ -1,7 +1,8 @@ import { getGameDb } from '@/lib/db/client' +import * as schema from '@/lib/db/schema' import { parseCrewTeam } from '@/app/api/crew-team' import { checkUpgradePreconditions, getShipState, ShipError } from '@/lib/game/ship' -import { queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' +import { makePowerOfFor, queueStateFromSave, saveInventory, saveTaskRow } from '@/lib/game/save' import { queueTask, TaskError } from '@/lib/game/tasks' // Level up a built ship system by one (capped at max level): assign the crew team (lead + @@ -30,7 +31,8 @@ export async function POST(req: Request) { const db = await getGameDb() await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level const state = await queueStateFromSave(db) - const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }) + const [save] = await db.db.select().from(schema.saves).limit(1) + const { task, inventory } = queueTask(state, { type: 'SHIP_UPGRADE', payload: { systemId }, leadCrewId: team.leadCrewId, helperCrewIds: team.helperCrewIds }, await makePowerOfFor(db, save, 'SHIP_UPGRADE')) await saveTaskRow(db, task) await saveInventory(db, inventory) // the cost was deducted at queue time return Response.json(await getShipState(db)) diff --git a/components/crew-panel.tsx b/components/crew-panel.tsx index f848c43..8130a4d 100644 --- a/components/crew-panel.tsx +++ b/components/crew-panel.tsx @@ -9,6 +9,12 @@ import type { CrewDto, CrewState } from '@/lib/game/save' // /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches // (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet. +// Spec §8: the three combat traits that carry no duration effect yet — their effect lands +// with the encounters iteration, so the trait line labels them encounter-ready (STANDBY). +// The label is additive — the original name always appears in the text unchanged. +const STANDBY_TRAITS = new Set(['SHARP SHOOTER', 'SHORT FUSE', 'JINXED IN A DOGFIGHT']) +const standbyLabel = (trait: string) => (STANDBY_TRAITS.has(trait) ? `${trait} (STANDBY — encounter-ready)` : trait) + interface CrewPanelProps { narrow?: boolean } @@ -80,15 +86,25 @@ export function CrewPanel({ narrow }: CrewPanelProps) { > {member.role}

+ {/* The member's four skill levels, fixed order (JC-27) — one `·`-separated line. */} +

+ {member.skills.map((s) => `${s.skill} L${s.level}`).join(' · ')} +

+ {/* The title only exists at L3+ (SENIOR) / L5 (MASTER) — titleFor returns null below. */} + {member.title !== null && ( +

+ {member.title} +

+ )}
+ - {member.traitGood} + {standbyLabel(member.traitGood)}
- - {member.traitBad} + {standbyLabel(member.traitBad)}

ORIGIN // {member.origin.toUpperCase()} diff --git a/components/team-builder.tsx b/components/team-builder.tsx index f88de85..78b5f22 100644 --- a/components/team-builder.tsx +++ b/components/team-builder.tsx @@ -4,14 +4,17 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import type { CrewDto, QueueDto } from '@/lib/game/save' import { computeDuration, type TaskType } from '@/lib/game/tasks' +import { makePowerOf, type SkillRow } from '@/lib/game/skills' // Shared crew picker for the four action panels — PR A: an action is queued as a lead + // helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState // it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel // performs its own fetch with the team fields in the body (Task 5a). The live preview is -// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call -// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask -// so the UI never offers a 409 (JC-11's fields make the member↔row check exact). +// Task 1's computeDuration (pure, no DB import) with the skill-band powerOf — Task 14's +// makePowerOf over the roster's { skill, level } rows (R-P3: the same computation the +// queueing routes make, so the preview equals the stored duration); each member row shows +// that member's power contribution for the armed action's skill. The rows mirror Task 3's +// queueTask so the UI never offers a 409 (JC-11's fields make the member↔row check exact). export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { type: TaskType hopCount?: number @@ -31,9 +34,15 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { const queuedJump = type === 'JUMP' ? (tasks.find((t) => t.type === 'JUMP') ?? null) : null const blocked = inTransit ?? queuedJump + // R-P3: the preview's powerOf is the same makePowerOf the queueing routes call (Task 14) — + // built from the roster's { skill, level } rows (JC-26), so the estimate equals the stored + // duration instead of assuming every member is power 1. + const skillRows: SkillRow[] = 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) + const lead = roster.find((c) => c.id === leadId) ?? null const helpers = roster.filter((c) => helperIds.includes(c.id)) - const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember + const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers, powerOf) : null // CrewDto ⊇ CrewMember const leadReason = (c: CrewDto) => { const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING') @@ -68,7 +77,7 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { /> setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} /> - {c.name} + {c.name} (power {powerOf(c)}) {leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''} diff --git a/docs/superpowers/plans/2026-10-01-skill-progression.md b/docs/superpowers/plans/2026-10-01-skill-progression.md new file mode 100644 index 0000000..4577c46 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-skill-progression.md @@ -0,0 +1,359 @@ +# 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 — YYYY-MM-DD HH:MM`); local verify ONLY `npx 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 deploy `docker 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 + +1. 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` (add `crew_skills` to the `DDL` template, after `tasks`), `lib/db/schema.ts` (drizzle table `crewSkills` + unique index), `lib/game/save.ts` (`SCHEMA_VERSION` 7→8; idempotent `seedCrewSkills` helper; the v8 step in `backfillSave`; a `createNewGame` hook — JC-21), `lib/db/admin.ts` (register `crew_skills` in `ADMIN_TABLES`) +- Test: extend the `backfill (old saves)` describe in `lib/game/save.test.ts` (Task 2's v7-fixture pattern: `freshFile()` + `getWorldState` + stub `schemaVersion` + reopen via `openGameDb(file)`); `lib/db/admin.test.ts`'s registry test picks up the table automatically (it derives its expected list from `schema.ts`) + +**Interfaces:** +- Consumes: `SkillName` + `STARTING_LEVELS` (`Record>`) from Task 1's `lib/game/crew-costs.ts`; `schema.crew`/`schema.saves`; `backfillSave`/`ensureSave` in `lib/game/save.ts` (now `SCHEMA_VERSION = 7` after Task 2) +- Produces (for Task 13+): the `crew_skills` table — `saveId` (FK saves), `crewId` (FK crew), `skill` (`SkillName`), `level` (0–5), `xp` — seeded to **exactly 4 rows per member** (one per skill) with `level = 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 in `crew-costs.ts` (`POWER_BANDS`) + +- [ ] **Create the branch** (PR B, off `master` post-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 in `lib/game/save.test.ts`; add `import { 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: + +```ts +// 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.crewSkills` is not exported (the test can't compile) and `SCHEMA_VERSION` is still 7. The other describes stay green (they don't reference `crewSkills`). + +- [ ] **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`): +```sql +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): +```ts +// 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): +```ts +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: +```ts + 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 up `crew_skills` automatically); `npx tsc --noEmit` clean. + +- [ ] **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 — `ensureSave` runs `backfillSave` only for pre-existing saves; a *fresh* save is built by `createNewGame`, which never backfills. So the v8 seeding is also called from `createNewGame` (the same idempotent `seedCrewSkills`) — otherwise fresh v8 saves would have no `crew_skills` rows at all. Mirrors how `seedStartingCrew`/`seedStartingShip`/`seedStartingTech` are invoked from both `createNewGame` and their backfill steps. + +### Task 13: skills.ts — XP, levels, power bands, titles (PR B) + +**Files:** +- Create: `lib/game/skills.ts` (pure — no `lib/db` imports; operates on plain `SkillRow` data from Task 12's `crew_skills` table; the frontend imports it for the CREW-tab skill display + titles, same server+client precedent as `crew-costs.ts`) and test `lib/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]); the `crew_skills` rows (Task 12) as plain `{ saveId, crewId, skill, level, xp }` +- Produces (for Task 14+): `SkillRow` interface, `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.ts` using the spec's EXACT values (run `npx vitest run lib/game/skills.test.ts` → red, the module does not exist yet): + +```ts +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 — no `lib/db` imports; frontend imports it for the CREW-tab skill display + titles, same precedent as `crew-costs.ts`): + +```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 ", ≥3 → "SENIOR ", 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, +): { 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 --noEmit` clean. + +- [ ] **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 `grantXp` emits 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 (via `titleFor` over 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` (add `makePowerOf` — 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, persist `crew_skills` in the final write; new `makePowerOfFor` route helper; imports `grantXp`/`makePowerOf`/`type SkillRow`/`ACTION_SKILL`); `lib/game/tasks.ts` (`queueTask` gains an optional `powerOf` param, forwarded to `computeDuration`); 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 passes `makePowerOfFor(db, save, type)` into `queueTask`) +- Test: extend `lib/game/save.test.ts` (the `runTurn (tick on the DB)` describe) and `lib/game/tasks.test.ts` (the `queueTask` block). No new files. + +**Interfaces:** +- Consumes: Task 4b's `runTurn(db)` orchestrator in `save.ts` — reads `saves`/`crew`/`tasks`/`inventory`/`shipSystems`/`tech` into a `QueueState`, runs `tickTurn`, writes `saves.turn` before resolving, dispatches `result.completed` through `TASK_RESOLVERS`, and ends in one whole-DB `persist(db)`; Task 13's `grantXp(skillRows, crewIds, skill, members) → { rows, events }`, `powerForLevel(level)`, `levelFromXp(xp)`, `titleFor(member, rows)`, `SkillRow`, `CrewIdentity`; Task 1's `ACTION_SKILL: Record`, the unchanged `computeDuration(type, hopCount, lead, helpers, powerOf?)`, and `CrewMember`; Task 12's `schema.crewSkills` table; Task 3's `queueTask(state, req)` / `QueueState`. +- Produces: `makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType) → (m: CrewMember) => number` in `skills.ts` — the exact `powerOf` closure 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); and `makePowerOfFor(db, save, type): Promise<(m: CrewMember) => number>` in `save.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` (`queueTask` block) — a power-band duration case: a solo member with a `powerOf` returning 2 → `queueTask(state, { type: 'EXPLORE', ... }, (m) => 2)` stores `remaining 2` (`ceil(4/2)`), while the no-`powerOf` call (default flat 1) still stores 4 — pins the new param AND its backward-compatible default. + - `lib/game/save.test.ts` (`runTurn` describe) — three cases over the 4a `buildWorldFixture`/`freshFile` pattern (patch a member's `crew_skills` row directly; a fresh save already has 4 rows per member from Task 12's seed): + 1. **XP accrual** — a 3-person EXPLORE (lead + 2 helpers) completes in one tick → all three members' `SCANNING` rows each gain exactly +1 XP (lead and helpers equally). + 2. **Level-up event** — a SCIENTIST whose `SCANNING` row is at xp 14 (L2) completes an EXPLORE → the tick writes the §6 text into `schema.events`: `AVA VANCE is now a SENIOR SCIENTIST — SCANNING L3` (xp 15 → L3 → SENIOR band). + 3. **§6 duration-shift** — a member at `SCANNING` L0/xp 0 queues an EXPLORE solo → stored `duration 4`; after 15 completed EXPLOREs (xp 15 → L3 → power 2) the next queued EXPLORE → stored `duration 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. +- [ ] **Run — expect red**: `npx vitest run lib/game/tasks.test.ts lib/game/save.test.ts` → the new `it`s red (`queueTask` has no `powerOf` param; `runTurn` doesn't read `crew_skills`, so no XP accrual, no level-up event, and the queued duration stays 4 at L3); every pre-existing test (Task 3's `queueTask` block, 4b's `runTurn` block, the 4a A/B + guard blocks) stays green. +- [ ] **Implement** — one action per file: + 1. `lib/game/skills.ts` — add `import { ACTION_SKILL } from './crew-costs'` and `import type { CrewDto } from './save'` (type-only, so no runtime cycle — the `tasks.ts` precedent), then `makePowerOf`: closes over `Map` (names are unique per single-player save) + `Map`; 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). + 2. `lib/game/tasks.ts` — `queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1)`; the one line `const duration = computeDuration(req.type, hops, lead, helpers)` becomes `computeDuration(req.type, hops, lead, helpers, powerOf)`. `computeDuration` itself is unchanged. + 3. `lib/game/save.ts` — import `grantXp`, `makePowerOf`, `type SkillRow` from `./skills` and `ACTION_SKILL` from `./crew-costs`. In `runTurn`: add `crewSkills` to the top `Promise.all` read; map the rows to `SkillRow[]` (stringify `saveId`/`crewId`) and build `members: Record` keyed by `String(crew.id)`; in the `result.completed` loop, after each `TASK_RESOLVERS[task.type](db, task)`, call `grantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members)` and reassign `skillRows` (so overlapping crew across several completions in one tick accumulate), pushing its `events` into a tick-level array; after the loop, insert the level-up events as one `schema.events` batch at `result.turn` and update every changed `crew_skills` row — both inside the existing final `persist(db)`. Add `makePowerOfFor(db, save, type)` (reads `crew` + `crew_skills`, returns `makePowerOf(roster, rows, type)`). + 4. The five queue-path routes — pass `await makePowerOfFor(db, save, type)` as the third `queueTask` arg so the stored duration uses power bands. +- [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (the three new `runTurn` cases, the new `queueTask` power-band case, and every pre-existing block); `npx tsc --noEmit` clean. +- [ ] **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 at `SCANNING` L0 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`/`saveId` are strings (Task 13) but `crew_skills.crew_id`/`save_id` and `tasks.leadCrewId`/`helpers` are numeric; `runTurn` stringifies `crew.id`/`save.id` when mapping rows to `SkillRow[]` and when building `members` and the `grantXp` `crewIds`, keeping one string-id domain end to end so `grantXp`'s `crewIds.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 the `crew_skills` updates "in the same single write" as the resolution, `runTurn` writes the level-up events as one batch insert and the `crew_skills` updates inside its existing final `persist(db)` (same `result.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 reader `getCrewState` (post-PR-A it returns the `CrewState { crew, tasks }` wrapper; this is the only lib change in the task) attaches per-member skill data to each `CrewDto`: `skills` (the member's four `crew_skills` rows as `{ skill, level }` in the fixed order NAVIGATION → SCANNING → ENGINEERING → RESEARCH) and `title` (Task 13's `titleFor({ name, role }, memberRows)` — `null` when the member's highest skill is below L3); the `CrewDto` interface gains those two fields; imports `titleFor`/`type SkillRow` from `./skills` and `type SkillName` from `./crew-costs` (type-only, no runtime cycle); the `crew_skills` select joins the existing crew read in the same `Promise.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`) from `member.skills`, and, only when `member.title` is 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's `makePowerOf`; the existing live duration preview and the dispatch control stay unchanged. +- Test: extend `lib/game/save.test.ts` — one new `it` in the `getCrewState` snapshot block for per-member `skills` + `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` with `CrewState { crew: CrewDto[]; tasks: QueueDto[] }` from `GET /api/crew` (PR A Task 6 / JC-7); Task 13's `titleFor(member: CrewIdentity, rows: SkillRow[]) → string | null` and `SkillRow`; Task 14's `makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType) → (m: CrewMember) => number`; Task 12's `crew_skills` table; Task 1's `ACTION_SKILL` / `SkillName`. +- Produces: `CrewDto.skills: { skill: SkillName; level: number }[]` (the four rows, fixed order) and `CrewDto.title: string | null` on the `GET /api/crew` response — 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`'s `getCrewState` snapshot block, over a fresh save (which Task 12 already seeds with four `crew_skills` rows per member): pin one member (e.g. a PILOT) to `NAVIGATION` L3 (xp 15) and its other three skills L0, and leave a second member all-L0. Assert the pinned member's `getCrewState(db).crew` entry carries `skills` = `[{ NAVIGATION, 3 }, { SCANNING, 0 }, { ENGINEERING, 0 }, { RESEARCH, 0 }]` (all four, fixed order) and `title` = `"SENIOR PILOT"`, and the all-L0 member's `title` = `null`. +- [ ] **Run — expect red**: `npx vitest run lib/game/save.test.ts` → the new `it` is red (`getCrewState` still returns `CrewDto` entries without `skills`/`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: + 1. `lib/game/save.ts` — in `getCrewState`, add `crew_skills` to the read and group rows by `crewId`; for each `CrewDto` set `skills` to the four fixed-order `{ skill, level }` rows and `title` to `titleFor({ name, role }, memberRows)`; add the two fields to the `CrewDto` interface and the type-only imports. + 2. `components/crew-panel.tsx` — under each member's role line, render the `·`-separated skill-level line from `member.skills` and, only when set, a `member.title` line; purely presentational. + 3. `components/team-builder.tsx` — import `makePowerOf` (and `type SkillRow`) from `@/lib/game/skills`; build `skillRows: 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 live `duration` preview and dispatch as-is. +- [ ] **Run — expect green**: `npx vitest run` → the whole unit suite passes (the new `getCrewState` skills/title `it` plus every pre-existing block); `npx tsc --noEmit` clean. 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 — `getCrewState` returns the lightweight `{ skill, level }` per member (not the full `SkillRow` with `xp`/`saveId`) to keep the `GET /api/crew` payload display-oriented; the team builder reconstructs just enough of a `SkillRow[]` (`crewId` + `skill` + `level`) to feed `makePowerOf`, 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/crew` response object gains two additive display-only fields, but per the locked design this task keeps the OpenAPI `CrewMember` schema (and its `openapi.test.ts` key pin) at its six documented keys, leaving the spec slightly under-documented; a trivial follow-up may mirror `skills`/`title` into 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) and `CHANGELOG.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. + +1. - [ ] **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. + +2. - [ ] **Version bump** — `package.json` → `"version": "0.11.0"` (from the current `0.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: the `crew_skills` table (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. + +3. - [ ] **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. + +4. - [ ] **Push + open the PR to master** — `git push -u origin feat/skill-progression`, then open a pull request to `master` on Gitea (repo `OpenCode/SpaceWanderer` at 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, `source` the repo-root `.env` (`GITEA_TOKEN`, gitignored). + +5. - [ ] **Auto-merge on green** — trigger Gitea's auto-merge: `POST /repos/OpenCode/SpaceWanderer/pulls//merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`. The PR merges automatically once the required status check `CI / build + unit + e2e (pull request)` passes (branch protection; CI runs the full e2e suite in an isolated dind stack, ~7 min). Poll the PR until `merged_at` is set (max ~15 min; poll the API every ~30s). If CI is red, **STOP and report the failure** — never force-merge. + +6. - [ ] **Tag on master** — after pulling the merge on `master`: `git tag v0.11.0 && git push origin v0.11.0`. + +7. - [ ] **Manual deploy** (CI never deploys) — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` returns the 0.11.0 payload. + +8. - [ ] **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. diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 3fc8068..5d7c1c2 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -334,7 +334,9 @@ test('CREW tab renders real roster cards, each showing both traits and an origin await expect(heading).toBeVisible(); // The card is the third div ancestor of the title (flex row → CardHeader → Card). const card = heading.locator('xpath=ancestor::div[3]'); - await expect(card.getByText(member.role)).toBeVisible(); + // exact: the new skill line renders 'ENGINEERING', which a loose 'ENGINEER' role search + // would also match (substring) — the role line is the only element with the role's exact text. + await expect(card.getByText(member.role, { exact: true })).toBeVisible(); await expect(card.getByText(member.traitGood)).toBeVisible(); await expect(card.getByText(member.traitBad)).toBeVisible(); await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible(); diff --git a/lib/db/admin.ts b/lib/db/admin.ts index 6f449d2..6ac57ce 100644 --- a/lib/db/admin.ts +++ b/lib/db/admin.ts @@ -19,6 +19,7 @@ export const ADMIN_TABLES = { missions: schema.missions, events: schema.events, tasks: schema.tasks, + crew_skills: schema.crewSkills, } 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 ccdc603..f0d8989 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -129,6 +129,16 @@ CREATE TABLE IF NOT EXISTS tasks ( status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE' queued_at_turn INTEGER NOT NULL DEFAULT 0 ); + +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) +); `; // 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 cb204a5..7cc9a08 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -153,3 +153,14 @@ export const tasks = sqliteTable('tasks', { status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE' queuedAtTurn: integer('queued_at_turn').notNull().default(0), }) + +// 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)]) diff --git a/lib/game/crew.ts b/lib/game/crew.ts index a39f9c6..0856a85 100644 --- a/lib/game/crew.ts +++ b/lib/game/crew.ts @@ -22,7 +22,9 @@ const LAST_NAMES = [ 'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL', ] -const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN'] +// The role universe: the starting-crew draw and the recruit roll both pick from here. +// Exported so tests can pin that every role is covered by crew-costs's STARTING_LEVELS. +export const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN'] const GOOD_TRAITS = [ 'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING', diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 56fad68..8984373 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -2,16 +2,17 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, describe, expect, it } from 'vitest' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' -import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew' +import { ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew' +import { STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs' import { MIN_AVAILABLE } from './missions' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { TECHS } from './tech' -import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, resolveJumpTask, resolveExploreTask, runTurn, type WorldState } from './save' +import { SCHEMA_VERSION, getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, makePowerOfFor, queueStateFromSave, resolveJumpTask, resolveExploreTask, runTurn, saveTaskRow, type WorldState } from './save' import { addCrew, buildWorldFixture, insertTaskRow } from './test-fixtures' -import type { Task } from './tasks' +import { queueTask, type Task } from './tasks' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' const dirs: string[] = [] @@ -258,6 +259,43 @@ describe('getCrewState', () => { expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role)) expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad])) }) + + it('attaches per-member skill levels + title: the four crew_skills rows in fixed order, SENIOR from L3, null below', async () => { + const { db } = await freshFile() + const { crew: seeded } = await getCrewState(db) // ensureSave seeds the starting crew + their crew_skills rows (v8) + expect(seeded.length).toBeGreaterThanOrEqual(2) + const [pinned, other] = [seeded[0], seeded[1]] + + // Pin the first member to NAVIGATION L3 (xp 15 — the L3 threshold, so levelFromXp round-trips) + // with the other three skills L0, and reset the second member to all-L0 (a specialist role + // would otherwise seed L2). + for (const skill of ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH'] as const) { + const level = skill === 'NAVIGATION' ? 3 : 0 + await db.db.update(schema.crewSkills).set({ level, xp: level > 0 ? XP_THRESHOLDS[level - 1] : 0 }).where(and(eq(schema.crewSkills.crewId, pinned.id), eq(schema.crewSkills.skill, skill))) + } + for (const r of (await db.db.select().from(schema.crewSkills)).filter((r) => r.crewId === other.id)) { + await db.db.update(schema.crewSkills).set({ level: 0, xp: 0 }).where(eq(schema.crewSkills.id, r.id)) + } + + const { crew } = await getCrewState(db) + const p = crew.find((c) => c.id === pinned.id)! + expect(p.skills).toEqual([ + { skill: 'NAVIGATION', level: 3 }, + { skill: 'SCANNING', level: 0 }, + { skill: 'ENGINEERING', level: 0 }, + { skill: 'RESEARCH', level: 0 }, + ]) + expect(p.title).toBe(`SENIOR ${p.role}`) // the pinned skill is the member's highest — L3 → SENIOR + + const o = crew.find((c) => c.id === other.id)! + expect(o.skills).toEqual([ + { skill: 'NAVIGATION', level: 0 }, + { skill: 'SCANNING', level: 0 }, + { skill: 'ENGINEERING', level: 0 }, + { skill: 'RESEARCH', level: 0 }, + ]) + expect(o.title).toBeNull() + }) }) describe('explorePlanet crew finds', () => { @@ -538,6 +576,103 @@ describe('runTurn (tick on the DB)', () => { expect(types.filter((t) => t === 'explore')).toHaveLength(1) expect((await db.db.select().from(schema.solarSystems)).filter((s) => s.isCurrent).map((s) => s.id)).toEqual([1]) // the ship did not move }) + + // PR B (#45): the fixture's crew rows never ran through createNewGame's seeder, so the + // tests here establish the crew_skills rows they need directly (insert / patch). + const seedRows = async (db: GameDb, saveId: number, crewId: number, rows: [skill: string, level: number, xp: number][]) => { + for (const [skill, level, xp] of rows) { + await db.db.insert(schema.crewSkills).values({ saveId, crewId, skill, level, xp }) + } + } + // addCrew always mints PILOTs — insert a SCIENTIST directly (the pinCrew pattern from the + // v8 backfill test above) with duration-neutral traits. + const insertScientist = async (db: GameDb, saveId: number) => + db.db.insert(schema.crew).values({ saveId, name: 'AVA VANCE', role: 'SCIENTIST', traitGood: 'FINDS HIDDEN THINGS', traitBad: 'JINXED IN A DOGFIGHT', origin: 'STARTING CREW' }).returning().then((r) => r[0]) + + it('a resolved EXPLORE grants +1 SCANNING XP to every assigned member — lead and helpers alike — and no level-up event below L3', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [c1, c2, c3] = await addCrew(db, 3) + const [save] = await db.db.select().from(schema.saves).limit(1) + for (const id of [c1, c2, c3]) await seedRows(db, save.id, id, [['NAVIGATION', 0, 0], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [c2, c3], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 }) + + const r = await runTurn(db) + expect(r.turn).toBe(1) + expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE']) + + for (const id of [c1, c2, c3]) { // every assigned member's SCANNING row gained exactly +1 XP (xp 1 < the L1 threshold 5 — still L0); the other skills are untouched + const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, id)) + expect(Object.fromEntries(rows.map((x) => [x.skill, { level: x.level, xp: x.xp }] as const))).toEqual({ NAVIGATION: { level: 0, xp: 0 }, SCANNING: { level: 0, xp: 1 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 0, xp: 0 } }) + } + expect((await db.db.select().from(schema.events)).filter((e) => e.type === 'level_up')).toHaveLength(0) // L1 is a plain level — no event + }) + + it('a SCIENTIST at SCANNING xp 14 (L2) completing an EXPLORE writes the §6 level-up event at the tick\'s new turn', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const sc = await insertScientist(db, save.id) + await seedRows(db, save.id, sc.id, [['NAVIGATION', 0, 0], ['SCANNING', 2, 14], ['ENGINEERING', 0, 0], ['RESEARCH', 2, 10]]) // one completion from L3 + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: sc.id, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 }) + + const r = await runTurn(db) + expect(r.turn).toBe(1) + expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE']) + + const levelUps = (await db.db.select().from(schema.events)).filter((e) => e.type === 'level_up') + expect(levelUps.map((e) => [e.turn, e.message] as const)).toEqual([[1, 'AVA VANCE is now a SENIOR SCIENTIST — SCANNING L3']]) // §6 text, at the tick's new turn + const scanning = (await db.db.select().from(schema.crewSkills).where(and(eq(schema.crewSkills.crewId, sc.id), eq(schema.crewSkills.skill, 'SCANNING'))))[0] + expect({ level: scanning.level, xp: scanning.xp }).toEqual({ level: 3, xp: 15 }) + }) + + it('§6 duration shift: a SCANNING L0 member queues a solo EXPLORE at stored duration 4; at L3 (xp 15) the next queued EXPLORE stores duration 2 (ceil(4/2))', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const sc = await insertScientist(db, save.id) + await seedRows(db, save.id, sc.id, [['NAVIGATION', 0, 0], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) // rookie — JC-23: SCIENTIST leads, no mismatch penalty + + // Rookie (SCANNING L0 → power 1): the queued EXPLORE stores the full 4 turns. + const first = await saveTaskRow(db, queueTask(await queueStateFromSave(db), { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: sc.id, helperCrewIds: [] }, await makePowerOfFor(db, save, 'EXPLORE')).task) + expect((await db.db.select().from(schema.tasks)).find((t) => t.id === first)!).toMatchObject({ status: 'ACTIVE', remaining: 4 }) + + // After 15 completed EXPLOREs the member is SCANNING L3 (power 2) — the next queued EXPLORE halves to 2. + await db.db.update(schema.crewSkills).set({ level: 3, xp: 15 }).where(and(eq(schema.crewSkills.crewId, sc.id), eq(schema.crewSkills.skill, 'SCANNING'))) + const second = await saveTaskRow(db, queueTask(await queueStateFromSave(db), { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: sc.id, helperCrewIds: [] }, await makePowerOfFor(db, save, 'EXPLORE')).task) + expect((await db.db.select().from(schema.tasks)).find((t) => t.id === second)!).toMatchObject({ status: 'PENDING', remaining: 2 }) // PENDING — the lead chains behind its own ACTIVE + }) + + it('a recruit found on a resolved EXPLORE is seeded with role-based starting skills — the 4-rows-per-member invariant holds (R-P6)', async () => { + const { db } = await freshFile() + const f = await buildWorldFixture(db) + const [save] = await db.db.select().from(schema.saves).limit(1) + const [c1] = await addCrew(db, 1) // a crew id (addCrew returns ids, not rows) + await seedRows(db, save.id, c1, [['NAVIGATION', 2, 10], ['SCANNING', 0, 0], ['ENGINEERING', 0, 0], ['RESEARCH', 0, 0]]) // PILOT profile + // Seed '3' + planet 1 (Alder) is the fixture's known recruit stream — the crew roll is + // recomputed here, mirroring save.ts: the item haul draws first, then the crew roll on the same stream. + await db.db.update(schema.saves).set({ seed: '3' }) + await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c1, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 1 }) + + const r = await runTurn(db) + expect(r.completed.map((t) => t.type)).toEqual(['EXPLORE']) + + const crew = await db.db.select().from(schema.crew) + expect(crew).toHaveLength(2) // exactly one join + const recruit = crew.find((c) => c.id !== c1)! + const expectedRng = explorationRng('3', f.p0) + rollExploration(expectedRng) // consume the haul draws first, exactly as save.ts does + const expectedMember = rollCrewMember(expectedRng, new Set(['CREW 1']))! + expect(recruit.name).toBe(expectedMember.name) + const levels = STARTING_LEVELS[expectedMember.role] + // The recruit gets the same profile as starting crew: role-based levels, xp = the level's threshold (levelFromXp round-trips). + const rows = await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, recruit.id)) + expect(Object.fromEntries(rows.map((x) => [x.skill, { level: x.level, xp: x.xp }] as const))).toEqual( + Object.fromEntries((Object.keys(levels) as SkillName[]).map((s) => [s, { level: levels[s], xp: levels[s] > 0 ? XP_THRESHOLDS[levels[s] - 1] : 0 }] as const)), + ) + // The seeder is idempotent — the pre-seeded member is not re-seeded: still exactly 4 rows per member. + expect((await db.db.select().from(schema.crewSkills)).length).toBe(4 * crew.length) + }) }) // --- The GET snapshots' task queue (Task 6) --------------------------------------------- @@ -718,10 +853,10 @@ describe('backfill (old saves)', () => { 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 + const world2 = await getWorldState(db) // ensureSave → backfill v6 → v7 → v8 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.saves))[0].schemaVersion).toBe(SCHEMA_VERSION) 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'") @@ -730,10 +865,56 @@ describe('backfill (old saves)', () => { 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((await reopened.db.select().from(schema.saves))[0].schemaVersion).toBe(SCHEMA_VERSION) expect(Number(reopened.raw.exec('SELECT COUNT(*) FROM tasks')[0]?.values[0][0] ?? 0)).toBe(0) }) + // 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 consistent with level, 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(SCHEMA_VERSION) + expect(await skillMap(db, pilot.id)).toEqual({ NAVIGATION: { level: 2, xp: 10 }, 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: 10 }, ENGINEERING: { level: 0, xp: 0 }, RESEARCH: { level: 2, xp: 10 } }) // 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 = the level's threshold (levelFromXp round-trips) + const expected = Object.fromEntries((Object.keys(STARTING_LEVELS[c.role]) as SkillName[]).map((s) => [s, { level: STARTING_LEVELS[c.role][s], xp: STARTING_LEVELS[c.role][s] > 0 ? XP_THRESHOLDS[STARTING_LEVELS[c.role][s] - 1] : 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(SCHEMA_VERSION) + 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 + }) + + it('pins the role universe: every role in crew.ts is a key of STARTING_LEVELS, so the v8 seeder never hits an unknown role', () => { + for (const role of ROLES) { + expect(STARTING_LEVELS[role], `STARTING_LEVELS[${role}]`).toBeDefined() + } + }) + 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 0d058f6..0e8bf81 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -1,12 +1,14 @@ -import { eq, inArray } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import type { GameDb } from '@/lib/db/client' import { persist } from '@/lib/db/client' import * as schema from '@/lib/db/schema' import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew' +import { ACTION_SKILL, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { checkMissionCompletion, topUpMissionBoard } from './missions' import { resolveResearchTask, ResearchError } from './research' import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship' +import { grantXp, makePowerOf, titleFor, type CrewIdentity, type SkillRow } from './skills' import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks' import { TECHS } from './tech' import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world' @@ -119,7 +121,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 = 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 +export const SCHEMA_VERSION = 8 // 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 · v8: crew_skills (role-based starting levels) // 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. @@ -136,6 +138,24 @@ async function seedStartingTech(db: GameDb, saveId: number): Promise { } } +// Idempotent: for each crew member with no crew_skills rows yet, seed one row per skill +// (level = STARTING_LEVELS[role][skill], xp = the threshold that level implies — levelFromXp +// must round-trip: a seeded L2 starts at xp 10, not 0, or Task 14's grantXp (which recomputes +// level from cumulative xp) would regress the member L2 → L0). Called by createNewGame and the +// v8 backfill. A role missing from STARTING_LEVELS seeds four all-L0 rows instead of throwing +// — the seeder must never brick a save. +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 + const levels = STARTING_LEVELS[m.role] ?? { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 } + for (const skill of Object.keys(levels) as SkillName[]) { + await db.db.insert(schema.crewSkills).values({ saveId, crewId: m.id, skill, level: levels[skill], xp: levels[skill] > 0 ? XP_THRESHOLDS[levels[skill] - 1] : 0 }) + } + } +} + // Creates the save + starting region when the DB has none; backfills older saves otherwise. // Safe to call on every request (single player, one process: check-then-insert cannot race). export async function ensureSave(db: GameDb): Promise { @@ -162,6 +182,7 @@ async function createNewGame(db: GameDb): Promise { const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1]) await insertRoutes(db, region.edges, resolve) await seedStartingCrew(db, save.id, seed) + await seedCrewSkills(db, save.id) 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 @@ -200,6 +221,10 @@ async function backfillSave(db: GameDb, save: { id: number; seed: string; schema // every file — new or old — so an upgraded save simply starts with an empty queue; stamp. version = 7 } + if (version < 8) { + await seedCrewSkills(db, save.id) // DDL creates the table at open; seed each member's 4 rows + version = 8 + } if (from !== SCHEMA_VERSION) { await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id)) @@ -460,6 +485,28 @@ export interface CrewDto { traitGood: string traitBad: string origin: string // 'STARTING CREW' or ', ' for members found while exploring + skills: { skill: SkillName; level: number }[] // the member's four crew_skills rows, fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (JC-27); display-oriented — no xp (JC-26) + title: string | null // 'SENIOR ' from the member's highest level ≥ 3, 'MASTER ' from ≥ 5, null below (titleFor) +} + +// The fixed display order of the four skills (JC-27, spec §6) — the CREW-tab line and the +// /api/crew payload never depend on DB row order. +const SKILL_ORDER: SkillName[] = ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH'] + +// One CrewDto from a crew row and that member's crew_skills rows: the four fixed-order +// { skill, level } entries (JC-26/27) plus the title from the member's highest level +// (titleFor — null below L3). A member with no rows (a hand-built save whose crew was never +// seeded) reads all-L0 with a null title — the same fallback as makePowerOf's missing-row → +// L0 → power 1, so a preview built from the DTO matches the queueing route's rows. +function buildCrewDto( + c: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string }, + skillRows: { crewId: number; skill: string; level: number }[], +): CrewDto { + const levelBySkill = new Map() + for (const r of skillRows) levelBySkill.set(r.skill as SkillName, r.level) + const skills = SKILL_ORDER.map((skill) => ({ skill, level: levelBySkill.get(skill) ?? 0 })) + const title = titleFor({ name: c.name, role: c.role }, skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 }))) + return { id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin, skills, title } } // GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot @@ -473,8 +520,17 @@ export interface CrewState { // Auto-initializes like the other reads. export async function getCrewState(db: GameDb): Promise { await ensureSave(db) - const crew = (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) - return { crew, tasks: await taskQueue(db) } + const [crew, skillRows] = await Promise.all([ + db.db.select().from(schema.crew), + db.db.select().from(schema.crewSkills), + ]) + const rowsByCrew = new Map() + for (const r of skillRows) { + const list = rowsByCrew.get(r.crewId) ?? [] + list.push(r) + rowsByCrew.set(r.crewId, list) + } + return { crew: crew.map((c) => buildCrewDto(c, rowsByCrew.get(c.id) ?? [])), tasks: await taskQueue(db) } } export interface ExploreResult extends SystemState { @@ -516,7 +572,8 @@ export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outc const systems = await db.db.select().from(schema.solarSystems) const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system' const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning() - crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin } + await seedCrewSkills(db, save.id) // the recruit gets role-based starting rows like the starting crew — the seeder is idempotent, so only the new member is seeded (R-P6) + crewFound = buildCrewDto(row, await db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.crewId, row.id))) } const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' @@ -589,16 +646,17 @@ function rowToTask(row: { id: number; saveId: number; type: string; payload: str export async function queueStateFromSave(db: GameDb): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) - const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([ + const [crew, taskRows, inventory, shipSystems, tech, skillRows] = await Promise.all([ db.db.select().from(schema.crew), db.db.select().from(schema.tasks), db.db.select().from(schema.inventoryItems), db.db.select().from(schema.shipSystems), db.db.select().from(schema.tech), + db.db.select().from(schema.crewSkills), ]) return { turn: save.turn, - crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })), + crew: crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id))), tasks: taskRows.map(rowToTask), inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })), shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })), @@ -640,6 +698,16 @@ export async function runTurn(db: GameDb): Promise { const [save] = await db.db.select().from(schema.saves).limit(1) const state = await queueStateFromSave(db) + // PR B (§6): the skill rows this tick's XP grant runs over — string ids end to end (JC-24): + // crew_skills' numeric id columns stringify into SkillRow's, and `members` keys off + // String(crew.id) so grantXp's crewIds.includes(r.crewId) matches. The snapshot is taken + // before any resolution: a recruit who joins mid-tick has no rows in it — and a recruit is + // never on a task that completes in the same tick. + const skillRows0: SkillRow[] = (await db.db.select().from(schema.crewSkills)).map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp })) + let skillRows = skillRows0 + const members: Record = Object.fromEntries(state.crew.map((c): [string, CrewIdentity] => [String(c.id), { name: c.name, role: c.role }])) + const levelUpEvents: string[] = [] + const result = tickTurn(state) // (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events @@ -654,12 +722,33 @@ export async function runTurn(db: GameDb): Promise { for (const task of result.completed) { try { await TASK_RESOLVERS[task.type](db, task) + // §6: every assigned member — lead and helpers equally — gains +1 XP in the action's + // skill. The rows reassign, so a member on several completions this tick accumulates, + // and the level-up events collect for the batch write below. A task whose resolver + // failed its 4a guard grants nothing: its effect did not land. + const { rows, events } = grantXp(skillRows, [String(task.leadCrewId), ...task.helpers.map(String)], ACTION_SKILL[task.type], members) + skillRows = rows + levelUpEvents.push(...events) } catch (err) { if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue throw err } } + // The tick's level-up events as one batch at the new turn, and the crew_skills rows that + // changed — both land in the final whole-DB write (JC-25): the resolution, the events and + // the skill progression commit together. Only rows whose level or xp moved are written. + for (const message of levelUpEvents) { + await db.db.insert(schema.events).values({ saveId: save.id, turn: result.turn, type: 'level_up', message, createdAt: new Date().toISOString() }) + } + const beforeSkill = new Map(skillRows0.map((r): [string, SkillRow] => [`${r.crewId}:${r.skill}`, r])) + for (const row of skillRows) { + const old = beforeSkill.get(`${row.crewId}:${row.skill}`)! + if (old.level !== row.level || old.xp !== row.xp) { + await db.db.update(schema.crewSkills).set({ level: row.level, xp: row.xp }).where(and(eq(schema.crewSkills.crewId, Number(row.crewId)), eq(schema.crewSkills.skill, row.skill))) + } + } + // Persist the tick's queue changes (the resolvers already persisted their own effects) — // one final whole-DB write makes the turn atomic on disk. for (const before of state.tasks) { @@ -672,3 +761,17 @@ export async function runTurn(db: GameDb): Promise { return result } + +// The queueing routes' power-band builder (PR B, §6): the save's roster and crew_skills rows +// folded into makePowerOf's closure for the action's skill — the same closure the live +// duration preview uses, so a queued task's stored duration matches what the crew picker +// shows. The single save row is passed in by the route rather than re-selected here. +export async function makePowerOfFor(db: GameDb, save: { id: number }, type: TaskType): Promise<(m: CrewMember) => number> { + const [crew, skillRows] = await Promise.all([ + db.db.select().from(schema.crew).where(eq(schema.crew.saveId, save.id)), + db.db.select().from(schema.crewSkills).where(eq(schema.crewSkills.saveId, save.id)), + ]) + const roster: CrewDto[] = crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id))) + const rows: SkillRow[] = skillRows.map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp })) + return makePowerOf(roster, rows, type) +} diff --git a/lib/game/skills.test.ts b/lib/game/skills.test.ts new file mode 100644 index 0000000..575f38c --- /dev/null +++ b/lib/game/skills.test.ts @@ -0,0 +1,56 @@ +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']) + }) + it('no event when a member is already at the titled level (stays at L3 after gaining xp)', () => { + // Member at SCANNING L3 (xp 16) gains 1 → xp 17, still L3 → NO event + const rows = [row('c1', 'SCANNING', 3, 16), row('c1', 'NAVIGATION', 0, 0)] + const { events } = grantXp(rows, ['c1'], 'SCANNING', { c1: { name: 'X', role: 'SCIENTIST' } }) + expect(events).toHaveLength(0) + }) +}) +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) + }) +}) diff --git a/lib/game/skills.ts b/lib/game/skills.ts new file mode 100644 index 0000000..ff82b42 --- /dev/null +++ b/lib/game/skills.ts @@ -0,0 +1,68 @@ +// 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 { ACTION_SKILL, POWER_BANDS, XP_THRESHOLDS, type SkillName } from './crew-costs' +import type { CrewMember } from './crew' +import type { CrewDto } from './save' +import type { TaskType } from './tasks' +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 ", ≥3 → "SENIOR ", 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, +): { 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 oldLevel = skillRows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0 + const newLevel = rows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0 + if (newLevel > oldLevel && (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 } +} + +// The duration formula's powerOf (Task 1) driven by the skill band (PR B, §6): each member's +// power is the band of that member's level in the action's skill (ACTION_SKILL[type]). Names +// map to crew ids (unique per single-player save); an unknown member or a missing row falls +// back to L0 → power 1 (Task 1's default). Called by save.ts's makePowerOfFor (the queueing +// routes) and — once /api/crew carries each member's skill rows — by the team builder's live +// preview (Task 15). +export function makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType): (m: CrewMember) => number { + const idByName = new Map(roster.map((c): [string, string] => [c.name, String(c.id)])) + const levelByCrew = new Map(skillRows.filter((r) => r.skill === ACTION_SKILL[type]).map((r): [string, number] => [r.crewId, r.level])) + return (m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0) +} diff --git a/lib/game/tasks.test.ts b/lib/game/tasks.test.ts index cbd5b77..052c3b2 100644 --- a/lib/game/tasks.test.ts +++ b/lib/game/tasks.test.ts @@ -93,12 +93,19 @@ describe('crew-costs — the §11 tuning surface', () => { // Shared queue fixtures (file scope — reused by the queueTask and tickTurn blocks): the roster, // a state factory (turn 10, empty queue) and a one-turn row factory. -// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills -// the CrewDto shape (unused by the duration model but part of the type). +// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` and +// `skills` fill the CrewDto shape (unused by the duration model but part of the type): +// each role's STARTING_LEVELS (all below L3, so every fixture member's title is null). +const skillsOf = (n: number, s: number, e: number, r: number) => [ + { skill: 'NAVIGATION' as const, level: n }, + { skill: 'SCANNING' as const, level: s }, + { skill: 'ENGINEERING' as const, level: e }, + { skill: 'RESEARCH' as const, level: r }, +] const CREW: CrewDto[] = [ - { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }, - { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' }, - { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' }, + { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW', skills: skillsOf(2, 0, 0, 0), title: null }, + { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW', skills: skillsOf(0, 0, 2, 0), title: null }, + { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW', skills: skillsOf(0, 2, 0, 2), title: null }, ] const state = (over: Partial = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over }) @@ -163,6 +170,15 @@ describe('queueTask', () => { expect(active.task.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]])) expect(active.task.queuedAtTurn).toBe(10) }) + + it('power-band param: a powerOf returning 2 halves a solo EXPLORE (ceil(4/2) = 2); the default flat 1 still stores 4 (backward-compatible)', () => { + // CASS (id 3) is a SCIENTIST — the preferred EXPLORE role, so no mismatch penalty; her + // trait pair (CALM IN A CRISIS −1 / FORGETS TO EAT +1) cancels on EXPLORE. + const withPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] }, () => 2) + expect(withPower.task.remaining).toBe(2) // ceil(4/2) + const defaultPower = queueTask(state(), { type: 'EXPLORE', payload: { planetId: 5 }, leadCrewId: 3, helperCrewIds: [] }) + expect(defaultPower.task.remaining).toBe(4) // no powerOf → the default flat 1 + }) }) // --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) ------------------- diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts index 0c2b2c8..67ed817 100644 --- a/lib/game/tasks.ts +++ b/lib/game/tasks.ts @@ -51,9 +51,10 @@ function traitModifier(trait: string, type: TaskType): number { // duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4. // totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the -// constant 1 until PR B feeds the skill band. The mismatch penalty and the trait modifiers -// apply to the LEAD only — helpers contribute power, are never penalised, and their traits do -// not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types). +// constant 1 — queueTask's optional powerOf param feeds the skill band instead (PR B). The +// mismatch penalty and the trait modifiers apply to the LEAD only — helpers contribute +// power, are never penalised, and their traits do not apply. `hopCount` is only meaningful +// for JUMP (pass 1 for the other types). export function computeDuration( type: TaskType, hopCount: number, @@ -119,7 +120,10 @@ export interface QueueResult { // Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all // types) → second jump (409) → cost (422, SHIP_* / RESEARCH only). Never advances the turn. -export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult { +// The optional powerOf (default: the constant 1) maps each team member to their power for +// the stored duration — the queueing routes pass makePowerOf over the roster's skill rows +// (PR B); callers without a skill context (tests, the immediate actions) use the default. +export function queueTask(state: QueueState, req: QueueTaskRequest, powerOf: (m: CrewMember) => number = () => 1): QueueResult { const lead = state.crew.find((c) => c.id === req.leadCrewId) if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`) const helpers: CrewDto[] = [] @@ -168,7 +172,7 @@ export function queueTask(state: QueueState, req: QueueTaskRequest): QueueResult // lead chaining behind its own ACTIVE can be non-free. const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING') const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0 - const duration = computeDuration(req.type, hops, lead, helpers) + const duration = computeDuration(req.type, hops, lead, helpers, powerOf) const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING' return { diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 175bc38..1d1273f 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -111,7 +111,7 @@ describe('OpenAPI spec', () => { const props = openApiSpec.components.schemas.Event.properties; expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']); - expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission']); + expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission', 'level_up']); const get = openApiSpec.paths['/api/events'].get; const limit = (get.parameters ?? []).find((p) => p.name === 'limit'); @@ -137,8 +137,13 @@ describe('OpenAPI spec', () => { expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']); expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); - const member = openApiSpec.components.schemas.CrewMember.properties; - expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']); + // R-P1: the response object is camelCase (traitGood/traitBad), and since the skill- + // progression work the member also carries its four fixed-order skill levels + title. + const member = openApiSpec.components.schemas.CrewMember as { properties: Record; required: string[] }; + expect(Object.keys(member.properties)).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']); + expect(member.required).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']); + expect((member.properties.skills as { items: { required: string[] } }).items.required).toEqual(['skill', 'level']); + expect((member.properties.title as { nullable: boolean }).nullable).toBe(true); const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] }; const extra = (exploreResult.allOf[1] as { properties: Record }).properties; diff --git a/lib/openapi.ts b/lib/openapi.ts index 9619ade..30016b3 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -976,11 +976,24 @@ export const openApiSpec = { id: { type: 'integer' }, name: { type: 'string' }, role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' }, - trait_good: { type: 'string', description: 'The member good trait' }, - trait_bad: { type: 'string', description: 'The member bad trait' }, + traitGood: { type: 'string', description: 'The member good trait' }, + traitBad: { type: 'string', description: 'The member bad trait' }, origin: { type: 'string', description: "'STARTING CREW' or ', ' for members found while exploring" }, + skills: { + type: 'array', + description: "The member's four crew_skills levels in the fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (display-oriented — levels only, no xp)", + items: { + type: 'object', + properties: { + skill: { type: 'string', enum: ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH'] }, + level: { type: 'integer' }, + }, + required: ['skill', 'level'], + }, + }, + title: { type: ['string', 'null'], nullable: true, description: "'SENIOR ' from the member's highest skill level ≥ 3, 'MASTER ' from ≥ 5 — null below" }, }, - required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'], + required: ['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title'], }, CrewState: { type: 'object', @@ -1080,7 +1093,7 @@ export const openApiSpec = { properties: { id: { type: 'integer' }, turn: { type: 'integer', description: 'The save turn the event was written (new_game is 0)' }, - type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] }, + type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission', 'level_up'] }, message: { type: 'string' }, createdAt: { type: 'string', description: 'ISO 8601 timestamp' }, }, diff --git a/package.json b/package.json index cc8f5ec..e8f710e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.10.1", + "version": "0.11.0", "private": true, "scripts": { "dev": "next dev -p 3000",