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:
parent
038f09bb50
commit
b8ff9ba47a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -7,3 +7,4 @@ export * from "./data/config";
|
|||
export * from "./data/onboarding";
|
||||
export * from "./data/upgrades";
|
||||
export * from "./bundle";
|
||||
export * from "./deal";
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue