feat: skill progression & specialisation (#45, v0.11.0) #49

Merged
kamilkrzywda merged 7 commits from feat/skill-progression into master 2026-10-04 05:52:27 +02:00
8 changed files with 146 additions and 25 deletions
Showing only changes of commit 1a36160392 - Show all commits
+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
// (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}
</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>
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<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 style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<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>
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
ORIGIN // {member.origin.toUpperCase()}
+14 -5
View File
@@ -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 }: {
/>
<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' }}>
{c.name}
{c.name} (power {powerOf(c)})
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
</span>
</label>
+3 -1
View File
@@ -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();
+37
View File
@@ -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', () => {
+39 -7
View File
@@ -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 '<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
@@ -498,8 +520,17 @@ export interface CrewState {
// Auto-initializes like the other reads.
export async function getCrewState(db: GameDb): Promise<CrewState> {
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<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 {
@@ -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<QueueState> {
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)
}
+12 -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,
// 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> = {}): QueueState => ({ turn: 10, crew: CREW, tasks: [], inventory: [], shipSystems: [], tech: [], ...over })
+7 -2
View File
@@ -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<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 extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).properties;
+16 -3
View File
@@ -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 '<planet>, <system>' 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 <ROLE>' from the member's highest skill level ≥ 3, 'MASTER <ROLE>' 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',