diff --git a/lib/game/skills.test.ts b/lib/game/skills.test.ts new file mode 100644 index 0000000..4c11604 --- /dev/null +++ b/lib/game/skills.test.ts @@ -0,0 +1,50 @@ +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) + }) +}) diff --git a/lib/game/skills.ts b/lib/game/skills.ts new file mode 100644 index 0000000..cdbb334 --- /dev/null +++ b/lib/game/skills.ts @@ -0,0 +1,53 @@ +// 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 ", ≥3 → "SENIOR ", 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, +): { 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 } +}