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).
|
- **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).
|
- **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
|
await tx
|
||||||
.update(companies)
|
.update(companies)
|
||||||
.set({ loanBalance: content.config.dealLoanAmount })
|
.set({ loanBalance: content.dealLoanAmount })
|
||||||
.where(eq(companies.playerId, playerId));
|
.where(eq(companies.playerId, playerId));
|
||||||
await tx
|
await tx
|
||||||
.update(players)
|
.update(players)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ describe("accepting the deal", () => {
|
||||||
assignedShipId: ship!.id,
|
assignedShipId: ship!.id,
|
||||||
locationId: "earth",
|
locationId: "earth",
|
||||||
});
|
});
|
||||||
expect(state.company.loanBalance).toBe(content.config.dealLoanAmount);
|
expect(state.company.loanBalance).toBe(content.dealLoanAmount);
|
||||||
|
|
||||||
const after = await snapshot(guest);
|
const after = await snapshot(guest);
|
||||||
expect(after.player).toMatchObject({
|
expect(after.player).toMatchObject({
|
||||||
|
|
@ -106,6 +106,6 @@ describe("accepting the deal", () => {
|
||||||
const after = await snapshot(guest);
|
const after = await snapshot(guest);
|
||||||
expect(after.ships).toHaveLength(1);
|
expect(after.ships).toHaveLength(1);
|
||||||
expect(after.bays).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)", () => {
|
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();
|
const { state } = await dealt();
|
||||||
expect(state.pilots).toHaveLength(2);
|
expect(state.pilots).toHaveLength(2);
|
||||||
const courier = state.ships[0]!;
|
const courier = state.ships[0]!;
|
||||||
|
|
@ -52,7 +52,7 @@ describe("the veteran comes with the deal (Step 10J.4)", () => {
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
isVeteran: true,
|
isVeteran: true,
|
||||||
level: 2,
|
level: 2,
|
||||||
skills: { "heavy-cargo": 1 },
|
skills: { "expensive-cargo": 1 },
|
||||||
skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints,
|
skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints,
|
||||||
assignedShipId: null,
|
assignedShipId: null,
|
||||||
locationId: "earth",
|
locationId: "earth",
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ export function DealModal() {
|
||||||
|
|
||||||
if (!state.tutorial?.dealAvailable || state.summaries.length > 0) return null;
|
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 courier = content.shipModel("courier")!;
|
||||||
const veteran = content.veteranPilot;
|
const veteran = content.veteranPilot;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function LoanCard() {
|
||||||
const repay = useGameAction("repayLoan");
|
const repay = useGameAction("repayLoan");
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const { credits, loanBalance } = state.company;
|
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 paidOff = Math.max(0, Math.min(100, Math.floor(((total - loanBalance) / total) * 100)));
|
||||||
const amount = Number(input);
|
const amount = Number(input);
|
||||||
const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);
|
const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const afterDeal = () =>
|
||||||
id: "co1",
|
id: "co1",
|
||||||
name: "Deadhead Freight",
|
name: "Deadhead Freight",
|
||||||
credits: 800,
|
credits: 800,
|
||||||
loanBalance: content.config.dealLoanAmount,
|
loanBalance: content.dealLoanAmount,
|
||||||
},
|
},
|
||||||
ships: [
|
ships: [
|
||||||
{
|
{
|
||||||
|
|
@ -63,7 +63,7 @@ describe("the deal", () => {
|
||||||
const dialog = await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
|
const dialog = await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
|
||||||
expect(dialog).toHaveTextContent("Courier and a bay at Earth");
|
expect(dialog).toHaveTextContent("Courier and a bay at Earth");
|
||||||
expect(screen.getByLabelText("Loan amount")).toHaveTextContent(
|
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(
|
expect(screen.getByLabelText("Repayment share")).toHaveTextContent(
|
||||||
`${Math.round(content.config.loanRepaymentShare * 100)}% of every payout`,
|
`${Math.round(content.config.loanRepaymentShare * 100)}% of every payout`,
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ describe("HQ screen", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loan on HQ", () => {
|
describe("loan on HQ", () => {
|
||||||
const total = content.config.dealLoanAmount;
|
const total = content.dealLoanAmount;
|
||||||
const owner = (credits: number, loanBalance: number): PlayerState =>
|
const owner = (credits: number, loanBalance: number): PlayerState =>
|
||||||
samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits, loanBalance } });
|
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.
|
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.2 — Sim: goal changes
|
||||||
- [ ] Step 10M.3 — Server: deal creates the Clipper
|
- [ ] Step 10M.3 — Server: deal creates the Clipper
|
||||||
- [ ] Step 10M.4 — Client: deal modal
|
- [ ] Step 10M.4 — Client: deal modal
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { cargoTypes, skills } from "./data/cargo";
|
import { cargoTypes, skills } from "./data/cargo";
|
||||||
import { gameConfig } from "./data/config";
|
import { gameConfig } from "./data/config";
|
||||||
|
import { dealLoanAmount } from "./deal";
|
||||||
import { goods } from "./data/goods";
|
import { goods } from "./data/goods";
|
||||||
import { locations } from "./data/locations";
|
import { locations } from "./data/locations";
|
||||||
import { onboardingGoals, scriptedTutorialContracts, veteranPilot } from "./data/onboarding";
|
import { onboardingGoals, scriptedTutorialContracts, veteranPilot } from "./data/onboarding";
|
||||||
|
|
@ -26,6 +27,8 @@ export const content = {
|
||||||
onboardingGoals,
|
onboardingGoals,
|
||||||
veteranPilot,
|
veteranPilot,
|
||||||
upgrades,
|
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),
|
location: byId(locations),
|
||||||
shipModel: byId(shipModels),
|
shipModel: byId(shipModels),
|
||||||
cargoType: byId(cargoTypes),
|
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.
|
// 20 t adds 0.4 game hours (24 real seconds) of loading and unloading.
|
||||||
handlingGameHoursPerTonne: 0.02,
|
handlingGameHoursPerTonne: 0.02,
|
||||||
loanRepaymentShare: 0.3,
|
loanRepaymentShare: 0.3,
|
||||||
// Courier price + Earth bay lease.
|
// The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the
|
||||||
dealLoanAmount: 5000,
|
// veteran, and the Earth bay. The loan is computed from their prices.
|
||||||
|
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
|
||||||
contractBoardPeriodRealMinutes: 10,
|
contractBoardPeriodRealMinutes: 10,
|
||||||
// A ship must be able to fly out of its destination two game days after arriving (CR-012).
|
// A ship must be able to fly out of its destination two game days after arriving (CR-012).
|
||||||
noStrandMarginGameHours: 48,
|
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. */
|
/** 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,
|
* Halvorsen's last hire, who comes with the deal crewing his Clipper
|
||||||
* one point already in Heavy Cargo, the rest unspent.
|
* (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 = {
|
export const veteranPilot: VeteranPilot = {
|
||||||
name: "Rosa Kell",
|
name: "Rosa Kell",
|
||||||
bio: "Halvorsen's pilot for twelve years.",
|
bio: "Halvorsen's pilot for twelve years.",
|
||||||
level: 2,
|
level: 2,
|
||||||
skills: { "heavy-cargo": 1 },
|
skills: { "expensive-cargo": 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const onboardingGoals: OnboardingGoal[] = [
|
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/onboarding";
|
||||||
export * from "./data/upgrades";
|
export * from "./data/upgrades";
|
||||||
export * from "./bundle";
|
export * from "./bundle";
|
||||||
|
export * from "./deal";
|
||||||
|
|
|
||||||
|
|
@ -210,8 +210,15 @@ export const gameConfigSchema = z
|
||||||
handlingGameHoursPerTonne: z.number().nonnegative(),
|
handlingGameHoursPerTonne: z.number().nonnegative(),
|
||||||
/** Share of each positive payout the lender takes until the loan is repaid (0.5.32). */
|
/** Share of each positive payout the lender takes until the loan is repaid (0.5.32). */
|
||||||
loanRepaymentShare: fraction,
|
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(),
|
contractBoardPeriodRealMinutes: z.number().positive(),
|
||||||
/** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */
|
/** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */
|
||||||
noStrandMarginGameHours: z.number().nonnegative(),
|
noStrandMarginGameHours: z.number().nonnegative(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
cargoTypes,
|
cargoTypes,
|
||||||
|
content,
|
||||||
|
dealLoanAmount,
|
||||||
gameConfig,
|
gameConfig,
|
||||||
gameConfigSchema,
|
gameConfigSchema,
|
||||||
locations,
|
locations,
|
||||||
|
|
@ -51,11 +53,33 @@ describe("game config", () => {
|
||||||
expect(gameConfig.tutorialMassTonnes.full).toBeLessThanOrEqual(loaner.cargoCapacityTonnes);
|
expect(gameConfig.tutorialMassTonnes.full).toBeLessThanOrEqual(loaner.cargoCapacityTonnes);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lends the Courier price plus an Earth bay lease in the deal", () => {
|
it("lends the Courier and Clipper prices plus an Earth bay lease in the deal (0.5.83)", () => {
|
||||||
const courier = shipModels.find((s) => s.id === "courier")!;
|
const price = (id: string) => shipModels.find((s) => s.id === id)!.price;
|
||||||
const earth = locations.find((l) => l.id === "earth")!;
|
const earth = locations.find((l) => l.id === "earth")!;
|
||||||
expect(gameConfig.dealLoanAmount).toBeGreaterThan(0);
|
expect(gameConfig.deal).toEqual({
|
||||||
expect(gameConfig.dealLoanAmount).toBe(courier.price + earth.bayLeasePrice);
|
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", () => {
|
it("has a contract mass band each purchasable ship can carry", () => {
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ const validGameConfig = {
|
||||||
handlingGameHoursPerTonne: 0.02,
|
handlingGameHoursPerTonne: 0.02,
|
||||||
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],
|
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],
|
||||||
loanRepaymentShare: 0.3,
|
loanRepaymentShare: 0.3,
|
||||||
dealLoanAmount: 5000,
|
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const validScripted = {
|
const validScripted = {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,6 @@ describe("veteran pilot and profit share (CR-010)", () => {
|
||||||
expect(skill, skillId).toBeDefined();
|
expect(skill, skillId).toBeDefined();
|
||||||
expect(level).toBeLessThanOrEqual(skill!.maxLevel);
|
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,
|
veteranContracts: 0,
|
||||||
locationsDiscovered: 0,
|
locationsDiscovered: 0,
|
||||||
dealAccepted: true,
|
dealAccepted: true,
|
||||||
loanBalance: content.config.dealLoanAmount,
|
loanBalance: content.dealLoanAmount,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isComplete = (id: string, progress: GoalProgress) =>
|
const isComplete = (id: string, progress: GoalProgress) =>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue