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.
|
||||
- **`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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 { 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<string>;
|
||||
/** 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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue