From b3ed6ef71d00707eda4ade19f29355bb16ca1427 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 10:59:25 -0400 Subject: [PATCH] Step 10A.5: add onboarding goal evaluation Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++++ memory-bank/progress.md | 2 +- packages/sim/src/goals.ts | 39 +++++++++++++++++++++++ packages/sim/src/index.ts | 3 ++ packages/sim/test/goals.test.ts | 55 +++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/goals.ts create mode 100644 packages/sim/test/goals.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 00b2efa..007897c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -510,3 +510,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The lender's cut is rounded down to whole credits:** repayment = `min(balance, floor(credits × share))`, or 0 when the balance is 0 or the credits after pilot pay are not positive. Credits are whole numbers everywhere, and rounding down favours the player by under one credit. - **`Resolution.credits` is now the net amount** (after pilot pay and repayment); `loanRepayment` is reported separately for the server to apply and the summary to show. - **Temporary:** the server resolves with `loanBalance: 0` until the balance column exists (Steps 10A.6 and 10A.9). + +## Step 10A.5 + +- **Goals are evaluated against a small `GoalProgress` summary** (own-ship deliveries, skill points spent, ships owned, pilots hired, extra locations discovered, deal accepted, loan balance) rather than full player state. The server builds it from the database, and the evaluator stays independent of storage. +- **"Pay off your loan" requires the deal to have been accepted**, so a player who never had a loan does not complete it by default. +- **The evaluator takes the goal list as an argument** (not part of `SimContent`), since only goal code needs it. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c8a1a15..8eb0720 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -110,7 +110,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [x] **Step 10A.2 — Sim: handling time.** `travelTimeGameHours(ship, distance, massTonnes, config)` adds `mass × handlingGameHoursPerTonne`; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated. - [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7. - [x] **Step 10A.4 — Sim: loan repayment.** `splitLoanRepayment(credits, balance, share)` (`src/loan.ts`); `resolveDispatch` takes `loanBalance` and returns `loanRepayment` with net `credits`. Server passes a zero balance until Step 10A.9. -- [ ] Step 10A.5 — Sim: onboarding goal evaluation +- [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%. - [ ] Step 10A.6 — Schema and database - [ ] Step 10A.7 — Server: tutorial flow v2 - [ ] Step 10A.8 — Server: accept the deal diff --git a/packages/sim/src/goals.ts b/packages/sim/src/goals.ts new file mode 100644 index 0000000..b88d81f --- /dev/null +++ b/packages/sim/src/goals.ts @@ -0,0 +1,39 @@ +import type { GoalCondition, OnboardingGoal } from "@deadhead/content"; + +/** The facts about a player that onboarding goals are checked against. */ +export interface GoalProgress { + /** Contracts delivered by the player's own ships (tutorial runs excluded). */ + ownShipDeliveries: number; + /** Skill points spent across all pilots. */ + skillPointsSpent: number; + shipsOwned: number; + /** Pilots hired (the avatar excluded). */ + pilotsHired: number; + /** Locations discovered beyond the ones that start discovered. */ + locationsDiscovered: number; + dealAccepted: boolean; + loanBalance: number; +} + +export function isGoalConditionMet(condition: GoalCondition, progress: GoalProgress): boolean { + switch (condition.kind) { + case "own-ship-deliveries": + return progress.ownShipDeliveries >= condition.count; + case "skill-points-spent": + return progress.skillPointsSpent >= condition.count; + case "ships-owned": + return progress.shipsOwned >= condition.count; + case "pilots-hired": + return progress.pilotsHired >= condition.count; + case "locations-discovered": + return progress.locationsDiscovered >= condition.count; + case "loan-paid-off": + // Only a loan that was taken can be paid off. + return progress.dealAccepted && progress.loanBalance <= 0; + } +} + +/** Ids of the goals whose conditions are met, in the goals' order (decision 0.5.34). */ +export function completedGoals(goals: readonly OnboardingGoal[], progress: GoalProgress): string[] { + return goals.filter((goal) => isGoalConditionMet(goal.condition, progress)).map((g) => g.id); +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index f9e2b66..f5c29b6 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -61,6 +61,9 @@ export { dispatchPosition, dispatchProgress } from "./flight"; export { deliveryXp, pilotLevel, skillPointsAvailable } from "./xp"; +export { completedGoals, isGoalConditionMet } from "./goals"; +export type { GoalProgress } from "./goals"; + export { splitLoanRepayment } from "./loan"; export type { LoanSplit } from "./loan"; diff --git a/packages/sim/test/goals.test.ts b/packages/sim/test/goals.test.ts new file mode 100644 index 0000000..05a6647 --- /dev/null +++ b/packages/sim/test/goals.test.ts @@ -0,0 +1,55 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { completedGoals, type GoalProgress } from "../src/index"; + +const goals = content.onboardingGoals; + +/** Just after the deal: a ship, an Earth bay and the full loan. */ +const fresh: GoalProgress = { + ownShipDeliveries: 0, + skillPointsSpent: 0, + shipsOwned: 1, + pilotsHired: 0, + locationsDiscovered: 0, + dealAccepted: true, + loanBalance: content.config.dealLoanAmount, +}; + +const isComplete = (id: string, progress: GoalProgress) => + completedGoals(goals, progress).includes(id); + +/** For each goal: a summary just short of its condition, and one that meets it. */ +const cases: [string, Partial, Partial][] = [ + ["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }], + ["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }], + ["second-ship", { shipsOwned: 1 }, { shipsOwned: 2 }], + ["first-hire", { pilotsHired: 0 }, { pilotsHired: 1 }], + ["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }], + ["loan-paid-off", { loanBalance: 1 }, { loanBalance: 0 }], +]; + +describe("onboarding goal evaluation", () => { + it("covers every goal in content, in order", () => { + expect(cases.map(([id]) => id)).toEqual(goals.map((g) => g.id)); + }); + + it.each(cases)( + "%s: incomplete just short of the condition, complete once met", + (id, short, met) => { + expect(isComplete(id, { ...fresh, ...short })).toBe(false); + expect(isComplete(id, { ...fresh, ...met })).toBe(true); + }, + ); + + it("does not count a loan as paid off before the deal", () => { + expect(isComplete("loan-paid-off", { ...fresh, dealAccepted: false, loanBalance: 0 })).toBe( + false, + ); + }); + + it("returns completed goals in content order, identically for identical inputs", () => { + const progress: GoalProgress = { ...fresh, pilotsHired: 3, ownShipDeliveries: 5 }; + expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "first-hire"]); + expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress })); + }); +});