Step 3.8: add contract model and payout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 18:44:02 -04:00
parent 0e5faab454
commit cbcc981b6d
5 changed files with 105 additions and 1 deletions

View File

@ -108,3 +108,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Landing is checked before range**, so a ship that can neither land nor reach reports `cannot-land`.
- **The route check looks only at the destination's landing requirement.** A ship is always already sitting at its origin, so it can take off from there.
- **Range tests find each planet's closest and farthest point from Earth by sampling** every 4 days over 12 years. All Step 2.3 range choices hold, including decision 0.5.25 (a Freighter reaches Jupiter at its closest).
## Step 3.8
- **Tier scaling is compounding:** pay is multiplied by `tierPayMultiplier ** tier`. Standard (tier 0) is ×1; a tier-5 Dangerous contract pays 1.3⁵ ≈ 3.7× its base rate.
- **Payouts are rounded to whole credits** when the contract is offered.
- **Contracts refer to content by id** (`originId`, `destinationId`, `cargoTypeId`), so they can be stored and sent over the API as plain data. `requiredSkill` is `{ skillId, level }`, or `null` for Standard.

View File

@ -26,7 +26,7 @@
- [x] **Step 3.5 — Travel time.** `src/travel.ts`: `travelTimeGameHours(ship, distance)` = cruise time + overhead.
- [x] **Step 3.6 — Fuel cost.** `src/fuel.ts`: `fuelCost(ship, origin, distance)` = distance × cost per million km × origin launch multiplier.
- [x] **Step 3.7 — Range and landing eligibility.** `src/eligibility.ts`: `checkRoute` returns `ok` or `cannot-land` / `out-of-range`; `canLandAt` ranks vacuum < atmospheric < hardened. Step 2.3 ranges confirmed against sampled real distances.
- [ ] Step 3.8 — Contract model and payout
- [x] **Step 3.8 — Contract model and payout.** `src/contract.ts`: `Contract` interface and `contractPayout` (base × multiplier^tier × mass × distance × first-contact multiplier, rounded).
- [ ] Step 3.9 — Contract board generation
- [ ] Step 3.10 — Tutorial contract board
- [ ] Step 3.11 — Pilot eligibility

View File

@ -0,0 +1,45 @@
import type { CargoType, GameConfig } from "@deadhead/content";
import type { RealTime } from "./clock";
export interface Contract {
id: string;
originId: string;
destinationId: string;
cargoTypeId: string;
/** 0 for Standard; 1–5 otherwise, and equal to the required cargo-skill level (decision 0.5.6). */
tier: number;
massTonnes: number;
/** Credits, fixed at offer time (decision 0.5.11). */
payout: number;
/** Cargo skill and level needed; null for Standard. */
requiredSkill: { skillId: string; level: number } | null;
requiredLongHaulLevel: number;
offeredAt: RealTime;
/** End of the board period the contract was offered in. */
expiresAt: RealTime;
firstContact: boolean;
}
export interface PayoutInput {
cargoType: CargoType;
tier: number;
massTonnes: number;
/** Route distance at offer time. */
distanceMillionKm: number;
firstContact: boolean;
}
/**
* Payout in whole credits: base pay per tonne per million km × tierMultiplier^tier
* × mass × distance, × the first-contact multiplier when flagged.
*/
export function contractPayout(input: PayoutInput, config: GameConfig): number {
const { cargoType, tier, massTonnes, distanceMillionKm, firstContact } = input;
const pay =
cargoType.basePayPerTonnePerMillionKm *
cargoType.tierPayMultiplier ** tier *
massTonnes *
distanceMillionKm *
(firstContact ? config.firstContactPayoutMultiplier : 1);
return Math.round(pay);
}

View File

@ -5,3 +5,4 @@ export * from "./distance";
export * from "./travel";
export * from "./fuel";
export * from "./eligibility";
export * from "./contract";

View File

@ -0,0 +1,52 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { contractPayout, type PayoutInput } from "../src/index";
const config = content.config;
const standard = content.cargoType("standard")!;
const dangerous = content.cargoType("dangerous")!;
const base: PayoutInput = {
cargoType: standard,
tier: 0,
massTonnes: 10,
distanceMillionKm: 100,
firstContact: false,
};
describe("contract payout", () => {
it("matches a hand calculation", () => {
// 100 credits/t/M km × 10 t × 100M km.
expect(contractPayout(base, config)).toBe(100_000);
});
it("scales linearly with mass and distance", () => {
const payout = contractPayout(base, config);
expect(contractPayout({ ...base, massTonnes: 30 }, config)).toBe(3 * payout);
expect(contractPayout({ ...base, distanceMillionKm: 250 }, config)).toBe(2.5 * payout);
});
it("pays more for Dangerous than Standard on the same mass and route", () => {
expect(contractPayout({ ...base, cargoType: dangerous, tier: 1 }, config)).toBeGreaterThan(
contractPayout(base, config),
);
});
it("pays more at a higher tier", () => {
const tier1 = contractPayout({ ...base, cargoType: dangerous, tier: 1 }, config);
const tier3 = contractPayout({ ...base, cargoType: dangerous, tier: 3 }, config);
expect(tier3).toBeGreaterThan(tier1);
});
it("pays more for a first-contact contract", () => {
expect(contractPayout({ ...base, firstContact: true }, config)).toBe(
contractPayout(base, config) * config.firstContactPayoutMultiplier,
);
});
it("rounds to whole credits", () => {
expect(Number.isInteger(contractPayout({ ...base, distanceMillionKm: 0.384 }, config))).toBe(
true,
);
});
});