68 lines
3.4 KiB
TypeScript
68 lines
3.4 KiB
TypeScript
// 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 { ACTION_SKILL, POWER_BANDS, XP_THRESHOLDS, type SkillName } from './crew-costs'
|
||
import type { CrewMember } from './crew'
|
||
import type { CrewDto } from './save'
|
||
import type { TaskType } from './tasks'
|
||
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 <ROLE>", ≥3 → "SENIOR <ROLE>", 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<string, CrewIdentity>,
|
||
): { 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 }
|
||
}
|
||
|
||
// The duration formula's powerOf (Task 1) driven by the skill band (PR B, §6): each member's
|
||
// power is the band of that member's level in the action's skill (ACTION_SKILL[type]). Names
|
||
// map to crew ids (unique per single-player save); an unknown member or a missing row falls
|
||
// back to L0 → power 1 (Task 1's default). Called by save.ts's makePowerOfFor (the queueing
|
||
// routes) and — once /api/crew carries each member's skill rows — by the team builder's live
|
||
// preview (Task 15).
|
||
export function makePowerOf(roster: CrewDto[], skillRows: SkillRow[], type: TaskType): (m: CrewMember) => number {
|
||
const idByName = new Map(roster.map((c): [string, string] => [c.name, String(c.id)]))
|
||
const levelByCrew = new Map(skillRows.filter((r) => r.skill === ACTION_SKILL[type]).map((r): [string, number] => [r.crewId, r.level]))
|
||
return (m) => powerForLevel(levelByCrew.get(idByName.get(m.name) ?? '') ?? 0)
|
||
}
|