From b8ff9ba47ac7ee0a861dbfc55df1b3ac968516f8 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 11:34:23 -0400 Subject: [PATCH] Step 10M.1: veteran skill and deal loan from content Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 +++++ apps/server/src/game/deal.ts | 2 +- apps/server/test/deal.test.ts | 4 ++-- apps/server/test/veteran.test.ts | 4 ++-- apps/web/src/components/DealModal.tsx | 3 ++- apps/web/src/components/LoanCard.tsx | 2 +- apps/web/test/deal-modal.test.tsx | 4 ++-- apps/web/test/hq.test.tsx | 2 +- memory-bank/progress.md | 2 +- packages/content/src/bundle.ts | 3 +++ packages/content/src/data/config.ts | 5 ++-- packages/content/src/data/onboarding.ts | 7 +++--- packages/content/src/deal.ts | 21 ++++++++++++++++ packages/content/src/index.ts | 1 + packages/content/src/schemas.ts | 11 +++++++-- packages/content/test/config.test.ts | 32 +++++++++++++++++++++---- packages/content/test/schemas.test.ts | 2 +- packages/content/test/veteran.test.ts | 2 +- packages/sim/test/goals.test.ts | 2 +- 19 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 packages/content/src/deal.ts diff --git a/DECISIONS.md b/DECISIONS.md index 26b08ee..9dee2d5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1204,3 +1204,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Renumbered on arrival:** titled "CR-010 … Phase 10J"; CR-010 to CR-012 and Phases 10J–10L already exist, so it is **CR-013**, done as **Phase 10M** (its 10J.n are 10M.n). - **Decisions:** the CR's 0.5.64 and 0.5.65 are **0.5.82** and **0.5.83**; its amended "0.5.52 Veteran skill" is this plan's **0.5.70**. Its "CR-007" (veteran, steps 10G.n) is **CR-010** (10J.n); its "CR-009" (new ships, 10I.11) is **CR-012** (10L.11). + +## Step 10M.1 + +- **The deal's contents are config** (`deal.avatarShipModelId`, `veteranShipModelId`, `bayLocationId`) so the loan formula and the server's deal both read the same ids; the stored `dealLoanAmount` number is gone. +- **The loan is a pure function of content** (`dealLoanAmount`), computed once into the bundle. Tests call it with altered prices to show it follows them. +- **The loan grows from 5,000 to 66,600 credits.** At the 30 % repayment share, paying it off takes far more deliveries than before; the "under 4 hours of active play" target is for the balance script (Step 12.1) to check. diff --git a/apps/server/src/game/deal.ts b/apps/server/src/game/deal.ts index af2500a..6558163 100644 --- a/apps/server/src/game/deal.ts +++ b/apps/server/src/game/deal.ts @@ -89,7 +89,7 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime) }); await tx .update(companies) - .set({ loanBalance: content.config.dealLoanAmount }) + .set({ loanBalance: content.dealLoanAmount }) .where(eq(companies.playerId, playerId)); await tx .update(players) diff --git a/apps/server/test/deal.test.ts b/apps/server/test/deal.test.ts index 137d8b0..3dd8b3e 100644 --- a/apps/server/test/deal.test.ts +++ b/apps/server/test/deal.test.ts @@ -59,7 +59,7 @@ describe("accepting the deal", () => { assignedShipId: ship!.id, locationId: "earth", }); - expect(state.company.loanBalance).toBe(content.config.dealLoanAmount); + expect(state.company.loanBalance).toBe(content.dealLoanAmount); const after = await snapshot(guest); expect(after.player).toMatchObject({ @@ -106,6 +106,6 @@ describe("accepting the deal", () => { const after = await snapshot(guest); expect(after.ships).toHaveLength(1); expect(after.bays).toHaveLength(1); - expect(after.company!.loanBalance).toBe(content.config.dealLoanAmount); + expect(after.company!.loanBalance).toBe(content.dealLoanAmount); }); }); diff --git a/apps/server/test/veteran.test.ts b/apps/server/test/veteran.test.ts index 1758df8..949414e 100644 --- a/apps/server/test/veteran.test.ts +++ b/apps/server/test/veteran.test.ts @@ -41,7 +41,7 @@ async function dealt(): Promise<{ guest: Guest; state: PlayerState }> { } describe("the veteran comes with the deal (Step 10J.4)", () => { - it("adds the veteran at Earth, unassigned, level 2 with Heavy Cargo 1", async () => { + it("adds the veteran at Earth, unassigned, level 2 with Expensive Cargo 1", async () => { const { state } = await dealt(); expect(state.pilots).toHaveLength(2); const courier = state.ships[0]!; @@ -52,7 +52,7 @@ describe("the veteran comes with the deal (Step 10J.4)", () => { isAvatar: false, isVeteran: true, level: 2, - skills: { "heavy-cargo": 1 }, + skills: { "expensive-cargo": 1 }, skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints, assignedShipId: null, locationId: "earth", diff --git a/apps/web/src/components/DealModal.tsx b/apps/web/src/components/DealModal.tsx index 3ff6513..d43938e 100644 --- a/apps/web/src/components/DealModal.tsx +++ b/apps/web/src/components/DealModal.tsx @@ -27,7 +27,8 @@ export function DealModal() { if (!state.tutorial?.dealAvailable || state.summaries.length > 0) return null; - const { dealLoanAmount, loanRepaymentShare } = content.config; + const { dealLoanAmount } = content; + const { loanRepaymentShare } = content.config; const courier = content.shipModel("courier")!; const veteran = content.veteranPilot; diff --git a/apps/web/src/components/LoanCard.tsx b/apps/web/src/components/LoanCard.tsx index 87f5d8b..6843674 100644 --- a/apps/web/src/components/LoanCard.tsx +++ b/apps/web/src/components/LoanCard.tsx @@ -18,7 +18,7 @@ export function LoanCard() { const repay = useGameAction("repayLoan"); const [input, setInput] = useState(""); const { credits, loanBalance } = state.company; - const total = content.config.dealLoanAmount; + const total = content.dealLoanAmount; const paidOff = Math.max(0, Math.min(100, Math.floor(((total - loanBalance) / total) * 100))); const amount = Number(input); const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance); diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx index 5606967..95723a5 100644 --- a/apps/web/test/deal-modal.test.tsx +++ b/apps/web/test/deal-modal.test.tsx @@ -22,7 +22,7 @@ const afterDeal = () => id: "co1", name: "Deadhead Freight", credits: 800, - loanBalance: content.config.dealLoanAmount, + loanBalance: content.dealLoanAmount, }, ships: [ { @@ -63,7 +63,7 @@ describe("the deal", () => { const dialog = await screen.findByRole("dialog", { name: "An offer from Halvorsen" }); expect(dialog).toHaveTextContent("Courier and a bay at Earth"); expect(screen.getByLabelText("Loan amount")).toHaveTextContent( - `${content.config.dealLoanAmount.toLocaleString("en-US")} cr`, + `${content.dealLoanAmount.toLocaleString("en-US")} cr`, ); expect(screen.getByLabelText("Repayment share")).toHaveTextContent( `${Math.round(content.config.loanRepaymentShare * 100)}% of every payout`, diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx index a4e71d3..71d456e 100644 --- a/apps/web/test/hq.test.tsx +++ b/apps/web/test/hq.test.tsx @@ -83,7 +83,7 @@ describe("HQ screen", () => { }); describe("loan on HQ", () => { - const total = content.config.dealLoanAmount; + const total = content.dealLoanAmount; const owner = (credits: number, loanBalance: number): PlayerState => samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits, loanBalance } }); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 0e5c012..9efa806 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -287,7 +287,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR-010 … Phase 10J"). New decisions recorded as plan §0.5.82–0.5.83; §0.5.70 amended. -- [ ] Step 10M.1 — Content: veteran skill and loan +- [x] **Step 10M.1 — Content: veteran skill and loan.** The veteran starts with **Expensive Cargo 1**. Config `dealLoanAmount` (5,000) is replaced by `deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }`, and the loan is computed by `dealLoanAmount({ config, shipModels, locations })` (`packages/content/src/deal.ts`), exposed as `content.dealLoanAmount`: 4,000 + 61,600 + 1,000 = **66,600 cr**. All users (server deal, loan card, deal modal, tests) read `content.dealLoanAmount`. Tests: config (deal ids; loan = Courier + Clipper + Earth lease; it moves by exactly the change when either price or the lease changes), veteran skills (content and server). - [ ] Step 10M.2 — Sim: goal changes - [ ] Step 10M.3 — Server: deal creates the Clipper - [ ] Step 10M.4 — Client: deal modal diff --git a/packages/content/src/bundle.ts b/packages/content/src/bundle.ts index 635116b..5949ce4 100644 --- a/packages/content/src/bundle.ts +++ b/packages/content/src/bundle.ts @@ -1,5 +1,6 @@ import { cargoTypes, skills } from "./data/cargo"; import { gameConfig } from "./data/config"; +import { dealLoanAmount } from "./deal"; import { goods } from "./data/goods"; import { locations } from "./data/locations"; import { onboardingGoals, scriptedTutorialContracts, veteranPilot } from "./data/onboarding"; @@ -26,6 +27,8 @@ export const content = { onboardingGoals, veteranPilot, upgrades, + /** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */ + dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }), location: byId(locations), shipModel: byId(shipModels), cargoType: byId(cargoTypes), diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 2d0686d..17d53c7 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -21,8 +21,9 @@ export const gameConfig: GameConfig = { // 20 t adds 0.4 game hours (24 real seconds) of loading and unloading. handlingGameHoursPerTonne: 0.02, loanRepaymentShare: 0.3, - // Courier price + Earth bay lease. - dealLoanAmount: 5000, + // The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the + // veteran, and the Earth bay. The loan is computed from their prices. + deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, contractBoardPeriodRealMinutes: 10, // A ship must be able to fly out of its destination two game days after arriving (CR-012). noStrandMarginGameHours: 48, diff --git a/packages/content/src/data/onboarding.ts b/packages/content/src/data/onboarding.ts index 5cd5e34..9d4ce0e 100644 --- a/packages/content/src/data/onboarding.ts +++ b/packages/content/src/data/onboarding.ts @@ -15,14 +15,15 @@ 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. + * Halvorsen's last hire, who comes with the deal crewing his Clipper + * (decisions 0.5.70, 0.5.82): level 2, one point already in Expensive Cargo + * (small, valuable loads suit the Clipper's hold), the rest unspent. */ export const veteranPilot: VeteranPilot = { name: "Rosa Kell", bio: "Halvorsen's pilot for twelve years.", level: 2, - skills: { "heavy-cargo": 1 }, + skills: { "expensive-cargo": 1 }, }; export const onboardingGoals: OnboardingGoal[] = [ diff --git a/packages/content/src/deal.ts b/packages/content/src/deal.ts new file mode 100644 index 0000000..4222ed2 --- /dev/null +++ b/packages/content/src/deal.ts @@ -0,0 +1,21 @@ +import type { GameConfig, Location, ShipModel } from "./schemas"; + +/** + * The deal's loan (decision 0.5.83): the price of every ship it hands over plus + * the lease of its bay, read from content so it follows any price change. + */ +export function dealLoanAmount(data: { + config: Pick; + shipModels: readonly ShipModel[]; + locations: readonly Location[]; +}): number { + const { avatarShipModelId, veteranShipModelId, bayLocationId } = data.config.deal; + const price = (id: string) => { + const model = data.shipModels.find((m) => m.id === id); + if (!model) throw new Error(`Unknown deal ship model ${id}`); + return model.price; + }; + const bay = data.locations.find((l) => l.id === bayLocationId); + if (!bay) throw new Error(`Unknown deal bay location ${bayLocationId}`); + return price(avatarShipModelId) + price(veteranShipModelId) + bay.bayLeasePrice; +} diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts index 2dd0076..f9c3d84 100644 --- a/packages/content/src/index.ts +++ b/packages/content/src/index.ts @@ -7,3 +7,4 @@ export * from "./data/config"; export * from "./data/onboarding"; export * from "./data/upgrades"; export * from "./bundle"; +export * from "./deal"; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index c6bbbc1..aa14a38 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -210,8 +210,15 @@ export const gameConfigSchema = z handlingGameHoursPerTonne: z.number().nonnegative(), /** Share of each positive payout the lender takes until the loan is repaid (0.5.32). */ loanRepaymentShare: fraction, - /** Loan taken on with the deal, in credits (decision 0.5.30). */ - dealLoanAmount: positiveInt, + /** + * What the deal hands over (decisions 0.5.30, 0.5.82): the avatar's ship, the + * veteran's ship and the bay. The loan is their prices plus the lease (0.5.83). + */ + deal: z.object({ + avatarShipModelId: z.string().min(1), + veteranShipModelId: z.string().min(1), + bayLocationId: z.string().min(1), + }), contractBoardPeriodRealMinutes: z.number().positive(), /** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */ noStrandMarginGameHours: z.number().nonnegative(), diff --git a/packages/content/test/config.test.ts b/packages/content/test/config.test.ts index 2c65ef1..752f89d 100644 --- a/packages/content/test/config.test.ts +++ b/packages/content/test/config.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { cargoTypes, + content, + dealLoanAmount, gameConfig, gameConfigSchema, locations, @@ -51,11 +53,33 @@ describe("game config", () => { expect(gameConfig.tutorialMassTonnes.full).toBeLessThanOrEqual(loaner.cargoCapacityTonnes); }); - it("lends the Courier price plus an Earth bay lease in the deal", () => { - const courier = shipModels.find((s) => s.id === "courier")!; + it("lends the Courier and Clipper prices plus an Earth bay lease in the deal (0.5.83)", () => { + const price = (id: string) => shipModels.find((s) => s.id === id)!.price; const earth = locations.find((l) => l.id === "earth")!; - expect(gameConfig.dealLoanAmount).toBeGreaterThan(0); - expect(gameConfig.dealLoanAmount).toBe(courier.price + earth.bayLeasePrice); + expect(gameConfig.deal).toEqual({ + avatarShipModelId: "courier", + veteranShipModelId: "clipper", + bayLocationId: "earth", + }); + expect(content.dealLoanAmount).toBe(price("courier") + price("clipper") + earth.bayLeasePrice); + }); + + it("recomputes the deal loan when any of those prices change", () => { + const base = dealLoanAmount({ config: gameConfig, shipModels, locations }); + const bump = (id: string) => + shipModels.map((s) => (s.id === id ? { ...s, price: s.price + 100 } : s)); + expect(dealLoanAmount({ config: gameConfig, shipModels: bump("courier"), locations })).toBe( + base + 100, + ); + expect(dealLoanAmount({ config: gameConfig, shipModels: bump("clipper"), locations })).toBe( + base + 100, + ); + const pricierEarth = locations.map((l) => + l.id === "earth" ? { ...l, bayLeasePrice: l.bayLeasePrice + 7 } : l, + ); + expect(dealLoanAmount({ config: gameConfig, shipModels, locations: pricierEarth })).toBe( + base + 7, + ); }); it("has a contract mass band each purchasable ship can carry", () => { diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 461d3d4..8861212 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -109,7 +109,7 @@ const validGameConfig = { handlingGameHoursPerTonne: 0.02, upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }], loanRepaymentShare: 0.3, - dealLoanAmount: 5000, + deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, }; const validScripted = { diff --git a/packages/content/test/veteran.test.ts b/packages/content/test/veteran.test.ts index 05ffa11..57945e1 100644 --- a/packages/content/test/veteran.test.ts +++ b/packages/content/test/veteran.test.ts @@ -28,6 +28,6 @@ describe("veteran pilot and profit share (CR-010)", () => { expect(skill, skillId).toBeDefined(); expect(level).toBeLessThanOrEqual(skill!.maxLevel); } - expect(veteranPilot.skills).toEqual({ "heavy-cargo": 1 }); + expect(veteranPilot.skills).toEqual({ "expensive-cargo": 1 }); }); }); diff --git a/packages/sim/test/goals.test.ts b/packages/sim/test/goals.test.ts index 0500eca..a26aadf 100644 --- a/packages/sim/test/goals.test.ts +++ b/packages/sim/test/goals.test.ts @@ -13,7 +13,7 @@ const fresh: GoalProgress = { veteranContracts: 0, locationsDiscovered: 0, dealAccepted: true, - loanBalance: content.config.dealLoanAmount, + loanBalance: content.dealLoanAmount, }; const isComplete = (id: string, progress: GoalProgress) =>