diff --git a/DECISIONS.md b/DECISIONS.md index 0c86a6e..ec4c45f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1006,3 +1006,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Rounding:** pilot pay uses `Math.round` to whole credits, the same as payouts and fuel, so the CR's 0.25 × (400 − 150) = 62.5 pays **63**. The loan repayment keeps its own rule (rounded down, decision 0.5.32), computed on what is left after pilot pay (0.5.69). - **The split is exact:** credits added + pilot pay + loan repayment always equal the payout (fuel was charged at dispatch); tested over 500 random cases. - **`Resolution.pilotPay`** is stored with each resolution so summaries can show the share (10J.6); older stored resolutions without it read as 0. + +## Step 10J.3 + +- **New goal id `veteran-contract`** (was `first-hire`), with a new condition kind `veteran-contracts`. A new id keeps any old "first hire" claim from counting as this different goal; `pilots-hired` stays available as a condition kind but no goal uses it now. +- **The title is built from content** (`Fly a contract with ${veteranPilot.name}`), so renaming the veteran renames the goal. +- **Counting is the server's job**: the sim only compares `veteranContracts` to the goal; which deliveries count (contracts, not deadheads or tutorial runs, flown by the veteran) is decided where they are stored (10J.4). diff --git a/apps/server/src/game/goals.ts b/apps/server/src/game/goals.ts index 655cff1..e9a76b8 100644 --- a/apps/server/src/game/goals.ts +++ b/apps/server/src/game/goals.ts @@ -55,6 +55,8 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise { expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id)); // Paid-off loan: the test owner has no balance. expect(goals.find((g) => g.id === "loan-paid-off")?.status).toBe("ready"); - expect(goals.find((g) => g.id === "first-hire")?.status).toBe("incomplete"); + expect(goals.find((g) => g.id === "veteran-contract")?.status).toBe("incomplete"); }); it("becomes ready when its condition is met: a delivery with the player's own ship", async () => { @@ -93,7 +93,7 @@ describe("onboarding goals", () => { it("refuses an incomplete goal and an unknown one", async () => { const owner = await dealOwner(); await setCredits(database().db, owner.playerId, 100); - const response = await claim(owner, "first-hire"); + const response = await claim(owner, "veteran-contract"); expect(response.statusCode).toBe(409); expect(errorCode(response)).toBe("goal-not-ready"); expect((await claim(owner, "win-the-game")).statusCode).toBe(404); diff --git a/apps/web/test/goals.test.tsx b/apps/web/test/goals.test.tsx index 8038235..c82e916 100644 --- a/apps/web/test/goals.test.tsx +++ b/apps/web/test/goals.test.tsx @@ -56,11 +56,11 @@ describe("goals on HQ", () => { }); renderApp(api); const card = await screen.findByRole("region", { name: "Goals" }); - expect(card).not.toHaveTextContent(title("first-hire")); + expect(card).not.toHaveTextContent(title("veteran-contract")); await userEvent.click(within(card).getByRole("button", { name: "Claim" })); expect(api.call).toHaveBeenCalledWith("claimGoal", { goalId: "first-own-delivery" }); - await waitFor(() => expect(card).toHaveTextContent(title("first-hire"))); + await waitFor(() => expect(card).toHaveTextContent(title("veteran-contract"))); expect(card).not.toHaveTextContent(title("first-own-delivery")); }); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c8fdfc7..d5acce0 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -242,7 +242,7 @@ Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (receiv - [x] **Step 10J.1 — Content: config and veteran.** Config `pilotProfitShare` 0.25 (a fraction 0–1); the flat `pilotPayPerDelivery` is removed in 10J.2 when the sim switches to the share. `veteranPilotSchema` and `content.veteranPilot`: Rosa Kell, "Halvorsen's pilot for twelve years.", level 2, Heavy Cargo 1. Test: `content/test/veteran.test.ts`. - [x] **Step 10J.2 — Sim: profit-share pay.** `sim/src/pay.ts` `pilotShare(payout, fuel)` = round(max(0, payout − fuel) × `pilotProfitShare`). Resolution charges it for hired pilots on contract deliveries (avatar, tutorial runs, deadheads: 0), then takes the loan repayment from what remains; `Resolution.pilotPay` records it. The flat `pilotPayPerDelivery` is gone (config, schema, client). The dispatch sheet's net profit uses the share. Test: `profit-share.test.ts` (400/150 → 63; fuel ≥ payout → 0; exemptions; repayment after pay; credits + pay + repayment = payout over 500 random cases); Step 3.17 tests updated. -- [ ] Step 10J.3 — Sim: goal change +- [x] **Step 10J.3 — Sim: goal change.** Goal 4 is now `veteran-contract`, "Fly a contract with Rosa Kell" (title from `content.veteranPilot.name`), condition `veteran-contracts` ≥ 1; `GoalProgress.veteranContracts`. The server reports 0 until the veteran is stored (10J.4). Tests: `goals.test.ts` (avatar deliveries alone don't complete it; one veteran contract does) and the goal-id updates in server and web goal tests. - [ ] Step 10J.4 — Server: veteran on the deal - [ ] Step 10J.5 — Client: deal modal and pilot card - [ ] Step 10J.6 — Client: pilot share in dispatch and summaries diff --git a/packages/content/src/data/onboarding.ts b/packages/content/src/data/onboarding.ts index cbde6c0..5cd5e34 100644 --- a/packages/content/src/data/onboarding.ts +++ b/packages/content/src/data/onboarding.ts @@ -14,6 +14,17 @@ export const scriptedTutorialContracts: ScriptedTutorialContract[] = [ ]; /** First-session goals, in the order they are shown (decision 0.5.34). Rewards are first guesses. */ +/** + * Halvorsen's last hire, who comes with the deal (decision 0.5.70): level 2, + * one point already in Heavy Cargo, the rest unspent. + */ +export const veteranPilot: VeteranPilot = { + name: "Rosa Kell", + bio: "Halvorsen's pilot for twelve years.", + level: 2, + skills: { "heavy-cargo": 1 }, +}; + export const onboardingGoals: OnboardingGoal[] = [ { id: "first-own-delivery", @@ -34,9 +45,10 @@ export const onboardingGoals: OnboardingGoal[] = [ reward: 1000, }, { - id: "first-hire", - title: "Hire your first pilot", - condition: { kind: "pilots-hired", count: 1 }, + // Replaces "Hire your first pilot" (CR-010): the veteran comes with the deal. + id: "veteran-contract", + title: `Fly a contract with ${veteranPilot.name}`, + condition: { kind: "veteran-contracts", count: 1 }, reward: 1000, }, { @@ -52,14 +64,3 @@ export const onboardingGoals: OnboardingGoal[] = [ reward: 2500, }, ]; - -/** - * Halvorsen's last hire, who comes with the deal (decision 0.5.70): level 2, - * one point already in Heavy Cargo, the rest unspent. - */ -export const veteranPilot: VeteranPilot = { - name: "Rosa Kell", - bio: "Halvorsen's pilot for twelve years.", - level: 2, - skills: { "heavy-cargo": 1 }, -}; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 4dc370d..285eacb 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -299,6 +299,8 @@ export const goalConditionSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("skill-points-spent"), count: positiveInt }), z.object({ kind: z.literal("ships-owned"), count: positiveInt }), z.object({ kind: z.literal("pilots-hired"), count: positiveInt }), + /** Contracts (not deadheads) delivered by the veteran pilot (CR-010). */ + z.object({ kind: z.literal("veteran-contracts"), count: positiveInt }), /** Locations discovered beyond the ones that start discovered. */ z.object({ kind: z.literal("locations-discovered"), count: positiveInt }), /** The deal's loan is fully repaid. */ diff --git a/packages/content/test/onboarding.test.ts b/packages/content/test/onboarding.test.ts index f728ee4..801f3ce 100644 --- a/packages/content/test/onboarding.test.ts +++ b/packages/content/test/onboarding.test.ts @@ -54,7 +54,7 @@ describe("onboarding goals", () => { "own-ship-deliveries", "skill-points-spent", "ships-owned", - "pilots-hired", + "veteran-contracts", "locations-discovered", "loan-paid-off", ]); diff --git a/packages/sim/src/goals.ts b/packages/sim/src/goals.ts index b88d81f..9ac7669 100644 --- a/packages/sim/src/goals.ts +++ b/packages/sim/src/goals.ts @@ -9,6 +9,8 @@ export interface GoalProgress { shipsOwned: number; /** Pilots hired (the avatar excluded). */ pilotsHired: number; + /** Resolved contract deliveries (not deadheads) flown by the veteran (CR-010). */ + veteranContracts: number; /** Locations discovered beyond the ones that start discovered. */ locationsDiscovered: number; dealAccepted: boolean; @@ -25,6 +27,8 @@ export function isGoalConditionMet(condition: GoalCondition, progress: GoalProgr return progress.shipsOwned >= condition.count; case "pilots-hired": return progress.pilotsHired >= condition.count; + case "veteran-contracts": + return progress.veteranContracts >= condition.count; case "locations-discovered": return progress.locationsDiscovered >= condition.count; case "loan-paid-off": diff --git a/packages/sim/test/goals.test.ts b/packages/sim/test/goals.test.ts index 05a6647..0500eca 100644 --- a/packages/sim/test/goals.test.ts +++ b/packages/sim/test/goals.test.ts @@ -10,6 +10,7 @@ const fresh: GoalProgress = { skillPointsSpent: 0, shipsOwned: 1, pilotsHired: 0, + veteranContracts: 0, locationsDiscovered: 0, dealAccepted: true, loanBalance: content.config.dealLoanAmount, @@ -23,7 +24,7 @@ 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 }], + ["veteran-contract", { veteranContracts: 0 }, { veteranContracts: 1 }], ["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }], ["loan-paid-off", { loanBalance: 1 }, { loanBalance: 0 }], ]; @@ -41,6 +42,16 @@ describe("onboarding goal evaluation", () => { }, ); + it("needs a contract flown by the veteran, not just the avatar's deliveries (CR-010)", () => { + expect( + isComplete("veteran-contract", { ...fresh, ownShipDeliveries: 12, pilotsHired: 1 }), + ).toBe(false); + expect(isComplete("veteran-contract", { ...fresh, veteranContracts: 1 })).toBe(true); + expect(goals.find((g) => g.id === "veteran-contract")?.title).toBe( + `Fly a contract with ${content.veteranPilot.name}`, + ); + }); + it("does not count a loan as paid off before the deal", () => { expect(isComplete("loan-paid-off", { ...fresh, dealAccepted: false, loanBalance: 0 })).toBe( false, @@ -48,8 +59,8 @@ describe("onboarding goal evaluation", () => { }); 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"]); + const progress: GoalProgress = { ...fresh, veteranContracts: 3, ownShipDeliveries: 5 }; + expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "veteran-contract"]); expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress })); }); });