Step 10J.3: goal 4 becomes flying a contract with the veteran

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 21:35:20 -04:00
parent cb54165c90
commit 8acc81bc10
10 changed files with 49 additions and 23 deletions

View File

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

View File

@ -55,6 +55,8 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
skillPointsSpent: Number(spent?.n ?? 0),
shipsOwned: owned!.n,
pilotsHired: hired!.n,
// Counted once the veteran is stored (Step 10J.4).
veteranContracts: 0,
locationsDiscovered: discovered!.n,
dealAccepted: player.dealAccepted,
loanBalance: company.loanBalance,

View File

@ -48,7 +48,7 @@ describe("onboarding goals", () => {
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -54,7 +54,7 @@ describe("onboarding goals", () => {
"own-ship-deliveries",
"skill-points-spent",
"ships-owned",
"pilots-hired",
"veteran-contracts",
"locations-discovered",
"loan-paid-off",
]);

View File

@ -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":

View File

@ -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<GoalProgress>, Partial<GoalProgress>][] = [
["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 }));
});
});