diff --git a/components/crew-panel.tsx b/components/crew-panel.tsx index f848c43..8130a4d 100644 --- a/components/crew-panel.tsx +++ b/components/crew-panel.tsx @@ -9,6 +9,12 @@ import type { CrewDto, CrewState } from '@/lib/game/save' // /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches // (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet. +// Spec §8: the three combat traits that carry no duration effect yet — their effect lands +// with the encounters iteration, so the trait line labels them encounter-ready (STANDBY). +// The label is additive — the original name always appears in the text unchanged. +const STANDBY_TRAITS = new Set(['SHARP SHOOTER', 'SHORT FUSE', 'JINXED IN A DOGFIGHT']) +const standbyLabel = (trait: string) => (STANDBY_TRAITS.has(trait) ? `${trait} (STANDBY — encounter-ready)` : trait) + interface CrewPanelProps { narrow?: boolean } @@ -80,15 +86,25 @@ export function CrewPanel({ narrow }: CrewPanelProps) { > {member.role}

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

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

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

+ {member.title} +

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

ORIGIN // {member.origin.toUpperCase()} diff --git a/components/team-builder.tsx b/components/team-builder.tsx index f88de85..78b5f22 100644 --- a/components/team-builder.tsx +++ b/components/team-builder.tsx @@ -4,14 +4,17 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import type { CrewDto, QueueDto } from '@/lib/game/save' import { computeDuration, type TaskType } from '@/lib/game/tasks' +import { makePowerOf, type SkillRow } from '@/lib/game/skills' // Shared crew picker for the four action panels — PR A: an action is queued as a lead + // helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState // it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel // performs its own fetch with the team fields in the body (Task 5a). The live preview is -// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call -// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask -// so the UI never offers a 409 (JC-11's fields make the member↔row check exact). +// Task 1's computeDuration (pure, no DB import) with the skill-band powerOf — Task 14's +// makePowerOf over the roster's { skill, level } rows (R-P3: the same computation the +// queueing routes make, so the preview equals the stored duration); each member row shows +// that member's power contribution for the armed action's skill. The rows mirror Task 3's +// queueTask so the UI never offers a 409 (JC-11's fields make the member↔row check exact). export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { type: TaskType hopCount?: number @@ -31,9 +34,15 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { const queuedJump = type === 'JUMP' ? (tasks.find((t) => t.type === 'JUMP') ?? null) : null const blocked = inTransit ?? queuedJump + // R-P3: the preview's powerOf is the same makePowerOf the queueing routes call (Task 14) — + // built from the roster's { skill, level } rows (JC-26), so the estimate equals the stored + // duration instead of assuming every member is power 1. + const skillRows: SkillRow[] = roster.flatMap((c) => c.skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 }))) + const powerOf = makePowerOf(roster, skillRows, type) + const lead = roster.find((c) => c.id === leadId) ?? null const helpers = roster.filter((c) => helperIds.includes(c.id)) - const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember + const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers, powerOf) : null // CrewDto ⊇ CrewMember const leadReason = (c: CrewDto) => { const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING') @@ -68,7 +77,7 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: { /> setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} /> - {c.name} + {c.name} (power {powerOf(c)}) {leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''} diff --git a/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',