Files
SpaceWanderer/lib/game/skills.ts
2026-10-04 02:50:47 +00:00

69 lines
3.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 oldLevel = skillRows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
const newLevel = rows.find((r) => r.crewId === crewId && r.skill === skill)?.level ?? 0
if (newLevel > oldLevel && (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)
}