Step 3.10: add tutorial contract board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b7a40aa490
commit
0a0a43a54e
|
|
@ -123,3 +123,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **First contact** is rolled only when an undiscovered location exists. If no other location is discovered, every contract is first contact. Destinations are picked uniformly from the eligible list.
|
||||
- **Mass** is a whole number of tonnes drawn uniformly from the configured range. **Tier** is drawn uniformly from 1 to the required skill's max level.
|
||||
- **Sim functions take a `SimContent` object** (`locations`, `cargoTypes`, `skills`, `config`); the `content` bundle satisfies it, and tests can pass fixtures.
|
||||
|
||||
## Step 3.10
|
||||
|
||||
- **Tutorial contract mass is capped at the loaner's capacity:** a whole number from the contract minimum (5 t) up to the smaller of the contract maximum and the Courier's 20 t. Otherwise most tutorial contracts would be too heavy to fly.
|
||||
- **The loaner is the content's Courier,** found by archetype `courier`. `SimContent` now includes `shipModels`.
|
||||
- **Tutorial payout** = round(normal payout × tutorial share), where the normal payout is the regular rounded payout for the same contract. The contract stores this reduced amount, so resolution credits `contract.payout` directly.
|
||||
- **Tutorial board seed** is `tutorial:<loanerLocation>:<periodIndex>`, giving one Earth→Moon and one Moon→Earth board per period (decision 0.5.21). Contract ids use the same prefix.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
- [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.
|
||||
- [x] **Step 3.8 — Contract model and payout.** `src/contract.ts`: `Contract` interface and `contractPayout` (base × multiplier^tier × mass × distance × first-contact multiplier, rounded).
|
||||
- [x] **Step 3.9 — Contract board generation.** `src/board.ts`: `generateContractBoard` (seeded per world, bay and period), plus `boardPeriodIndex`, `requiredLongHaulLevel` and the `SimContent` input type. First contact ≈10% over 1,000 boards.
|
||||
- [ ] Step 3.10 — Tutorial contract board
|
||||
- [x] **Step 3.10 — Tutorial contract board.** `src/tutorial.ts`: `generateTutorialBoard` (Earth↔Moon from the loaner's location, Standard cargo capped at Courier capacity, payout × tutorial share); `loanerModel`. `SimContent` gained `shipModels`.
|
||||
- [ ] Step 3.11 — Pilot eligibility
|
||||
- [ ] Step 3.12 — Dispatch validation
|
||||
- [ ] Step 3.13 — Dispatch record creation
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { CargoType, GameConfig, Location, Skill } from "@deadhead/content";
|
||||
import type { CargoType, GameConfig, Location, ShipModel, Skill } from "@deadhead/content";
|
||||
import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
|
||||
import { contractPayout, type Contract } from "./contract";
|
||||
import { routeDistance } from "./distance";
|
||||
|
|
@ -9,6 +9,7 @@ export interface SimContent {
|
|||
locations: readonly Location[];
|
||||
cargoTypes: readonly CargoType[];
|
||||
skills: readonly Skill[];
|
||||
shipModels: readonly ShipModel[];
|
||||
config: GameConfig;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ export * from "./fuel";
|
|||
export * from "./eligibility";
|
||||
export * from "./contract";
|
||||
export * from "./board";
|
||||
export * from "./tutorial";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import type { Location, ShipModel } from "@deadhead/content";
|
||||
import { buildContract, standardCargo, type SimContent } from "./board";
|
||||
import type { Contract } from "./contract";
|
||||
import { createRng, deriveSeed, type Seed } from "./rng";
|
||||
|
||||
/** The tutorial loaner is the company's Courier. */
|
||||
export function loanerModel(content: SimContent): ShipModel {
|
||||
const courier = content.shipModels.find((s) => s.archetype === "courier");
|
||||
if (!courier) throw new Error("Content has no Courier ship model");
|
||||
return courier;
|
||||
}
|
||||
|
||||
export type TutorialLocationId = "earth" | "moon";
|
||||
|
||||
export interface TutorialBoardInput {
|
||||
/** Where the loaner currently is; contracts start here (decision 0.5.3). */
|
||||
loanerLocationId: TutorialLocationId;
|
||||
periodIndex: number;
|
||||
worldSeed: Seed;
|
||||
}
|
||||
|
||||
function location(id: string, content: SimContent): Location {
|
||||
const found = content.locations.find((l) => l.id === id);
|
||||
if (!found) throw new Error(`Unknown location ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tutorial "company board": Earth↔Moon only, Standard cargo sized to fit
|
||||
* the loaner, starting at the loaner's location. Each payout is the tutorial
|
||||
* share of the equivalent normal payout, rounded to whole credits.
|
||||
*/
|
||||
export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] {
|
||||
const { loanerLocationId, periodIndex, worldSeed } = input;
|
||||
const { config } = content;
|
||||
const origin = location(loanerLocationId, content);
|
||||
const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content);
|
||||
const cargoType = standardCargo(content);
|
||||
const maxMass = Math.min(config.contractMassTonnes.max, loanerModel(content).cargoCapacityTonnes);
|
||||
const rng = createRng(deriveSeed(worldSeed, `tutorial:${loanerLocationId}:${periodIndex}`));
|
||||
|
||||
return Array.from({ length: config.tutorialBoardSize }, (_, i) => {
|
||||
const contract = buildContract(
|
||||
{
|
||||
id: `tutorial:${loanerLocationId}:${periodIndex}:${i}`,
|
||||
origin,
|
||||
destination,
|
||||
cargoType,
|
||||
tier: 0,
|
||||
massTonnes: rng.int(config.contractMassTonnes.min, maxMass),
|
||||
firstContact: false,
|
||||
periodIndex,
|
||||
},
|
||||
content,
|
||||
);
|
||||
return { ...contract, payout: Math.round(contract.payout * config.tutorialPayoutShare) };
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardPeriodIndex,
|
||||
buildContract,
|
||||
generateTutorialBoard,
|
||||
loanerModel,
|
||||
type TutorialBoardInput,
|
||||
} from "../src/index";
|
||||
|
||||
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
|
||||
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", periodIndex, worldSeed: 99 };
|
||||
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon" };
|
||||
|
||||
describe("tutorial board", () => {
|
||||
it("offers only Earth→Moon when the loaner is at Earth", () => {
|
||||
for (const c of generateTutorialBoard(atEarth, content)) {
|
||||
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers only Moon→Earth when the loaner is at the Moon", () => {
|
||||
for (const c of generateTutorialBoard(atMoon, content)) {
|
||||
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("has the configured size, Standard cargo only, sized to fit the loaner", () => {
|
||||
const board = generateTutorialBoard(atEarth, content);
|
||||
expect(board).toHaveLength(content.config.tutorialBoardSize);
|
||||
for (const c of board) {
|
||||
expect(c.cargoTypeId).toBe("standard");
|
||||
expect(c.tier).toBe(0);
|
||||
expect(c.requiredSkill).toBeNull();
|
||||
expect(c.requiredLongHaulLevel).toBe(0);
|
||||
expect(c.firstContact).toBe(false);
|
||||
expect(c.massTonnes).toBeLessThanOrEqual(loanerModel(content).cargoCapacityTonnes);
|
||||
}
|
||||
});
|
||||
|
||||
it("pays exactly the tutorial share of the equivalent normal payout", () => {
|
||||
for (const input of [atEarth, atMoon]) {
|
||||
for (const c of generateTutorialBoard(input, content)) {
|
||||
const normal = buildContract(
|
||||
{
|
||||
id: c.id,
|
||||
origin: content.location(c.originId)!,
|
||||
destination: content.location(c.destinationId)!,
|
||||
cargoType: content.cargoType("standard")!,
|
||||
tier: 0,
|
||||
massTonnes: c.massTonnes,
|
||||
firstContact: false,
|
||||
periodIndex,
|
||||
},
|
||||
content,
|
||||
);
|
||||
expect(c.payout).toBe(Math.round(normal.payout * content.config.tutorialPayoutShare));
|
||||
expect(c.payout).toBeLessThan(normal.payout);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
expect(generateTutorialBoard(atEarth, content)).toEqual(
|
||||
generateTutorialBoard(atEarth, content),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue