diff --git a/DECISIONS.md b/DECISIONS.md index 3a3dc58..00b2efa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **`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. + +## 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). diff --git a/apps/server/src/game/resolve.ts b/apps/server/src/game/resolve.ts index 3b03ded..067e4a0 100644 --- a/apps/server/src/game/resolve.ts +++ b/apps/server/src/game/resolve.ts @@ -43,7 +43,14 @@ export async function resolveArrivedDispatches( if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); 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, ); if (result.status !== "resolved") continue; diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 78792b9..c8a1a15 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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. -- [ ] 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.6 — Schema and database - [ ] Step 10A.7 — Server: tutorial flow v2 diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index dc85bf2..f9e2b66 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -61,5 +61,8 @@ export { dispatchPosition, dispatchProgress } from "./flight"; export { deliveryXp, pilotLevel, skillPointsAvailable } from "./xp"; +export { splitLoanRepayment } from "./loan"; +export type { LoanSplit } from "./loan"; + export { resolveDispatch } from "./resolution"; export type { Resolution, ResolveInput, ResolveResult } from "./resolution"; diff --git a/packages/sim/src/loan.ts b/packages/sim/src/loan.ts new file mode 100644 index 0000000..890cab7 --- /dev/null +++ b/packages/sim/src/loan.ts @@ -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 }; +} diff --git a/packages/sim/src/resolution.ts b/packages/sim/src/resolution.ts index bf4c050..df46120 100644 --- a/packages/sim/src/resolution.ts +++ b/packages/sim/src/resolution.ts @@ -1,11 +1,17 @@ import type { SimContent } from "./board"; import type { RealTime } from "./clock"; import type { DispatchRecord } from "./dispatch"; +import { splitLoanRepayment } from "./loan"; import { deliveryXp } from "./xp"; 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 that went to the lender out of this payout (decision 0.5.32). */ + loanRepayment: number; pilotXp: number; /** Where the ship (or tutorial loaner) is now. */ shipLocationId: string; @@ -21,12 +27,14 @@ export interface ResolveInput { /** The avatar is exempt from pilot pay (decision 0.5.12). */ pilotIsAvatar: boolean; discoveredIds: ReadonlySet; + /** The company's loan balance before this resolution. */ + loanBalance: number; now: RealTime; } /** Resolves a dispatch once it has arrived. Same inputs always give the same result. */ 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" }; 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. return { 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; + // Tutorial contracts already store exactly the tutorial share as their payout. + const { repayment, net } = splitLoanRepayment( + contract.payout - pilotPay, + loanBalance, + content.config.loanRepaymentShare, + ); return { status: "resolved", resolution: { ...base, - // Tutorial contracts already store exactly the tutorial share as their payout. - credits: contract.payout - pilotPay, + credits: net, + loanRepayment: repayment, pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), discoversDestination: !discoveredIds.has(record.destinationId), }, diff --git a/packages/sim/test/loan.test.ts b/packages/sim/test/loan.test.ts new file mode 100644 index 0000000..5e68004 --- /dev/null +++ b/packages/sim/test/loan.test.ts @@ -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, + }); + }); +}); diff --git a/packages/sim/test/resolution.test.ts b/packages/sim/test/resolution.test.ts index 5918731..4ddc25a 100644 --- a/packages/sim/test/resolution.test.ts +++ b/packages/sim/test/resolution.test.ts @@ -41,6 +41,7 @@ const arrived: ResolveInput = { record, pilotIsAvatar: false, discoveredIds: new Set(["earth", "moon"]), + loanBalance: 0, now: record.arrivalAt, }; @@ -106,6 +107,7 @@ describe("dispatch resolution", () => { ); expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({ credits: 0, + loanRepayment: 0, pilotXp: 0, shipLocationId: "moon", discoversDestination: false,