From a685235e90a707bf7a9d23e83982baab58e809bb Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 18:50:14 -0400 Subject: [PATCH] Step 3.16: add XP and leveling Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 +++++ memory-bank/progress.md | 2 +- packages/sim/src/index.ts | 1 + packages/sim/src/xp.ts | 21 +++++++++++++++++ packages/sim/test/xp.test.ts | 44 ++++++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/xp.ts create mode 100644 packages/sim/test/xp.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index a67530d..6526fcd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -163,3 +163,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Progress is linear in real time.** Takeoff and landing overhead are not modelled separately, so the ship moves at a constant rate along its line. - **A zero-length flight reports progress 1** instead of dividing by zero. + +## Step 3.16 + +- **Delivery XP** = round(base + perMillionKm × distance + perCargoTier × tier), using the distance at departure stored on the dispatch record. +- **Pilot level** is the number of `pilotLevelXpThresholds` the total XP has reached (0 below the first threshold), so levels cap at the list length (10). +- **Skill points available** = level × points per level − points spent, floored at 0. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 673491d..93b5726 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -34,6 +34,6 @@ - [x] **Step 3.13 — Dispatch record creation.** `createDispatchRecord` in `src/dispatch.ts`: `DispatchRecord` with arrival from travel time, fuel cost, departure distance, a `tutorial` flag and a per-dispatch seed. - [x] **Step 3.14 — Reposition dispatch.** `validateReposition` and `createRepositionRecord` in `src/dispatch.ts`: empty flight to a player bay, range/landing/fuel only, zero payout. Zero XP is covered by resolution (Step 3.17). - [x] **Step 3.15 — In-flight position and progress.** `src/flight.ts`: `dispatchProgress` (clamped 0–1) and `dispatchPosition` (straight line from origin at departure to destination at arrival). -- [ ] Step 3.16 — XP and leveling +- [x] **Step 3.16 — XP and leveling.** `src/xp.ts`: `deliveryXp`, `pilotLevel`, `skillPointsAvailable`. - [ ] Step 3.17 — Dispatch resolution - [ ] Step 3.18 — Sim package public API and coverage diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index ca007fe..368a248 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -11,3 +11,4 @@ export * from "./tutorial"; export * from "./pilot"; export * from "./dispatch"; export * from "./flight"; +export * from "./xp"; diff --git a/packages/sim/src/xp.ts b/packages/sim/src/xp.ts new file mode 100644 index 0000000..49cad0b --- /dev/null +++ b/packages/sim/src/xp.ts @@ -0,0 +1,21 @@ +import type { GameConfig } from "@deadhead/content"; + +/** XP for one delivery, in whole points: base + per-million-km × distance + per-tier × tier. */ +export function deliveryXp(distanceMillionKm: number, tier: number, config: GameConfig): number { + const { base, perMillionKm, perCargoTier } = config.xpPerDelivery; + return Math.round(base + perMillionKm * distanceMillionKm + perCargoTier * tier); +} + +/** Pilot level: how many level thresholds the pilot's total XP has reached. */ +export function pilotLevel(totalXp: number, config: GameConfig): number { + return config.pilotLevelXpThresholds.filter((threshold) => totalXp >= threshold).length; +} + +/** Unspent skill points: levels × points per level minus points spent, never below 0. */ +export function skillPointsAvailable( + level: number, + pointsSpent: number, + config: GameConfig, +): number { + return Math.max(0, level * config.skillPointsPerLevel - pointsSpent); +} diff --git a/packages/sim/test/xp.test.ts b/packages/sim/test/xp.test.ts new file mode 100644 index 0000000..51ecc13 --- /dev/null +++ b/packages/sim/test/xp.test.ts @@ -0,0 +1,44 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { deliveryXp, pilotLevel, skillPointsAvailable } from "../src/index"; + +const config = content.config; + +describe("delivery XP", () => { + it("grants more XP for a longer delivery", () => { + expect(deliveryXp(150, 0, config)).toBeGreaterThan(deliveryXp(0.4, 0, config)); + }); + + it("grants more XP for a higher cargo tier", () => { + expect(deliveryXp(50, 3, config)).toBeGreaterThan(deliveryXp(50, 0, config)); + }); + + it("matches a hand calculation", () => { + // base 10 + 0.2 × 100 + 5 × 2. + expect(deliveryXp(100, 2, config)).toBe(40); + }); +}); + +describe("pilot level", () => { + it("starts at level 0", () => { + expect(pilotLevel(0, config)).toBe(0); + }); + + it("goes up by exactly one when crossing each threshold", () => { + config.pilotLevelXpThresholds.forEach((threshold, i) => { + expect(pilotLevel(threshold - 1, config)).toBe(i); + expect(pilotLevel(threshold, config)).toBe(i + 1); + }); + }); +}); + +describe("skill points", () => { + it("gives points per level minus points spent", () => { + expect(skillPointsAvailable(3, 2, config)).toBe(3 * config.skillPointsPerLevel - 2); + }); + + it("never goes negative", () => { + expect(skillPointsAvailable(0, 5, config)).toBe(0); + expect(skillPointsAvailable(1, 100, config)).toBe(0); + }); +});