Step 10M.1: veteran skill and deal loan from content

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 11:34:23 -04:00
parent 038f09bb50
commit b8ff9ba47a
19 changed files with 90 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[] = [

View File

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

View File

@ -7,3 +7,4 @@ export * from "./data/config";
export * from "./data/onboarding";
export * from "./data/upgrades";
export * from "./bundle";
export * from "./deal";

View File

@ -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(),

View File

@ -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", () => {

View File

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

View File

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

View File

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