feat(ui): skill levels + titles on CREW tab and team-builder (#45)
CI / build + unit + e2e (pull_request) Successful in 3m7s
CI / build + unit + e2e (pull_request) Successful in 3m7s
This commit is contained in:
@@ -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
@@ -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
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user