Step 10A.4: add loan repayment to resolution

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 10:57:08 -04:00
parent dd0b26813f
commit 401914e76b
8 changed files with 150 additions and 7 deletions

View File

@ -504,3 +504,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Contracts carry an optional `scriptedId`** (sim `Contract` and API `contractSchema`) so the client can style scripted contracts and look up their flavor message. - **Contracts carry an optional `scriptedId`** (sim `Contract` and API `contractSchema`) so the client can style scripted contracts and look up their flavor message.
- **`SimContent` includes `scriptedTutorialContracts`;** the content bundle already provides it. - **`SimContent` includes `scriptedTutorialContracts`;** the content bundle already provides it.
- **Temporary:** the server passes `runsCompleted: 0` until Step 10A.7 adds the counter, so every server-generated board is a first-run board for now. - **Temporary:** the server passes `runsCompleted: 0` until Step 10A.7 adds the counter, so every server-generated board is a first-run board for now.
## Step 10A.4
- **The lender's cut is rounded down to whole credits:** repayment = `min(balance, floor(credits × share))`, or 0 when the balance is 0 or the credits after pilot pay are not positive. Credits are whole numbers everywhere, and rounding down favours the player by under one credit.
- **`Resolution.credits` is now the net amount** (after pilot pay and repayment); `loanRepayment` is reported separately for the server to apply and the summary to show.
- **Temporary:** the server resolves with `loanBalance: 0` until the balance column exists (Steps 10A.6 and 10A.9).

View File

@ -43,7 +43,14 @@ export async function resolveArrivedDispatches(
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
const result = resolveDispatch( const result = resolveDispatch(
{ record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, now }, // The loan balance is applied in Step 10A.9.
{
record: dispatch.record,
pilotIsAvatar: pilot.isAvatar,
discoveredIds,
loanBalance: 0,
now,
},
content, content,
); );
if (result.status !== "resolved") continue; if (result.status !== "resolved") continue;

View File

@ -109,7 +109,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
- [x] **Step 10A.1 — Content: new config and loaner ship.** `purchasable` on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: `tutorialLoanerShipModelId`, `tutorialRunsBeforeDeal` 4, `tutorialBoardSize` 3, `tutorialMassTonnes` 5/12/20, `handlingGameHoursPerTonne` 0.02, `loanRepaymentShare` 0.3, `dealLoanAmount` 5000, `startingCredits` 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (`src/data/onboarding.ts`). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner. - [x] **Step 10A.1 — Content: new config and loaner ship.** `purchasable` on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: `tutorialLoanerShipModelId`, `tutorialRunsBeforeDeal` 4, `tutorialBoardSize` 3, `tutorialMassTonnes` 5/12/20, `handlingGameHoursPerTonne` 0.02, `loanRepaymentShare` 0.3, `dealLoanAmount` 5000, `startingCredits` 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (`src/data/onboarding.ts`). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner.
- [x] **Step 10A.2 — Sim: handling time.** `travelTimeGameHours(ship, distance, massTonnes, config)` adds `mass × handlingGameHoursPerTonne`; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated. - [x] **Step 10A.2 — Sim: handling time.** `travelTimeGameHours(ship, distance, massTonnes, config)` adds `mass × handlingGameHoursPerTonne`; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated.
- [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7. - [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7.
- [ ] Step 10A.4 — Sim: loan repayment - [x] **Step 10A.4 — Sim: loan repayment.** `splitLoanRepayment(credits, balance, share)` (`src/loan.ts`); `resolveDispatch` takes `loanBalance` and returns `loanRepayment` with net `credits`. Server passes a zero balance until Step 10A.9.
- [ ] Step 10A.5 — Sim: onboarding goal evaluation - [ ] Step 10A.5 — Sim: onboarding goal evaluation
- [ ] Step 10A.6 — Schema and database - [ ] Step 10A.6 — Schema and database
- [ ] Step 10A.7 — Server: tutorial flow v2 - [ ] Step 10A.7 — Server: tutorial flow v2

View File

@ -61,5 +61,8 @@ export { dispatchPosition, dispatchProgress } from "./flight";
export { deliveryXp, pilotLevel, skillPointsAvailable } from "./xp"; export { deliveryXp, pilotLevel, skillPointsAvailable } from "./xp";
export { splitLoanRepayment } from "./loan";
export type { LoanSplit } from "./loan";
export { resolveDispatch } from "./resolution"; export { resolveDispatch } from "./resolution";
export type { Resolution, ResolveInput, ResolveResult } from "./resolution"; export type { Resolution, ResolveInput, ResolveResult } from "./resolution";

17
packages/sim/src/loan.ts Normal file
View File

@ -0,0 +1,17 @@
export interface LoanSplit {
/** Credits that go to the lender. */
repayment: number;
/** Credits the player keeps. */
net: number;
}
/**
* Splits a payout between the lender and the player (decision 0.5.32): the
* lender takes `min(balance, credits × share)`, rounded down to whole credits,
* and nothing when there is no balance or no positive payout.
*/
export function splitLoanRepayment(credits: number, balance: number, share: number): LoanSplit {
if (credits <= 0 || balance <= 0) return { repayment: 0, net: credits };
const repayment = Math.min(balance, Math.floor(credits * share));
return { repayment, net: credits - repayment };
}

View File

@ -1,11 +1,17 @@
import type { SimContent } from "./board"; import type { SimContent } from "./board";
import type { RealTime } from "./clock"; import type { RealTime } from "./clock";
import type { DispatchRecord } from "./dispatch"; import type { DispatchRecord } from "./dispatch";
import { splitLoanRepayment } from "./loan";
import { deliveryXp } from "./xp"; import { deliveryXp } from "./xp";
export interface Resolution { export interface Resolution {
/** Credits to add: payout minus pilot pay. Fuel was already charged at departure. */ /**
* Credits to add: payout minus pilot pay, minus the loan repayment.
* Fuel was already charged at departure.
*/
credits: number; credits: number;
/** Credits that went to the lender out of this payout (decision 0.5.32). */
loanRepayment: number;
pilotXp: number; pilotXp: number;
/** Where the ship (or tutorial loaner) is now. */ /** Where the ship (or tutorial loaner) is now. */
shipLocationId: string; shipLocationId: string;
@ -21,12 +27,14 @@ export interface ResolveInput {
/** The avatar is exempt from pilot pay (decision 0.5.12). */ /** The avatar is exempt from pilot pay (decision 0.5.12). */
pilotIsAvatar: boolean; pilotIsAvatar: boolean;
discoveredIds: ReadonlySet<string>; discoveredIds: ReadonlySet<string>;
/** The company's loan balance before this resolution. */
loanBalance: number;
now: RealTime; now: RealTime;
} }
/** Resolves a dispatch once it has arrived. Same inputs always give the same result. */ /** Resolves a dispatch once it has arrived. Same inputs always give the same result. */
export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult { export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult {
const { record, pilotIsAvatar, discoveredIds, now } = input; const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input;
if (now < record.arrivalAt) return { status: "in-flight" }; if (now < record.arrivalAt) return { status: "in-flight" };
const { contract } = record; const { contract } = record;
@ -35,17 +43,29 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
// Repositions earn nothing and only fly to bays, which are already discovered. // Repositions earn nothing and only fly to bays, which are already discovered.
return { return {
status: "resolved", status: "resolved",
resolution: { ...base, credits: 0, pilotXp: 0, discoversDestination: false }, resolution: {
...base,
credits: 0,
loanRepayment: 0,
pilotXp: 0,
discoversDestination: false,
},
}; };
} }
const pilotPay = record.tutorial || pilotIsAvatar ? 0 : content.config.pilotPayPerDelivery; const pilotPay = record.tutorial || pilotIsAvatar ? 0 : content.config.pilotPayPerDelivery;
// Tutorial contracts already store exactly the tutorial share as their payout.
const { repayment, net } = splitLoanRepayment(
contract.payout - pilotPay,
loanBalance,
content.config.loanRepaymentShare,
);
return { return {
status: "resolved", status: "resolved",
resolution: { resolution: {
...base, ...base,
// Tutorial contracts already store exactly the tutorial share as their payout. credits: net,
credits: contract.payout - pilotPay, loanRepayment: repayment,
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config),
discoversDestination: !discoveredIds.has(record.destinationId), discoversDestination: !discoveredIds.has(record.destinationId),
}, },

View File

@ -0,0 +1,88 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
createDispatchRecord,
resolveDispatch,
splitLoanRepayment,
type Contract,
} from "../src/index";
describe("loan repayment split", () => {
it("takes min(balance, credits × share), rounded down to whole credits", () => {
expect(splitLoanRepayment(1000, 5000, 0.3)).toEqual({ repayment: 300, net: 700 });
expect(splitLoanRepayment(1001, 5000, 0.3)).toEqual({ repayment: 300, net: 701 });
expect(splitLoanRepayment(1000, 120, 0.3)).toEqual({ repayment: 120, net: 880 });
});
it("takes nothing when there is no balance, or no positive payout", () => {
expect(splitLoanRepayment(1000, 0, 0.3)).toEqual({ repayment: 0, net: 1000 });
expect(splitLoanRepayment(0, 5000, 0.3)).toEqual({ repayment: 0, net: 0 });
expect(splitLoanRepayment(-60, 5000, 0.3)).toEqual({ repayment: 0, net: -60 });
});
it("always splits the whole amount and never repays more than the balance", () => {
for (const credits of [-500, -1, 0, 1, 7, 99, 1000, 123_457]) {
for (const balance of [0, 1, 50, 299, 5000, 1_000_000]) {
for (const share of [0, 0.1, 0.3, 1]) {
const { repayment, net } = splitLoanRepayment(credits, balance, share);
expect(net + repayment).toBe(credits);
expect(repayment).toBeLessThanOrEqual(balance);
expect(repayment).toBeGreaterThanOrEqual(0);
expect(repayment).toBe(
credits > 0 && balance > 0 ? Math.min(balance, Math.floor(credits * share)) : 0,
);
}
}
}
});
});
describe("resolution with a loan", () => {
const now = Date.parse("2100-01-01T00:30:00Z");
const contract: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: now,
expiresAt: now + 3_600_000,
firstContact: false,
};
const record = createDispatchRecord(
{
id: "d1",
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
contract,
tutorial: false,
now,
worldSeed: 7,
},
content,
);
it("returns the repayment alongside the credits to add, after pilot pay", () => {
const result = resolveDispatch(
{
record,
pilotIsAvatar: false,
discoveredIds: new Set(["earth", "moon"]),
loanBalance: 5000,
now: record.arrivalAt,
},
content,
);
if (result.status !== "resolved") throw new Error("expected a resolution");
const afterPay = 2000 - content.config.pilotPayPerDelivery;
const repayment = Math.floor(afterPay * content.config.loanRepaymentShare);
expect(result.resolution).toMatchObject({
loanRepayment: repayment,
credits: afterPay - repayment,
});
});
});

View File

@ -41,6 +41,7 @@ const arrived: ResolveInput = {
record, record,
pilotIsAvatar: false, pilotIsAvatar: false,
discoveredIds: new Set(["earth", "moon"]), discoveredIds: new Set(["earth", "moon"]),
loanBalance: 0,
now: record.arrivalAt, now: record.arrivalAt,
}; };
@ -106,6 +107,7 @@ describe("dispatch resolution", () => {
); );
expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({ expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({
credits: 0, credits: 0,
loanRepayment: 0,
pilotXp: 0, pilotXp: 0,
shipLocationId: "moon", shipLocationId: "moon",
discoversDestination: false, discoversDestination: false,