Step 10A.4: add loan repayment to resolution
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dd0b26813f
commit
401914e76b
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue