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).
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe("onboarding goals", () => {
|
|||
"own-ship-deliveries",
|
||||
"skill-points-spent",
|
||||
"ships-owned",
|
||||
"pilots-hired",
|
||||
"veteran-contracts",
|
||||
"locations-discovered",
|
||||
"loan-paid-off",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue