From f52a59c5fca001b3f44186f2aa83840a93b5c1da Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Fri, 2 Oct 2026 12:58:57 +0000 Subject: [PATCH] feat(game): crew-costs balance module + computeDuration duration model (#44) --- lib/game/crew-costs.ts | 71 ++++++++++++++++++++++++++++++++ lib/game/tasks.test.ts | 92 ++++++++++++++++++++++++++++++++++++++++++ lib/game/tasks.ts | 47 +++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 lib/game/crew-costs.ts create mode 100644 lib/game/tasks.test.ts create mode 100644 lib/game/tasks.ts diff --git a/lib/game/crew-costs.ts b/lib/game/crew-costs.ts new file mode 100644 index 0000000..56f7273 --- /dev/null +++ b/lib/game/crew-costs.ts @@ -0,0 +1,71 @@ +// Pure crew-balance module (spec §11): every tuning knob for the duration model and skill +// progression lives here, so tuning a number means editing one file. No lib/db imports — +// the frontend imports this for the live duration preview in the crew picker (same precedent +// as ship-costs.ts). +import type { TaskType } from './tasks' + +export type SkillName = 'NAVIGATION' | 'SCANNING' | 'ENGINEERING' | 'RESEARCH' + +export const WORK_POINTS = { EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 } as const + +export const ACTION_SKILL: Record = { + JUMP: 'NAVIGATION', + EXPLORE: 'SCANNING', + SHIP_UPGRADE: 'ENGINEERING', + SHIP_EXPAND: 'ENGINEERING', + SHIP_BUILD: 'ENGINEERING', + RESEARCH: 'RESEARCH', +} + +export const PREFERRED_ROLES: Record = { + JUMP: ['PILOT', 'NAVIGATOR'], + EXPLORE: ['SCIENTIST'], + SHIP_UPGRADE: ['ENGINEER', 'TECHNICIAN'], + SHIP_EXPAND: ['ENGINEER', 'TECHNICIAN'], + SHIP_BUILD: ['ENGINEER', 'TECHNICIAN'], + RESEARCH: ['SCIENTIST'], +} + +export const POWER_BANDS = [1, 1, 1, 2, 2, 3] // skill level 0..5 → power (L0–L2 → 1 · L3–L4 → 2 · L5 → 3) +export const XP_THRESHOLDS = [5, 10, 15, 20, 25] // cumulative task completions for L1..L5 (cap 5) + +// Role → starting skill levels (spec §6): specialists start at L2, everyone else at L0. +export const STARTING_LEVELS: Record> = { + COMMANDER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + PILOT: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + ENGINEER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 }, + MEDIC: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + NAVIGATOR: { NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + SCIENTIST: { NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 }, + GUNNER: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }, + TECHNICIAN: { NAVIGATION: 0, SCANNING: 0, ENGINEERING: 2, RESEARCH: 0 }, +} + +// The lead's good trait that cancels the role-mismatch penalty (spec §5). +export const MISMATCH_CANCEL_TRAIT = 'STEADY UNDER PRESSURE' + +// All 15 duration traits of spec §5, keyed by the EXACT trait strings from lib/game/crew.ts. +// Value = additive turn modifier keyed by action type or 'ANY'; empty object = no additive +// modifier (the trait's effect is the mismatch cancel, see MISMATCH_CANCEL_TRAIT). Traits +// with no entry (the three STANDBY combat traits and #45's outcome traits) contribute 0. +// "SHIP work" covers all three SHIP types. +export const TRAIT_DURATION_MODIFIERS: Record>> = { + 'STEADY UNDER PRESSURE': {}, + 'RECKLESS COURAGE': { JUMP: -1 }, + 'CALM IN A CRISIS': { EXPLORE: -1 }, + 'FIXES ANYTHING': { SHIP_UPGRADE: -1, SHIP_EXPAND: -1, SHIP_BUILD: -1 }, + 'READS STARS BLIND': { JUMP: -1 }, + 'QUICK HANDS': { ANY: -1 }, + 'IRON WILL': { RESEARCH: -1 }, + 'RIGID — SLOW TO ADAPT': { RESEARCH: 1 }, + 'OVERCAUTIOUS': { ANY: 1 }, + 'SLOWS THE CREW DOWN': { ANY: 1 }, + 'CARELESS WITH FUSES': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 }, + 'HOARDS RATIONS': { SHIP_UPGRADE: 1, SHIP_EXPAND: 1, SHIP_BUILD: 1 }, + 'FORGETS TO EAT': { EXPLORE: 1 }, + 'ARGUES WITH EVERYONE': { RESEARCH: 1 }, + 'DOUBTS THE CAPTAIN': { JUMP: 1 }, +} + +export const RECRUIT_CHANCE_BASE = 0.18 // a scan finds a joining member (matches crew.ts's current CREW_CHANCE) +export const RECRUIT_CHANCE_TALKER = 0.3 // lead with TALKS TO ANYONE (wired in PR C) diff --git a/lib/game/tasks.test.ts b/lib/game/tasks.test.ts new file mode 100644 index 0000000..56cebea --- /dev/null +++ b/lib/game/tasks.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { CrewMember } from './crew' +import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, POWER_BANDS, RECRUIT_CHANCE_BASE, RECRUIT_CHANCE_TALKER, STARTING_LEVELS, TRAIT_DURATION_MODIFIERS, WORK_POINTS, XP_THRESHOLDS } from './crew-costs' +import type { TaskType } from './tasks' +import { computeDuration } from './tasks' + +// Trait strings copied verbatim from lib/game/crew.ts GOOD_TRAITS/BAD_TRAITS — TRAIT_DURATION_MODIFIERS +// is keyed by exactly these strings, so a rename in crew.ts breaks these tests. +const NEUTRAL_GOOD = 'FINDS HIDDEN THINGS' // no duration entry → no effect +const NEUTRAL_BAD = 'JINXED IN A DOGFIGHT' // STANDBY combat trait → no duration effect +const crew = (role: string, traitGood = NEUTRAL_GOOD, traitBad = NEUTRAL_BAD): CrewMember => ({ name: 'TEST', role, traitGood, traitBad }) +const WORK: Record = { JUMP: 3, EXPLORE: 4, SHIP_UPGRADE: 6, SHIP_EXPAND: 6, SHIP_BUILD: 6, RESEARCH: 9 } +const TYPES = Object.keys(WORK) as TaskType[] +const SHIP: TaskType[] = ['SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD'] + +describe('solo baselines (one crew, power 1, no modifiers)', () => { + it('work points are the duration for the preferred role: EXPLORE 4 · JUMP 3 / 6 · SHIP 6 · RESEARCH 9', () => { + for (const type of TYPES) { + expect(computeDuration(type, 1, crew(PREFERRED_ROLES[type][0]), [])).toBe(WORK[type]) + } + expect(computeDuration('JUMP', 2, crew('PILOT'), [])).toBe(6) // two-hop = 2 × 3 + }) +}) + +describe('role mismatch (lead only, helpers never penalised)', () => { + it('a lead outside the preferred role adds +1; helpers contribute power only', () => { + expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [])).toBe(4) // 3 + 1 mismatch + expect(computeDuration('JUMP', 1, crew('SCIENTIST'), [crew('PILOT')])).toBe(3) // ceil(3/2) + 1 — power 2, mismatch still on the lead + expect(computeDuration('EXPLORE', 1, crew('SCIENTIST'), [crew('PILOT', 'QUICK HANDS')])).toBe(2) // 4/2 — the helper's trait does not apply + }) + it('STEADY UNDER PRESSURE cancels the mismatch penalty and adds nothing of its own', () => { + expect(computeDuration('JUMP', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(3) + expect(computeDuration('EXPLORE', 1, crew('SCIENTIST', 'STEADY UNDER PRESSURE'), [])).toBe(4) + }) +}) + +describe('floor', () => { + it('never returns below 1 turn', () => { + const lead = crew('PILOT', 'QUICK HANDS') + expect(computeDuration('JUMP', 1, lead, [crew('PILOT'), crew('PILOT')], () => 3)).toBe(1) // ceil(3/9) − 1 = 0 → floor 1 + }) +}) + +// All 15 duration traits from spec §5, each tested once in its own action context (plus its +// non-applicable types). target 'SHIP_UPGRADE' stands for the whole "SHIP work" class. +const TABLE: [trait: string, isGood: boolean, target: TaskType | 'ANY', delta: number][] = [ + ['RECKLESS COURAGE', true, 'JUMP', -1], + ['CALM IN A CRISIS', true, 'EXPLORE', -1], + ['FIXES ANYTHING', true, 'SHIP_UPGRADE', -1], + ['READS STARS BLIND', true, 'JUMP', -1], + ['QUICK HANDS', true, 'ANY', -1], + ['IRON WILL', true, 'RESEARCH', -1], + ['RIGID — SLOW TO ADAPT', false, 'RESEARCH', 1], + ['OVERCAUTIOUS', false, 'ANY', 1], + ['SLOWS THE CREW DOWN', false, 'ANY', 1], + ['CARELESS WITH FUSES', false, 'SHIP_UPGRADE', 1], + ['HOARDS RATIONS', false, 'SHIP_UPGRADE', 1], + ['FORGETS TO EAT', false, 'EXPLORE', 1], + ['ARGUES WITH EVERYONE', false, 'RESEARCH', 1], + ['DOUBTS THE CAPTAIN', false, 'JUMP', 1], +] + +describe('the 15 duration traits (spec §5, exact strings from crew.ts)', () => { + const applies = (type: TaskType, target: TaskType | 'ANY') => { + if (target === 'ANY') return true + if (target === type) return true + return SHIP.includes(target) && SHIP.includes(type) // "SHIP work" class: any SHIP type → any SHIP type + } + it('each trait shifts only its own action class, lead only, across all six types', () => { + for (const [trait, isGood, target, delta] of TABLE) { + for (const type of TYPES) { + const lead = isGood ? crew(PREFERRED_ROLES[type][0], trait) : crew(PREFERRED_ROLES[type][0], NEUTRAL_GOOD, trait) + expect(computeDuration(type, 1, lead, []), `${trait} → ${type}`).toBe(WORK[type] + (applies(type, target) ? delta : 0)) + } + } + }) +}) + +describe('crew-costs — the §11 tuning surface', () => { + it('pins the balance knobs', () => { + expect(WORK_POINTS).toEqual({ EXPLORE: 4, JUMP_PER_HOP: 3, SHIP_WORK: 6, RESEARCH: 9 }) + expect(XP_THRESHOLDS).toEqual([5, 10, 15, 20, 25]) + expect(POWER_BANDS).toEqual([1, 1, 1, 2, 2, 3]) // L0–L2 → 1 · L3–L4 → 2 · L5 → 3 + expect(STARTING_LEVELS.PILOT).toEqual({ NAVIGATION: 2, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }) + expect(STARTING_LEVELS.SCIENTIST).toEqual({ NAVIGATION: 0, SCANNING: 2, ENGINEERING: 0, RESEARCH: 2 }) + expect(STARTING_LEVELS.COMMANDER).toEqual({ NAVIGATION: 0, SCANNING: 0, ENGINEERING: 0, RESEARCH: 0 }) + expect(RECRUIT_CHANCE_BASE).toBe(0.18) + expect(RECRUIT_CHANCE_TALKER).toBe(0.3) + expect(Object.keys(TRAIT_DURATION_MODIFIERS)).toHaveLength(15) // all 15 duration traits of spec §5, nothing else + expect(TRAIT_DURATION_MODIFIERS[MISMATCH_CANCEL_TRAIT]).toEqual({}) // its effect is the cancel, not an additive modifier + }) +}) diff --git a/lib/game/tasks.ts b/lib/game/tasks.ts new file mode 100644 index 0000000..e36501b --- /dev/null +++ b/lib/game/tasks.ts @@ -0,0 +1,47 @@ +// Pure task-engine model (spec §3–§4): the task types and the duration formula. No lib/db +// imports — the frontend imports this for the live duration preview in the crew picker +// (same precedent as tech.ts / ship-costs.ts). +import { MISMATCH_CANCEL_TRAIT, PREFERRED_ROLES, TRAIT_DURATION_MODIFIERS, WORK_POINTS } from './crew-costs' +import type { CrewMember } from './crew' + +export type TaskType = 'JUMP' | 'EXPLORE' | 'SHIP_UPGRADE' | 'SHIP_EXPAND' | 'SHIP_BUILD' | 'RESEARCH' + +// Work points per action (spec §4): JUMP scales with hop count, the rest are flat. +function workPoints(type: TaskType, hopCount: number): number { + switch (type) { + case 'JUMP': + return hopCount * WORK_POINTS.JUMP_PER_HOP + case 'EXPLORE': + return WORK_POINTS.EXPLORE + case 'RESEARCH': + return WORK_POINTS.RESEARCH + default: + return WORK_POINTS.SHIP_WORK // SHIP_UPGRADE | SHIP_EXPAND | SHIP_BUILD + } +} + +// One trait's modifier for a lead on an action type: its per-type entry plus the 'ANY' entry. +// Traits without a map entry contribute 0. +function traitModifier(trait: string, type: TaskType): number { + const mods = TRAIT_DURATION_MODIFIERS[trait] + if (!mods) return 0 + return (mods[type] ?? 0) + (mods['ANY'] ?? 0) +} + +// duration = max(1, ceil(work / totalPower) + mismatchPenalty + traitModifierSum) — spec §4. +// totalPower = Σ powerOf over the WHOLE team (lead + helpers); the default powerOf is the +// constant 1 until PR B feeds the skill band. The mismatch penalty and the trait modifiers +// apply to the LEAD only — helpers contribute power, are never penalised, and their traits do +// not apply. `hopCount` is only meaningful for JUMP (pass 1 for the other types). +export function computeDuration( + type: TaskType, + hopCount: number, + lead: CrewMember, + helpers: CrewMember[], + powerOf: (m: CrewMember) => number = () => 1, +): number { + const totalPower = Math.max(1, [lead, ...helpers].reduce((sum, m) => sum + powerOf(m), 0)) + const mismatchPenalty = lead.traitGood === MISMATCH_CANCEL_TRAIT || PREFERRED_ROLES[type].includes(lead.role) ? 0 : 1 + const traitModifierSum = traitModifier(lead.traitGood, type) + traitModifier(lead.traitBad, type) + return Math.max(1, Math.ceil(workPoints(type, hopCount) / totalPower) + mismatchPenalty + traitModifierSum) +}