Compare commits
18 Commits
241e2613f4
...
f33a9155cc
| Author | SHA1 | Date |
|---|---|---|
|
|
f33a9155cc | |
|
|
50b038047f | |
|
|
a685235e90 | |
|
|
1472dc6786 | |
|
|
039df0ac5b | |
|
|
e3967c10e4 | |
|
|
1e3b45b529 | |
|
|
dfd76ba9bc | |
|
|
0a0a43a54e | |
|
|
b7a40aa490 | |
|
|
cbcc981b6d | |
|
|
0e5faab454 | |
|
|
ace0d8502d | |
|
|
36bdbf3af0 | |
|
|
ccd571fdd0 | |
|
|
301c0934e1 | |
|
|
79c63c13b7 | |
|
|
f44789878e |
|
|
@ -16,4 +16,4 @@ jobs:
|
|||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm test
|
||||
- run: pnpm coverage
|
||||
|
|
|
|||
105
DECISIONS.md
105
DECISIONS.md
|
|
@ -77,3 +77,108 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
|
||||
- **Lookups return `undefined`** for an unknown id ("not found" without throwing). Callers decide whether a missing id is an error.
|
||||
- **The package also exports each data list and schema individually**, alongside the `content` bundle, so tests and the server can validate data with the schemas.
|
||||
|
||||
## Step 3.1
|
||||
|
||||
- **Times are Unix-millisecond numbers.** A real time is a wall-clock instant; a game time is the instant of the in-game date, so `new Date(gameTime)` gives the in-game date and can be passed straight to `astronomy-engine`.
|
||||
- **Every sim function takes the content it needs as arguments** (here the `GameConfig`), rather than importing the default bundle, so tests can pass custom fixtures.
|
||||
|
||||
## Step 3.2
|
||||
|
||||
- **Seeds are 32-bit unsigned integers.** The PRNG is mulberry32; child seeds are an FNV-1a hash of the label, started from the parent seed, plus a final mixing step.
|
||||
- **`createRng` returns an object with internal state.** It is still deterministic: the same seed always gives the same sequence. Each use (a board, a name) creates its own generator from a derived seed, so draws never depend on call order elsewhere.
|
||||
|
||||
## Step 3.3
|
||||
|
||||
- **Positions are heliocentric, in the J2000 mean ecliptic plane** (`HelioVector` rotated by `Rotation_EQJ_ECL`), keeping x and y and dropping the small out-of-plane z.
|
||||
- **The Moon is Earth plus 0.384M km on the +x axis**, as the plan specifies; its real orbit is ignored.
|
||||
- **Ceres uses a circular orbit** (astronomy-engine has no Ceres): radius 2.7675 AU (~414M km), period 1,680.5 days, mean longitude ~160° at J2000. The angle is approximate, which is fine for gameplay.
|
||||
- **Test date is 2100-03-01**, close to the placeholder game epoch.
|
||||
|
||||
## Step 3.4
|
||||
|
||||
- **Route distance is the straight line between the two positions at one game time.** Orbital transfer paths are not modelled.
|
||||
|
||||
## Step 3.5
|
||||
|
||||
- **Travel time takes the distance as a number**, not two locations and a time. The caller measures the distance at departure, so the function cannot accidentally use a mid-flight distance.
|
||||
|
||||
## Step 3.7
|
||||
|
||||
- **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.
|
||||
|
||||
## Step 3.9
|
||||
|
||||
- **Board seed:** `deriveSeed(worldSeed, "board:<bayLocationId>:<periodIndex>")`, with one generator per board.
|
||||
- **Board periods count from the Unix epoch:** period index = floor(real ms ÷ period length). Contracts are offered at the start of their period, and their payout and Long Haul level use the distance at that moment, so a board generated later in the period is identical (decision 0.5.5).
|
||||
- **Contract ids** are `<origin>:<periodIndex>:<slot>`, unique within a player's world. The server may add its own database ids.
|
||||
- **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.
|
||||
|
||||
## Step 3.11
|
||||
|
||||
- **Pilot skills are a `{ skillId: level }` map**; a missing skill counts as level 0.
|
||||
- **The Long Haul skill id is the constant `long-haul`** in sim, because the rule (decision 0.5.7) is specifically about that skill.
|
||||
- **The cargo skill is checked before Long Haul** when reporting a failure reason.
|
||||
|
||||
## Step 3.12
|
||||
|
||||
- **Minimal state shapes:** `ShipState` { id, modelId, locationId, inFlight } and `PilotState` { id, isAvatar, assignedShipId, inFlight, skills }. The server maps its database rows to these.
|
||||
- **Tutorial dispatch:** the caller passes the loaner as the ship (a `ShipState` built from the player's `tutorial` object, decision 0.5.26) and sets `tutorial: true`. The pilot must be the avatar, and the fuel check is skipped. The validator does not check that a tutorial contract came from the tutorial board; the server does that.
|
||||
- **Checks run in the plan's order**, and the first failure is returned: ship idle → ship at origin → pilot idle → pilot assigned → capacity → landing → range → cargo skill → Long Haul → expiry → fuel credits.
|
||||
- **Fuel is charged in whole credits** (`tripFuelCost` rounds), using the distance at departure (`now`).
|
||||
|
||||
## Step 3.13
|
||||
|
||||
- **Extra record fields:** `tutorial` (resolution pays the tutorial share and skips pilot pay) and `distanceMillionKm` (the distance at departure, used for XP), on top of the plan's fields.
|
||||
- **The caller supplies the dispatch id**; the seed is `deriveSeed(worldSeed, "dispatch:<id>")`.
|
||||
- **Arrival** = departure + travel time in game ms ÷ compression, rounded to whole real milliseconds.
|
||||
- **`fuelCost` is what the player pays:** 0 for tutorial runs.
|
||||
- **`expectedPayout` is the contract payout** before pilot pay; resolution deducts pilot pay.
|
||||
|
||||
## Step 3.14
|
||||
|
||||
- **A reposition needs a player bay at the destination**, checked against `bayLocationIds`. Bays exist only at discovered locations, so this also guarantees the destination is discovered.
|
||||
- **Reposition check order:** ship idle → pilot idle → pilot assigned → not already there → bay at destination → landing → range → fuel credits. There are no skill checks (decision 0.5.24). Tutorial loaners cannot reposition.
|
||||
- **Contract and reposition records share one builder**; a reposition has `contract: null` and `expectedPayout: 0`.
|
||||
- **"Zero XP" is enforced at resolution** (Step 3.17), because the record has no XP field. That step's tests cover it.
|
||||
|
||||
## Step 3.15
|
||||
|
||||
- **Progress is linear in real time.** Takeoff and landing overhead are not modelled separately, so the ship moves at a constant rate along its line.
|
||||
- **A zero-length flight reports progress 1** instead of dividing by zero.
|
||||
|
||||
## Step 3.16
|
||||
|
||||
- **Delivery XP** = round(base + perMillionKm × distance + perCargoTier × tier), using the distance at departure stored on the dispatch record.
|
||||
- **Pilot level** is the number of `pilotLevelXpThresholds` the total XP has reached (0 below the first threshold), so levels cap at the list length (10).
|
||||
- **Skill points available** = level × points per level − points spent, floored at 0.
|
||||
|
||||
## Step 3.17
|
||||
|
||||
- **Resolution credits = contract payout − pilot pay.** Fuel is not included, because it is charged when the ship departs (the record's `fuelCost`). Credits can be negative if a payout is below pilot pay; the server applies the number as given.
|
||||
- **Tutorial runs grant XP to the avatar** like any delivery; the plan does not exclude it. They credit the stored tutorial payout with no pilot pay.
|
||||
- **Repositions resolve to 0 credits and 0 XP** and never discover anything, since they only fly to bays.
|
||||
- **The caller passes `pilotIsAvatar` and the current discovered set**, so the function stays pure. "Exactly once" is the server's job: it must store the resolution and the acknowledged-at state transactionally.
|
||||
|
||||
## Step 3.18
|
||||
|
||||
- **Explicit public API.** `packages/sim/src/index.ts` lists every export. Internal helpers (`findLocation`, `findShipModel`, `standardCargo`, `buildContract`, `skillLevel`) are not exported; tests that need one import it from its module.
|
||||
- **Coverage** uses `@vitest/coverage-v8`, configured in the root `vitest.config.ts` to measure only `packages/sim/src` with a 90% line threshold. `pnpm coverage` runs the whole suite with coverage, and CI runs it in place of `pnpm test`, so a coverage drop fails CI.
|
||||
- **The Step 1.3 smoke test is removed** now that the sim package has real tests.
|
||||
|
|
|
|||
|
|
@ -16,3 +16,24 @@
|
|||
- [x] **Step 2.4 — Cargo types and skills.** `src/data/cargo.ts`: Standard, Heavy, High-Value, Dangerous; skills Long Haul, Heavy Cargo, Expensive Cargo, Dangerous Cargo (max level 5). The location test now checks cargo ids against the real data.
|
||||
- [x] **Step 2.5 — Default game config.** `src/data/config.ts`. Courier + Earth bay ≈ 21 tutorial runs with the placeholder average payout. Money values rescaled ×10 so tutorial runs pay meaningful amounts; epoch moved from `.env.example` into GameConfig.
|
||||
- [x] **Step 2.6 — Single content bundle.** `content` in `src/bundle.ts` holds all data plus `location`, `shipModel`, `cargoType` and `skill` lookups that return `undefined` for unknown ids.
|
||||
|
||||
## Phase 3 — Simulation Package
|
||||
|
||||
- [x] **Step 3.1 — Game clock conversion.** `src/clock.ts`: `realToGame` / `gameToReal` using the config epoch and compression; times are Unix-ms numbers.
|
||||
- [x] **Step 3.2 — Seeded RNG.** `src/rng.ts`: `createRng(seed)` (mulberry32) with `next`, `int`, `pick`; `deriveSeed(parent, label)` for child seeds.
|
||||
- [x] **Step 3.3 — Location positions.** `src/positions.ts`: heliocentric ecliptic positions (million km) from `astronomy-engine` for Earth, Mars and Jupiter; Moon = Earth + fixed offset; Ceres on a circular orbit.
|
||||
- [x] **Step 3.4 — Route distance.** `src/distance.ts`: `routeDistance(from, to, gameTime)`, straight-line million km.
|
||||
- [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.
|
||||
- [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.
|
||||
- [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`.
|
||||
- [x] **Step 3.11 — Pilot eligibility.** `src/pilot.ts`: `checkPilotEligibility(skills, contract)` returns `ok` or `cargo-skill-too-low` / `long-haul-too-low`.
|
||||
- [x] **Step 3.12 — Dispatch validation.** `src/dispatch.ts`: `ShipState`, `PilotState`, `validateDispatch` (first failing reason, plan order), `tripFuelCost` (rounded). One test per failure reason plus tutorial cases.
|
||||
- [x] **Step 3.13 — Dispatch record creation.** `createDispatchRecord` in `src/dispatch.ts`: `DispatchRecord` with arrival from travel time, fuel cost, departure distance, a `tutorial` flag and a per-dispatch seed.
|
||||
- [x] **Step 3.14 — Reposition dispatch.** `validateReposition` and `createRepositionRecord` in `src/dispatch.ts`: empty flight to a player bay, range/landing/fuel only, zero payout. Zero XP is covered by resolution (Step 3.17).
|
||||
- [x] **Step 3.15 — In-flight position and progress.** `src/flight.ts`: `dispatchProgress` (clamped 0–1) and `dispatchPosition` (straight line from origin at departure to destination at arrival).
|
||||
- [x] **Step 3.16 — XP and leveling.** `src/xp.ts`: `deliveryXp`, `pilotLevel`, `skillPointsAvailable`.
|
||||
- [x] **Step 3.17 — Dispatch resolution.** `src/resolution.ts`: `resolveDispatch` returns `in-flight` or a `Resolution` (credits after pilot pay, XP, new ship location, discovery).
|
||||
- [x] **Step 3.18 — Public API and coverage.** `src/index.ts` exports an explicit list (content lookups and contract building stay internal). `pnpm coverage` (V8, `packages/sim/src`, 90% line threshold) reports 100% lines; CI now runs `pnpm coverage` instead of `pnpm test`.
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@
|
|||
"typecheck": "pnpm -r --parallel typecheck",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24",
|
||||
"@vitest/coverage-v8": "^5.0.3",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.13.0",
|
||||
|
|
|
|||
|
|
@ -7,5 +7,9 @@
|
|||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deadhead/content": "workspace:*",
|
||||
"astronomy-engine": "^2.1.19"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
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";
|
||||
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
|
||||
|
||||
/** The static data the sim needs. The `content` bundle satisfies this. */
|
||||
export interface SimContent {
|
||||
locations: readonly Location[];
|
||||
cargoTypes: readonly CargoType[];
|
||||
skills: readonly Skill[];
|
||||
shipModels: readonly ShipModel[];
|
||||
config: GameConfig;
|
||||
}
|
||||
|
||||
export function boardPeriodMs(config: GameConfig): number {
|
||||
return config.contractBoardPeriodRealMinutes * MS_PER_MINUTE;
|
||||
}
|
||||
|
||||
/** The board period a real time falls in. */
|
||||
export function boardPeriodIndex(real: RealTime, config: GameConfig): number {
|
||||
return Math.floor(real / boardPeriodMs(config));
|
||||
}
|
||||
|
||||
/** Required Long Haul level: the number of cut-offs the distance exceeds (decision 0.5.7). */
|
||||
export function requiredLongHaulLevel(distanceMillionKm: number, config: GameConfig): number {
|
||||
return config.longHaulCutoffsMillionKm.filter((cutoff) => distanceMillionKm > cutoff).length;
|
||||
}
|
||||
|
||||
export function standardCargo(content: SimContent): CargoType {
|
||||
const standard = content.cargoTypes.find((c) => c.requiredSkillId === null);
|
||||
if (!standard) throw new Error("Content has no Standard cargo type");
|
||||
return standard;
|
||||
}
|
||||
|
||||
export interface BoardInput {
|
||||
origin: Location;
|
||||
discoveredIds: ReadonlySet<string>;
|
||||
periodIndex: number;
|
||||
worldSeed: Seed;
|
||||
}
|
||||
|
||||
/** Builds one contract from a chosen route, cargo, tier and mass. */
|
||||
export function buildContract(
|
||||
args: {
|
||||
id: string;
|
||||
origin: Location;
|
||||
destination: Location;
|
||||
cargoType: CargoType;
|
||||
tier: number;
|
||||
massTonnes: number;
|
||||
firstContact: boolean;
|
||||
periodIndex: number;
|
||||
},
|
||||
content: SimContent,
|
||||
): Contract {
|
||||
const { config } = content;
|
||||
const offeredAt = args.periodIndex * boardPeriodMs(config);
|
||||
const distance = routeDistance(args.origin, args.destination, realToGame(offeredAt, config));
|
||||
return {
|
||||
id: args.id,
|
||||
originId: args.origin.id,
|
||||
destinationId: args.destination.id,
|
||||
cargoTypeId: args.cargoType.id,
|
||||
tier: args.tier,
|
||||
massTonnes: args.massTonnes,
|
||||
payout: contractPayout(
|
||||
{
|
||||
cargoType: args.cargoType,
|
||||
tier: args.tier,
|
||||
massTonnes: args.massTonnes,
|
||||
distanceMillionKm: distance,
|
||||
firstContact: args.firstContact,
|
||||
},
|
||||
config,
|
||||
),
|
||||
requiredSkill:
|
||||
args.cargoType.requiredSkillId === null
|
||||
? null
|
||||
: { skillId: args.cargoType.requiredSkillId, level: args.tier },
|
||||
requiredLongHaulLevel: requiredLongHaulLevel(distance, config),
|
||||
offeredAt,
|
||||
expiresAt: offeredAt + boardPeriodMs(config),
|
||||
firstContact: args.firstContact,
|
||||
};
|
||||
}
|
||||
|
||||
function pickCargo(rng: Rng, origin: Location, destination: Location, content: SimContent) {
|
||||
const matching = content.cargoTypes.filter(
|
||||
(c) => origin.produces.includes(c.id) && destination.demands.includes(c.id),
|
||||
);
|
||||
// Decision 0.5.17: fall back to Standard when no produced cargo is demanded.
|
||||
return matching.length > 0 ? rng.pick(matching) : standardCargo(content);
|
||||
}
|
||||
|
||||
function pickTier(rng: Rng, cargoType: CargoType, content: SimContent): number {
|
||||
if (cargoType.requiredSkillId === null) return 0;
|
||||
const skill = content.skills.find((s) => s.id === cargoType.requiredSkillId);
|
||||
if (!skill) throw new Error(`Unknown skill ${cargoType.requiredSkillId}`);
|
||||
return rng.int(1, skill.maxLevel);
|
||||
}
|
||||
|
||||
/** Generates a bay's contract board for one period. Same inputs always give the same board. */
|
||||
export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] {
|
||||
const { origin, discoveredIds, periodIndex, worldSeed } = input;
|
||||
const { config } = content;
|
||||
const rng = createRng(deriveSeed(worldSeed, `board:${origin.id}:${periodIndex}`));
|
||||
|
||||
const others = content.locations.filter((l) => l.id !== origin.id);
|
||||
const discovered = others.filter((l) => discoveredIds.has(l.id));
|
||||
const undiscovered = others.filter((l) => !discoveredIds.has(l.id));
|
||||
|
||||
const board: Contract[] = [];
|
||||
for (let i = 0; i < config.contractBoardSize; i++) {
|
||||
const firstContact =
|
||||
undiscovered.length > 0 &&
|
||||
(discovered.length === 0 || rng.next() < config.firstContactChance);
|
||||
const destination = rng.pick(firstContact ? undiscovered : discovered);
|
||||
const cargoType = pickCargo(rng, origin, destination, content);
|
||||
board.push(
|
||||
buildContract(
|
||||
{
|
||||
id: `${origin.id}:${periodIndex}:${i}`,
|
||||
origin,
|
||||
destination,
|
||||
cargoType,
|
||||
tier: pickTier(rng, cargoType, content),
|
||||
massTonnes: rng.int(config.contractMassTonnes.min, config.contractMassTonnes.max),
|
||||
firstContact,
|
||||
periodIndex,
|
||||
},
|
||||
content,
|
||||
),
|
||||
);
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { GameConfig } from "@deadhead/content";
|
||||
|
||||
/**
|
||||
* Times are milliseconds since the Unix epoch. A real time is a wall-clock
|
||||
* instant; a game time is the instant of the in-game (ephemeris) date, so
|
||||
* `new Date(gameTime)` is the in-game date.
|
||||
*/
|
||||
export type RealTime = number;
|
||||
export type GameTime = number;
|
||||
|
||||
export const MS_PER_MINUTE = 60_000;
|
||||
export const MS_PER_HOUR = 3_600_000;
|
||||
|
||||
/** Converts a real instant to the in-game instant it maps to. */
|
||||
export function realToGame(real: RealTime, config: GameConfig): GameTime {
|
||||
const realStart = Date.parse(config.epoch.realStart);
|
||||
const gameStart = Date.parse(config.epoch.ephemerisDate);
|
||||
return gameStart + (real - realStart) * config.timeCompression;
|
||||
}
|
||||
|
||||
/** Converts an in-game instant back to the real instant it maps to. */
|
||||
export function gameToReal(game: GameTime, config: GameConfig): RealTime {
|
||||
const realStart = Date.parse(config.epoch.realStart);
|
||||
const gameStart = Date.parse(config.epoch.ephemerisDate);
|
||||
return realStart + (game - gameStart) / config.timeCompression;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import type { Location, ShipModel } from "@deadhead/content";
|
||||
import type { SimContent } from "./board";
|
||||
import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock";
|
||||
import type { Contract } from "./contract";
|
||||
import { routeDistance } from "./distance";
|
||||
import { checkRoute } from "./eligibility";
|
||||
import { fuelCost } from "./fuel";
|
||||
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
||||
import { deriveSeed, type Seed } from "./rng";
|
||||
import { travelTimeGameHours } from "./travel";
|
||||
|
||||
export interface ShipState {
|
||||
id: string;
|
||||
modelId: string;
|
||||
locationId: string;
|
||||
inFlight: boolean;
|
||||
}
|
||||
|
||||
export interface PilotState {
|
||||
id: string;
|
||||
/** The player's own pilot: exempt from pilot pay (decision 0.5.12). */
|
||||
isAvatar: boolean;
|
||||
assignedShipId: string | null;
|
||||
inFlight: boolean;
|
||||
skills: SkillLevels;
|
||||
}
|
||||
|
||||
export interface DispatchProposal {
|
||||
/** For a tutorial run, the company loaner (decision 0.5.26: it is not in the ships list). */
|
||||
ship: ShipState;
|
||||
pilot: PilotState;
|
||||
contract: Contract;
|
||||
/** Tutorial runs are flown by the avatar in the loaner, and the company pays fuel. */
|
||||
tutorial: boolean;
|
||||
credits: number;
|
||||
now: RealTime;
|
||||
}
|
||||
|
||||
export type DispatchFailure =
|
||||
| "ship-not-idle"
|
||||
| "ship-not-at-origin"
|
||||
| "pilot-not-idle"
|
||||
| "pilot-not-assigned"
|
||||
| "over-capacity"
|
||||
| "cannot-land"
|
||||
| "out-of-range"
|
||||
| "cargo-skill-too-low"
|
||||
| "long-haul-too-low"
|
||||
| "contract-expired"
|
||||
| "insufficient-credits";
|
||||
|
||||
export type DispatchCheck = { ok: true } | { ok: false; reason: DispatchFailure };
|
||||
|
||||
export function findShipModel(id: string, content: SimContent): ShipModel {
|
||||
const model = content.shipModels.find((s) => s.id === id);
|
||||
if (!model) throw new Error(`Unknown ship model ${id}`);
|
||||
return model;
|
||||
}
|
||||
|
||||
export function findLocation(id: string, content: SimContent): Location {
|
||||
const location = content.locations.find((l) => l.id === id);
|
||||
if (!location) throw new Error(`Unknown location ${id}`);
|
||||
return location;
|
||||
}
|
||||
|
||||
/** Fuel charged for a trip, in whole credits, using the distance at departure. */
|
||||
export function tripFuelCost(
|
||||
model: ShipModel,
|
||||
origin: Location,
|
||||
destination: Location,
|
||||
departure: GameTime,
|
||||
): number {
|
||||
return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure)));
|
||||
}
|
||||
|
||||
const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason });
|
||||
|
||||
/** Validates a proposed contract dispatch, returning the first failing reason. */
|
||||
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
|
||||
const { ship, pilot, contract, tutorial, credits, now } = proposal;
|
||||
const model = findShipModel(ship.modelId, content);
|
||||
const origin = findLocation(contract.originId, content);
|
||||
const destination = findLocation(contract.destinationId, content);
|
||||
const departure = realToGame(now, content.config);
|
||||
|
||||
if (ship.inFlight) return fail("ship-not-idle");
|
||||
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
|
||||
if (pilot.inFlight) return fail("pilot-not-idle");
|
||||
// Decision 0.5.23: during the tutorial the avatar flies only the loaner.
|
||||
if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) {
|
||||
return fail("pilot-not-assigned");
|
||||
}
|
||||
if (contract.massTonnes > model.cargoCapacityTonnes) return fail("over-capacity");
|
||||
|
||||
const route = checkRoute(model, origin, destination, departure);
|
||||
if (!route.ok) return fail(route.reason);
|
||||
|
||||
const pilotCheck = checkPilotEligibility(pilot.skills, contract);
|
||||
if (!pilotCheck.ok) return fail(pilotCheck.reason);
|
||||
|
||||
if (now >= contract.expiresAt) return fail("contract-expired");
|
||||
|
||||
if (!tutorial) {
|
||||
if (credits < tripFuelCost(model, origin, destination, departure))
|
||||
return fail("insufficient-credits");
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface DispatchRecord {
|
||||
id: string;
|
||||
shipId: string;
|
||||
pilotId: string;
|
||||
/** Null for a reposition. */
|
||||
contract: Contract | null;
|
||||
originId: string;
|
||||
destinationId: string;
|
||||
tutorial: boolean;
|
||||
departureAt: RealTime;
|
||||
arrivalAt: RealTime;
|
||||
/** Route distance at departure, in million km. */
|
||||
distanceMillionKm: number;
|
||||
/** Credits charged to the player; 0 for tutorial runs (the company pays). */
|
||||
fuelCost: number;
|
||||
expectedPayout: number;
|
||||
/** Seed for anything rolled about this trip. */
|
||||
seed: Seed;
|
||||
}
|
||||
|
||||
export interface RecordInput {
|
||||
id: string;
|
||||
ship: ShipState;
|
||||
pilot: PilotState;
|
||||
contract: Contract;
|
||||
tutorial: boolean;
|
||||
now: RealTime;
|
||||
worldSeed: Seed;
|
||||
}
|
||||
|
||||
function buildRecord(
|
||||
args: {
|
||||
id: string;
|
||||
ship: ShipState;
|
||||
pilot: PilotState;
|
||||
contract: Contract | null;
|
||||
originId: string;
|
||||
destinationId: string;
|
||||
tutorial: boolean;
|
||||
now: RealTime;
|
||||
worldSeed: Seed;
|
||||
},
|
||||
content: SimContent,
|
||||
): DispatchRecord {
|
||||
const { id, ship, pilot, contract, tutorial, now, worldSeed } = args;
|
||||
const { config } = content;
|
||||
const model = findShipModel(ship.modelId, content);
|
||||
const origin = findLocation(args.originId, content);
|
||||
const destination = findLocation(args.destinationId, content);
|
||||
const departure = realToGame(now, config);
|
||||
const distance = routeDistance(origin, destination, departure);
|
||||
const travelGameMs = travelTimeGameHours(model, distance) * MS_PER_HOUR;
|
||||
|
||||
return {
|
||||
id,
|
||||
shipId: ship.id,
|
||||
pilotId: pilot.id,
|
||||
contract,
|
||||
originId: origin.id,
|
||||
destinationId: destination.id,
|
||||
tutorial,
|
||||
departureAt: now,
|
||||
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
|
||||
distanceMillionKm: distance,
|
||||
fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure),
|
||||
expectedPayout: contract?.payout ?? 0,
|
||||
seed: deriveSeed(worldSeed, `dispatch:${id}`),
|
||||
};
|
||||
}
|
||||
|
||||
/** Turns an already-validated dispatch into its record. Same inputs give the same record. */
|
||||
export function createDispatchRecord(input: RecordInput, content: SimContent): DispatchRecord {
|
||||
const { contract } = input;
|
||||
return buildRecord(
|
||||
{ ...input, originId: contract.originId, destinationId: contract.destinationId },
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
export interface RepositionProposal {
|
||||
ship: ShipState;
|
||||
pilot: PilotState;
|
||||
destinationId: string;
|
||||
/** Locations where the player has a bay (bays exist only at discovered locations). */
|
||||
bayLocationIds: ReadonlySet<string>;
|
||||
credits: number;
|
||||
now: RealTime;
|
||||
}
|
||||
|
||||
export type RepositionFailure =
|
||||
| "ship-not-idle"
|
||||
| "pilot-not-idle"
|
||||
| "pilot-not-assigned"
|
||||
| "already-there"
|
||||
| "no-bay-at-destination"
|
||||
| "cannot-land"
|
||||
| "out-of-range"
|
||||
| "insufficient-credits";
|
||||
|
||||
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };
|
||||
|
||||
/**
|
||||
* Validates an empty flight to one of the player's bays. Only range and landing
|
||||
* apply; no skills are needed (decision 0.5.24).
|
||||
*/
|
||||
export function validateReposition(
|
||||
proposal: RepositionProposal,
|
||||
content: SimContent,
|
||||
): RepositionCheck {
|
||||
const { ship, pilot, destinationId, bayLocationIds, credits, now } = proposal;
|
||||
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
||||
|
||||
if (ship.inFlight) return failWith("ship-not-idle");
|
||||
if (pilot.inFlight) return failWith("pilot-not-idle");
|
||||
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
|
||||
if (destinationId === ship.locationId) return failWith("already-there");
|
||||
if (!bayLocationIds.has(destinationId)) return failWith("no-bay-at-destination");
|
||||
|
||||
const model = findShipModel(ship.modelId, content);
|
||||
const origin = findLocation(ship.locationId, content);
|
||||
const destination = findLocation(destinationId, content);
|
||||
const departure = realToGame(now, content.config);
|
||||
const route = checkRoute(model, origin, destination, departure);
|
||||
if (!route.ok) return failWith(route.reason);
|
||||
|
||||
if (credits < tripFuelCost(model, origin, destination, departure)) {
|
||||
return failWith("insufficient-credits");
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface RepositionRecordInput {
|
||||
id: string;
|
||||
ship: ShipState;
|
||||
pilot: PilotState;
|
||||
destinationId: string;
|
||||
now: RealTime;
|
||||
worldSeed: Seed;
|
||||
}
|
||||
|
||||
/** Record for a validated reposition: no contract, fuel only, zero payout. */
|
||||
export function createRepositionRecord(
|
||||
input: RepositionRecordInput,
|
||||
content: SimContent,
|
||||
): DispatchRecord {
|
||||
return buildRecord(
|
||||
{ ...input, contract: null, originId: input.ship.locationId, tutorial: false },
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { Location } from "@deadhead/content";
|
||||
import type { GameTime } from "./clock";
|
||||
import { locationPosition } from "./positions";
|
||||
|
||||
/** Straight-line distance between two locations at a game time, in million km. */
|
||||
export function routeDistance(from: Location, to: Location, gameTime: GameTime): number {
|
||||
const a = locationPosition(from, gameTime);
|
||||
const b = locationPosition(to, gameTime);
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { LandingClass, Location, ShipModel } from "@deadhead/content";
|
||||
import type { GameTime } from "./clock";
|
||||
import { routeDistance } from "./distance";
|
||||
|
||||
const LANDING_RANK: Record<LandingClass, number> = { vacuum: 0, atmospheric: 1, hardened: 2 };
|
||||
|
||||
export type RouteCheck = { ok: true } | { ok: false; reason: "cannot-land" | "out-of-range" };
|
||||
|
||||
/** Whether a ship's landing class meets a location's landing requirement. */
|
||||
export function canLandAt(ship: ShipModel, location: Location): boolean {
|
||||
return LANDING_RANK[ship.landingClass] >= LANDING_RANK[location.landingRequirement];
|
||||
}
|
||||
|
||||
/** Whether a ship can fly from one location to another at a game time, or why not. */
|
||||
export function checkRoute(
|
||||
ship: ShipModel,
|
||||
from: Location,
|
||||
to: Location,
|
||||
gameTime: GameTime,
|
||||
): RouteCheck {
|
||||
if (!canLandAt(ship, to)) return { ok: false, reason: "cannot-land" };
|
||||
if (routeDistance(from, to, gameTime) > ship.fuelRangeMillionKm) {
|
||||
return { ok: false, reason: "out-of-range" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { SimContent } from "./board";
|
||||
import { type RealTime, realToGame } from "./clock";
|
||||
import { type DispatchRecord, findLocation } from "./dispatch";
|
||||
import { locationPosition, type Position } from "./positions";
|
||||
|
||||
/** Fraction of the trip completed at a real time, clamped to [0, 1]. */
|
||||
export function dispatchProgress(record: DispatchRecord, now: RealTime): number {
|
||||
const duration = record.arrivalAt - record.departureAt;
|
||||
if (duration <= 0) return 1;
|
||||
return Math.min(1, Math.max(0, (now - record.departureAt) / duration));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ship position at a real time: a straight line from the origin's position at
|
||||
* departure to the destination's position at arrival.
|
||||
*/
|
||||
export function dispatchPosition(
|
||||
record: DispatchRecord,
|
||||
now: RealTime,
|
||||
content: SimContent,
|
||||
): Position {
|
||||
const { config } = content;
|
||||
const start = locationPosition(
|
||||
findLocation(record.originId, content),
|
||||
realToGame(record.departureAt, config),
|
||||
);
|
||||
const end = locationPosition(
|
||||
findLocation(record.destinationId, content),
|
||||
realToGame(record.arrivalAt, config),
|
||||
);
|
||||
const t = dispatchProgress(record, now);
|
||||
return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t };
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { Location, ShipModel } from "@deadhead/content";
|
||||
|
||||
/** Fuel cost in credits: distance × the ship's cost per million km × the origin's launch multiplier. */
|
||||
export function fuelCost(ship: ShipModel, origin: Location, distanceMillionKm: number): number {
|
||||
return distanceMillionKm * ship.fuelCostPerMillionKm * origin.launchCostMultiplier;
|
||||
}
|
||||
|
|
@ -1 +1,63 @@
|
|||
export {};
|
||||
// Public API of the deterministic simulation. Internal helpers (content
|
||||
// lookups, contract building) stay unexported.
|
||||
|
||||
export { MS_PER_HOUR, MS_PER_MINUTE, gameToReal, realToGame } from "./clock";
|
||||
export type { GameTime, RealTime } from "./clock";
|
||||
|
||||
export { createRng, deriveSeed } from "./rng";
|
||||
export type { Rng, Seed } from "./rng";
|
||||
|
||||
export { bodyPosition, locationPosition } from "./positions";
|
||||
export type { Position } from "./positions";
|
||||
|
||||
export { routeDistance } from "./distance";
|
||||
export { travelTimeGameHours } from "./travel";
|
||||
export { fuelCost } from "./fuel";
|
||||
|
||||
export { canLandAt, checkRoute } from "./eligibility";
|
||||
export type { RouteCheck } from "./eligibility";
|
||||
|
||||
export { contractPayout } from "./contract";
|
||||
export type { Contract, PayoutInput } from "./contract";
|
||||
|
||||
export {
|
||||
boardPeriodIndex,
|
||||
boardPeriodMs,
|
||||
generateContractBoard,
|
||||
requiredLongHaulLevel,
|
||||
} from "./board";
|
||||
export type { BoardInput, SimContent } from "./board";
|
||||
|
||||
export { generateTutorialBoard, loanerModel } from "./tutorial";
|
||||
export type { TutorialBoardInput, TutorialLocationId } from "./tutorial";
|
||||
|
||||
export { LONG_HAUL_SKILL_ID, checkPilotEligibility } from "./pilot";
|
||||
export type { PilotCheck, SkillLevels } from "./pilot";
|
||||
|
||||
export {
|
||||
createDispatchRecord,
|
||||
createRepositionRecord,
|
||||
tripFuelCost,
|
||||
validateDispatch,
|
||||
validateReposition,
|
||||
} from "./dispatch";
|
||||
export type {
|
||||
DispatchCheck,
|
||||
DispatchFailure,
|
||||
DispatchProposal,
|
||||
DispatchRecord,
|
||||
PilotState,
|
||||
RecordInput,
|
||||
RepositionCheck,
|
||||
RepositionFailure,
|
||||
RepositionProposal,
|
||||
RepositionRecordInput,
|
||||
ShipState,
|
||||
} from "./dispatch";
|
||||
|
||||
export { dispatchPosition, dispatchProgress } from "./flight";
|
||||
|
||||
export { deliveryXp, pilotLevel, skillPointsAvailable } from "./xp";
|
||||
|
||||
export { resolveDispatch } from "./resolution";
|
||||
export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import type { Contract } from "./contract";
|
||||
|
||||
export const LONG_HAUL_SKILL_ID = "long-haul";
|
||||
|
||||
/** A pilot's skill levels by skill id; a missing skill is level 0. */
|
||||
export type SkillLevels = Readonly<Record<string, number>>;
|
||||
|
||||
export type PilotCheck =
|
||||
{ ok: true } | { ok: false; reason: "cargo-skill-too-low" | "long-haul-too-low" };
|
||||
|
||||
export function skillLevel(skills: SkillLevels, skillId: string): number {
|
||||
return skills[skillId] ?? 0;
|
||||
}
|
||||
|
||||
/** Whether a pilot's skills meet a contract's cargo-skill and Long Haul requirements. */
|
||||
export function checkPilotEligibility(skills: SkillLevels, contract: Contract): PilotCheck {
|
||||
const required = contract.requiredSkill;
|
||||
if (required && skillLevel(skills, required.skillId) < required.level) {
|
||||
return { ok: false, reason: "cargo-skill-too-low" };
|
||||
}
|
||||
if (skillLevel(skills, LONG_HAUL_SKILL_ID) < contract.requiredLongHaulLevel) {
|
||||
return { ok: false, reason: "long-haul-too-low" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { AstronomyBody, Location } from "@deadhead/content";
|
||||
import { Body, HelioVector, KM_PER_AU, RotateVector, Rotation_EQJ_ECL } from "astronomy-engine";
|
||||
import type { GameTime } from "./clock";
|
||||
|
||||
/** A heliocentric point in the ecliptic plane, in million km. */
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const MILLION_KM_PER_AU = KM_PER_AU / 1e6;
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const J2000 = Date.UTC(2000, 0, 1, 12);
|
||||
|
||||
/** Fixed Earth→Moon offset (mean lunar distance); the Moon's orbit is ignored. */
|
||||
const MOON_OFFSET_MILLION_KM = 0.384;
|
||||
|
||||
/**
|
||||
* Ceres is not in astronomy-engine, so it follows a circular orbit with its
|
||||
* real mean radius and period, starting from its approximate J2000 mean longitude.
|
||||
*/
|
||||
const CERES_RADIUS_AU = 2.7675;
|
||||
const CERES_PERIOD_DAYS = 1680.5;
|
||||
const CERES_J2000_LONGITUDE_DEG = 160;
|
||||
|
||||
const eqjToEcliptic = Rotation_EQJ_ECL();
|
||||
|
||||
function planetPosition(body: Body, gameTime: GameTime): Position {
|
||||
const ecliptic = RotateVector(eqjToEcliptic, HelioVector(body, new Date(gameTime)));
|
||||
return { x: ecliptic.x * MILLION_KM_PER_AU, y: ecliptic.y * MILLION_KM_PER_AU };
|
||||
}
|
||||
|
||||
function ceresPosition(gameTime: GameTime): Position {
|
||||
const days = (gameTime - J2000) / MS_PER_DAY;
|
||||
const angle = ((CERES_J2000_LONGITUDE_DEG + (360 * days) / CERES_PERIOD_DAYS) * Math.PI) / 180;
|
||||
const radius = CERES_RADIUS_AU * MILLION_KM_PER_AU;
|
||||
return { x: radius * Math.cos(angle), y: radius * Math.sin(angle) };
|
||||
}
|
||||
|
||||
export function bodyPosition(body: AstronomyBody, gameTime: GameTime): Position {
|
||||
switch (body) {
|
||||
case "earth":
|
||||
return planetPosition(Body.Earth, gameTime);
|
||||
case "moon": {
|
||||
const earth = planetPosition(Body.Earth, gameTime);
|
||||
return { x: earth.x + MOON_OFFSET_MILLION_KM, y: earth.y };
|
||||
}
|
||||
case "mars":
|
||||
return planetPosition(Body.Mars, gameTime);
|
||||
case "ceres":
|
||||
return ceresPosition(gameTime);
|
||||
case "jupiter":
|
||||
return planetPosition(Body.Jupiter, gameTime);
|
||||
}
|
||||
}
|
||||
|
||||
/** A location's 2D position at a game time, in million km from the Sun. */
|
||||
export function locationPosition(location: Location, gameTime: GameTime): Position {
|
||||
return bodyPosition(location.body, gameTime);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { SimContent } from "./board";
|
||||
import type { RealTime } from "./clock";
|
||||
import type { DispatchRecord } from "./dispatch";
|
||||
import { deliveryXp } from "./xp";
|
||||
|
||||
export interface Resolution {
|
||||
/** Credits to add: payout minus pilot pay. Fuel was already charged at departure. */
|
||||
credits: number;
|
||||
pilotXp: number;
|
||||
/** Where the ship (or tutorial loaner) is now. */
|
||||
shipLocationId: string;
|
||||
/** True when this delivery discovers the destination. */
|
||||
discoversDestination: boolean;
|
||||
}
|
||||
|
||||
export type ResolveResult =
|
||||
{ status: "in-flight" } | { status: "resolved"; resolution: Resolution };
|
||||
|
||||
export interface ResolveInput {
|
||||
record: DispatchRecord;
|
||||
/** The avatar is exempt from pilot pay (decision 0.5.12). */
|
||||
pilotIsAvatar: boolean;
|
||||
discoveredIds: ReadonlySet<string>;
|
||||
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;
|
||||
if (now < record.arrivalAt) return { status: "in-flight" };
|
||||
|
||||
const { contract } = record;
|
||||
const base = { shipLocationId: record.destinationId };
|
||||
if (contract === null) {
|
||||
// Repositions earn nothing and only fly to bays, which are already discovered.
|
||||
return {
|
||||
status: "resolved",
|
||||
resolution: { ...base, credits: 0, pilotXp: 0, discoversDestination: false },
|
||||
};
|
||||
}
|
||||
|
||||
const pilotPay = record.tutorial || pilotIsAvatar ? 0 : content.config.pilotPayPerDelivery;
|
||||
return {
|
||||
status: "resolved",
|
||||
resolution: {
|
||||
...base,
|
||||
// Tutorial contracts already store exactly the tutorial share as their payout.
|
||||
credits: contract.payout - pilotPay,
|
||||
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config),
|
||||
discoversDestination: !discoveredIds.has(record.destinationId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/** A 32-bit unsigned integer seed. */
|
||||
export type Seed = number;
|
||||
|
||||
export interface Rng {
|
||||
/** Float in [0, 1). */
|
||||
next(): number;
|
||||
/** Integer in [min, max], inclusive. */
|
||||
int(min: number, max: number): number;
|
||||
/** A uniformly chosen element. Throws on an empty list. */
|
||||
pick<T>(list: readonly T[]): T;
|
||||
}
|
||||
|
||||
/** Seeded PRNG (mulberry32). The same seed always yields the same sequence. */
|
||||
export function createRng(seed: Seed): Rng {
|
||||
let state = seed >>> 0;
|
||||
|
||||
const next = () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
|
||||
return {
|
||||
next,
|
||||
int(min, max) {
|
||||
return min + Math.floor(next() * (max - min + 1));
|
||||
},
|
||||
pick(list) {
|
||||
if (list.length === 0) throw new Error("Cannot pick from an empty list");
|
||||
return list[Math.floor(next() * list.length)]!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Derives an independent child seed from a parent seed and a label (FNV-1a, then a final mix). */
|
||||
export function deriveSeed(parent: Seed, label: string): Seed {
|
||||
let hash = 0x811c9dc5 ^ (parent >>> 0);
|
||||
for (let i = 0; i < label.length; i++) {
|
||||
hash ^= label.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
hash ^= hash >>> 16;
|
||||
hash = Math.imul(hash, 0x85ebca6b);
|
||||
hash ^= hash >>> 13;
|
||||
hash = Math.imul(hash, 0xc2b2ae35);
|
||||
hash ^= hash >>> 16;
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { ShipModel } from "@deadhead/content";
|
||||
|
||||
/**
|
||||
* Trip duration in game hours: cruise time plus the ship's fixed takeoff/landing
|
||||
* overhead. Pass the distance at departure; it is not recalculated mid-flight.
|
||||
*/
|
||||
export function travelTimeGameHours(ship: ShipModel, distanceMillionKm: number): number {
|
||||
const cruiseHours = (distanceMillionKm * 1e6) / ship.cruiseSpeedKmPerGameHour;
|
||||
return cruiseHours + ship.overheadGameHours;
|
||||
}
|
||||
|
|
@ -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,21 @@
|
|||
import type { GameConfig } from "@deadhead/content";
|
||||
|
||||
/** XP for one delivery, in whole points: base + per-million-km × distance + per-tier × tier. */
|
||||
export function deliveryXp(distanceMillionKm: number, tier: number, config: GameConfig): number {
|
||||
const { base, perMillionKm, perCargoTier } = config.xpPerDelivery;
|
||||
return Math.round(base + perMillionKm * distanceMillionKm + perCargoTier * tier);
|
||||
}
|
||||
|
||||
/** Pilot level: how many level thresholds the pilot's total XP has reached. */
|
||||
export function pilotLevel(totalXp: number, config: GameConfig): number {
|
||||
return config.pilotLevelXpThresholds.filter((threshold) => totalXp >= threshold).length;
|
||||
}
|
||||
|
||||
/** Unspent skill points: levels × points per level minus points spent, never below 0. */
|
||||
export function skillPointsAvailable(
|
||||
level: number,
|
||||
pointsSpent: number,
|
||||
config: GameConfig,
|
||||
): number {
|
||||
return Math.max(0, level * config.skillPointsPerLevel - pointsSpent);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardPeriodIndex,
|
||||
boardPeriodMs,
|
||||
generateContractBoard,
|
||||
requiredLongHaulLevel,
|
||||
type BoardInput,
|
||||
} from "../src/index";
|
||||
|
||||
const earth = content.location("earth")!;
|
||||
const discoveredIds = new Set(["earth", "moon"]);
|
||||
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
|
||||
const input: BoardInput = { origin: earth, discoveredIds, periodIndex, worldSeed: 12345 };
|
||||
|
||||
describe("contract board generation", () => {
|
||||
it("produces an identical board from the same inputs", () => {
|
||||
expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content));
|
||||
});
|
||||
|
||||
it("produces a different board for a different period", () => {
|
||||
const next = generateContractBoard({ ...input, periodIndex: periodIndex + 1 }, content);
|
||||
expect(next).not.toEqual(generateContractBoard(input, content));
|
||||
});
|
||||
|
||||
it("has the configured number of contracts, none to its own origin", () => {
|
||||
const board = generateContractBoard(input, content);
|
||||
expect(board).toHaveLength(content.config.contractBoardSize);
|
||||
for (const contract of board) {
|
||||
expect(contract.originId).toBe("earth");
|
||||
expect(contract.destinationId).not.toBe("earth");
|
||||
}
|
||||
});
|
||||
|
||||
it("expires every contract at the end of its board period", () => {
|
||||
const periodEnd = (periodIndex + 1) * boardPeriodMs(content.config);
|
||||
for (const contract of generateContractBoard(input, content)) {
|
||||
expect(contract.expiresAt).toBe(periodEnd);
|
||||
}
|
||||
});
|
||||
|
||||
it("ties tier to skill: Standard is tier 0 with no skill, others 1–5 matching the skill level", () => {
|
||||
const moon = content.location("moon")!;
|
||||
for (let p = 0; p < 50; p++) {
|
||||
for (const c of generateContractBoard({ ...input, origin: moon, periodIndex: p }, content)) {
|
||||
if (c.cargoTypeId === "standard") {
|
||||
expect(c.tier).toBe(0);
|
||||
expect(c.requiredSkill).toBeNull();
|
||||
} else {
|
||||
expect(c.tier).toBeGreaterThanOrEqual(1);
|
||||
expect(c.tier).toBeLessThanOrEqual(5);
|
||||
expect(c.requiredSkill?.level).toBe(c.tier);
|
||||
}
|
||||
expect(c.massTonnes).toBeGreaterThanOrEqual(content.config.contractMassTonnes.min);
|
||||
expect(c.massTonnes).toBeLessThanOrEqual(content.config.contractMassTonnes.max);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("makes 5–15% of contracts first contact over 1,000 boards, all to undiscovered places", () => {
|
||||
let total = 0;
|
||||
let firstContact = 0;
|
||||
for (let p = 0; p < 1000; p++) {
|
||||
for (const contract of generateContractBoard({ ...input, periodIndex: p }, content)) {
|
||||
total++;
|
||||
if (contract.firstContact) {
|
||||
firstContact++;
|
||||
expect(discoveredIds.has(contract.destinationId)).toBe(false);
|
||||
} else {
|
||||
expect(discoveredIds.has(contract.destinationId)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
const share = firstContact / total;
|
||||
expect(share).toBeGreaterThanOrEqual(0.05);
|
||||
expect(share).toBeLessThanOrEqual(0.15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("required Long Haul level", () => {
|
||||
it("counts the cut-offs a distance exceeds", () => {
|
||||
expect(requiredLongHaulLevel(0.4, content.config)).toBe(0);
|
||||
expect(requiredLongHaulLevel(100, content.config)).toBe(0);
|
||||
expect(requiredLongHaulLevel(101, content.config)).toBe(1);
|
||||
expect(requiredLongHaulLevel(900, content.config)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { gameConfig } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MS_PER_HOUR, MS_PER_MINUTE, gameToReal, realToGame } from "../src/index";
|
||||
|
||||
const realStart = Date.parse(gameConfig.epoch.realStart);
|
||||
const gameStart = Date.parse(gameConfig.epoch.ephemerisDate);
|
||||
|
||||
describe("game clock", () => {
|
||||
it("maps the real epoch to the game epoch", () => {
|
||||
expect(realToGame(realStart, gameConfig)).toBe(gameStart);
|
||||
});
|
||||
|
||||
it("round-trips real → game → real", () => {
|
||||
for (const real of [realStart, realStart + 12_345_678, Date.parse("2027-06-15T08:30:00Z")]) {
|
||||
expect(gameToReal(realToGame(real, gameConfig), gameConfig)).toBe(real);
|
||||
}
|
||||
});
|
||||
|
||||
it("advances one game hour per real minute at compression 60", () => {
|
||||
expect(gameConfig.timeCompression).toBe(60);
|
||||
expect(realToGame(realStart + MS_PER_MINUTE, gameConfig)).toBe(gameStart + MS_PER_HOUR);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { content as defaultContent, type ShipModel } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateDispatch,
|
||||
type Contract,
|
||||
type DispatchProposal,
|
||||
type PilotState,
|
||||
type ShipState,
|
||||
type SimContent,
|
||||
} from "../src/index";
|
||||
|
||||
/** Default content plus a test-only ship that cannot land on atmospheric worlds. */
|
||||
const vacuumModel: ShipModel = {
|
||||
...defaultContent.shipModel("courier")!,
|
||||
id: "test-vacuum",
|
||||
landingClass: "vacuum",
|
||||
};
|
||||
const content: SimContent = {
|
||||
...defaultContent,
|
||||
shipModels: [...defaultContent.shipModels, vacuumModel],
|
||||
};
|
||||
|
||||
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: Date.parse("2100-01-01T00:00:00Z"),
|
||||
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
||||
firstContact: false,
|
||||
};
|
||||
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
|
||||
const pilot: PilotState = {
|
||||
id: "p1",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
};
|
||||
const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };
|
||||
|
||||
const reason = (proposal: DispatchProposal) => {
|
||||
const result = validateDispatch(proposal, content);
|
||||
return result.ok ? "valid" : result.reason;
|
||||
};
|
||||
|
||||
describe("dispatch validation", () => {
|
||||
it("accepts a fully valid dispatch", () => {
|
||||
expect(reason(valid)).toBe("valid");
|
||||
});
|
||||
|
||||
it("rejects a ship that is in flight", () => {
|
||||
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle");
|
||||
});
|
||||
|
||||
it("rejects a ship that is not at the contract origin", () => {
|
||||
expect(reason({ ...valid, ship: { ...ship, locationId: "moon" } })).toBe("ship-not-at-origin");
|
||||
});
|
||||
|
||||
it("rejects a pilot who is in flight", () => {
|
||||
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
|
||||
});
|
||||
|
||||
it("rejects a pilot assigned to another ship", () => {
|
||||
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: "s2" } })).toBe(
|
||||
"pilot-not-assigned",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects cargo heavier than the ship's capacity", () => {
|
||||
expect(reason({ ...valid, contract: { ...contract, massTonnes: 50 } })).toBe("over-capacity");
|
||||
});
|
||||
|
||||
it("rejects a destination the ship cannot land at", () => {
|
||||
expect(
|
||||
reason({
|
||||
...valid,
|
||||
ship: { ...ship, modelId: "test-vacuum", locationId: "moon" },
|
||||
contract: { ...contract, originId: "moon", destinationId: "earth" },
|
||||
}),
|
||||
).toBe("cannot-land");
|
||||
});
|
||||
|
||||
it("rejects a destination out of the ship's range", () => {
|
||||
expect(reason({ ...valid, contract: { ...contract, destinationId: "mars" } })).toBe(
|
||||
"out-of-range",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a pilot without the required cargo skill", () => {
|
||||
const heavy = {
|
||||
...contract,
|
||||
cargoTypeId: "heavy",
|
||||
tier: 1,
|
||||
requiredSkill: { skillId: "heavy-cargo", level: 1 },
|
||||
};
|
||||
expect(reason({ ...valid, contract: heavy })).toBe("cargo-skill-too-low");
|
||||
});
|
||||
|
||||
it("rejects a pilot without the required Long Haul level", () => {
|
||||
expect(reason({ ...valid, contract: { ...contract, requiredLongHaulLevel: 1 } })).toBe(
|
||||
"long-haul-too-low",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an expired contract", () => {
|
||||
expect(reason({ ...valid, now: contract.expiresAt })).toBe("contract-expired");
|
||||
});
|
||||
|
||||
it("rejects a dispatch the player cannot afford fuel for", () => {
|
||||
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
||||
});
|
||||
|
||||
describe("tutorial runs", () => {
|
||||
const loaner: ShipState = {
|
||||
id: "loaner",
|
||||
modelId: "courier",
|
||||
locationId: "earth",
|
||||
inFlight: false,
|
||||
};
|
||||
const avatar: PilotState = { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null };
|
||||
const tutorial: DispatchProposal = { ...valid, ship: loaner, pilot: avatar, tutorial: true };
|
||||
|
||||
it("accepts the avatar in the loaner without credits for fuel", () => {
|
||||
expect(reason({ ...tutorial, credits: 0 })).toBe("valid");
|
||||
});
|
||||
|
||||
it("rejects a hired pilot in the loaner", () => {
|
||||
expect(reason({ ...tutorial, pilot })).toBe("pilot-not-assigned");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeDistance } from "../src/index";
|
||||
|
||||
const earth = content.location("earth")!;
|
||||
const moon = content.location("moon")!;
|
||||
const mars = content.location("mars")!;
|
||||
|
||||
const start = Date.parse("2100-01-01T00:00:00Z");
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
/** Game times sampled every ~30 days across two years. */
|
||||
const monthly = Array.from({ length: 25 }, (_, i) => start + i * 30 * MS_PER_DAY);
|
||||
|
||||
describe("route distance", () => {
|
||||
it("keeps Earth→Moon under 1 million km at any time", () => {
|
||||
for (const t of monthly) {
|
||||
expect(routeDistance(earth, moon, t)).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("varies Earth→Mars by at least 200 million km over two years", () => {
|
||||
const distances = monthly.map((t) => routeDistance(earth, mars, t));
|
||||
expect(Math.max(...distances) - Math.min(...distances)).toBeGreaterThanOrEqual(200);
|
||||
});
|
||||
|
||||
it("is symmetric", () => {
|
||||
for (const a of content.locations) {
|
||||
for (const b of content.locations) {
|
||||
expect(routeDistance(a, b, start)).toBe(routeDistance(b, a, start));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { content, type ShipModel } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkRoute, routeDistance } from "../src/index";
|
||||
|
||||
const earth = content.location("earth")!;
|
||||
const moon = content.location("moon")!;
|
||||
const mars = content.location("mars")!;
|
||||
const ceres = content.location("ceres-station")!;
|
||||
const jupiter = content.location("jupiter-cloud-platform")!;
|
||||
const courier = content.shipModel("courier")!;
|
||||
const freighter = content.shipModel("freighter")!;
|
||||
const bulkHauler = content.shipModel("bulk-hauler")!;
|
||||
|
||||
/** Test-only ship that cannot land on atmospheric worlds. */
|
||||
const vacuumShip: ShipModel = { ...courier, id: "test-vacuum", landingClass: "vacuum" };
|
||||
|
||||
// Every 4 days across 12 years (one Jupiter orbit).
|
||||
const start = Date.parse("2100-01-01T00:00:00Z");
|
||||
const samples = Array.from({ length: 1096 }, (_, i) => start + i * 4 * 86_400_000);
|
||||
|
||||
/** The sampled time when `to` is closest to (or farthest from) Earth. */
|
||||
function extremeTime(to: typeof mars, pick: "closest" | "farthest"): number {
|
||||
let best = samples[0]!;
|
||||
let bestDistance = routeDistance(earth, to, best);
|
||||
for (const t of samples) {
|
||||
const d = routeDistance(earth, to, t);
|
||||
if (pick === "closest" ? d < bestDistance : d > bestDistance) {
|
||||
best = t;
|
||||
bestDistance = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
describe("route eligibility", () => {
|
||||
it("lets a Courier fly Earth→Moon and Moon→Earth", () => {
|
||||
expect(checkRoute(courier, earth, moon, start)).toEqual({ ok: true });
|
||||
expect(checkRoute(courier, moon, earth, start)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps a Courier out of range of Mars, Ceres and Jupiter even at their closest", () => {
|
||||
for (const to of [mars, ceres, jupiter]) {
|
||||
expect(checkRoute(courier, earth, to, extremeTime(to, "closest"))).toEqual({
|
||||
ok: false,
|
||||
reason: "out-of-range",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a vacuum-only ship at Earth because it cannot land", () => {
|
||||
expect(checkRoute(vacuumShip, moon, earth, start)).toEqual({
|
||||
ok: false,
|
||||
reason: "cannot-land",
|
||||
});
|
||||
expect(checkRoute(vacuumShip, earth, moon, start)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("lets a Freighter reach Mars and Ceres even at their farthest", () => {
|
||||
for (const to of [mars, ceres]) {
|
||||
expect(checkRoute(freighter, earth, to, extremeTime(to, "farthest"))).toEqual({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("lets only the Bulk Hauler reach Jupiter at its farthest", () => {
|
||||
const t = extremeTime(jupiter, "farthest");
|
||||
expect(checkRoute(bulkHauler, earth, jupiter, t)).toEqual({ ok: true });
|
||||
expect(checkRoute(freighter, earth, jupiter, t).ok).toBe(false);
|
||||
expect(checkRoute(courier, earth, jupiter, t).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a Freighter reach Jupiter at its closest (decision 0.5.25)", () => {
|
||||
expect(checkRoute(freighter, earth, jupiter, extremeTime(jupiter, "closest"))).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createRepositionRecord,
|
||||
dispatchPosition,
|
||||
dispatchProgress,
|
||||
locationPosition,
|
||||
realToGame,
|
||||
} from "../src/index";
|
||||
|
||||
const record = createRepositionRecord(
|
||||
{
|
||||
id: "r1",
|
||||
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
|
||||
pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
|
||||
destinationId: "moon",
|
||||
now: Date.parse("2100-01-01T00:30:00Z"),
|
||||
worldSeed: 7,
|
||||
},
|
||||
content,
|
||||
);
|
||||
const midpoint = (record.departureAt + record.arrivalAt) / 2;
|
||||
|
||||
describe("in-flight progress", () => {
|
||||
it("is 0 at departure, 0.5 at the midpoint and 1 at arrival", () => {
|
||||
expect(dispatchProgress(record, record.departureAt)).toBe(0);
|
||||
expect(dispatchProgress(record, midpoint)).toBe(0.5);
|
||||
expect(dispatchProgress(record, record.arrivalAt)).toBe(1);
|
||||
});
|
||||
|
||||
it("stays clamped outside the flight", () => {
|
||||
expect(dispatchProgress(record, record.departureAt - 60_000)).toBe(0);
|
||||
expect(dispatchProgress(record, record.arrivalAt + 3_600_000)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in-flight position", () => {
|
||||
const origin = locationPosition(
|
||||
content.location("earth")!,
|
||||
realToGame(record.departureAt, content.config),
|
||||
);
|
||||
const destination = locationPosition(
|
||||
content.location("moon")!,
|
||||
realToGame(record.arrivalAt, content.config),
|
||||
);
|
||||
|
||||
it("starts at the origin's position at departure", () => {
|
||||
expect(dispatchPosition(record, record.departureAt, content)).toEqual(origin);
|
||||
});
|
||||
|
||||
it("ends at the destination's position at arrival", () => {
|
||||
const end = dispatchPosition(record, record.arrivalAt, content);
|
||||
expect(end.x).toBeCloseTo(destination.x, 9);
|
||||
expect(end.y).toBeCloseTo(destination.y, 9);
|
||||
});
|
||||
|
||||
it("is halfway along the line at the midpoint", () => {
|
||||
const mid = dispatchPosition(record, midpoint, content);
|
||||
expect(mid.x).toBeCloseTo((origin.x + destination.x) / 2, 9);
|
||||
expect(mid.y).toBeCloseTo((origin.y + destination.y) / 2, 9);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fuelCost, routeDistance } from "../src/index";
|
||||
|
||||
const courier = content.shipModel("courier")!;
|
||||
const earth = content.location("earth")!;
|
||||
const moon = content.location("moon")!;
|
||||
|
||||
describe("fuel cost", () => {
|
||||
it("costs more Earth→Moon than Moon→Earth for the same ship", () => {
|
||||
const t = Date.parse("2100-01-01T00:00:00Z");
|
||||
const distance = routeDistance(earth, moon, t);
|
||||
expect(fuelCost(courier, earth, distance)).toBeGreaterThan(fuelCost(courier, moon, distance));
|
||||
});
|
||||
|
||||
it("scales linearly with distance", () => {
|
||||
expect(fuelCost(courier, earth, 2)).toBe(2 * fuelCost(courier, earth, 1));
|
||||
expect(fuelCost(courier, earth, 10)).toBe(10 * fuelCost(courier, earth, 1));
|
||||
});
|
||||
|
||||
it("matches a hand calculation", () => {
|
||||
// 0.5M km × 200 credits/M km × Earth multiplier 3.
|
||||
expect(fuelCost(courier, earth, 0.5)).toBe(300);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { checkPilotEligibility, type Contract } from "../src/index";
|
||||
|
||||
const shortStandard: Contract = {
|
||||
id: "c1",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
tier: 0,
|
||||
massTonnes: 10,
|
||||
payout: 1000,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: 0,
|
||||
expiresAt: 3_600_000,
|
||||
firstContact: false,
|
||||
};
|
||||
const heavyTier2: Contract = {
|
||||
...shortStandard,
|
||||
cargoTypeId: "heavy",
|
||||
tier: 2,
|
||||
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
||||
};
|
||||
const longRoute: Contract = {
|
||||
...shortStandard,
|
||||
destinationId: "ceres-station",
|
||||
requiredLongHaulLevel: 1,
|
||||
};
|
||||
|
||||
describe("pilot eligibility", () => {
|
||||
it("lets a level-0 pilot take a short Standard contract", () => {
|
||||
expect(checkPilotEligibility({}, shortStandard)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("stops a level-0 pilot from taking a Heavy contract needing Heavy Cargo 2", () => {
|
||||
expect(checkPilotEligibility({}, heavyTier2)).toEqual({
|
||||
ok: false,
|
||||
reason: "cargo-skill-too-low",
|
||||
});
|
||||
expect(checkPilotEligibility({ "heavy-cargo": 1 }, heavyTier2).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a pilot with Heavy Cargo 2 take it", () => {
|
||||
expect(checkPilotEligibility({ "heavy-cargo": 2 }, heavyTier2)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("stops a level-0 pilot from taking a route needing Long Haul 1", () => {
|
||||
expect(checkPilotEligibility({}, longRoute)).toEqual({
|
||||
ok: false,
|
||||
reason: "long-haul-too-low",
|
||||
});
|
||||
expect(checkPilotEligibility({ "long-haul": 1 }, longRoute)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { locationPosition } from "../src/index";
|
||||
|
||||
const when = Date.parse("2100-03-01T00:00:00Z");
|
||||
const fromSun = (id: string) => {
|
||||
const { x, y } = locationPosition(content.location(id)!, when);
|
||||
return Math.hypot(x, y);
|
||||
};
|
||||
|
||||
describe("location positions", () => {
|
||||
it("puts Earth about 150 million km from the Sun", () => {
|
||||
expect(fromSun("earth")).toBeGreaterThan(147);
|
||||
expect(fromSun("earth")).toBeLessThan(153);
|
||||
});
|
||||
|
||||
it("puts Mars 206–250 million km from the Sun", () => {
|
||||
expect(fromSun("mars")).toBeGreaterThan(206);
|
||||
expect(fromSun("mars")).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it("puts Jupiter Cloud Platform 740–820 million km from the Sun", () => {
|
||||
expect(fromSun("jupiter-cloud-platform")).toBeGreaterThan(740);
|
||||
expect(fromSun("jupiter-cloud-platform")).toBeLessThan(820);
|
||||
});
|
||||
|
||||
it("puts Ceres Station on its ~414 million km orbit", () => {
|
||||
expect(fromSun("ceres-station")).toBeCloseTo(414, 0);
|
||||
});
|
||||
|
||||
it("keeps the Moon within 1 million km of Earth", () => {
|
||||
const earth = locationPosition(content.location("earth")!, when);
|
||||
const moon = locationPosition(content.location("moon")!, when);
|
||||
expect(Math.hypot(moon.x - earth.x, moon.y - earth.y)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("returns identical values for the same time", () => {
|
||||
for (const location of content.locations) {
|
||||
expect(locationPosition(location, when)).toEqual(locationPosition(location, when));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDispatchRecord,
|
||||
MS_PER_HOUR,
|
||||
travelTimeGameHours,
|
||||
type Contract,
|
||||
type RecordInput,
|
||||
} from "../src/index";
|
||||
|
||||
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: Date.parse("2100-01-01T00:00:00Z"),
|
||||
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
||||
firstContact: false,
|
||||
};
|
||||
const input: RecordInput = {
|
||||
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,
|
||||
};
|
||||
|
||||
describe("dispatch record", () => {
|
||||
it("arrives after exactly the travel time converted to real time", () => {
|
||||
const record = createDispatchRecord(input, content);
|
||||
const courier = content.shipModel("courier")!;
|
||||
const travelGameMs = travelTimeGameHours(courier, record.distanceMillionKm) * MS_PER_HOUR;
|
||||
expect(record.arrivalAt - record.departureAt).toBe(
|
||||
Math.round(travelGameMs / content.config.timeCompression),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies the route, payout and parties, and charges fuel", () => {
|
||||
const record = createDispatchRecord(input, content);
|
||||
expect(record).toMatchObject({
|
||||
id: "d1",
|
||||
shipId: "s1",
|
||||
pilotId: "p1",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
departureAt: now,
|
||||
expectedPayout: 2000,
|
||||
tutorial: false,
|
||||
});
|
||||
expect(record.fuelCost).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("charges no fuel on a tutorial run", () => {
|
||||
expect(createDispatchRecord({ ...input, tutorial: true }, content).fuelCost).toBe(0);
|
||||
});
|
||||
|
||||
it("is identical for identical inputs and seeded per dispatch", () => {
|
||||
expect(createDispatchRecord(input, content)).toEqual(createDispatchRecord(input, content));
|
||||
expect(createDispatchRecord({ ...input, id: "d2" }, content).seed).not.toBe(
|
||||
createDispatchRecord(input, content).seed,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createRepositionRecord,
|
||||
validateReposition,
|
||||
type PilotState,
|
||||
type RepositionProposal,
|
||||
type ShipState,
|
||||
} from "../src/index";
|
||||
|
||||
const now = Date.parse("2100-01-01T00:30:00Z");
|
||||
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
|
||||
const pilot: PilotState = {
|
||||
id: "p1",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
};
|
||||
const valid: RepositionProposal = {
|
||||
ship,
|
||||
pilot,
|
||||
destinationId: "moon",
|
||||
bayLocationIds: new Set(["earth", "moon"]),
|
||||
credits: 10_000,
|
||||
now,
|
||||
};
|
||||
|
||||
const reason = (proposal: RepositionProposal) => {
|
||||
const result = validateReposition(proposal, content);
|
||||
return result.ok ? "valid" : result.reason;
|
||||
};
|
||||
|
||||
describe("reposition", () => {
|
||||
it("accepts an empty flight to a bay in range, with no skills needed", () => {
|
||||
expect(reason(valid)).toBe("valid");
|
||||
});
|
||||
|
||||
it("fails validation when the destination has no player bay", () => {
|
||||
expect(reason({ ...valid, bayLocationIds: new Set(["earth"]) })).toBe("no-bay-at-destination");
|
||||
});
|
||||
|
||||
it("still applies range and fuel checks", () => {
|
||||
expect(
|
||||
reason({ ...valid, destinationId: "mars", bayLocationIds: new Set(["earth", "mars"]) }),
|
||||
).toBe("out-of-range");
|
||||
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
||||
});
|
||||
|
||||
it("rejects flying to where the ship already is, or with a busy or unassigned crew", () => {
|
||||
expect(reason({ ...valid, destinationId: "earth" })).toBe("already-there");
|
||||
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle");
|
||||
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
|
||||
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(
|
||||
"pilot-not-assigned",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a record with no contract, zero payout and a fuel charge", () => {
|
||||
const record = createRepositionRecord(
|
||||
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
|
||||
content,
|
||||
);
|
||||
expect(record.contract).toBeNull();
|
||||
expect(record.expectedPayout).toBe(0);
|
||||
expect(record.fuelCost).toBeGreaterThan(0);
|
||||
expect([record.originId, record.destinationId]).toEqual(["earth", "moon"]);
|
||||
expect(record.arrivalAt).toBeGreaterThan(record.departureAt);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDispatchRecord,
|
||||
createRepositionRecord,
|
||||
resolveDispatch,
|
||||
type Contract,
|
||||
type PilotState,
|
||||
type ResolveInput,
|
||||
type ShipState,
|
||||
} from "../src/index";
|
||||
|
||||
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: Date.parse("2100-01-01T00:00:00Z"),
|
||||
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
||||
firstContact: false,
|
||||
};
|
||||
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
|
||||
const pilot: PilotState = {
|
||||
id: "p1",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
};
|
||||
const record = createDispatchRecord(
|
||||
{ id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 },
|
||||
content,
|
||||
);
|
||||
const arrived: ResolveInput = {
|
||||
record,
|
||||
pilotIsAvatar: false,
|
||||
discoveredIds: new Set(["earth", "moon"]),
|
||||
now: record.arrivalAt,
|
||||
};
|
||||
|
||||
const resolve = (input: ResolveInput) => {
|
||||
const result = resolveDispatch(input, content);
|
||||
if (result.status !== "resolved") throw new Error("expected a resolution");
|
||||
return result.resolution;
|
||||
};
|
||||
|
||||
describe("dispatch resolution", () => {
|
||||
it("reports in flight before arrival", () => {
|
||||
expect(resolveDispatch({ ...arrived, now: record.arrivalAt - 1 }, content)).toEqual({
|
||||
status: "in-flight",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves at and after arrival, moving the ship to the destination", () => {
|
||||
expect(resolve(arrived).shipLocationId).toBe("moon");
|
||||
expect(resolveDispatch({ ...arrived, now: record.arrivalAt + 60_000 }, content).status).toBe(
|
||||
"resolved",
|
||||
);
|
||||
});
|
||||
|
||||
it("deducts pilot pay for a hired pilot and grants XP", () => {
|
||||
const resolution = resolve(arrived);
|
||||
expect(resolution.credits).toBe(2000 - content.config.pilotPayPerDelivery);
|
||||
expect(resolution.pilotXp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("deducts no pilot pay when the avatar flies", () => {
|
||||
expect(resolve({ ...arrived, pilotIsAvatar: true }).credits).toBe(2000);
|
||||
});
|
||||
|
||||
it("pays exactly the stored tutorial share on a tutorial run", () => {
|
||||
const tutorialRecord = createDispatchRecord(
|
||||
{
|
||||
id: "t1",
|
||||
ship: { ...ship, id: "loaner" },
|
||||
pilot: { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null },
|
||||
contract: { ...contract, payout: 1000 },
|
||||
tutorial: true,
|
||||
now,
|
||||
worldSeed: 7,
|
||||
},
|
||||
content,
|
||||
);
|
||||
const resolution = resolve({ ...arrived, record: tutorialRecord, pilotIsAvatar: true });
|
||||
expect(resolution.credits).toBe(1000);
|
||||
expect(resolution.shipLocationId).toBe("moon");
|
||||
});
|
||||
|
||||
it("marks an undiscovered destination as discovered", () => {
|
||||
expect(resolve(arrived).discoversDestination).toBe(false);
|
||||
expect(resolve({ ...arrived, discoveredIds: new Set(["earth"]) }).discoversDestination).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("gives no credits or XP for a reposition", () => {
|
||||
const reposition = createRepositionRecord(
|
||||
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
|
||||
content,
|
||||
);
|
||||
expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({
|
||||
credits: 0,
|
||||
pilotXp: 0,
|
||||
shipLocationId: "moon",
|
||||
discoversDestination: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns identical results when resolving the same dispatch twice", () => {
|
||||
expect(resolveDispatch(arrived, content)).toEqual(resolveDispatch(arrived, content));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createRng, deriveSeed } from "../src/index";
|
||||
|
||||
const sequence = (seed: number, length = 1000) => {
|
||||
const rng = createRng(seed);
|
||||
return Array.from({ length }, () => rng.next());
|
||||
};
|
||||
|
||||
describe("seeded RNG", () => {
|
||||
it("produces the same 1,000 values for the same seed", () => {
|
||||
expect(sequence(42)).toEqual(sequence(42));
|
||||
});
|
||||
|
||||
it("produces different sequences for different seeds", () => {
|
||||
expect(sequence(42)).not.toEqual(sequence(43));
|
||||
});
|
||||
|
||||
it("keeps next() in [0, 1)", () => {
|
||||
for (const value of sequence(7, 10_000)) {
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps int() within inclusive bounds and reaches both ends", () => {
|
||||
const rng = createRng(1);
|
||||
const seen = new Set<number>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const value = rng.int(1, 5);
|
||||
expect(value).toBeGreaterThanOrEqual(1);
|
||||
expect(value).toBeLessThanOrEqual(5);
|
||||
seen.add(value);
|
||||
}
|
||||
expect([...seen].sort()).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("picks only list elements and throws on an empty list", () => {
|
||||
const rng = createRng(3);
|
||||
const list = ["a", "b", "c"];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(list).toContain(rng.pick(list));
|
||||
}
|
||||
expect(() => rng.pick([])).toThrow();
|
||||
});
|
||||
|
||||
it("derives different child seeds for different labels", () => {
|
||||
expect(deriveSeed(42, "board")).not.toBe(deriveSeed(42, "names"));
|
||||
expect(deriveSeed(42, "board")).not.toBe(deriveSeed(43, "board"));
|
||||
expect(deriveSeed(42, "board")).toBe(deriveSeed(42, "board"));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("sim", () => {
|
||||
it("runs tests", () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeDistance, travelTimeGameHours } from "../src/index";
|
||||
|
||||
const courier = content.shipModel("courier")!;
|
||||
const freighter = content.shipModel("freighter")!;
|
||||
|
||||
describe("travel time", () => {
|
||||
it("matches a hand calculation", () => {
|
||||
// 150M km at 2M km/h = 75 h cruise, plus 4 h overhead.
|
||||
expect(travelTimeGameHours(freighter, 150)).toBe(79);
|
||||
});
|
||||
|
||||
it("gets a faster ship there sooner on the same route", () => {
|
||||
for (const distance of [0.384, 50, 400]) {
|
||||
expect(travelTimeGameHours(courier, distance)).toBeLessThan(
|
||||
travelTimeGameHours(freighter, distance),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("is mostly overhead for a Courier on Earth→Moon", () => {
|
||||
const t = Date.parse("2100-01-01T00:00:00Z");
|
||||
const distance = routeDistance(content.location("earth")!, content.location("moon")!, t);
|
||||
const total = travelTimeGameHours(courier, distance);
|
||||
expect(courier.overheadGameHours).toBeGreaterThan(total / 2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardPeriodIndex,
|
||||
generateTutorialBoard,
|
||||
loanerModel,
|
||||
type TutorialBoardInput,
|
||||
} from "../src/index";
|
||||
import { buildContract } from "../src/board";
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deliveryXp, pilotLevel, skillPointsAvailable } from "../src/index";
|
||||
|
||||
const config = content.config;
|
||||
|
||||
describe("delivery XP", () => {
|
||||
it("grants more XP for a longer delivery", () => {
|
||||
expect(deliveryXp(150, 0, config)).toBeGreaterThan(deliveryXp(0.4, 0, config));
|
||||
});
|
||||
|
||||
it("grants more XP for a higher cargo tier", () => {
|
||||
expect(deliveryXp(50, 3, config)).toBeGreaterThan(deliveryXp(50, 0, config));
|
||||
});
|
||||
|
||||
it("matches a hand calculation", () => {
|
||||
// base 10 + 0.2 × 100 + 5 × 2.
|
||||
expect(deliveryXp(100, 2, config)).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pilot level", () => {
|
||||
it("starts at level 0", () => {
|
||||
expect(pilotLevel(0, config)).toBe(0);
|
||||
});
|
||||
|
||||
it("goes up by exactly one when crossing each threshold", () => {
|
||||
config.pilotLevelXpThresholds.forEach((threshold, i) => {
|
||||
expect(pilotLevel(threshold - 1, config)).toBe(i);
|
||||
expect(pilotLevel(threshold, config)).toBe(i + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill points", () => {
|
||||
it("gives points per level minus points spent", () => {
|
||||
expect(skillPointsAvailable(3, 2, config)).toBe(3 * config.skillPointsPerLevel - 2);
|
||||
});
|
||||
|
||||
it("never goes negative", () => {
|
||||
expect(skillPointsAvailable(0, 5, config)).toBe(0);
|
||||
expect(skillPointsAvailable(1, 100, config)).toBe(0);
|
||||
});
|
||||
});
|
||||
122
pnpm-lock.yaml
122
pnpm-lock.yaml
|
|
@ -14,6 +14,9 @@ importers:
|
|||
'@types/node':
|
||||
specifier: ^24
|
||||
version: 24.19.1
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3(vitest@5.0.3)
|
||||
eslint:
|
||||
specifier: ^10.12.0
|
||||
version: 10.12.0
|
||||
|
|
@ -34,7 +37,7 @@ importers:
|
|||
version: 8.71.0(eslint@10.12.0)(typescript@6.0.3)
|
||||
vitest:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1))
|
||||
version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1))
|
||||
|
||||
apps/server: {}
|
||||
|
||||
|
|
@ -48,10 +51,38 @@ importers:
|
|||
|
||||
packages/schema: {}
|
||||
|
||||
packages/sim: {}
|
||||
packages/sim:
|
||||
dependencies:
|
||||
'@deadhead/content':
|
||||
specifier: workspace:*
|
||||
version: link:../content
|
||||
astronomy-engine:
|
||||
specifier: ^2.1.19
|
||||
version: 2.1.19
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@cacheable/memory@2.2.0':
|
||||
resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==}
|
||||
|
||||
|
|
@ -315,6 +346,23 @@ packages:
|
|||
resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitest/coverage-v8@5.0.3':
|
||||
resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 5.0.3
|
||||
vitest: 5.0.3
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/istanbul-lib-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@vitest/istanbul-lib-report@1.0.2':
|
||||
resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@vitest/mocker@5.0.3':
|
||||
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
|
||||
peerDependencies:
|
||||
|
|
@ -346,6 +394,12 @@ packages:
|
|||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@1.0.7:
|
||||
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
|
||||
|
||||
astronomy-engine@2.1.19:
|
||||
resolution: {integrity: sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w==}
|
||||
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
|
@ -520,6 +574,9 @@ packages:
|
|||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
|
|
@ -614,6 +671,9 @@ packages:
|
|||
magic-string@1.4.2:
|
||||
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
|
||||
|
||||
magicast@0.5.5:
|
||||
resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==}
|
||||
|
||||
minimatch@10.2.6:
|
||||
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
|
@ -718,6 +778,10 @@ packages:
|
|||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.2.0:
|
||||
resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
ts-api-utils@2.5.0:
|
||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
|
@ -853,6 +917,21 @@ packages:
|
|||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/parser@7.29.9':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/types@7.29.8':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@cacheable/memory@2.2.0':
|
||||
dependencies:
|
||||
'@cacheable/utils': 2.5.0
|
||||
|
|
@ -1089,6 +1168,24 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.71.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitest/coverage-v8@5.0.3(vitest@5.0.3)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/istanbul-lib-coverage': 1.0.2
|
||||
'@vitest/istanbul-lib-report': 1.0.2
|
||||
ast-v8-to-istanbul: 1.0.7
|
||||
magicast: 0.5.5
|
||||
obug: 2.2.1
|
||||
std-env: 4.3.0
|
||||
tinyrainbow: 3.2.0
|
||||
vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1))
|
||||
|
||||
'@vitest/istanbul-lib-coverage@1.0.2': {}
|
||||
|
||||
'@vitest/istanbul-lib-report@1.0.2':
|
||||
dependencies:
|
||||
'@vitest/istanbul-lib-coverage': 1.0.2
|
||||
|
||||
'@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.1))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
|
@ -1115,6 +1212,14 @@ snapshots:
|
|||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@1.0.7:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
astronomy-engine@2.1.19: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
|
|
@ -1281,6 +1386,8 @@ snapshots:
|
|||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
|
@ -1351,6 +1458,12 @@ snapshots:
|
|||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
magicast@0.5.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.9
|
||||
'@babel/types': 7.29.8
|
||||
source-map-js: 1.2.2
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.12
|
||||
|
|
@ -1446,6 +1559,8 @@ snapshots:
|
|||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
tinyrainbow@3.2.0: {}
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
|
@ -1484,7 +1599,7 @@ snapshots:
|
|||
'@types/node': 24.19.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)):
|
||||
vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.1)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.1))
|
||||
|
|
@ -1502,6 +1617,7 @@ snapshots:
|
|||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/node': 24.19.1
|
||||
'@vitest/coverage-v8': 5.0.3(vitest@5.0.3)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
|
|
|
|||
|
|
@ -5,5 +5,12 @@ import { defineConfig } from "vitest/config";
|
|||
export default defineConfig({
|
||||
test: {
|
||||
projects: ["apps/*", "packages/*"],
|
||||
// `pnpm coverage` measures the simulation core (plan Step 3.18).
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["packages/sim/src/**/*.ts"],
|
||||
reporter: ["text", "html"],
|
||||
thresholds: { lines: 90 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue