Step 3.8: add contract model and payout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0e5faab454
commit
cbcc981b6d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -5,3 +5,4 @@ export * from "./distance";
|
|||
export * from "./travel";
|
||||
export * from "./fuel";
|
||||
export * from "./eligibility";
|
||||
export * from "./contract";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue