Compare commits

...
Author SHA1 Message Date
kamilkrzywda 34e8fd8514 chore(ui): address review nits — stale strip comment, test assertion (#52)
CI / build + unit + e2e (pull_request) Successful in 3m40s
2026-10-06 13:03:21 +00:00
kamilkrzywda 3ea2d1c38b chore(release): v0.14.0 — NEXT TURN button in the right pane (#52) 2026-10-06 12:51:11 +00:00
kamilkrzywda ca590c7b53 test(e2e): pin NEXT TURN full-width and no-overlap with the active-tasks strip (#52) 2026-10-06 12:51:11 +00:00
kamilkrzywda bb44530649 fix(ui): move NEXT TURN into the right pane — full width, no longer overlapped (#52) 2026-10-06 12:51:11 +00:00
kamilkrzywda 311eb5c713 Merge pull request 'feat: map spread limit — random 1-4 legs + loop-closing routes (#51, v0.13.0)' (#53) from feat/map-spread-limit into master
CI / build + unit + e2e (push) Successful in 3m15s
2026-10-06 14:40:01 +02:00
kamilkrzywda d20037762d chore: bump to v0.13.0 — #51 map spread limit + loop-closing routes
CI / build + unit + e2e (pull_request) Successful in 3m15s
2026-10-06 12:18:38 +00:00
kamilkrzywda ab22a7e68b save/e2e: #51 — wire generateRing attach into resolveJumpTask + spread-limit e2e
- resolveJumpTask: build existingEdgeKeys from all jump routes (both
  endpoints in the save's system set), pass all systems except the
  target as attachCandidates, insert closing routes for each attach.
- save.test.ts: jump test now expects 1–4 fresh systems (was pinned
  to exactly 4); A/B snapshot test stays green (both paths share the
  same resolveJumpTask code).
- e2e/helpers.ts: update stale comment ('4 fresh neighbours' →
  '1–4 new systems').
- e2e/smoke.spec.ts: new test verifying 1–4 mints, all undiscovered,
  each routed to the target, and BFS connectivity of the full graph.
2026-10-06 12:18:33 +00:00
kamilkrzywda 90de8818f8 world: #51 — ringEdges small-n fix + generateRing seeded 1–4 legs with attach
- ringEdges: n=0 → [], n=1 → [[0,1]], n=2 → [[0,1],[0,2],[1,2]];
  n≥3 wheel is byte-identical to the legacy output.
- generateRing: new params attachCandidates, existingEdgeKeys, anchorId;
  legCount is seeded-random in [EXPAND_MIN, EXPAND_MAX] (1–4);
  each leg may attach a known system (closing a loop) with prob ATTACH_PROB
  instead of minting; frontier guarantee converts the last-drawn attach
  to a mint when all legs attached.
- RingRegion gains attaches: number[] (candidate indices attached).
- Remove the EXPAND_RING constant (replaced by EXPAND_MIN/EXPAND_MAX).
2026-10-06 12:18:27 +00:00
kamilkrzywda a593cd04cd Merge pull request 'feat: trait outcome effects — FINDS HIDDEN THINGS + TALKS TO ANYONE (#43, v0.12.0)' (#50) from feat/trait-outcome-effects into master
CI / build + unit + e2e (push) Successful in 3m11s
2026-10-04 23:24:30 +02:00
kamilkrzywda 352ef31625 test(game): pin RECRUIT_CHANCE_BASE === CREW_CHANCE so the no-trait recruit path can't silently diverge (#43)
CI / build + unit + e2e (pull_request) Successful in 3m3s
2026-10-04 21:20:27 +00:00
kamilkrzywda 9af7d21d93 chore(release): v0.12.0 — trait outcome effects (#43) 2026-10-04 21:04:46 +00:00
kamilkrzywda 7d5a561882 docs: trait outcome effects implementation plan (#43) 2026-10-04 21:04:28 +00:00
kamilkrzywda 39920b3e05 feat(game): trait outcome effects — extra haul item + recruit chance (#43) 2026-10-04 21:04:24 +00:00
kamilkrzywda 92c2c26047 Merge pull request 'feat: skill progression & specialisation (#45, v0.11.0)' (#49) from feat/skill-progression into master
CI / build + unit + e2e (push) Successful in 3m2s
2026-10-04 05:52:27 +02:00
kamilkrzywda b8a998dcbb chore(release): v0.11.0 — skill progression & specialisation (#45)
CI / build + unit + e2e (pull_request) Successful in 2m52s
2026-10-04 03:49:06 +00:00
kamilkrzywda 1a36160392 feat(ui): skill levels + titles on CREW tab and team-builder (#45)
CI / build + unit + e2e (pull_request) Successful in 3m7s
2026-10-04 03:22:51 +00:00
kamilkrzywda 9d1a8e9d89 fix(game): grantXp event fires only on level transition, not while at-band (#45)
CI / build + unit + e2e (pull_request) Successful in 3m8s
2026-10-04 02:50:47 +00:00
kamilkrzywda f3751b4747 feat(game): skill XP on task completion + power bands in duration (#45)
CI / build + unit + e2e (pull_request) Successful in 2m48s
2026-10-04 02:34:56 +00:00
kamilkrzywda 5bc411cb76 fix(db): consistent starting xp in crew_skills seeder (#45) 2026-10-04 02:19:36 +00:00
kamilkrzywda 7f2121a717 feat(game): skills.ts — XP, levels, power bands, titles (#45)
CI / build + unit + e2e (pull_request) Successful in 2m53s
2026-10-04 01:33:05 +00:00
kamilkrzywda 449d4b9895 feat(db): crew_skills table + role-based starting levels — schema v8 (#45)
CI / build + unit + e2e (pull_request) Successful in 2m48s
2026-10-04 00:56:30 +00:00
kamilkrzywda 678cd48a94 Merge pull request 'fix: hydration-safe banner clock (#44, v0.10.1)' (#47) from fix/hydration-jump-dispatch into master
CI / build + unit + e2e (push) Successful in 3m32s
2026-10-04 01:52:28 +02:00
kamilkrzywda e792c8a06c fix(ui): team builder — coalesce the queued-jump block to null so an empty queue no longer disables JUMP dispatch (#44)
CI / build + unit + e2e (pull_request) Successful in 2m38s
2026-10-03 23:42:32 +00:00
kamilkrzywda 8f018a6ee9 chore(release): v0.10.1 — hydration-safe banner clock (#44)
CI / build + unit + e2e (pull_request) Failing after 33m24s
2026-10-03 18:36:40 +00:00
kamilkrzywda f1633eb518 fix(ui): hydration-safe banner clock — no build-time clock in the prerendered banner (#44, v0.10.1) 2026-10-03 18:36:19 +00:00
kamilkrzywda 1993623548 docs(agents): next.js agent rules block (added by next dev) 2026-10-03 18:36:15 +00:00
32 changed files with 1549 additions and 118 deletions
+10
View File
@@ -19,3 +19,13 @@
## Other ## Other
1. For longer terminal tasks you should use pty_* tools. 1. For longer terminal tasks you should use pty_* tools.
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## v0.14.0 — 2026-10-06 12:50
- The NEXT TURN button now lives inside the right pane (full width of the pane, larger) instead of floating as a fixed bottom-right overlay — it can no longer be covered by the active-tasks strip (#52).
- The button grew from the SM to the MD size and spans the whole pane content width; it pins to the pane bottom when the content is short and scrolls with it when the cargo list is long. The `TURN <n>` counter, the EXEC styling and the in-flight behaviour are unchanged.
- E2E: new spec pins the button full-width in the right pane (≥ 280px, within ±8px of the CARGO HOLD panel) and asserts its bounding box does not intersect the active-tasks strip's while a task is queued.
## v0.13.0 — 2026-10-06 12:17
- Jump spread limit (#51): jumping into an undiscovered system now mints a seeded-random 1–4 new systems (was a fixed 4). Each leg can instead attach an already-known system (closing a loop) with probability 0.35, so the chart grows slower and more circular. A frontier guarantee ensures at least one new system is always minted.
- `ringEdges` small-n fix: n=1 no longer emits a `[1,1]` self-loop, n=2 no longer emits a duplicate edge. n≥3 output is byte-identical to the legacy wheel.
- `generateRing` gains `attachCandidates`, `existingEdgeKeys`, and `anchorId` parameters; `RingRegion` gains an `attaches` field. The `EXPAND_RING` constant is removed in favour of `EXPAND_MIN`/`EXPAND_MAX`.
## v0.12.0 — 2026-10-04 21:04
- FINDS HIDDEN THINGS now grants +1 exploration haul item — one extra roll on the save's seeded per-planet stream, appended to the scan's haul.
- TALKS TO ANYONE now raises the crew-recruit chance from 18% to 30% when a member with the trait is on the exploring team.
- Determinism preserved: both effects draw from the existing seeded stream (same save+planet+team → identical haul), unit-tested with and without each trait; the no-trait path is byte-identical to v0.11.0.
## 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`).
## v0.10.0 — 2026-10-03 00:57 ## v0.10.0 — 2026-10-03 00:57
- Queue-based actions (#44): every game action (jump, explore, ship upgrade/expand/build, research) is now a queued task with an assigned team — a lead + optional helpers. Validation and cost deduction happen at queue time (unknown crew → 404, busy/committed crew → 409, in-transit or second jump → 409); effects apply when the task completes, mission accept is instant and free, and mission-completion checks run when the satisfying task resolves. - Queue-based actions (#44): every game action (jump, explore, ship upgrade/expand/build, research) is now a queued task with an assigned team — a lead + optional helpers. Validation and cost deduction happen at queue time (unknown crew → 404, busy/committed crew → 409, in-transit or second jump → 409); effects apply when the task completes, mission accept is instant and free, and mission-completion checks run when the satisfying task resolves.
- Manual time: new `POST /api/turn` advances exactly one turn — each active task ticks down, completions resolve via per-type resolvers (reusing the existing jump/explore/ship/research logic), FIFO successors auto-start; an empty queue is a free skip. `save.turn` now only increments here, and the UI gets the NEXT TURN button (bottom-right) with the turn counter. - Manual time: new `POST /api/turn` advances exactly one turn — each active task ticks down, completions resolve via per-type resolvers (reusing the existing jump/explore/ship/research logic), FIFO successors auto-start; an empty queue is a free skip. `save.turn` now only increments here, and the UI gets the NEXT TURN button (bottom-right) with the turn counter.
+4 -2
View File
@@ -1,6 +1,7 @@
import { getGameDb } from '@/lib/db/client' import { getGameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { parseCrewTeam } from '@/app/api/crew-team' 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' import { queueTask, TaskError } from '@/lib/game/tasks'
// Scan one body of the current system: assign the crew team (lead + helpers) and queue the // 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() const db = await getGameDb()
await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned await checkExploreTarget(db, planetId) // existing pre-checks: 404 unknown body · 409 already scanned
const state = await queueStateFromSave(db) 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 await saveTaskRow(db, task) // inventory is unchanged until the scan resolves — no hold write
return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null }) return Response.json({ ...(await getSystemState(db)), outcome: [], crewFound: null })
} catch (err) { } catch (err) {
+4 -2
View File
@@ -1,6 +1,7 @@
import { getGameDb } from '@/lib/db/client' import { getGameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { parseCrewTeam } from '@/app/api/crew-team' 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' import { queueTask, TaskError } from '@/lib/game/tasks'
// Jump to a system within range: assign the crew team (lead + helpers) and queue the JUMP // 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 db = await getGameDb()
const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range const hops = await checkJumpTarget(db, systemId) // existing pre-checks: 404 unknown · 409 already there / out of range
const state = await queueStateFromSave(db) 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 await saveTaskRow(db, task) // inventory is unchanged for a JUMP — no hold write
return Response.json(await getWorldState(db)) return Response.json(await getWorldState(db))
} catch (err) { } catch (err) {
+4 -2
View File
@@ -1,7 +1,8 @@
import { getGameDb } from '@/lib/db/client' import { getGameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { parseCrewTeam } from '@/app/api/crew-team' import { parseCrewTeam } from '@/app/api/crew-team'
import { getTechState, checkResearchPreconditions, ResearchError } from '@/lib/game/research' 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' import { queueTask, TaskError } from '@/lib/game/tasks'
// Research one tech: assign the crew team (lead + helpers) and queue the RESEARCH task — the // 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() const db = await getGameDb()
await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq await checkResearchPreconditions(db, techId) // existing pre-checks: 404 unknown · 409 already researched / missing prereq
const state = await queueStateFromSave(db) 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 saveTaskRow(db, task)
await saveInventory(db, inventory) // the cost was deducted at queue time await saveInventory(db, inventory) // the cost was deducted at queue time
return Response.json(await getTechState(db)) return Response.json(await getTechState(db))
+4 -2
View File
@@ -1,7 +1,8 @@
import { getGameDb } from '@/lib/db/client' import { getGameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { parseCrewTeam } from '@/app/api/crew-team' import { parseCrewTeam } from '@/app/api/crew-team'
import { checkBuildPreconditions, getShipState, ShipError } from '@/lib/game/ship' 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' import { queueTask, TaskError } from '@/lib/game/tasks'
// Build an unbuilt ship system type at base values: assign the crew team (lead + helpers) // 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() const db = await getGameDb()
await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate await checkBuildPreconditions(db, type) // existing pre-checks: 404 unknown type · 409 already built / build gate
const state = await queueStateFromSave(db) 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 saveTaskRow(db, task)
await saveInventory(db, inventory) // the cost was deducted at queue time await saveInventory(db, inventory) // the cost was deducted at queue time
return Response.json(await getShipState(db)) return Response.json(await getShipState(db))
+4 -2
View File
@@ -1,7 +1,8 @@
import { getGameDb } from '@/lib/db/client' import { getGameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { parseCrewTeam } from '@/app/api/crew-team' import { parseCrewTeam } from '@/app/api/crew-team'
import { checkExpandPreconditions, getShipState, ShipError } from '@/lib/game/ship' 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' import { queueTask, TaskError } from '@/lib/game/tasks'
// Raise a built ship system's capacity by one (capped at max capacity): assign the crew team // 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() const db = await getGameDb()
await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity await checkExpandPreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max capacity
const state = await queueStateFromSave(db) 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 saveTaskRow(db, task)
await saveInventory(db, inventory) // the cost was deducted at queue time await saveInventory(db, inventory) // the cost was deducted at queue time
return Response.json(await getShipState(db)) return Response.json(await getShipState(db))
+4 -2
View File
@@ -1,7 +1,8 @@
import { getGameDb } from '@/lib/db/client' import { getGameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema'
import { parseCrewTeam } from '@/app/api/crew-team' import { parseCrewTeam } from '@/app/api/crew-team'
import { checkUpgradePreconditions, getShipState, ShipError } from '@/lib/game/ship' 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' import { queueTask, TaskError } from '@/lib/game/tasks'
// Level up a built ship system by one (capped at max level): assign the crew team (lead + // 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() const db = await getGameDb()
await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level await checkUpgradePreconditions(db, systemId) // existing pre-checks: 404 unknown · 409 not built / max level
const state = await queueStateFromSave(db) 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 saveTaskRow(db, task)
await saveInventory(db, inventory) // the cost was deducted at queue time await saveInventory(db, inventory) // the cost was deducted at queue time
return Response.json(await getShipState(db)) return Response.json(await getShipState(db))
+18 -2
View File
@@ -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 // /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. // (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 { interface CrewPanelProps {
narrow?: boolean narrow?: boolean
} }
@@ -80,15 +86,25 @@ export function CrewPanel({ narrow }: CrewPanelProps) {
> >
{member.role} {member.role}
</p> </p>
{/* The member's four skill levels, fixed order (JC-27) — one `·`-separated line. */}
<p style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
{member.skills.map((s) => `${s.skill} L${s.level}`).join(' · ')}
</p>
{/* The title only exists at L3+ (SENIOR) / L5 (MASTER) — titleFor returns null below. */}
{member.title !== null && (
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>
{member.title}
</p>
)}
</CardHeader> </CardHeader>
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}> <CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}> <div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-green)' }}>+ </span> <span style={{ color: 'var(--color-green)' }}>+ </span>
<span style={{ color: 'var(--text-secondary)' }}>{member.traitGood}</span> <span style={{ color: 'var(--text-secondary)' }}>{standbyLabel(member.traitGood)}</span>
</div> </div>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}> <div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-red)' }}>- </span> <span style={{ color: 'var(--color-red)' }}>- </span>
<span style={{ color: 'var(--text-secondary)' }}>{member.traitBad}</span> <span style={{ color: 'var(--text-secondary)' }}>{standbyLabel(member.traitBad)}</span>
</div> </div>
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}> <p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
ORIGIN // {member.origin.toUpperCase()} ORIGIN // {member.origin.toUpperCase()}
+20 -8
View File
@@ -63,6 +63,9 @@ export function MissionControl() {
const [activeTab, setActiveTab] = useState<string | null>(null) const [activeTab, setActiveTab] = useState<string | null>(null)
// #38 — admin DB CRUD overlay, opened from the header. // #38 — admin DB CRUD overlay, opened from the header.
const [adminOpen, setAdminOpen] = useState(false) const [adminOpen, setAdminOpen] = useState(false)
// #44 (v0.10.1) — true only after hydration: the banner clock must render '' in both SSR and the
// first client render, otherwise the prerendered (build-time) string desyncs at hydration.
const [hydrated, setHydrated] = useState(false)
// #44 — the four mount fetches, extracted into a callback so the NEXT TURN button (5b) can // #44 — the four mount fetches, extracted into a callback so the NEXT TURN button (5b) can
// re-run them after /api/turn: a tick can change all four snapshots (the turn always; inventory // re-run them after /api/turn: a tick can change all four snapshots (the turn always; inventory
@@ -120,6 +123,9 @@ export function MissionControl() {
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
// #44 (v0.10.1) — flip after hydration so the banner clock can start (SSR/first render both '').
useEffect(() => setHydrated(true), [])
// #27 — restore the persisted tab once on mount (validated against TAB_IDS). // #27 — restore the persisted tab once on mount (validated against TAB_IDS).
useEffect(() => { useEffect(() => {
const stored = window.localStorage.getItem(ACTIVE_TAB_KEY) const stored = window.localStorage.getItem(ACTIVE_TAB_KEY)
@@ -139,7 +145,9 @@ export function MissionControl() {
.catch(() => {}) .catch(() => {})
}, []) }, [])
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false }) // #44 (v0.10.1) — hydration-safe: '' until hydrated (SSR and first client render agree), then the
// live time on the 1s tick — no more build-time clock baked into the prerendered HTML.
const timeStr = hydrated ? new Date().toLocaleTimeString('en-GB', { hour12: false }) : ''
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS // Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
// tab via the same pure helpers the API enforces (lib/game/tech.ts). // tab via the same pure helpers the API enforces (lib/game/tech.ts).
@@ -521,6 +529,14 @@ export function MissionControl() {
)} )}
</PanelContent> </PanelContent>
</Panel> </Panel>
{/* #52 — the manual tick control (5b), moved in from the fixed bottom-right overlay:
last child of the pane's flex column — full width, size MD, pinned to the pane
bottom when the content is short (the wrapper's marginTop:auto) and scrolling
with the pane when the cargo list is long. In normal flow, the active-tasks strip
can no longer paint over it; the pane (unlike the tab content) is outside Radix's
unmount, so it stays visible on every tab. */}
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
</div> </div>
</div> </div>
</div> </div>
@@ -559,13 +575,9 @@ export function MissionControl() {
<Badge variant="ACTIVE">SYS ONLINE</Badge> <Badge variant="ACTIVE">SYS ONLINE</Badge>
</footer> </footer>
{/* #44 — the manual tick control (5b): fixed bottom-right, outside the tab grid so it {/* #44 — the active-tasks strip: fixed bottom-left, outside the tab grid so it stays
stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes visible on every tab. (#52 moved the NEXT TURN button out of this fixed-overlay area
the snapshots this shell owns) */} and into the right pane, so the two can no longer overlap.) Data: the shell's system
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
{/* #44 — the active-tasks strip: fixed bottom-left (same mount convention as the button —
outside the tab grid so it stays visible on every tab). Data: the shell's system
snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on
every shell refresh with no fetch of its own. */} every shell refresh with no fetch of its own. */}
<TaskStrip tasks={system?.tasks ?? []} /> <TaskStrip tasks={system?.tasks ?? []} />
+14 -13
View File
@@ -3,12 +3,16 @@
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from // NEXT TURN — the manual tick control (5b): a normal-flow block in the right pane (#52) carrying
// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell // the current turn from the ship snapshot the shell holds. One click = POST /api/turn (no body);
// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an // on success the shell re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by
// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an // game state — an empty queue is a FREE SKIP (the route always returns 200) — and the only
// in-flight request, which the handler ignores. `disabled` is never set, so the cva // transient is an in-flight request, which the handler ignores. `disabled` is never set, so the
// disabled:opacity-40 styling never applies either. // cva disabled:opacity-40 styling never applies either.
// #52 — the fixed bottom-right overlay became the right pane's last flex child (mission-control):
// full width, size MD, pinned to the pane bottom when the content is short (marginTop:auto) and
// scrolling with the pane when the cargo list is long. No position/zIndex any more — so the
// active-tasks strip can no longer paint over it.
export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) { export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
const [inFlight, setInFlight] = useState(false) const [inFlight, setInFlight] = useState(false)
@@ -30,14 +34,11 @@ export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTick
return ( return (
<div <div
style={{ style={{
position: 'fixed',
right: '1rem',
bottom: '1rem',
zIndex: 60, // above the header/footer's 50
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'flex-end', gap: '0.5rem',
gap: '0.375rem', width: '100%',
marginTop: 'auto',
}} }}
> >
{error && ( {error && (
@@ -46,7 +47,7 @@ export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTick
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}> <span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
TURN {turn ?? '—'} TURN {turn ?? '—'}
</span> </span>
<Button variant="EXEC" size="SM" onClick={tick}> <Button variant="EXEC" size="MD" className="w-full" onClick={tick}>
NEXT TURN NEXT TURN
</Button> </Button>
</div> </div>
+2 -1
View File
@@ -2,7 +2,8 @@
import type { QueueDto } from '@/lib/game/save' import type { QueueDto } from '@/lib/game/save'
// TASK STRIP — the save's task queue, fixed bottom-left next to the NEXT TURN button (#44). // TASK STRIP — the save's task queue, fixed bottom-left (#44); the NEXT TURN button moved into
// the right pane in #52, so the two can no longer overlap.
// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING // The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING
// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows, // block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows,
// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on // and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on
+20 -7
View File
@@ -4,14 +4,17 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { CrewDto, QueueDto } from '@/lib/game/save' import type { CrewDto, QueueDto } from '@/lib/game/save'
import { computeDuration, type TaskType } from '@/lib/game/tasks' 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 + // 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 // 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 // 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 // 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 // Task 1's computeDuration (pure, no DB import) with the skill-band powerOf — Task 14's
// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask // makePowerOf over the roster's { skill, level } rows (R-P3: the same computation the
// so the UI never offers a 409 (JC-11's fields make the member↔row check exact). // 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 }: { export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
type: TaskType type: TaskType
hopCount?: number hopCount?: number
@@ -23,13 +26,23 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
const [helperIds, setHelperIds] = useState<number[]>([]) const [helperIds, setHelperIds] = useState<number[]>([])
// Whole-builder blocks: an ACTIVE jump is in transit (all types); in the jump panel any // Whole-builder blocks: an ACTIVE jump is in transit (all types); in the jump panel any
// queued jump is — the second-jump 409 (JC-12). // queued jump is — the second-jump 409 (JC-12). The JUMP branch coalesces find()'s
// undefined to null so blocked is strictly QueueDto | null: the roster ternary below tests
// truthiness, but the button guard compares to null — undefined slipped past it and
// disabled every empty-queue JUMP dispatch (v0.10.1 e2e jump regression).
const inTransit = tasks.find((t) => t.type === 'JUMP' && t.status === 'ACTIVE') const inTransit = tasks.find((t) => t.type === 'JUMP' && t.status === 'ACTIVE')
const blocked = inTransit ?? (type === 'JUMP' ? tasks.find((t) => t.type === 'JUMP') : null) 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 lead = roster.find((c) => c.id === leadId) ?? null
const helpers = roster.filter((c) => helperIds.includes(c.id)) 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 leadReason = (c: CrewDto) => {
const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING') const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
@@ -64,7 +77,7 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
/> />
<input type="checkbox" checked={helperIds.includes(c.id)} disabled={leadId === c.id || helperReason(c) !== null} onChange={() => setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} /> <input type="checkbox" checked={helperIds.includes(c.id)} disabled={leadId === c.id || helperReason(c) !== null} onChange={() => setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} />
<span style={{ fontSize: '0.65rem', color: leadId === c.id ? 'var(--text-secondary)' : 'var(--text-muted)', letterSpacing: '0.08em' }}> <span style={{ fontSize: '0.65rem', color: leadId === c.id ? 'var(--text-secondary)' : 'var(--text-muted)', letterSpacing: '0.08em' }}>
{c.name} {c.name} (power {powerOf(c)})
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''} {leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
</span> </span>
</label> </label>
@@ -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<version> — 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<version> 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<string, Record<SkillName, number>>`) 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 <ROLE>", ≥3 → "SENIOR <ROLE>", below → null.
export function titleFor(member: CrewIdentity, skillRows: SkillRow[]): string | null {
const highest = skillRows.reduce((max, r) => Math.max(max, r.level), 0)
if (highest >= 5) return `MASTER ${member.role}`
if (highest >= 3) return `SENIOR ${member.role}`
return null
}
// +1 XP to every listed member's row for `skill`; returns updated rows + any level-up events. An event
// fires when the gained skill steps into a titled band (3 → SENIOR, 5 → MASTER), e.g. "BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3" (title from the member's HIGHEST skill).
export function grantXp(
skillRows: SkillRow[],
crewIds: string[],
skill: SkillName,
members: Record<string, CrewIdentity>,
): { rows: SkillRow[]; events: string[] } {
const rows = skillRows.map((r) =>
r.skill === skill && crewIds.includes(r.crewId)
? { ...r, xp: r.xp + 1, level: levelFromXp(r.xp + 1) }
: r,
)
const events: string[] = []
for (const crewId of crewIds) {
const member = members[crewId]
if (!member) continue
const newLevel = rows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
if (newLevel === 3 || newLevel === 5) {
const title = titleFor(member, rows.filter((r) => r.crewId === crewId))
if (title) events.push(`${member.name} is now a ${title} — ${skill} L${newLevel}`)
}
}
return { rows, events }
}
```
- [ ] **Run — expect green**: `npx vitest run lib/game/skills.test.ts` → green (both levelFromXp/powerForLevel tables, the three grantXp crossings, the two titleFor cases); `npx tsc --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<TaskType, SkillName>`, 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<name, crewId>` (names are unique per single-player save) + `Map<crewId, level-in-ACTION_SKILL[type]>`; returns `(m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0)` — an unknown member / no row falls back to L0 → power 1 (Task 1's default).
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<string, CrewIdentity>` 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<CrewState>` 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/<n>/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.
@@ -0,0 +1,77 @@
# Trait Outcome Effects Implementation Plan (PR C — Gitea #43, v0.12.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:** The two remaining good traits (FINDS HIDDEN THINGS, TALKS TO ANYONE) get deterministic mechanical effects in the explore resolver.
**Architecture:** A small change to the explore resolver's seeded stream (PR A plan Task 4a) plus the recruit-chance constant; no new tables.
**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 (§5)
**Upstream:** Builds on the post-v0.11.0 state; the explore resolver it modifies is defined in the PR A plan (`resolveExploreTask`, Task 4a); task numbering continues from the PR B plan (12–16).
## Global Constraints
- AGENTS.md change process per PR: feature branch off master, no direct pushes to master; version bump in package.json + CHANGELOG entry at top (`## v<version> — YYYY-MM-DD HH:MM`); local verify 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<version> 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.
5. Seeded-stream draw order must be fixed regardless of traits: an extra haul roll for FINDS HIDDEN THINGS shifts later draws on that per-planet stream — pin the exploration outcome as a pure function of (seed, planetId, assigned crew) with determinism tests both with and without the trait.
---
### Task 17: Trait Outcome Effects — extra haul item + recruit chance (PR C — Gitea issue #43)
**Files:**
- `lib/game/save.ts` — modify `resolveExploreTask` (defined in PR A, Task 4a): insert the FINDS HIDDEN THINGS extra haul-item roll between the regular haul and the recruit roll on the per-planet seeded stream; switch the recruit chance from the hardcoded 0.18 to the `RECRUIT_CHANCE_TALKER` / `RECRUIT_CHANCE_BASE` constants from `crew-costs.ts`
- `lib/game/save.test.ts` — extend the existing `describe('resolveExploreTask (A/B against explorePlanet)')` block with three new `it` cases
- `lib/game/crew-costs.ts` (created in PR A, Task 1) — consume `RECRUIT_CHANCE_BASE` (0.18) and `RECRUIT_CHANCE_TALKER` (0.30); no new constants added
- `lib/game/crew.ts` — add an optional `chance?: number` parameter to `rollCrewMember` (JC-30); default `CREW_CHANCE` so the no-trait path is bit-identical
**Interfaces:**
- **Consumes:** `explorationRng(seed, planetId)`, `rollExploration(rng)`, `rollCrewMember(rng, takenNames)` — all already in `save.ts` from PR A; `RECRUIT_CHANCE_BASE` / `RECRUIT_CHANCE_TALKER` from `./crew-costs`; the `Task` row's `leadCrewId` and `helpers` (assigned crew); crew trait columns (`traitGood` / `traitBad`) from the `crew` table
- **Produces:** `resolveExploreTask(db, task) → Promise<{outcome: FoundItem[]; crewFound: CrewDto | null}>` — same signature and return shape; the trait-modified draw order is internal and invisible to callers (`explorePlanet`, the tick resolver)
1. - [ ] **Write tests** — extend the `resolveExploreTask` describe block in `lib/game/save.test.ts`; add `import { RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER } from './crew-costs'` to the import. Three new `it` cases:
- **Regression pin** — an explore with NO trait-bearing crew assigned produces a byte-identical haul + recruit outcome to the pre-change (PR A) behavior for the same seed and planet.
- **Review Focus 5 pin** — same seed + same planet; compare assignment [A] (no traits) vs [A, B] where B has FINDS HIDDEN THINGS: the first N haul items are identical between the two, the FHT run has exactly one extra item, and the whole outcome (haul + recruit) is reproducible across repeated runs of the same save.
- **TALKS TO ANYONE constant pin** — with a TALKS TO ANYONE member assigned the recruit roll uses 0.30: assert `RECRUIT_CHANCE_BASE === 0.18` and `RECRUIT_CHANCE_TALKER === 0.30`, and (via a seeded case or by asserting the exact constant is consumed by `rollCrewMember`) that the TALKS TO ANYONE path selects 0.30.
2. - [ ] **Run — expect failure**: `npx vitest run lib/game/save.test.ts` → the three new tests fail: the current code has no extra haul roll and uses the hardcoded 0.18 recruit chance.
3. - [ ] **Implement** — in `resolveExploreTask` (`lib/game/save.ts`): read assigned crew ids from `task.leadCrewId` + `task.helpers`; load their trait columns; set `hasFHT` / `hasTalker` booleans; if `hasFHT`, call `rollExploration(rng)` one additional time on the same seeded stream and append the result to `outcome`; pass `hasTalker ? RECRUIT_CHANCE_TALKER : RECRUIT_CHANCE_BASE` as the recruit chance to `rollCrewMember` (add an optional `chance` param defaulting to `RECRUIT_CHANCE_BASE` if not already present — see JC-30).
4. - [ ] **Run — expect green**: `npx vitest run lib/game/save.test.ts` → all tests pass (the three new ones plus all pre-existing PR A tests).
5. - [ ] **Commit**: `git commit -m "feat(game): trait outcome effects — extra haul item + recruit chance (#43)"`
**Judgment calls**
- JC-29 — Trait-name matching uses the literal strings `'FINDS HIDDEN THINGS'` and `'TALKS TO ANYONE'` from the design spec §5, checking both `traitGood` and `traitBad` columns (defensive; in practice both are good traits). If PR A introduced trait-name constants in `crew-costs.ts` or elsewhere, use those instead — verify at implementation time.
- JC-30 — `rollCrewMember`'s current signature (PR A code is the source of truth) may or may not accept a chance parameter. If 0.18 is hardcoded inside the function, add an optional `chance?: number` param defaulting to `RECRUIT_CHANCE_BASE` so the no-trait path is bit-identical; if it already takes one, just pass the correct value.
### Task 18: Ship v0.12.0 (PR C — Gitea issue #43)
**Files:**
- `docs/superpowers/plans/2026-10-01-trait-outcome-effects.md` — this plan (commit on the PR C branch; the spec doc is unchanged — already committed in PR A)
- `package.json` (version bump 0.11.0 → 0.12.0)
- `CHANGELOG.md` (new top entry)
This is a process task that ships the PR C already built by Task 17. No failing-test step; ends in a verification gate.
1. - [ ] **Commit the PR C plan doc on the PR C branch** (`feat/trait-outcome-effects`, created in Task 17) — only this plan file needs committing (spec unchanged): `git add docs/superpowers/plans/2026-10-01-trait-outcome-effects.md && git commit -m "docs: trait outcome effects implementation plan (#43)"`.
2. - [ ] **Version bump** — `package.json` → `"version": "0.12.0"` (from 0.11.0). `CHANGELOG.md`: new top entry `## v0.12.0 — YYYY-MM-DD HH:MM` (fill date/time at execution) with bullets: FINDS HIDDEN THINGS grants +1 exploration haul item; TALKS TO ANYONE raises crew-recruit chance 18% → 30%.
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). Do NOT run `npx playwright test`.
4. - [ ] **Push + open the PR to master** — `git push -u origin feat/trait-outcome-effects`, then open a PR to `master` on Gitea (repo `OpenCode/SpaceWanderer`). Credentials per JC-20 (gitea MCP preferred, else `GITEA_TOKEN` from repo-root `.env`).
5. - [ ] **Auto-merge on green** — `POST /repos/OpenCode/SpaceWanderer/pulls/<n>/merge` body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`. Wait for CI (~7 min); poll until `merged_at` is set. If CI is red, **STOP and report the failure** — do not fix it in this step.
6. - [ ] **Tag on master** — `git checkout master && git pull && git tag v0.12.0 && git push origin v0.12.0`.
7. - [ ] **Manual deploy** — `docker compose up -d --build` in the repo root, then verify `curl -s https://space-wanderer.dev.kkhost.pl/api/version` reports 0.12.0.
8. - [ ] **Close Gitea issue #43** — close #43 with a comment summarizing the shipped state (v0.12.0, tag v0.12.0, live deploy verified).
**Verification gate (whole task):** `/api/version` = 0.12.0 AND Gitea issue #43 is closed.
**Judgment calls** — none (process task mirroring the PR A plan's Task 11; no new judgment calls arise here).
+7 -6
View File
@@ -49,12 +49,13 @@ export async function resolveAction(page: Page, path: string, data: Record<strin
} }
// Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an // Jump to an adjacent system, preferring one that is still undiscovered. Every jump into an
// undiscovered system generates 4 fresh neighbours (lib/game/world.ts), so fresh territory is // undiscovered system mints a seeded-random 1–4 new systems (at least one always minted —
// always available — picking the FIRST edge instead ping-pongs between exhausted systems, because // lib/game/world.ts #51), so fresh territory is always available — picking the FIRST edge
// edges come back in insertion order and the oldest route out of a system is usually the one that // instead ping-pongs between exhausted systems, because edges come back in insertion order and
// led here. Falls back to any non-current adjacent system if none is undiscovered. The jump is // the oldest route out of a system is usually the one that led here. Falls back to any
// queued with a default one-person crew and ticked to completion (resolveAction) before this // non-current adjacent system if none is undiscovered. The jump is queued with a default
// returns, so the caller is already standing in the target system, not merely en route to it. // one-person crew and ticked to completion (resolveAction) before this returns, so the caller
// is already standing in the target system, not merely en route to it.
export async function jumpToFreshSystem(page: Page): Promise<WorldState> { export async function jumpToFreshSystem(page: Page): Promise<WorldState> {
const world = (await (await page.request.get('/api/world')).json()) as WorldState; const world = (await (await page.request.get('/api/world')).json()) as WorldState;
const currentId = world.systems.find((s) => s.current)!.id; const currentId = world.systems.find((s) => s.current)!.id;
+87 -1
View File
@@ -199,6 +199,46 @@ test('jumping to an adjacent system updates the world state and turn counter', a
expect(after.systems.find((s) => s.current)?.id).toBe(targetId); expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
}); });
test('#51: jumping into undiscovered territory mints 1–4 new systems, each routed to the target, and the graph stays connected', async ({ page }) => {
await drainQueue(page);
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
const after = await jumpToFreshSystem(page);
const target = after.systems.find((s) => s.current)!;
const beforeIds = new Set(before.systems.map((s) => s.id));
const fresh = after.systems.filter((s) => !beforeIds.has(s.id));
// #51: seeded-random 1–4 new systems (the frontier guarantee ensures at least one)
expect(fresh.length).toBeGreaterThanOrEqual(1);
expect(fresh.length).toBeLessThanOrEqual(4);
// all new systems are undiscovered
expect(fresh.every((s) => !s.discovered)).toBe(true);
// each new system is directly routed to the jump target
for (const n of fresh) {
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true);
}
// the whole graph stays connected: BFS from the target reaches every system
const adj = new Map<number, number[]>();
for (const [a, b] of after.edges) {
if (!adj.has(a)) adj.set(a, []);
if (!adj.has(b)) adj.set(b, []);
adj.get(a)!.push(b);
adj.get(b)!.push(a);
}
const seen = new Set([target.id]);
const queue = [target.id];
while (queue.length > 0) {
const cur = queue.shift()!;
for (const nb of adj.get(cur) ?? []) {
if (!seen.has(nb)) {
seen.add(nb);
queue.push(nb);
}
}
}
expect(seen.size).toBe(after.systems.length); // no orphaned systems
});
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => { test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue) await drainQueue(page); // retry-safety: a failed earlier attempt may have left a row queued (an in-transit JUMP would 409 every new queue)
const before = (await (await page.request.get('/api/world')).json()) as WorldState; const before = (await (await page.request.get('/api/world')).json()) as WorldState;
@@ -334,7 +374,9 @@ test('CREW tab renders real roster cards, each showing both traits and an origin
await expect(heading).toBeVisible(); await expect(heading).toBeVisible();
// The card is the third div ancestor of the title (flex row → CardHeader → Card). // The card is the third div ancestor of the title (flex row → CardHeader → Card).
const card = heading.locator('xpath=ancestor::div[3]'); 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.traitGood)).toBeVisible();
await expect(card.getByText(member.traitBad)).toBeVisible(); await expect(card.getByText(member.traitBad)).toBeVisible();
await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible(); await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible();
@@ -1004,3 +1046,47 @@ test('NEXT TURN with an empty queue is a free skip — +1 turn, the strip stays
expect(after.turn).toBe(T + 1); expect(after.turn).toBe(T + 1);
expect(after.tasks.length).toBe(0); expect(after.tasks.length).toBe(0);
}); });
test('NEXT TURN button is full-width in the right pane and not covered by the task strip (#52)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
const currentId = world.systems.find((s) => s.current)!.id;
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(1);
// Queue a one-person JUMP and deliberately do not tick it (the #44 jump scenarios' pattern,
// the in-transit 409 one leaves it that way on purpose): the strip renders from the shell's
// system snapshot — the same queue — so a non-empty queue keeps ACTIVE TASKS visible all test.
const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200);
const queued = (await res.json()) as WorldStateQ;
expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
await page.goto('/');
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toBeVisible(); // the strip is up
// Two rects intersect when their x AND y ranges overlap (touching edges do not count).
type Box = { x: number; y: number; width: number; height: number };
const intersects = (a: Box, b: Box) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
const buttonBox = await page.getByRole('button', { name: 'NEXT TURN' }).boundingBox();
expect(buttonBox).not.toBeNull();
// The strip's root div is the parent of its 'ACTIVE TASKS' label (components/task-strip.tsx).
const stripBox = await page.getByText('ACTIVE TASKS', { exact: true }).locator('xpath=..').boundingBox();
expect(stripBox).not.toBeNull();
// The CARGO HOLD panel: the Panel root is the h3 heading's grandparent div (components/ui/panel.tsx).
const panelBox = await page.getByRole('heading', { name: 'CARGO HOLD // INVENTORY' }).locator('xpath=ancestor::div[2]').boundingBox();
expect(panelBox).not.toBeNull();
// (a) The button spans the right pane's content width — >= 280 on its own, and within ±8px of
// the CARGO HOLD panel, a sibling full-width element in the same pane column.
expect(buttonBox!.width).toBeGreaterThanOrEqual(280);
expect(Math.abs(buttonBox!.width - panelBox!.width)).toBeLessThanOrEqual(8);
// (b) The button's box does not intersect the active-tasks strip's box.
expect(intersects(buttonBox!, stripBox!)).toBe(false);
await drainQueue(page); // leave the queue clean for the next scenario
});
+1
View File
@@ -19,6 +19,7 @@ export const ADMIN_TABLES = {
missions: schema.missions, missions: schema.missions,
events: schema.events, events: schema.events,
tasks: schema.tasks, tasks: schema.tasks,
crew_skills: schema.crewSkills,
} as const satisfies Record<string, SQLiteTable> } as const satisfies Record<string, SQLiteTable>
const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t))) const TABLE_NAMES: Set<string> = new Set(Object.values(ADMIN_TABLES).map((t) => getTableName(t)))
+10
View File
@@ -129,6 +129,16 @@ CREATE TABLE IF NOT EXISTS tasks (
status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE' status TEXT NOT NULL DEFAULT 'ACTIVE', -- 'ACTIVE' | 'PENDING' | 'DONE'
queued_at_turn INTEGER NOT NULL DEFAULT 0 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 // SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
+11
View File
@@ -153,3 +153,14 @@ export const tasks = sqliteTable('tasks', {
status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE' status: text('status').notNull().default('ACTIVE'), // 'ACTIVE' | 'PENDING' | 'DONE'
queuedAtTurn: integer('queued_at_turn').notNull().default(0), 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)])
+11 -7
View File
@@ -22,7 +22,9 @@ const LAST_NAMES = [
'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL', '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 = [ const GOOD_TRAITS = [
'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING', 'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING',
@@ -88,12 +90,14 @@ export function generateStartingCrew(rng: () => number): CrewMember[] {
return crew return crew
} }
// The exploration crew roll: with CREW_CHANCE a member is found and joins the ship. Draws // The exploration crew roll: with the given chance (defaults to CREW_CHANCE, matching
// from the SAME per-planet rng stream as the resource/part haul (called after it in // RECRUIT_CHANCE_BASE in crew-costs) a member is found and joins the ship. Draws from the
// save.ts), so the outcome stays deterministic per save+planet. `taken` is the current // SAME per-planet rng stream as the resource/part haul (called after it in save.ts), so the
// roster's names, keeping every name on the ship unique. // outcome stays deterministic per save+planet. `taken` is the current roster's names, keeping
export function rollCrewMember(rng: () => number, taken: Set<string>): CrewMember | null { // every name on the ship unique. The chance is raised to RECRUIT_CHANCE_TALKER when the
if (rng() >= CREW_CHANCE) return null // assigned team includes TALKS TO ANYONE (wired in save.ts, #43).
export function rollCrewMember(rng: () => number, taken: Set<string>, chance: number = CREW_CHANCE): CrewMember | null {
if (rng() >= chance) return null
const role = pick(rng, ROLES) const role = pick(rng, ROLES)
return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) } return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) }
} }
+353 -10
View File
@@ -2,16 +2,17 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { afterAll, describe, expect, it } from 'vitest' 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 { openGameDb, type GameDb } from '@/lib/db/client'
import * as schema from '@/lib/db/schema' import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew' import { CREW_CHANCE, ROLES, generateStartingCrew, rollCrewMember, startingCrewRng } from './crew'
import { RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
import { MIN_AVAILABLE } from './missions' import { MIN_AVAILABLE } from './missions'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { TECHS } from './tech' 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 { addCrew, buildWorldFixture, insertTaskRow, T0 } from './test-fixtures'
import type { Task } from './tasks' import { queueTask, type Task } from './tasks'
import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world'
const dirs: string[] = [] const dirs: string[] = []
@@ -113,9 +114,10 @@ describe('jumpToSystem success path', () => {
expect(currentOf(after).id).toBe(target.id) expect(currentOf(after).id).toBe(target.id)
expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true) expect(after.systems.find((s) => s.id === target.id)!.discovered).toBe(true)
// Expansion: 4 new undiscovered neighbours around the target, each routed to it. // #51: 1–4 new undiscovered systems minted around the target, each routed to it.
const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id)) const fresh = after.systems.filter((s) => !w0.systems.some((p) => p.id === s.id))
expect(fresh).toHaveLength(4) expect(fresh.length).toBeGreaterThanOrEqual(1)
expect(fresh.length).toBeLessThanOrEqual(4)
expect(fresh.every((s) => !s.discovered)).toBe(true) expect(fresh.every((s) => !s.discovered)).toBe(true)
for (const n of fresh) { for (const n of fresh) {
expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true) expect(after.edges.some(([a, b]) => (a === target.id && b === n.id) || (a === n.id && b === target.id))).toBe(true)
@@ -258,6 +260,43 @@ describe('getCrewState', () => {
expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role)) 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])) 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', () => { describe('explorePlanet crew finds', () => {
@@ -428,6 +467,167 @@ describe('resolveExploreTask (A/B against explorePlanet)', () => {
await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 })) await resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))
await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' }) await expect(resolveExploreTask(db, task('EXPLORE', { planetId: f.p1 }))).rejects.toMatchObject({ status: 409, message: 'Basalt was already explored' })
}) })
it('no trait-bearing assigned crew ⇒ identical haul and recruit gate to the un-assigned path (regression pin)', async () => {
// World A: un-assigned (syntheticTask, leadCrewId: 0, no crew rows → taken = Set())
const { db: a } = await freshFile()
const fa = await buildWorldFixture(a)
const resultA = await explorePlanet(a, fa.p0)
// World B: one assigned crew member with non-FHT/non-TALKER traits
const { db: b } = await freshFile()
const fb = await buildWorldFixture(b)
const [saveB] = await b.db.select().from(schema.saves).limit(1)
const [c1] = await b.db
.insert(schema.crew)
.values({ saveId: saveB.id, name: 'QUICK GUY', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' })
.returning({ id: schema.crew.id })
await b.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, saveB.id))
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fb.p0 }), leadCrewId: c1.id, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultB = await resolveExploreTask(b, taskB)
// The haul is draw-order-stable (drawn before any crew interaction) — must be identical.
expect(resultB.outcome).toEqual(resultA.outcome)
// The recruit gate (first draw after the haul) uses the same rng value → same found/null result.
// (The recruit NAME can differ because the taken set differs: world A has no crew rows,
// world B has QUICK GUY. We assert gate equality, then recompute the recruit for
// world B's taken set to prove determinism.)
expect(resultB.crewFound === null).toBe(resultA.crewFound === null)
if (resultB.crewFound !== null) {
// Recompute: same rng stream, same taken set as world B.
const rng = explorationRng('42', fb.p0)
rollExploration(rng) // consume the haul draws first, exactly as save.ts does
const expected = rollCrewMember(rng, new Set(['QUICK GUY']))!
expect(resultB.crewFound!.name).toBe(expected.name)
expect(resultB.crewFound!.role).toBe(expected.role)
expect([resultB.crewFound!.traitGood, resultB.crewFound!.traitBad]).toEqual([expected.traitGood, expected.traitBad])
}
})
it('FINDS HIDDEN THINGS: the FHT team gets exactly one extra haul item — the no-FHT haul is a prefix (Review Focus 5)', async () => {
// Find a planet id whose SECOND rollExploration draw yields exactly 1 item (so the
// FHT delta is concretely +1 item). Mirrors the explorePlanet crew-finds id-scanning pattern.
let fhtPlanetId = -1
for (let id = 1; id <= 50 && fhtPlanetId === -1; id++) {
const rng = explorationRng('42', id)
rollExploration(rng) // first haul (consumes its draws)
const second = rollExploration(rng) // second haul (the FHT extra)
if (second.length === 1) fhtPlanetId = id
}
expect(fhtPlanetId).toBeGreaterThan(0)
// Hand-built world (same pattern as the crew-finds test): seed '42', one system,
// planets P1..P{fhtPlanetId} so the target planet has id = fhtPlanetId.
const setupWorld = async (db: GameDb, withFht: boolean) => {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
const [sys] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
for (let i = 1; i <= fhtPlanetId; i++) {
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
}
// Always one non-FHT lead; add an FHT helper when withFht.
const [lead] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'LEAD', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
const helperIds: number[] = []
if (withFht) {
const [helper] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'FINDS', role: 'SCIENTIST', traitGood: 'FINDS HIDDEN THINGS', traitBad: 'JINXED IN A DOGFIGHT', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
helperIds.push(helper.id)
}
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, save.id))
return { leadId: lead.id, helperIds }
}
// World A: no FHT in the team → one haul roll.
const { db: a } = await freshFile()
const worldA = await setupWorld(a, false)
const taskA: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldA.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultA = await resolveExploreTask(a, taskA)
// World B: FHT in the team (as helper) → two haul rolls.
const { db: b } = await freshFile()
const worldB = await setupWorld(b, true)
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldB.leadId, helpers: worldB.helperIds, duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultB = await resolveExploreTask(b, taskB)
// The no-FHT haul is a prefix of the FHT haul.
const noFhtHaul = resultA.outcome
const fhtHaul = resultB.outcome
expect(fhtHaul.slice(0, noFhtHaul.length)).toEqual(noFhtHaul)
// The FHT run has EXACTLY ONE more haul item (the second rollExploration yielded 1 item).
expect(fhtHaul.length).toBe(noFhtHaul.length + 1)
// Reproducibility: a second independent fresh file with the same inserts produces
// the identical outcome (haul + recruit).
const { db: c } = await freshFile()
const worldC = await setupWorld(c, true)
const taskC: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: fhtPlanetId }), leadCrewId: worldC.leadId, helpers: worldC.helperIds, duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultC = await resolveExploreTask(c, taskC)
expect(resultC.outcome).toEqual(resultB.outcome)
if (resultB.crewFound !== null) {
expect(resultC.crewFound).not.toBeNull()
expect(resultC.crewFound!.name).toBe(resultB.crewFound!.name)
expect(resultC.crewFound!.role).toBe(resultB.crewFound!.role)
expect([resultC.crewFound!.traitGood, resultC.crewFound!.traitBad]).toEqual([resultB.crewFound!.traitGood, resultB.crewFound!.traitBad])
} else {
expect(resultC.crewFound).toBeNull()
}
})
it('TALKS TO ANYONE: recruit chance raised 18% → 30% — constant consumed by rollCrewMember, end-to-end on a discriminating planet', async () => {
// 1. Pin the constants.
expect(RECRUIT_CHANCE_BASE).toBe(0.18)
expect(RECRUIT_CHANCE_TALKER).toBe(0.30)
// Guard the two separately-declared 0.18 constants: the no-trait recruit path passes
// RECRUIT_CHANCE_BASE explicitly while rollCrewMember's default uses CREW_CHANCE — if
// they ever diverge the no-trait outcome would silently drift from its bit-identity pin.
expect(RECRUIT_CHANCE_BASE).toBe(CREW_CHANCE)
// 2. Prove the constant is consumed by rollCrewMember: a fixed rng in [0.18, 0.30)
// (a member is found iff rng() < chance).
const rng024 = () => 0.24
expect(rollCrewMember(rng024, new Set<string>(), RECRUIT_CHANCE_TALKER)).not.toBeNull() // 0.24 < 0.30 → found
expect(rollCrewMember(rng024, new Set<string>(), RECRUIT_CHANCE_BASE)).toBeNull() // 0.24 >= 0.18 → null
expect(rollCrewMember(rng024, new Set<string>())).toBeNull() // default = CREW_CHANCE = 0.18 → null
// 3. End-to-end: find a planet whose recruit gate value (the rng draw after the haul)
// falls in [0.18, 0.30) — TALKER finds a member, non-talker does not.
let talkerPlanetId = -1
for (let id = 1; id <= 100 && talkerPlanetId === -1; id++) {
const rng = explorationRng('42', id)
rollExploration(rng) // consume the haul draws
const gate = rng() // the recruit gate draw
if (gate >= RECRUIT_CHANCE_BASE && gate < RECRUIT_CHANCE_TALKER) talkerPlanetId = id
}
expect(talkerPlanetId).toBeGreaterThan(0)
// Hand-built world (same pattern as the FHT test above).
const setupWorld = async (db: GameDb, talker: boolean) => {
const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: T0, schemaVersion: SCHEMA_VERSION }).returning()
const [sys] = await db.db.insert(schema.solarSystems).values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true }).returning({ id: schema.solarSystems.id })
for (let i = 1; i <= talkerPlanetId; i++) {
await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false })
}
const traitGood = talker ? 'TALKS TO ANYONE' : 'QUICK HANDS'
const [lead] = await db.db.insert(schema.crew).values({ saveId: save.id, name: 'LEAD', role: 'PILOT', traitGood, traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' }).returning({ id: schema.crew.id })
await db.db.update(schema.saves).set({ turn: 1 }).where(eq(schema.saves.id, save.id))
return { leadId: lead.id }
}
// World A: non-talker crew → gate >= 0.18 → crewFound == null.
const { db: a } = await freshFile()
const worldA = await setupWorld(a, false)
const taskA: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: talkerPlanetId }), leadCrewId: worldA.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultA = await resolveExploreTask(a, taskA)
expect(resultA.crewFound).toBeNull()
// World B: talker crew → gate < 0.30 → crewFound != null.
const { db: b } = await freshFile()
const worldB = await setupWorld(b, true)
const taskB: Task = { id: 0, saveId: 0, type: 'EXPLORE', payload: JSON.stringify({ planetId: talkerPlanetId }), leadCrewId: worldB.leadId, helpers: [], duration: 0, remaining: 0, status: 'ACTIVE', queuedAtTurn: 0 }
const resultB = await resolveExploreTask(b, taskB)
expect(resultB.crewFound).not.toBeNull()
})
}) })
// --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) ----------------- // --- runTurn: the DB-side tick (POST /api/turn — Task 5 wires the route) -----------------
@@ -538,6 +738,103 @@ describe('runTurn (tick on the DB)', () => {
expect(types.filter((t) => t === 'explore')).toHaveLength(1) 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 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) --------------------------------------------- // --- The GET snapshots' task queue (Task 6) ---------------------------------------------
@@ -718,10 +1015,10 @@ describe('backfill (old saves)', () => {
const invBefore = await db.db.select().from(schema.inventoryItems) 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 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(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.crew)).toEqual(crewBefore)
expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore) expect(await db.db.select().from(schema.inventoryItems)).toEqual(invBefore)
const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'") const tables = db.raw.exec("SELECT name FROM sqlite_master WHERE type = 'table'")
@@ -730,10 +1027,56 @@ describe('backfill (old saves)', () => {
const reopened = await openGameDb(file) // restart: the backfill must be a no-op const reopened = await openGameDb(file) // restart: the backfill must be a no-op
await getWorldState(reopened) 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) 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 () => { it('seeds the starting ship on a new game: core types built at base values, WEAPONS unbuilt', async () => {
const { db } = await freshFile() const { db } = await freshFile()
await getWorldState(db) await getWorldState(db)
+151 -13
View File
@@ -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 type { GameDb } from '@/lib/db/client'
import { persist } from '@/lib/db/client' import { persist } from '@/lib/db/client'
import * as schema from '@/lib/db/schema' import * as schema from '@/lib/db/schema'
import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew' import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew'
import { ACTION_SKILL, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, XP_THRESHOLDS, type SkillName } from './crew-costs'
import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs' import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions' import { checkMissionCompletion, topUpMissionBoard } from './missions'
import { resolveResearchTask, ResearchError } from './research' import { resolveResearchTask, ResearchError } from './research'
import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship' 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 { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks'
import { TECHS } from './tech' import { TECHS } from './tech'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world' 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 // 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 // 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). // 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 // 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. // unbuilt (level/capacity 0) until a BUILD action.
@@ -136,6 +138,24 @@ async function seedStartingTech(db: GameDb, saveId: number): Promise<void> {
} }
} }
// 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. // 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). // Safe to call on every request (single player, one process: check-then-insert cannot race).
export async function ensureSave(db: GameDb): Promise<void> { export async function ensureSave(db: GameDb): Promise<void> {
@@ -162,6 +182,7 @@ async function createNewGame(db: GameDb): Promise<void> {
const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1]) const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1])
await insertRoutes(db, region.edges, resolve) await insertRoutes(db, region.edges, resolve)
await seedStartingCrew(db, save.id, seed) await seedStartingCrew(db, save.id, seed)
await seedCrewSkills(db, save.id)
await seedStartingShip(db, save.id) await seedStartingShip(db, save.id)
await seedStartingTech(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 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. // every file — new or old — so an upgraded save simply starts with an empty queue; stamp.
version = 7 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) { if (from !== SCHEMA_VERSION) {
await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id)) await db.db.update(schema.saves).set({ schemaVersion: version }).where(eq(schema.saves.id, save.id))
@@ -329,15 +354,33 @@ export async function resolveJumpTask(db: GameDb, task: Task): Promise<void> {
await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id)) await db.db.update(schema.solarSystems).set({ isCurrent: true, discovered: true }).where(eq(schema.solarSystems.id, target.id))
if (!target.discovered) { if (!target.discovered) {
// Growing the chart: a ring of new (undiscovered) neighbours around the newly reached // #51 — growing the chart: a seeded-random 1–4 leg ring around the newly reached
// system, so there is always somewhere to jump next and a route back. // system; each leg may attach an existing system (closing a loop) instead of minting.
const rng = expansionRng(save.seed, save.turn) const rng = expansionRng(save.seed, save.turn)
const takenNames = new Set(systems.map((s) => s.name)) const takenNames = new Set(systems.map((s) => s.name))
const ring = generateRing(rng, target.x, target.y, takenNames, systems)
// Edge keys already touching any pair of this save's systems — an attach must not
// duplicate one (the PK on (fromSystem, toSystem) would reject it).
const systemIds = new Set(systems.map((s) => s.id))
const routes = await db.db.select().from(schema.jumpRoutes)
const existingEdgeKeys = new Set(
routes
.filter((r) => systemIds.has(r.fromSystem) && systemIds.has(r.toSystem))
.map((r) => `${Math.min(r.fromSystem, r.toSystem)}-${Math.max(r.fromSystem, r.toSystem)}`),
)
const candidates = systems.filter((s) => s.id !== target.id)
const ring = generateRing(rng, target.x, target.y, takenNames, systems, candidates, existingEdgeKeys, target.id)
const ringIds: number[] = [] const ringIds: number[] = []
for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed)) for (const s of ring.systems) ringIds.push(await insertSystemWithBodies(db, save.id, s, save.seed))
const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1]) const resolve = (index: number) => (index === 0 ? target.id : ringIds[index - 1])
await insertRoutes(db, ring.edges, resolve) await insertRoutes(db, ring.edges, resolve)
// Loop-closing attaches: a two-way route from the anchor to each attached candidate.
for (const ci of ring.attaches) {
const cand = candidates[ci]!
await db.db.insert(schema.jumpRoutes).values({ fromSystem: target.id, toSystem: cand.id })
await db.db.insert(schema.jumpRoutes).values({ fromSystem: cand.id, toSystem: target.id })
}
} }
await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() }) await db.db.insert(schema.events).values({ saveId: save.id, turn: save.turn, type: 'jump', message: `Jumped to ${target.name}`, createdAt: new Date().toISOString() })
@@ -460,6 +503,28 @@ export interface CrewDto {
traitGood: string traitGood: string
traitBad: string traitBad: string
origin: string // 'STARTING CREW' or '<planet>, <system>' for members found while exploring origin: string // 'STARTING CREW' or '<planet>, <system>' 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 <ROLE>' from the member's highest level ≥ 3, 'MASTER <ROLE>' 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<SkillName, number>()
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 // GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot
@@ -473,8 +538,17 @@ export interface CrewState {
// Auto-initializes like the other reads. // Auto-initializes like the other reads.
export async function getCrewState(db: GameDb): Promise<CrewState> { export async function getCrewState(db: GameDb): Promise<CrewState> {
await ensureSave(db) 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 })) const [crew, skillRows] = await Promise.all([
return { crew, tasks: await taskQueue(db) } db.db.select().from(schema.crew),
db.db.select().from(schema.crewSkills),
])
const rowsByCrew = new Map<number, typeof skillRows>()
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 { export interface ExploreResult extends SystemState {
@@ -496,9 +570,26 @@ export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outc
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`) if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
const rng = explorationRng(save.seed, target.id) const rng = explorationRng(save.seed, target.id)
const outcome = rollExploration(rng) // resource/part haul first… let outcome = rollExploration(rng) // resource/part haul first…
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => c.name))
const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream // Trait outcome effects (#43): the assigned team (lead + helpers) can change how this
// per-planet stream is drawn. Read their traits from the roster (a plain select — no rng draw).
const assigned = new Set([task.leadCrewId, ...task.helpers].filter((id) => id > 0))
const crewRows = await db.db.select().from(schema.crew)
let hasFHT = false
let hasTalker = false
for (const c of crewRows) {
if (!assigned.has(c.id)) continue
if (c.traitGood === 'FINDS HIDDEN THINGS' || c.traitBad === 'FINDS HIDDEN THINGS') hasFHT = true
if (c.traitGood === 'TALKS TO ANYONE' || c.traitBad === 'TALKS TO ANYONE') hasTalker = true
}
// FINDS HIDDEN THINGS: one extra haul roll on the same seeded stream, appended (spec §5).
if (hasFHT) outcome = [...outcome, ...rollExploration(rng)]
const takenNames = new Set(crewRows.map((c) => c.name))
// TALKS TO ANYONE: recruit chance 30% instead of the base 18% (base === crew.ts CREW_CHANCE).
const foundMember = rollCrewMember(rng, takenNames, hasTalker ? RECRUIT_CHANCE_TALKER : RECRUIT_CHANCE_BASE)
await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id)) await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id))
for (const item of outcome) { for (const item of outcome) {
@@ -516,7 +607,8 @@ export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outc
const systems = await db.db.select().from(schema.solarSystems) const systems = await db.db.select().from(schema.solarSystems)
const systemName = systems.find((s) => s.id === target.systemId)?.name ?? 'unknown system' 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() 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' const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing'
@@ -589,16 +681,17 @@ function rowToTask(row: { id: number; saveId: number; type: string; payload: str
export async function queueStateFromSave(db: GameDb): Promise<QueueState> { export async function queueStateFromSave(db: GameDb): Promise<QueueState> {
await ensureSave(db) await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1) 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.crew),
db.db.select().from(schema.tasks), db.db.select().from(schema.tasks),
db.db.select().from(schema.inventoryItems), db.db.select().from(schema.inventoryItems),
db.db.select().from(schema.shipSystems), db.db.select().from(schema.shipSystems),
db.db.select().from(schema.tech), db.db.select().from(schema.tech),
db.db.select().from(schema.crewSkills),
]) ])
return { return {
turn: save.turn, 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), tasks: taskRows.map(rowToTask),
inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })), 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 })), shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
@@ -640,6 +733,16 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
const [save] = await db.db.select().from(schema.saves).limit(1) const [save] = await db.db.select().from(schema.saves).limit(1)
const state = await queueStateFromSave(db) 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<string, CrewIdentity> = Object.fromEntries(state.crew.map((c): [string, CrewIdentity] => [String(c.id), { name: c.name, role: c.role }]))
const levelUpEvents: string[] = []
const result = tickTurn(state) const result = tickTurn(state)
// (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events // (e) the tick IS the turn increment — written BEFORE the resolvers run, so their events
@@ -654,12 +757,33 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
for (const task of result.completed) { for (const task of result.completed) {
try { try {
await TASK_RESOLVERS[task.type](db, task) 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) { } catch (err) {
if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue if (err instanceof JumpError || err instanceof ExploreError || err instanceof ShipError || err instanceof ResearchError) continue
throw err 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) — // Persist the tick's queue changes (the resolvers already persisted their own effects) —
// one final whole-DB write makes the turn atomic on disk. // one final whole-DB write makes the turn atomic on disk.
for (const before of state.tasks) { for (const before of state.tasks) {
@@ -672,3 +796,17 @@ export async function runTurn(db: GameDb): Promise<TickResult> {
return result 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)
}
+56
View File
@@ -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)
})
})
+68
View File
@@ -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 <ROLE>", ≥3 → "SENIOR <ROLE>", below → null.
export function titleFor(member: CrewIdentity, skillRows: SkillRow[]): string | null {
const highest = skillRows.reduce((max, r) => Math.max(max, r.level), 0)
if (highest >= 5) return `MASTER ${member.role}`
if (highest >= 3) return `SENIOR ${member.role}`
return null
}
// +1 XP to every listed member's row for `skill`; returns updated rows + any level-up events. An event
// fires when the gained skill steps into a titled band (3 → SENIOR, 5 → MASTER), e.g. "BEN OKONKWO is now a SENIOR PILOT — NAVIGATION L3" (title from the member's HIGHEST skill).
export function grantXp(
skillRows: SkillRow[],
crewIds: string[],
skill: SkillName,
members: Record<string, CrewIdentity>,
): { rows: SkillRow[]; events: string[] } {
const rows = skillRows.map((r) =>
r.skill === skill && crewIds.includes(r.crewId)
? { ...r, xp: r.xp + 1, level: levelFromXp(r.xp + 1) }
: r,
)
const events: string[] = []
for (const crewId of crewIds) {
const member = members[crewId]
if (!member) continue
const 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)
}
+21 -5
View File
@@ -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, // 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. // 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 roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` and
// the CrewDto shape (unused by the duration model but part of the type). // `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[] = [ const CREW: CrewDto[] = [
{ id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', 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' }, { 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' }, { 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> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over }) const state = (over: Partial<QueueState> = {}): 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.remaining).toBe(computeDuration('EXPLORE', 0, CREW[0], [CREW[1]]))
expect(active.task.queuedAtTurn).toBe(10) 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) ------------------- // --- The turn tick: decrement, resolve, FIFO auto-start (pure — no DB) -------------------
+9 -5
View File
@@ -51,9 +51,10 @@ function traitModifier(trait: string, type: TaskType): number {
// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4. // duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4.
// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the // 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 // constant 1 — queueTask's optional powerOf param feeds the skill band instead (PR B). The
// apply to the LEAD only — helpers contribute power, are never penalised, and their traits do // mismatch penalty and the trait modifiers apply to the LEAD only — helpers contribute
// not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types). // 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( export function computeDuration(
type: TaskType, type: TaskType,
hopCount: number, hopCount: number,
@@ -119,7 +120,10 @@ export interface QueueResult {
// Queue a task. Validation order: unknown crew (404) → busy (409) → in transit (409, all // 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. // 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) const lead = state.crew.find((c) => c.id === req.leadCrewId)
if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`) if (!lead) throw new TaskError(404, `Unknown crew member ${req.leadCrewId}`)
const helpers: CrewDto[] = [] 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. // lead chaining behind its own ACTIVE can be non-free.
const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING') const free = (id: number) => !inStatus(id, 'ACTIVE') && !inStatus(id, 'PENDING')
const hops = req.type === 'JUMP' ? (req.payload.hops as number) : 0 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' const status: TaskStatus = [lead, ...helpers].every((c) => free(c.id)) ? 'ACTIVE' : 'PENDING'
return { return {
+107 -10
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, rollExploration } from './world' import { ATTACH_PROB, EXPAND_MAX, EXPAND_MIN, bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, ringEdges, rollExploration } from './world'
import type { WorldSystem } from './world'
describe('generateStartRegion', () => { describe('generateStartRegion', () => {
const region = generateStartRegion(mulberry32(42)) const region = generateStartRegion(mulberry32(42))
@@ -47,37 +48,133 @@ describe('generateStartRegion', () => {
}) })
}) })
describe('ringEdges (#51 dedup)', () => {
it('n=0 is empty and n=1 is the single anchor↔member edge (the old wheel emitted a [1,1] self-loop here)', () => {
expect(ringEdges(0)).toEqual([])
expect(ringEdges(1)).toEqual([[0, 1]])
})
it('n=2 is the complete triangle (the old wheel emitted the [1,2] edge twice, as [1,2] and [2,1])', () => {
expect(ringEdges(2)).toEqual([[0, 1], [0, 2], [1, 2]])
})
it('n>=3 stays the simple wheel, byte-identical to the legacy output', () => {
expect(ringEdges(3)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 1]])
expect(ringEdges(4)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 4], [0, 4], [4, 1]])
expect(ringEdges(6)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 4], [0, 4], [4, 5], [0, 5], [5, 6], [0, 6], [6, 1]])
})
})
describe('generateRing (expansion)', () => { describe('generateRing (expansion)', () => {
it('adds four systems spaced away from the anchor and existing positions', () => { // A scripted rng: returns the given values in order, then a constant fallback for the rest
// (placeRing's position jitter/radius draws) so a test can pin the leg-drawing phase exactly.
function scriptedRng(script: number[], fallback = 0.5): () => number {
let i = 0
return () => (i < script.length ? script[i++] : fallback)
}
it('#51: mints a seeded-random 1–4 new systems, and all four widths occur across seeds', () => {
const counts = new Set<number>()
for (let seed = 1; seed <= 200; seed++) {
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
expect(ring.systems.length).toBeGreaterThanOrEqual(EXPAND_MIN)
expect(ring.systems.length).toBeLessThanOrEqual(EXPAND_MAX)
counts.add(ring.systems.length)
}
expect(counts).toEqual(new Set([EXPAND_MIN, EXPAND_MIN + 1, EXPAND_MIN + 2, EXPAND_MAX]))
})
it('with no attach candidates every leg mints, so systems.length is the leg count and attaches is empty', () => {
for (let seed = 1; seed <= 50; seed++) {
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
expect(ring.attaches).toEqual([])
}
})
it('mints are spaced away from the anchor and existing positions and never reuse a taken name', () => {
const takenNames = new Set(['Kepler Drift']) const takenNames = new Set(['Kepler Drift'])
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }] const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions) const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
expect(ring.systems).toHaveLength(4)
const ringNames = ring.systems.map((s) => s.name) const ringNames = ring.systems.map((s) => s.name)
for (const s of ring.systems) { for (const s of ring.systems) {
expect(Math.hypot(s.x - 50, s.y - 50)).toBeGreaterThanOrEqual(16) expect(Math.hypot(s.x - 50, s.y - 50)).toBeGreaterThanOrEqual(16)
expect(s.name).not.toBe('Kepler Drift') // never reuses a pre-taken name expect(s.name).not.toBe('Kepler Drift') // never reuses a pre-taken name
} }
expect(new Set(ringNames).size).toBe(4) // and makeName claimed them into the set expect(new Set(ringNames).size).toBe(ringNames.length) // and makeName claimed them into the set
for (const n of ringNames) expect(takenNames.has(n)).toBe(true) for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
expect(ring.edges).toHaveLength(8) // anchor↔each + cycle })
it('edges are exactly the wheel for the mint count, indexing into [anchor, ...systems]', () => {
for (let seed = 1; seed <= 50; seed++) {
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
const n = ring.systems.length
expect(ring.edges).toEqual(ringEdges(n)) // anchor↔each member + the member cycle
for (const [a, b] of ring.edges) { for (const [a, b] of ring.edges) {
expect(a).toBeGreaterThanOrEqual(0) expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThan(5) expect(a).toBeLessThan(n + 1)
expect(b).toBeGreaterThanOrEqual(0) expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThan(5) expect(b).toBeLessThan(n + 1)
}
} }
}) })
it('never reuses a taken name', () => { it('attaches existing systems to close loops, but never one already routed to the anchor', () => {
const candidates: (WorldSystem & { id: number })[] = [
{ id: 100, name: 'A', x: 10, y: 10 },
{ id: 200, name: 'B', x: 20, y: 20 },
{ id: 300, name: 'C', x: 30, y: 30 },
]
// The anchor (id 7) is already routed to candidate 200 — that one can never be attached.
const existing = new Set(['7-200'])
for (let seed = 1; seed <= 200; seed++) {
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [], candidates, existing, 7)
for (const ci of ring.attaches) {
expect(ci).toBeGreaterThanOrEqual(0)
expect(ci).toBeLessThan(candidates.length)
expect(candidates[ci].id).not.toBe(200) // the already-routed candidate is never re-routed
}
expect(new Set(ring.attaches).size).toBe(ring.attaches.length) // attaches are unique
expect(ring.systems.length).toBeGreaterThanOrEqual(EXPAND_MIN) // the frontier still grows
}
})
it('frontier guarantee: a scripted all-attach run still mints exactly one system', () => {
// Force legCount=4 (first draw 0.8) and all four legs to attach to four DISTINCT candidates
// (attach-check 0.1 < ATTACH_PROB, ci draws 0.1/0.3/0.6/0.9 → candidates 0/1/2/3). Without the
// guarantee this would mint zero systems and strand the chart; with it, one attach becomes a mint.
const candidates: (WorldSystem & { id: number })[] = [
{ id: 1, name: 'A', x: 10, y: 10 },
{ id: 2, name: 'B', x: 20, y: 20 },
{ id: 3, name: 'C', x: 30, y: 30 },
{ id: 4, name: 'D', x: 40, y: 40 },
]
const ring = generateRing(scriptedRng([0.8, 0.1, 0.1, 0.1, 0.3, 0.1, 0.6, 0.1, 0.9]), 50, 50, new Set(), [], candidates, new Set(), 99)
expect(ring.systems.length).toBe(1) // one attach converted into a mint
expect(ring.attaches).toEqual([0, 1, 2]) // the last-drawn attach (index 3) became the mint
})
it('is deterministic per seed and differs across seeds (candidates + existing edges included)', () => {
const candidates: (WorldSystem & { id: number })[] = [
{ id: 100, name: 'A', x: 10, y: 10 },
{ id: 200, name: 'B', x: 20, y: 20 },
]
const existing = new Set(['7-100'])
const positions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
const a = generateRing(mulberry32(11), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
const b = generateRing(mulberry32(11), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
expect(b).toEqual(a) // same seed + inputs → identical region
const c = generateRing(mulberry32(12), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
expect(JSON.stringify(c)).not.toBe(JSON.stringify(a))
})
it('makeName never reuses a taken name', () => {
const rng = mulberry32(1) const rng = mulberry32(1)
const taken = new Set<string>() const taken = new Set<string>()
for (let i = 0; i < 40; i++) makeName(rng, taken) for (let i = 0; i < 40; i++) makeName(rng, taken)
expect(taken.size).toBe(40) expect(taken.size).toBe(40)
}) })
it('terminates with numbered variants once all 128 base names are taken', () => { it('makeName terminates with numbered variants once all 128 base names are taken', () => {
const rng = mulberry32(9) const rng = mulberry32(9)
const taken = new Set<string>() const taken = new Set<string>()
for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool
+54 -5
View File
@@ -11,7 +11,13 @@ export const START_X = 50
export const START_Y = 50 export const START_Y = 50
const START_RING = 6 // neighbours around the starting system on a new game const START_RING = 6 // neighbours around the starting system on a new game
const EXPAND_RING = 4 // new neighbours generated when jumping into an undiscovered system // #51 — the spread limit: jumping into an undiscovered system mints a seeded-random number of
// new systems in [EXPAND_MIN, EXPAND_MAX]. Each "leg" can instead attach an already-known
// system (closing a loop) with probability ATTACH_PROB, so the chart grows slower and more
// circular. EXPAND_RING (the old fixed count) is replaced by this range.
export const EXPAND_MIN = 1
export const EXPAND_MAX = 4
export const ATTACH_PROB = 0.35
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
// mulberry32 — small fast seeded PRNG; plenty for name/position picking. // mulberry32 — small fast seeded PRNG; plenty for name/position picking.
@@ -95,7 +101,13 @@ function placeRing(
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the // Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
// cycle connecting consecutive members (so every system has ≥2 routes and you can always // cycle connecting consecutive members (so every system has ≥2 routes and you can always
// get back). Pairs are indices into [anchor, ...members]. // get back). Pairs are indices into [anchor, ...members].
function ringEdges(n: number): [number, number][] { // #51 — small-n special cases: n=0 is empty, n=1 is the single spoke, n=2 is the complete
// triangle (the old wheel formula produced a self-loop at n=1 and a duplicate edge at n=2).
// n≥3 is the unchanged wheel.
export function ringEdges(n: number): [number, number][] {
if (n === 0) return []
if (n === 1) return [[0, 1]]
if (n === 2) return [[0, 1], [0, 2], [1, 2]]
const edges: [number, number][] = [] const edges: [number, number][] = []
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
edges.push([0, i + 1]) edges.push([0, i + 1])
@@ -121,19 +133,56 @@ export function generateStartRegion(rng: () => number): StartRegion {
export interface RingRegion { export interface RingRegion {
systems: WorldSystem[] systems: WorldSystem[]
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
attaches: number[] // indices into the attachCandidates array that were attached (loop-closed) rather than minted
} }
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered) // Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
// neighbours around it. `takenNames`/`takenPositions` are every system already in the save. // neighbours around it. #51 — the ring width is a seeded-random 1–4 legs; each leg may
// instead attach an already-known system (closing a loop) with probability ATTACH_PROB.
// `takenNames`/`takenPositions` are every system already in the save. `attachCandidates`
// are the systems eligible for loop-closing (all save systems except the anchor).
// `existingEdgeKeys` is the set of `${min(a,b)}-${max(a,b)}` keys for routes already
// touching the anchor, so an attach never duplicates one. `anchorId` is the DB id of the
// anchor system (used to build edge keys).
export function generateRing( export function generateRing(
rng: () => number, rng: () => number,
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
takenNames: Set<string>, takenNames: Set<string>,
takenPositions: WorldSystem[], takenPositions: WorldSystem[],
attachCandidates: (WorldSystem & { id: number })[] = [],
existingEdgeKeys: Set<string> = new Set(),
anchorId: number = 0,
): RingRegion { ): RingRegion {
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions) const legCount = EXPAND_MIN + Math.floor(rng() * (EXPAND_MAX - EXPAND_MIN + 1))
return { systems, edges: ringEdges(systems.length) }
let mints = 0
const attachSet = new Set<number>()
let lastAttachCi = -1
for (let leg = 0; leg < legCount; leg++) {
let isAttach = false
if (attachCandidates.length > 0 && rng() < ATTACH_PROB) {
const ci = Math.floor(rng() * attachCandidates.length)
const a = Math.min(anchorId, attachCandidates[ci]!.id)
const b = Math.max(anchorId, attachCandidates[ci]!.id)
if (!existingEdgeKeys.has(`${a}-${b}`)) {
attachSet.add(ci)
lastAttachCi = ci
isAttach = true
}
}
if (!isAttach) mints++
}
// Frontier guarantee: at least one new system must be minted so the chart always grows.
if (mints === 0 && lastAttachCi >= 0) {
attachSet.delete(lastAttachCi)
mints = 1
}
const systems = placeRing(rng, anchorX, anchorY, mints, takenNames, takenPositions)
return { systems, edges: ringEdges(mints), attaches: [...attachSet] }
} }
// BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count. // BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count.
+8 -3
View File
@@ -111,7 +111,7 @@ describe('OpenAPI spec', () => {
const props = openApiSpec.components.schemas.Event.properties; const props = openApiSpec.components.schemas.Event.properties;
expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']); 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 get = openApiSpec.paths['/api/events'].get;
const limit = (get.parameters ?? []).find((p) => p.name === 'limit'); 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.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']);
expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
const member = openApiSpec.components.schemas.CrewMember.properties; // R-P1: the response object is camelCase (traitGood/traitBad), and since the skill-
expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']); // progression work the member also carries its four fixed-order skill levels + title.
const member = openApiSpec.components.schemas.CrewMember as { properties: Record<string, unknown>; 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 exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] };
const extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).properties; const extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).properties;
+19 -6
View File
@@ -149,7 +149,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Queue a scan of a planet or moon', summary: 'Queue a scan of a planet or moon',
description: description:
'Queues an EXPLORE task with the given crew team (lead + helpers): validates that the body exists and has not been scanned yet, and that the team is free, then the scan — a deterministic haul into the cargo hold, the explored flag, the ~18% crew roll and the explore event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh system state, with the outcome key empty and crewFound null until the scan resolves.', 'Queues an EXPLORE task with the given crew team (lead + helpers): validates that the body exists and has not been scanned yet, and that the team is free, then the scan — a deterministic haul into the cargo hold, the explored flag, the crew roll (~18% base, raised to 30% when the assigned team includes TALKS TO ANYONE) and the explore event — lands when the task completes on the turn tick. Queueing spends no turn. Returns the fresh system state, with the outcome key empty and crewFound null until the scan resolves.',
operationId: 'explorePlanet', operationId: 'explorePlanet',
requestBody: { requestBody: {
required: true, required: true,
@@ -976,11 +976,24 @@ export const openApiSpec = {
id: { type: 'integer' }, id: { type: 'integer' },
name: { type: 'string' }, name: { type: 'string' },
role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' }, role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' },
trait_good: { type: 'string', description: 'The member good trait' }, traitGood: { type: 'string', description: 'The member good trait' },
trait_bad: { type: 'string', description: 'The member bad trait' }, traitBad: { type: 'string', description: 'The member bad trait' },
origin: { type: 'string', description: "'STARTING CREW' or '<planet>, <system>' for members found while exploring" }, origin: { type: 'string', description: "'STARTING CREW' or '<planet>, <system>' 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: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'], required: ['skill', 'level'],
},
},
title: { type: ['string', 'null'], nullable: true, description: "'SENIOR <ROLE>' from the member's highest skill level ≥ 3, 'MASTER <ROLE>' from ≥ 5 — null below" },
},
required: ['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title'],
}, },
CrewState: { CrewState: {
type: 'object', type: 'object',
@@ -1080,7 +1093,7 @@ export const openApiSpec = {
properties: { properties: {
id: { type: 'integer' }, id: { type: 'integer' },
turn: { type: 'integer', description: 'The save turn the event was written (new_game is 0)' }, 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' }, message: { type: 'string' },
createdAt: { type: 'string', description: 'ISO 8601 timestamp' }, createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
}, },
@@ -1170,7 +1183,7 @@ export const openApiSpec = {
items: { $ref: '#/components/schemas/FoundItem' }, items: { $ref: '#/components/schemas/FoundItem' },
}, },
crewFound: { crewFound: {
description: 'A crew member who joined the ship on this scan (~18% of scans), null when none — always null in the queueing response', description: 'A crew member who joined the ship on this scan (~18% of scans, raised to 30% when the assigned team includes TALKS TO ANYONE), null when none — always null in the queueing response',
anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }], anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }],
}, },
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "space-wanderer", "name": "space-wanderer",
"version": "0.10.0", "version": "0.14.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",