Step 3.16: add XP and leveling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 18:50:14 -04:00
parent 1472dc6786
commit a685235e90
5 changed files with 73 additions and 1 deletions

View File

@ -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. - **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. - **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.

View File

@ -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.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.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). - [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.17 — Dispatch resolution
- [ ] Step 3.18 — Sim package public API and coverage - [ ] Step 3.18 — Sim package public API and coverage

View File

@ -11,3 +11,4 @@ export * from "./tutorial";
export * from "./pilot"; export * from "./pilot";
export * from "./dispatch"; export * from "./dispatch";
export * from "./flight"; export * from "./flight";
export * from "./xp";

21
packages/sim/src/xp.ts Normal file
View File

@ -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);
}

View File

@ -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);
});
});