From 1a361603920b519306fa087c15d07a5a4eb17603 Mon Sep 17 00:00:00 2001
From: Kamil Krzywda
Date: Sun, 4 Oct 2026 03:22:51 +0000
Subject: [PATCH] feat(ui): skill levels + titles on CREW tab and team-builder
(#45)
---
components/crew-panel.tsx | 20 ++++++++++++++--
components/team-builder.tsx | 19 +++++++++++----
e2e/smoke.spec.ts | 4 +++-
lib/game/save.test.ts | 37 +++++++++++++++++++++++++++++
lib/game/save.ts | 46 +++++++++++++++++++++++++++++++------
lib/game/tasks.test.ts | 17 ++++++++++----
lib/openapi.test.ts | 9 ++++++--
lib/openapi.ts | 19 ++++++++++++---
8 files changed, 146 insertions(+), 25 deletions(-)
diff --git a/components/crew-panel.tsx b/components/crew-panel.tsx
index f848c43..8130a4d 100644
--- a/components/crew-panel.tsx
+++ b/components/crew-panel.tsx
@@ -9,6 +9,12 @@ import type { CrewDto, CrewState } from '@/lib/game/save'
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet.
+// Spec §8: the three combat traits that carry no duration effect yet — their effect lands
+// with the encounters iteration, so the trait line labels them encounter-ready (STANDBY).
+// The label is additive — the original name always appears in the text unchanged.
+const STANDBY_TRAITS = new Set(['SHARP SHOOTER', 'SHORT FUSE', 'JINXED IN A DOGFIGHT'])
+const standbyLabel = (trait: string) => (STANDBY_TRAITS.has(trait) ? `${trait} (STANDBY — encounter-ready)` : trait)
+
interface CrewPanelProps {
narrow?: boolean
}
@@ -80,15 +86,25 @@ export function CrewPanel({ narrow }: CrewPanelProps) {
>
{member.role}
+ {/* The member's four skill levels, fixed order (JC-27) — one `·`-separated line. */}
+
+ {member.skills.map((s) => `${s.skill} L${s.level}`).join(' · ')}
+
+ {/* The title only exists at L3+ (SENIOR) / L5 (MASTER) — titleFor returns null below. */}
+ {member.title !== null && (
+
+
- {member.traitGood}
+ {standbyLabel(member.traitGood)}
-
- {member.traitBad}
+ {standbyLabel(member.traitBad)}
ORIGIN // {member.origin.toUpperCase()}
diff --git a/components/team-builder.tsx b/components/team-builder.tsx
index f88de85..78b5f22 100644
--- a/components/team-builder.tsx
+++ b/components/team-builder.tsx
@@ -4,14 +4,17 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import type { CrewDto, QueueDto } from '@/lib/game/save'
import { computeDuration, type TaskType } from '@/lib/game/tasks'
+import { makePowerOf, type SkillRow } from '@/lib/game/skills'
// Shared crew picker for the four action panels — PR A: an action is queued as a lead +
// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState
// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel
// performs its own fetch with the team fields in the body (Task 5a). The live preview is
-// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call
-// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask
-// so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
+// Task 1's computeDuration (pure, no DB import) with the skill-band powerOf — Task 14's
+// makePowerOf over the roster's { skill, level } rows (R-P3: the same computation the
+// queueing routes make, so the preview equals the stored duration); each member row shows
+// that member's power contribution for the armed action's skill. The rows mirror Task 3's
+// queueTask so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
type: TaskType
hopCount?: number
@@ -31,9 +34,15 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
const queuedJump = type === 'JUMP' ? (tasks.find((t) => t.type === 'JUMP') ?? null) : null
const blocked = inTransit ?? queuedJump
+ // R-P3: the preview's powerOf is the same makePowerOf the queueing routes call (Task 14) —
+ // built from the roster's { skill, level } rows (JC-26), so the estimate equals the stored
+ // duration instead of assuming every member is power 1.
+ const skillRows: SkillRow[] = roster.flatMap((c) => c.skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))
+ const powerOf = makePowerOf(roster, skillRows, type)
+
const lead = roster.find((c) => c.id === leadId) ?? null
const helpers = roster.filter((c) => helperIds.includes(c.id))
- const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember
+ const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers, powerOf) : null // CrewDto ⊇ CrewMember
const leadReason = (c: CrewDto) => {
const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
@@ -68,7 +77,7 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
/>
setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} />
- {c.name}
+ {c.name} (power {powerOf(c)})
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts
index 3fc8068..5d7c1c2 100644
--- a/e2e/smoke.spec.ts
+++ b/e2e/smoke.spec.ts
@@ -334,7 +334,9 @@ test('CREW tab renders real roster cards, each showing both traits and an origin
await expect(heading).toBeVisible();
// The card is the third div ancestor of the title (flex row → CardHeader → Card).
const card = heading.locator('xpath=ancestor::div[3]');
- await expect(card.getByText(member.role)).toBeVisible();
+ // exact: the new skill line renders 'ENGINEERING', which a loose 'ENGINEER' role search
+ // would also match (substring) — the role line is the only element with the role's exact text.
+ await expect(card.getByText(member.role, { exact: true })).toBeVisible();
await expect(card.getByText(member.traitGood)).toBeVisible();
await expect(card.getByText(member.traitBad)).toBeVisible();
await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible();
diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts
index a163103..5498b21 100644
--- a/lib/game/save.test.ts
+++ b/lib/game/save.test.ts
@@ -259,6 +259,43 @@ describe('getCrewState', () => {
expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role))
expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad]))
})
+
+ it('attaches per-member skill levels + title: the four crew_skills rows in fixed order, SENIOR from L3, null below', async () => {
+ const { db } = await freshFile()
+ const { crew: seeded } = await getCrewState(db) // ensureSave seeds the starting crew + their crew_skills rows (v8)
+ expect(seeded.length).toBeGreaterThanOrEqual(2)
+ const [pinned, other] = [seeded[0], seeded[1]]
+
+ // Pin the first member to NAVIGATION L3 (xp 15 — the L3 threshold, so levelFromXp round-trips)
+ // with the other three skills L0, and reset the second member to all-L0 (a specialist role
+ // would otherwise seed L2).
+ for (const skill of ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH'] as const) {
+ const level = skill === 'NAVIGATION' ? 3 : 0
+ await db.db.update(schema.crewSkills).set({ level, xp: level > 0 ? XP_THRESHOLDS[level - 1] : 0 }).where(and(eq(schema.crewSkills.crewId, pinned.id), eq(schema.crewSkills.skill, skill)))
+ }
+ for (const r of (await db.db.select().from(schema.crewSkills)).filter((r) => r.crewId === other.id)) {
+ await db.db.update(schema.crewSkills).set({ level: 0, xp: 0 }).where(eq(schema.crewSkills.id, r.id))
+ }
+
+ const { crew } = await getCrewState(db)
+ const p = crew.find((c) => c.id === pinned.id)!
+ expect(p.skills).toEqual([
+ { skill: 'NAVIGATION', level: 3 },
+ { skill: 'SCANNING', level: 0 },
+ { skill: 'ENGINEERING', level: 0 },
+ { skill: 'RESEARCH', level: 0 },
+ ])
+ expect(p.title).toBe(`SENIOR ${p.role}`) // the pinned skill is the member's highest — L3 → SENIOR
+
+ const o = crew.find((c) => c.id === other.id)!
+ expect(o.skills).toEqual([
+ { skill: 'NAVIGATION', level: 0 },
+ { skill: 'SCANNING', level: 0 },
+ { skill: 'ENGINEERING', level: 0 },
+ { skill: 'RESEARCH', level: 0 },
+ ])
+ expect(o.title).toBeNull()
+ })
})
describe('explorePlanet crew finds', () => {
diff --git a/lib/game/save.ts b/lib/game/save.ts
index 9f35703..0e8bf81 100644
--- a/lib/game/save.ts
+++ b/lib/game/save.ts
@@ -8,7 +8,7 @@ import { BASE_CAPACITY, BASE_LEVEL, SHIP_TYPES } from './ship-costs'
import { checkMissionCompletion, topUpMissionBoard } from './missions'
import { resolveResearchTask, ResearchError } from './research'
import { resolveShipBuildTask, resolveShipExpandTask, resolveShipUpgradeTask, ShipError } from './ship'
-import { grantXp, makePowerOf, type CrewIdentity, type SkillRow } from './skills'
+import { grantXp, makePowerOf, titleFor, type CrewIdentity, type SkillRow } from './skills'
import { tickTurn, type QueueState, type Task, type TaskStatus, type TickResult, type TaskType } from './tasks'
import { TECHS } from './tech'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, reachableWithin, rollExploration, type FoundItem } from './world'
@@ -485,6 +485,28 @@ export interface CrewDto {
traitGood: string
traitBad: string
origin: string // 'STARTING CREW' or ', ' for members found while exploring
+ skills: { skill: SkillName; level: number }[] // the member's four crew_skills rows, fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (JC-27); display-oriented — no xp (JC-26)
+ title: string | null // 'SENIOR ' from the member's highest level ≥ 3, 'MASTER ' from ≥ 5, null below (titleFor)
+}
+
+// The fixed display order of the four skills (JC-27, spec §6) — the CREW-tab line and the
+// /api/crew payload never depend on DB row order.
+const SKILL_ORDER: SkillName[] = ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH']
+
+// One CrewDto from a crew row and that member's crew_skills rows: the four fixed-order
+// { skill, level } entries (JC-26/27) plus the title from the member's highest level
+// (titleFor — null below L3). A member with no rows (a hand-built save whose crew was never
+// seeded) reads all-L0 with a null title — the same fallback as makePowerOf's missing-row →
+// L0 → power 1, so a preview built from the DTO matches the queueing route's rows.
+function buildCrewDto(
+ c: { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string },
+ skillRows: { crewId: number; skill: string; level: number }[],
+): CrewDto {
+ const levelBySkill = new Map()
+ for (const r of skillRows) levelBySkill.set(r.skill as SkillName, r.level)
+ const skills = SKILL_ORDER.map((skill) => ({ skill, level: levelBySkill.get(skill) ?? 0 }))
+ const title = titleFor({ name: c.name, role: c.role }, skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))
+ return { id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin, skills, title }
}
// GET /api/crew wraps the roster with the snapshot task queue (JC-7): a bare array cannot
@@ -498,8 +520,17 @@ export interface CrewState {
// Auto-initializes like the other reads.
export async function getCrewState(db: GameDb): Promise {
await ensureSave(db)
- const crew = (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
- return { crew, tasks: await taskQueue(db) }
+ const [crew, skillRows] = await Promise.all([
+ db.db.select().from(schema.crew),
+ db.db.select().from(schema.crewSkills),
+ ])
+ const rowsByCrew = new Map()
+ for (const r of skillRows) {
+ const list = rowsByCrew.get(r.crewId) ?? []
+ list.push(r)
+ rowsByCrew.set(r.crewId, list)
+ }
+ return { crew: crew.map((c) => buildCrewDto(c, rowsByCrew.get(c.id) ?? [])), tasks: await taskQueue(db) }
}
export interface ExploreResult extends SystemState {
@@ -542,7 +573,7 @@ export async function resolveExploreTask(db: GameDb, task: Task): Promise<{ outc
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()
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 = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin }
+ 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'
@@ -615,16 +646,17 @@ function rowToTask(row: { id: number; saveId: number; type: string; payload: str
export async function queueStateFromSave(db: GameDb): Promise {
await ensureSave(db)
const [save] = await db.db.select().from(schema.saves).limit(1)
- const [crew, taskRows, inventory, shipSystems, tech] = await Promise.all([
+ const [crew, taskRows, inventory, shipSystems, tech, skillRows] = await Promise.all([
db.db.select().from(schema.crew),
db.db.select().from(schema.tasks),
db.db.select().from(schema.inventoryItems),
db.db.select().from(schema.shipSystems),
db.db.select().from(schema.tech),
+ db.db.select().from(schema.crewSkills),
])
return {
turn: save.turn,
- crew: crew.map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })),
+ crew: crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id))),
tasks: taskRows.map(rowToTask),
inventory: inventory.map((i) => ({ kind: i.kind as 'resource' | 'part', name: i.name, qty: i.qty })),
shipSystems: shipSystems.map((s) => ({ id: s.id, type: s.type, level: s.level, capacity: s.capacity })),
@@ -739,7 +771,7 @@ export async function makePowerOfFor(db: GameDb, save: { id: number }, type: Tas
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) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin }))
+ const roster: CrewDto[] = crew.map((c) => buildCrewDto(c, skillRows.filter((r) => r.crewId === c.id)))
const rows: SkillRow[] = skillRows.map((r) => ({ saveId: String(r.saveId), crewId: String(r.crewId), skill: r.skill as SkillName, level: r.level, xp: r.xp }))
return makePowerOf(roster, rows, type)
}
diff --git a/lib/game/tasks.test.ts b/lib/game/tasks.test.ts
index c169d68..052c3b2 100644
--- a/lib/game/tasks.test.ts
+++ b/lib/game/tasks.test.ts
@@ -93,12 +93,19 @@ describe('crew-costs — the §11 tuning surface', () => {
// Shared queue fixtures (file scope — reused by the queueTask and tickTurn blocks): the roster,
// a state factory (turn 10, empty queue) and a one-turn row factory.
-// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` fills
-// the CrewDto shape (unused by the duration model but part of the type).
+// The roster: a PILOT lead, an ENGINEER helper, a SCIENTIST third member. `origin` and
+// `skills` fill the CrewDto shape (unused by the duration model but part of the type):
+// each role's STARTING_LEVELS (all below L3, so every fixture member's title is null).
+const skillsOf = (n: number, s: number, e: number, r: number) => [
+ { skill: 'NAVIGATION' as const, level: n },
+ { skill: 'SCANNING' as const, level: s },
+ { skill: 'ENGINEERING' as const, level: e },
+ { skill: 'RESEARCH' as const, level: r },
+]
const CREW: CrewDto[] = [
- { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW' },
- { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW' },
- { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW' },
+ { id: 1, name: 'AVA VANCE', role: 'PILOT', traitGood: 'QUICK HANDS', traitBad: 'OVERCAUTIOUS', origin: 'STARTING CREW', skills: skillsOf(2, 0, 0, 0), title: null },
+ { id: 2, name: 'BEN OKONKWO', role: 'ENGINEER', traitGood: 'FIXES ANYTHING', traitBad: 'HOARDS RATIONS', origin: 'STARTING CREW', skills: skillsOf(0, 0, 2, 0), title: null },
+ { id: 3, name: 'CASS SORENSEN', role: 'SCIENTIST', traitGood: 'CALM IN A CRISIS', traitBad: 'FORGETS TO EAT', origin: 'STARTING CREW', skills: skillsOf(0, 2, 0, 2), title: null },
]
const state = (over: Partial = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts
index 86cbf46..1d1273f 100644
--- a/lib/openapi.test.ts
+++ b/lib/openapi.test.ts
@@ -137,8 +137,13 @@ describe('OpenAPI spec', () => {
expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']);
expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
- const member = openApiSpec.components.schemas.CrewMember.properties;
- expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']);
+ // R-P1: the response object is camelCase (traitGood/traitBad), and since the skill-
+ // progression work the member also carries its four fixed-order skill levels + title.
+ const member = openApiSpec.components.schemas.CrewMember as { properties: Record; required: string[] };
+ expect(Object.keys(member.properties)).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']);
+ expect(member.required).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']);
+ expect((member.properties.skills as { items: { required: string[] } }).items.required).toEqual(['skill', 'level']);
+ expect((member.properties.title as { nullable: boolean }).nullable).toBe(true);
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] };
const extra = (exploreResult.allOf[1] as { properties: Record }).properties;
diff --git a/lib/openapi.ts b/lib/openapi.ts
index a767f17..25ac4dd 100644
--- a/lib/openapi.ts
+++ b/lib/openapi.ts
@@ -976,11 +976,24 @@ export const openApiSpec = {
id: { type: 'integer' },
name: { type: 'string' },
role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' },
- trait_good: { type: 'string', description: 'The member good trait' },
- trait_bad: { type: 'string', description: 'The member bad trait' },
+ traitGood: { type: 'string', description: 'The member good trait' },
+ traitBad: { type: 'string', description: 'The member bad trait' },
origin: { type: 'string', description: "'STARTING CREW' or ', ' for members found while exploring" },
+ skills: {
+ type: 'array',
+ description: 'The member four crew_skills levels in the fixed NAVIGATION → SCANNING → ENGINEERING → RESEARCH order (display-oriented — levels only, no xp)',
+ items: {
+ type: 'object',
+ properties: {
+ skill: { type: 'string', enum: ['NAVIGATION', 'SCANNING', 'ENGINEERING', 'RESEARCH'] },
+ level: { type: 'integer' },
+ },
+ required: ['skill', 'level'],
+ },
+ },
+ title: { type: ['string', 'null'], nullable: true, description: "'SENIOR ' from the member's highest skill level ≥ 3, 'MASTER ' from ≥ 5 — null below" },
},
- required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'],
+ required: ['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title'],
},
CrewState: {
type: 'object',