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:
parent
cb54165c90
commit
8acc81bc10
|
|
@ -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).
|
- **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.
|
- **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.
|
- **`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).
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
|
||||||
skillPointsSpent: Number(spent?.n ?? 0),
|
skillPointsSpent: Number(spent?.n ?? 0),
|
||||||
shipsOwned: owned!.n,
|
shipsOwned: owned!.n,
|
||||||
pilotsHired: hired!.n,
|
pilotsHired: hired!.n,
|
||||||
|
// Counted once the veteran is stored (Step 10J.4).
|
||||||
|
veteranContracts: 0,
|
||||||
locationsDiscovered: discovered!.n,
|
locationsDiscovered: discovered!.n,
|
||||||
dealAccepted: player.dealAccepted,
|
dealAccepted: player.dealAccepted,
|
||||||
loanBalance: company.loanBalance,
|
loanBalance: company.loanBalance,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ describe("onboarding goals", () => {
|
||||||
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
|
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
|
||||||
// Paid-off loan: the test owner has no balance.
|
// 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 === "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 () => {
|
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 () => {
|
it("refuses an incomplete goal and an unknown one", async () => {
|
||||||
const owner = await dealOwner();
|
const owner = await dealOwner();
|
||||||
await setCredits(database().db, owner.playerId, 100);
|
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(response.statusCode).toBe(409);
|
||||||
expect(errorCode(response)).toBe("goal-not-ready");
|
expect(errorCode(response)).toBe("goal-not-ready");
|
||||||
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);
|
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,11 @@ describe("goals on HQ", () => {
|
||||||
});
|
});
|
||||||
renderApp(api);
|
renderApp(api);
|
||||||
const card = await screen.findByRole("region", { name: "Goals" });
|
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" }));
|
await userEvent.click(within(card).getByRole("button", { name: "Claim" }));
|
||||||
expect(api.call).toHaveBeenCalledWith("claimGoal", { goalId: "first-own-delivery" });
|
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"));
|
expect(card).not.toHaveTextContent(title("first-own-delivery"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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.
|
- [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.4 — Server: veteran on the deal
|
||||||
- [ ] Step 10J.5 — Client: deal modal and pilot card
|
- [ ] Step 10J.5 — Client: deal modal and pilot card
|
||||||
- [ ] Step 10J.6 — Client: pilot share in dispatch and summaries
|
- [ ] Step 10J.6 — Client: pilot share in dispatch and summaries
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** 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[] = [
|
export const onboardingGoals: OnboardingGoal[] = [
|
||||||
{
|
{
|
||||||
id: "first-own-delivery",
|
id: "first-own-delivery",
|
||||||
|
|
@ -34,9 +45,10 @@ export const onboardingGoals: OnboardingGoal[] = [
|
||||||
reward: 1000,
|
reward: 1000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "first-hire",
|
// Replaces "Hire your first pilot" (CR-010): the veteran comes with the deal.
|
||||||
title: "Hire your first pilot",
|
id: "veteran-contract",
|
||||||
condition: { kind: "pilots-hired", count: 1 },
|
title: `Fly a contract with ${veteranPilot.name}`,
|
||||||
|
condition: { kind: "veteran-contracts", count: 1 },
|
||||||
reward: 1000,
|
reward: 1000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -52,14 +64,3 @@ export const onboardingGoals: OnboardingGoal[] = [
|
||||||
reward: 2500,
|
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 },
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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("skill-points-spent"), count: positiveInt }),
|
||||||
z.object({ kind: z.literal("ships-owned"), count: positiveInt }),
|
z.object({ kind: z.literal("ships-owned"), count: positiveInt }),
|
||||||
z.object({ kind: z.literal("pilots-hired"), 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. */
|
/** Locations discovered beyond the ones that start discovered. */
|
||||||
z.object({ kind: z.literal("locations-discovered"), count: positiveInt }),
|
z.object({ kind: z.literal("locations-discovered"), count: positiveInt }),
|
||||||
/** The deal's loan is fully repaid. */
|
/** The deal's loan is fully repaid. */
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ describe("onboarding goals", () => {
|
||||||
"own-ship-deliveries",
|
"own-ship-deliveries",
|
||||||
"skill-points-spent",
|
"skill-points-spent",
|
||||||
"ships-owned",
|
"ships-owned",
|
||||||
"pilots-hired",
|
"veteran-contracts",
|
||||||
"locations-discovered",
|
"locations-discovered",
|
||||||
"loan-paid-off",
|
"loan-paid-off",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export interface GoalProgress {
|
||||||
shipsOwned: number;
|
shipsOwned: number;
|
||||||
/** Pilots hired (the avatar excluded). */
|
/** Pilots hired (the avatar excluded). */
|
||||||
pilotsHired: number;
|
pilotsHired: number;
|
||||||
|
/** Resolved contract deliveries (not deadheads) flown by the veteran (CR-010). */
|
||||||
|
veteranContracts: number;
|
||||||
/** Locations discovered beyond the ones that start discovered. */
|
/** Locations discovered beyond the ones that start discovered. */
|
||||||
locationsDiscovered: number;
|
locationsDiscovered: number;
|
||||||
dealAccepted: boolean;
|
dealAccepted: boolean;
|
||||||
|
|
@ -25,6 +27,8 @@ export function isGoalConditionMet(condition: GoalCondition, progress: GoalProgr
|
||||||
return progress.shipsOwned >= condition.count;
|
return progress.shipsOwned >= condition.count;
|
||||||
case "pilots-hired":
|
case "pilots-hired":
|
||||||
return progress.pilotsHired >= condition.count;
|
return progress.pilotsHired >= condition.count;
|
||||||
|
case "veteran-contracts":
|
||||||
|
return progress.veteranContracts >= condition.count;
|
||||||
case "locations-discovered":
|
case "locations-discovered":
|
||||||
return progress.locationsDiscovered >= condition.count;
|
return progress.locationsDiscovered >= condition.count;
|
||||||
case "loan-paid-off":
|
case "loan-paid-off":
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const fresh: GoalProgress = {
|
||||||
skillPointsSpent: 0,
|
skillPointsSpent: 0,
|
||||||
shipsOwned: 1,
|
shipsOwned: 1,
|
||||||
pilotsHired: 0,
|
pilotsHired: 0,
|
||||||
|
veteranContracts: 0,
|
||||||
locationsDiscovered: 0,
|
locationsDiscovered: 0,
|
||||||
dealAccepted: true,
|
dealAccepted: true,
|
||||||
loanBalance: content.config.dealLoanAmount,
|
loanBalance: content.config.dealLoanAmount,
|
||||||
|
|
@ -23,7 +24,7 @@ const cases: [string, Partial<GoalProgress>, Partial<GoalProgress>][] = [
|
||||||
["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }],
|
["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }],
|
||||||
["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }],
|
["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }],
|
||||||
["second-ship", { shipsOwned: 1 }, { shipsOwned: 2 }],
|
["second-ship", { shipsOwned: 1 }, { shipsOwned: 2 }],
|
||||||
["first-hire", { pilotsHired: 0 }, { pilotsHired: 1 }],
|
["veteran-contract", { veteranContracts: 0 }, { veteranContracts: 1 }],
|
||||||
["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }],
|
["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }],
|
||||||
["loan-paid-off", { loanBalance: 1 }, { loanBalance: 0 }],
|
["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", () => {
|
it("does not count a loan as paid off before the deal", () => {
|
||||||
expect(isComplete("loan-paid-off", { ...fresh, dealAccepted: false, loanBalance: 0 })).toBe(
|
expect(isComplete("loan-paid-off", { ...fresh, dealAccepted: false, loanBalance: 0 })).toBe(
|
||||||
false,
|
false,
|
||||||
|
|
@ -48,8 +59,8 @@ describe("onboarding goal evaluation", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns completed goals in content order, identically for identical inputs", () => {
|
it("returns completed goals in content order, identically for identical inputs", () => {
|
||||||
const progress: GoalProgress = { ...fresh, pilotsHired: 3, ownShipDeliveries: 5 };
|
const progress: GoalProgress = { ...fresh, veteranContracts: 3, ownShipDeliveries: 5 };
|
||||||
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "first-hire"]);
|
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "veteran-contract"]);
|
||||||
expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress }));
|
expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue