Compare commits

..

No commits in common. "f7cb312ea19175f38d5c59cf9b6362c996b9ed1c" and "cab0a1cb8133c85e29114f97d6f6f2eda62df117" have entirely different histories.

52 changed files with 110 additions and 1427 deletions

View File

@ -1122,80 +1122,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Amendment "12.x"** is recorded on Step 12.3 and applies to all the end-to-end steps (12.3–12.6).
- **Step 11.2's offline rule is noted for Phase 11**, where the offline behaviour is built; server-backed settings already fail with the usual error when offline.
- **The two-device manual check was scripted** with separate sessions; a real second phone or a private window is the user's check.
## CR-012 — Lighter and Clipper, Per-Leg Range, Gravity-Well Range (Phase 10L)
- **Renumbered on arrival:** titled "CR-009 … Phase 10I"; CR-009 to CR-011 and Phases 10I–10K already exist, so it is **CR-012**, done as **Phase 10L** (its 10I.n are 10L.n).
- **Decisions:** the CR's 0.5.59–0.5.63 are **0.5.77–0.5.81**; its amended "0.5.49" and "0.5.50" are this plan's **0.5.67** and **0.5.68** (ship upgrades CR). Its "CR-006" is **CR-009**; "CR-008" is **CR-011**. Error code `would-strand`.
## Step 10L.1
- **New ship values** follow the CR's §2 descriptions: Lighter 42,400 cr (40 % of the way from Courier to Freighter), 1.6M km/h, 80 t (80 % of a Freighter), 2 h overhead, 0.012 h/t handling (40 % faster loading), 350 cr/M km; Clipper 61,600 cr (60 %), 6.5M km/h, 30 t (1.5× Courier), 2 h overhead, 600 cr/M km. Ranges (Lighter 10, Clipper 198) come from the measured leg distances; the reach table is enforced in 10L.4.
- **"Clipper is the fastest model" includes the loaner** (6.0M km/h), so the Clipper is 6.5M km/h rather than just the fastest for sale.
- **Handling time becomes per model where set** (`ShipModel.handlingGameHoursPerTonne`, optional, defaulting to the game-wide value); upgrades scale the model's own base.
- **Roles are content** (`role`), one line per model including the loaner, for the Market (10L.7).
- **The new sprites are listed in the manifest as pending** (`file: null`) so the "every ship has a sprite in the manifest" test holds; they use the placeholder until art arrives (10L.10).
## Step 10L.2
- **The factor applies to the whole leg from the origin** (decision 0.5.78): `checkRoute` is the single range check, so dispatch, reposition, the board's eligibility and the map pick it up together. The destination's factor plays no part in that leg.
- **The dispatch sheet quotes range from the origin**, rounded, e.g. "reaches 130M km from Moon", so the number matches what the check used.
## Step 10L.3
- **The check runs after range and landing, before Priority, skills, expiry and credits** (the CR says after range and landing). The arrival time is the trip's own travel time (cruise, overhead, handling for the contract's mass; empty for a deadhead).
- **"Other discovered location" includes the origin**, so a ship can always be allowed to fly back where it came from if that is still in range.
- **`discoveredIds` is optional on a contract proposal**; absent means every location counts. Tutorial runs and older callers keep working, and the client's eligibility (which doesn't pass it yet) is lenient until 10L.9; the server's check is the one that counts.
- **The rule only looks at arrival plus margin**, as the CR says. A ship may land somewhere it can't leave at once but can within the margin: with today's ranges a Clipper flying Moon→Mars can land on a date when it can't leave Mars right away but can 48 hours later. The exhaustive test checks the margin time, not the arrival.
- **Tests use a fixture** (a Moon with factor 0.5 and a "moon-station" sharing its orbit) because no real ship and date strands cleanly enough to also show "passes once another location is discovered".
## Step 10L.4
- **Ranges come from the measured leg distances over 16 game years** (monthly): Earth–Mars 58.9–399.6, Moon–Mars 58.5–400.0, Earth–Ceres 262.7–565.8, Moon–Ceres 262.8–565.9, Mars–Ceres 175.5–662.8, Ceres–Jupiter 378.9–1,216.8, Earth–Jupiter 592.8–964.8, Moon–Jupiter 592.4–965.1, Mars–Jupiter 526.9–1,029.8 M km.
- **Freighter 650 → 450.** It must cover Earth→Mars at its farthest (≥ 400), but never Moon→Jupiter (450 × 1.3 = 585 < 592.4) or Mars→Jupiter (450 × 1.15 = 517.5 < 526.9); from Ceres (585) it reaches Jupiter on some dates. 450 also clears Earth→Ceres on most dates.
- **Bulk Hauler 1000 → 1250**, so every leg is always in range, including Jupiter→Ceres at its farthest (1,216.8) with a factor of 1.
- **Clipper stays at 198**: Earth→Mars is in range on just over 30 % of dates, and Moon→Ceres (198 × 1.3 = 257.4) stays below Ceres's closest approach (262.8). The margins are small, so the Clipper's range shouldn't move without re-running this table.
- **Decision 0.5.25 is superseded** (noted in the plan): the Freighter no longer reaches Jupiter from Earth at all.
- **No other stats changed.** The existing upgrade-cap tests (0.5.67) and the Courier-to-Mars test (0.5.68) still pass at these values; 10L.5 adds the new caps.
## Step 10L.5
- **Courier tanks 600 → 450 % per level.** Over 16 years Mars comes as close as 58.9M km from Earth, so the old level 2 (65M km) would have reached it, breaking decision 0.5.68. At 450 % level 2 is 50M km (never) and level 3 is 72.5M km, which reaches Mars only in the closest approaches (months 150 and 176–177 of the 16 years sampled). A maxed Courier now reaches Mars rarely, which fits "near closest approach only".
- **The Freighter's 15 % tanks override stays**: 450 → 652.5M km maxed, short of the Bulk Hauler (1,250) and of Jupiter from Earth at its farthest (965). Its comment now quotes the new numbers.
- **No caps for the new models**: with the default percentages a maxed Clipper holds 43.5 t (Freighter 100), a maxed Courier flies 3.9M km/h (Clipper 6.5M), and a maxed Lighter's range is 16M km (20.8 from the Moon), far below Mars's closest 58.5.
- **The Lighter cap is a sim test** because it needs distances; the plain stat inequalities stay in the content test.
## Step 10L.6
- **No schema change was needed** beyond the sim's failure lists: `ERROR_CODES` spreads `DISPATCH_FAILURES` and `REPOSITION_FAILURES`, so `would-strand` arrived with 10L.3 (with its server message and the contract-dispatch discovered ids).
- **The server's stranding test uses a real date** (1,420 game days after the test clock's start) on which a base Clipper can hop Moon→Mars but can't leave Mars within the margin, and asserts that with the sim first so a future range change fails loudly rather than silently.
## Step 10L.7
- **A third part, "Other dates", was added** to the CR's "From Earth now … · By hopping …": without it a Clipper on a far-Mars date would show Mars nowhere, though it can fly there directly later. It lists places an HQ reaches directly on some date but not today.
- **"By hopping" means reachable only through other locations**, over legs each in range on some date in the 16-year horizon; places an HQ reaches directly never appear there. Hops are not checked for timing (that is the player's job), and undiscovered places are included because the Market describes the ship, not the player's map.
- **The horizon starts at the game epoch** and is computed once per model, then cached; only the "now" part depends on the current date. The line is computed at render from the server clock, not ticked, since range changes over game days.
- **Origins are the player's HQ locations** (one part each); a player with no bays sees no Reach line.
## Step 10L.8
- **Origins are discovered locations with an HQ or an idle ship**, in content order. In-flight ships don't count (they aren't anywhere yet). A player with neither, as in the tutorial, is measured from Earth, as before.
- **Upgraded ships are listed under the origin they are at**, only when their base model can't make the leg from there but their effective stats can ("your Swift Heron (upgraded)"). Before, they were checked from Earth wherever they were.
- **Origins with nobody are left out**; if none can reach, the panel says "No ship can reach it from your bases right now."
## Step 10L.9
- **The client now passes discovered ids** to `validateDispatch`, so the sheet agrees with the server (10L.3 left it lenient).
- **The stranding line replaces the range line, never adds to it**: it is shown only when the leg itself is in range, matching the sim's check order.
- **`wouldStrand` accepts a bare `ShipModel`** (travel time already falls back to the game-wide handling rate), so the sheet's explainer can use it with the stats it already has.
## Step 10L.10
- **The art arrived with the CR**, so both ships use real sprites rather than the placeholder. Both face nose-up on transparent backgrounds, like the existing ships.
- **The transparency check tolerates alpha ≤ 2/255** rather than editing the user's file: `ship-lighter.png` has a stray alpha-1 pixel in one corner, which is invisible. The script never writes sources (decision 0.5.37).
## Step 10L.11
- **The Lighter-first / Clipper-first balance check waits for the balance script** (plan Step 12.1, not built yet); the amendment is recorded on Step 12.1.
- **The manual check found a hop needs Long Haul**: every Mars→Ceres leg is over 100M km, so it needs Long Haul 1. A fresh veteran (Heavy Cargo 1, 3 unspent points) qualifies once a point goes into Long Haul. This is existing skill gating, not a range problem, but it makes the Clipper's hop to Ceres a mid-game move.
- **Clipper fuel is expensive by design** (600 cr/M km): a ~190M km hop costs about 110,000 credits, in line with a Freighter's long runs. The check topped up credits with the developer tool. Payouts for those legs are tuned elsewhere and were not changed here.

View File

@ -229,17 +229,11 @@ export async function dispatchContract(
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const discovered = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const proposal = {
ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)),
pilot: pilotState(pilot),
contract,
// The no-strand rule counts only discovered locations (decision 0.5.79).
discoveredIds: new Set(discovered.map((d) => d.locationId)),
tutorial: false,
credits: company.credits,
now,

View File

@ -11,8 +11,6 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
"over-capacity": "The cargo is too heavy for that ship.",
"cannot-land": "That ship cannot land at the destination.",
"out-of-range": "The destination is out of that ship's range right now.",
"would-strand":
"That ship couldn't fly anywhere else from the destination, so it would be stranded there.",
"too-slow": "That ship can't make this Priority delivery's window.",
"cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.",
"long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.",

View File

@ -1,80 +0,0 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { checkRoute, effectiveStats, gameToReal, realToGame, wouldStrand } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { discoveredLocations, pilots, ships } from "../src/db/schema";
import { setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, signUpOwner, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("Lighter and Clipper (Step 10L.6)", () => {
for (const modelId of ["lighter", "clipper"]) {
it(`buys a ${modelId} at the chosen HQ`, async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const price = content.shipModel(modelId)!.price;
await setCredits(database().db, owner.playerId, price);
const response = await call(app, owner, "POST", "/api/ships", {
modelId,
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({ modelId, status: "idle", locationId: "earth" });
expect((await getState(app, owner)).company.credits).toBe(0);
});
}
it("refuses a deadhead that would strand the ship, changing nothing", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const db = database().db;
await setCredits(db, owner.playerId, 100_000);
// A Clipper at the Moon, 1,420 game days in: Mars is in range from the Moon
// (198 × 1.3), but from Mars (198 × 1.15) nothing is when it gets there.
const departure = realToGame(TEST_NOW, content.config) + 1420 * 24 * 3_600_000;
clock.now = gameToReal(departure, content.config);
await db
.update(ships)
.set({ modelId: "clipper", locationId: "moon" })
.where(eq(ships.id, owner.shipId));
await db.update(pilots).set({ locationId: "moon" }).where(eq(pilots.id, owner.avatarId));
await db
.insert(discoveredLocations)
.values({ playerId: owner.playerId, locationId: "mars", discoveredAt: TEST_NOW });
// The scenario as the sim sees it.
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
expect(checkRoute(clipper, moon, mars, realToGame(clock.now, content.config)).ok).toBe(true);
expect(
wouldStrand(
clipper,
moon,
mars,
0,
realToGame(clock.now, content.config),
undefined,
content,
),
).toBe(true);
const before = await getState(app, owner);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "reposition",
shipId: owner.shipId,
pilotId: owner.avatarId,
destinationId: "mars",
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("would-strand");
const after = await getState(app, owner);
expect(after.company.credits).toBe(before.company.credits);
expect(after.ships).toEqual(before.ships);
expect(after.activeDispatches).toEqual([]);
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -1,14 +1,12 @@
import { content, type Location, type ShipModel } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import {
checkRoute,
effectiveStats,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
type EffectiveStats,
type RealTime,
type ShipState,
} from "@deadhead/sim";
@ -68,74 +66,3 @@ export function simShip(ship: Ship): ShipState {
inShopUntil: ship.inShopUntil,
};
}
/** Monthly samples over 16 game years from the epoch: the reach horizon (CR-012). */
const REACH_SAMPLES = (() => {
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const month = 30.44 * 24 * MS_PER_HOUR;
return Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * month);
})();
const legKey = (fromId: string, toId: string) => `${fromId}>${toId}`;
const everCache = new Map<string, Set<string>>();
/** Legs a model flies on at least one sampled date. */
function everInRange(model: ShipModel): Set<string> {
let legs = everCache.get(model.id);
if (!legs) {
legs = new Set();
for (const from of content.locations) {
for (const to of content.locations) {
if (from.id !== to.id && REACH_SAMPLES.some((t) => checkRoute(model, from, to, t).ok)) {
legs.add(legKey(from.id, to.id));
}
}
}
everCache.set(model.id, legs);
}
return legs;
}
export interface ShipReach {
/** Per origin, the locations in range right now. */
now: { originId: string; destinationIds: string[] }[];
/** Reachable directly from an origin, but not right now. */
otherDates: string[];
/** Reachable only through other locations. */
byHopping: string[];
}
/**
* Where a base model can fly from `originIds` (Market, Step 10L.7): legs in range
* at `now`, legs in range on other dates, and places it can reach only by
* hopping (each leg in range on some date over 16 game years).
*/
export function shipReach(model: ShipModel, originIds: string[], now: RealTime): ShipReach {
const t = realToGame(now, content.config);
const ever = everInRange(model);
const ids = content.locations.map((l) => l.id);
const origins = new Set(originIds);
const nowLegs = originIds.map((originId) => ({
originId,
destinationIds: ids.filter(
(to) => to !== originId && checkRoute(model, location(originId), location(to), t).ok,
),
}));
const inRangeNow = new Set(nowLegs.flatMap((l) => l.destinationIds));
const direct = new Set(ids.filter((to) => originIds.some((o) => ever.has(legKey(o, to)))));
const reached = new Set(originIds);
const queue = [...originIds];
while (queue.length > 0) {
const from = queue.shift()!;
for (const to of ids) {
if (!reached.has(to) && ever.has(legKey(from, to))) {
reached.add(to);
queue.push(to);
}
}
}
return {
now: nowLegs,
otherDates: ids.filter((id) => direct.has(id) && !inRangeNow.has(id) && !origins.has(id)),
byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)),
};
}

View File

@ -1,61 +1,17 @@
{
"sourceDir": "public/assets/map",
"assets": {
"bg": {
"kind": "background",
"file": "bg.png"
},
"sun": {
"kind": "body",
"file": "body-sun.png"
},
"body-earth": {
"kind": "body",
"file": "body-earth.png"
},
"body-moon": {
"kind": "body",
"file": "body-moon.png"
},
"body-mars": {
"kind": "body",
"file": "body-mars.png"
},
"body-ceres": {
"kind": "body",
"file": "body-ceres.png"
},
"body-jupiter": {
"kind": "body",
"file": "body-jupiter.png"
},
"badge-cloud-platform": {
"kind": "badge",
"file": null
},
"ship-courier": {
"kind": "ship",
"file": "ship-courier.png"
},
"ship-freighter": {
"kind": "ship",
"file": "ship-freighter.png"
},
"ship-bulk-hauler": {
"kind": "ship",
"file": "ship-bulk-hauler.png"
},
"ship-company-courier": {
"kind": "ship",
"file": "ship-company-courier.png"
},
"ship-lighter": {
"kind": "ship",
"file": "ship-lighter.png"
},
"ship-clipper": {
"kind": "ship",
"file": "ship-clipper.png"
}
"bg": { "kind": "background", "file": "bg.png" },
"sun": { "kind": "body", "file": "body-sun.png" },
"body-earth": { "kind": "body", "file": "body-earth.png" },
"body-moon": { "kind": "body", "file": "body-moon.png" },
"body-mars": { "kind": "body", "file": "body-mars.png" },
"body-ceres": { "kind": "body", "file": "body-ceres.png" },
"body-jupiter": { "kind": "body", "file": "body-jupiter.png" },
"badge-cloud-platform": { "kind": "badge", "file": null },
"ship-courier": { "kind": "ship", "file": "ship-courier.png" },
"ship-freighter": { "kind": "ship", "file": "ship-freighter.png" },
"ship-bulk-hauler": { "kind": "ship", "file": "ship-bulk-hauler.png" },
"ship-company-courier": { "kind": "ship", "file": "ship-company-courier.png" }
}
}

View File

@ -1,57 +1,40 @@
import { content } from "@deadhead/content";
import type { PlayerState, Ship } from "@deadhead/schema";
import { content, type ShipModel } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import { checkRoute, effectiveStats, realToGame } from "@deadhead/sim";
export interface OriginReach {
originId: string;
/** Purchasable models in range from there, then the player's upgraded ships there. */
reachers: string[];
/**
* Purchasable ship models that can fly from Earth to `locationId` at real time
* `now` (range and landing, via the sim's route check) (Step 10A.16).
*/
export function modelsReachingFromEarth(locationId: string, now: number): ShipModel[] {
const earth = content.location("earth");
const target = content.location(locationId);
if (!earth || !target) return [];
const gameTime = realToGame(now, content.config);
return content.purchasableShipModels.filter(
(model) => checkRoute(model, earth, target, gameTime).ok,
);
}
/**
* Where the player could fly from: discovered locations with one of their HQs
* or idle ships (CR-012). Earth when there are none yet (the tutorial).
* The player's own ships that reach `locationId` from Earth only thanks to
* their upgrades (effective stats, CR-009): the base model can't, they can.
*/
export function playerOrigins(state: Pick<PlayerState, "bays" | "ships" | "locations">): string[] {
const used = new Set([
...state.bays.map((b) => b.locationId),
...state.ships.flatMap((s) => (s.locationId ? [s.locationId] : [])),
]);
const origins = state.locations.filter((l) => l.discovered && used.has(l.id)).map((l) => l.id);
return origins.length > 0 ? origins : ["earth"];
}
/**
* Who can fly to `locationId` right now, per origin (Steps 10A.16, 10L.8):
* purchasable models whose range from that origin covers the leg (the sim's
* route check, with the origin's range factor), and the player's ships at that
* origin that get there only thanks to their upgrades (CR-009). Origins with
* nobody are left out.
*/
export function reachersByOrigin(
state: Pick<PlayerState, "bays" | "ships" | "locations">,
export function upgradedShipsReachingFromEarth(
ships: readonly Ship[],
locationId: string,
now: number,
): OriginReach[] {
): Ship[] {
const earth = content.location("earth");
const target = content.location(locationId);
if (!target) return [];
if (!earth || !target) return [];
const gameTime = realToGame(now, content.config);
return playerOrigins(state)
.filter((id) => id !== locationId)
.map((originId) => {
const origin = content.location(originId)!;
const reaches = (stats: Parameters<typeof checkRoute>[0]) =>
checkRoute(stats, origin, target, gameTime).ok;
const models = content.purchasableShipModels.filter(reaches).map((m) => m.name);
const upgraded = state.ships
.filter((ship: Ship) => {
const model = content.shipModel(ship.modelId);
if (ship.locationId !== originId || !model?.purchasable) return false;
if (Object.keys(ship.upgrades).length === 0 || reaches(model)) return false;
return reaches(effectiveStats(model, ship.upgrades, content));
})
.map((ship) => `your ${ship.name} (upgraded)`);
return { originId, reachers: [...models, ...upgraded] };
})
.filter((o) => o.reachers.length > 0);
return ships.filter((ship) => {
const model = content.shipModel(ship.modelId);
if (!model?.purchasable || Object.keys(ship.upgrades).length === 0) return false;
return (
!checkRoute(model, earth, target, gameTime).ok &&
checkRoute(effectiveStats(model, ship.upgrades, content), earth, target, gameTime).ok
);
});
}

View File

@ -4,7 +4,6 @@ import {
checkRoute,
contractTripGameHours,
isInShop,
rangeFrom,
pilotShare,
dutyAfterTrip,
LONG_HAUL_SKILL_ID,
@ -13,7 +12,6 @@ import {
tripFuelCost,
tripGameHours,
validateDispatch,
wouldStrand,
type DispatchFailure,
} from "@deadhead/sim";
import { useState } from "react";
@ -53,7 +51,6 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
"over-capacity": "Too heavy",
"cannot-land": "Can't land there",
"out-of-range": "Out of range",
"would-strand": "Would be stranded",
"too-slow": "Too slow for the Priority window",
"cargo-skill-too-low": "Cargo skill too low",
"long-haul-too-low": "Long Haul too low",
@ -61,17 +58,6 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
"insufficient-credits": "Not enough credits for fuel",
};
/** The no-strand rule in words (CR-012), for the dispatch sheet and deadhead picker. */
export function strandedText(destinationId: string): string {
const name = locationName(destinationId);
return `Would be stranded — can't fly out of ${name} from there`;
}
/** Locations the player has discovered, for the no-strand rule. */
export function discoveredIdsOf(state: PlayerState): Set<string> {
return new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
}
/**
* Every reason a ship and pilot cannot take a contract, in plain words with the
* numbers involved, e.g. "Too heavy: 228 t (holds 20 t)". Display only: the
@ -106,21 +92,9 @@ export function explainProblems(
if (!route.ok) {
problems.push(
route.reason === "out-of-range"
? `Out of range right now (${model.name} reaches ${Math.round(rangeFrom(model, location(contract.originId)))}M km from ${locationName(contract.originId)})`
? `Out of range right now (${model.name} reaches ${model.fuelRangeMillionKm}M km)`
: INELIGIBLE_REASONS["cannot-land"],
);
} else if (
wouldStrand(
model,
location(contract.originId),
location(contract.destinationId),
contract.massTonnes,
gameNow,
discoveredIdsOf(state),
content,
)
) {
problems.push(strandedText(contract.destinationId));
}
// Priority (CR-007): the trip must fit the delivery window.
const window = contract.priority ? contract.deliveryWindowGameHours : null;
@ -228,7 +202,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
tutorial: false,
credits: state.company.credits,
now,
discoveredIds: discoveredIdsOf(state),
},
content,
);

View File

@ -12,7 +12,6 @@ import { Link } from "react-router";
import { useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown";
import { ShipUpgrades } from "./ShipUpgrades";
import { strandedText } from "./DispatchSheet";
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
@ -188,7 +187,6 @@ function ShipRow(props: {
const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
"cannot-land": "Can't land there",
"out-of-range": "Out of range",
"would-strand": "Would be stranded",
"insufficient-credits": "Not enough credits for fuel",
"ship-in-shop": "Ship is in the shop",
"pilot-resting": "Pilot is resting",
@ -235,11 +233,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
);
return {
id,
problem: check.ok
? null
: check.reason === "would-strand"
? strandedText(id)
: (DEADHEAD_REASONS[check.reason] ?? check.reason),
problem: check.ok ? null : (DEADHEAD_REASONS[check.reason] ?? check.reason),
fuelCost: tripFuelCost(model, location(origin), location(id), gameNow),
tripMs: tripRealMs(model, origin, id, 0, now),
};

View File

@ -10,7 +10,7 @@ import { distanceAt } from "../game";
import { resetMapView } from "../map/camera";
import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { reachersByOrigin } from "../map/reach";
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { Art, buttonClass, Icon } from "../ui";
@ -96,8 +96,13 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
const location = content.location(locationId)!;
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
const hasBay = state.bays.some((b) => b.locationId === locationId);
// Per origin the player has a base at (CR-012), e.g. "From Mars: Clipper · From Earth: Freighter".
const reach = discovered ? [] : reachersByOrigin(state, locationId, now);
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
// Owned ships whose upgrades get them there when their model can't (CR-009).
const upgraded = discovered ? [] : upgradedShipsReachingFromEarth(state.ships, locationId, now);
const reachers = [
...reachable.map((m) => m.name),
...upgraded.map((s) => `your ${s.name} (upgraded)`),
];
return (
<Panel
@ -140,11 +145,9 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
Undiscovered — deliver here to unlock.
</p>
<p aria-label="Reachable with" className="text-ink-200">
{reach.length > 0
? reach
.map((o) => `From ${locationName(o.originId)}: ${o.reachers.join(", ")}`)
.join(" · ")
: "No ship can reach it from your bases right now."}
{reachers.length > 0
? `Reachable with: ${reachers.join(", ")}`
: "No ship can reach it from Earth right now."}
</p>
</>
)}

View File

@ -1,8 +1,7 @@
import { content, type ShipModel } from "@deadhead/content";
import { useState } from "react";
import { formatCredits, formatMillionKm, locationName } from "../format";
import { serverNow } from "../clock/clock";
import { shipReach, shipStatsOf } from "../game";
import { shipStatsOf } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
@ -14,7 +13,7 @@ const best = {
capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)),
range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)),
};
/** Range spans 5 to 1,250M km, so its bar uses a log scale to stay readable. */
/** Range spans 5 to 1,000M km, so its bar uses a log scale to stay readable. */
const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range);
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
@ -65,7 +64,6 @@ export function Market() {
{formatCredits(model.price)}
</span>
</div>
<p className="text-ink-300 italic">{model.role}</p>
<dl className="mt-3 space-y-2 text-ink-200">
<Spec
icon="speed"
@ -87,7 +85,6 @@ export function Market() {
/>
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
</dl>
<Reach model={model} />
<OwnedUpgraded modelId={model.id} />
<Button
aria-label={`Buy ${model.name}`}
@ -139,33 +136,6 @@ function Spec(props: { icon: IconName; label: string; value: string; share?: num
);
}
const names = (ids: string[]) => ids.map(locationName).join(", ");
/**
* Where this model can fly from the player's HQs (CR-012), e.g.
* "From Earth now: Moon, Mars · By hopping: Ceres". Computed with the sim.
*/
function Reach({ model }: { model: ShipModel }) {
const state = useLoadedPlayer();
const origins = [...new Set(state.bays.map((b) => b.locationId))];
const reach = shipReach(model, origins, serverNow());
const parts = [
...reach.now.map(
(leg) =>
`From ${locationName(leg.originId)} now: ${leg.destinationIds.length > 0 ? names(leg.destinationIds) : "nowhere"}`,
),
...(reach.otherDates.length > 0 ? [`Other dates: ${names(reach.otherDates)}`] : []),
...(reach.byHopping.length > 0 ? [`By hopping: ${names(reach.byHopping)}`] : []),
];
if (parts.length === 0) return null;
return (
<p aria-label="Reach" className="mt-3 text-xs text-ink-300">
<span className="font-display tracking-[0.14em] text-ink-400 uppercase">Reach </span>
{parts.join(" · ")}
</p>
);
}
/** "Deliver to which HQ?": choose the bay the new ship appears at (decision 0.5.35). */
function DeliveryPicker(props: {
model: ShipModel;

View File

@ -7,9 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
/** Mars at its closest in 16 game years (~59M km, month 176; decision 0.5.68). */
/** Mars near closest approach in the first two game years (~82M km, decision 0.5.68). */
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const NOW = gameToReal(gameStart + 176 * 30.44 * 24 * 3_600_000, content.config);
const NOW = gameToReal(gameStart + 23 * 30.44 * 24 * 3_600_000, content.config);
const courier = (upgrades: Record<string, number>): Ship => ({
id: "s1",
@ -99,7 +99,7 @@ describe("effective stats in the client (Step 10I.7)", () => {
content.location("mars")!,
realToGame(NOW, content.config),
);
expect(distance).toBeLessThan(72.5);
expect(distance).toBeLessThan(95);
const open = async () => {
const card = (await screen.findByText("Dusty Mule")).closest("li")!;

View File

@ -94,8 +94,7 @@ describe("map art optimization script (CR-003)", () => {
alpha(info.width - 1, info.height - 1),
];
expect(
// Alpha up to 2/255 is invisible (ship-lighter.png has a stray alpha-1 corner pixel).
corners.every((a) => a! <= 2),
corners.every((a) => a === 0),
`${asset.file} (${id}): background isn't transparent`,
).toBe(true);
}

View File

@ -47,7 +47,7 @@ describe("whole map from the start", () => {
expect(within(canvas).getByRole("button", { name: "Tap Moon" })).toBeInTheDocument();
});
it("lists the purchasable ships that can reach Mars from Earth now (no bases yet), per the sim's range check", async () => {
it("lists the purchasable ships that can reach Mars from Earth now, per the sim's range check", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars (locked)" }));
const panel = screen.getByRole("dialog", { name: "Mars" });
@ -61,9 +61,8 @@ describe("whole map from the start", () => {
.map((m) => m.name);
expect(expected.length).toBeGreaterThan(0);
expect(expected).not.toContain("Courier");
// A tutorial player has no bases yet, so reach is measured from Earth (CR-012).
expect(screen.getByLabelText("Reachable with")).toHaveTextContent(
`From Earth: ${expected.join(", ")}`,
`Reachable with: ${expected.join(", ")}`,
);
});

View File

@ -1,66 +0,0 @@
import { content } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import { checkRoute, gameToReal, realToGame } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const MONTH = 30.44 * 24 * 3_600_000;
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const clipper = content.shipModel("clipper")!;
const [mars, ceres] = [content.location("mars")!, content.location("ceres-station")!];
/** The first monthly date on which a Clipper at Mars can reach Ceres. */
const NOW = (() => {
for (let i = 0; i <= 16 * 12; i++) {
const t = gameStart + i * MONTH;
if (checkRoute(clipper, mars, ceres, t).ok) return gameToReal(t, content.config);
}
throw new Error("no such date");
})();
const atMars: Ship = {
id: "s1",
modelId: "clipper",
name: "Swift Heron",
status: "idle",
locationId: "mars",
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
...shipExtras("clipper"),
};
describe("map reachability per origin (Step 10L.8)", () => {
afterEach(() => vi.useRealTimers());
it("lists the Clipper from Mars, where it is, and not from Earth", async () => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
const state = samplePlayerState({
bays: [{ id: "b-earth", locationId: "earth" }],
ships: [atMars],
});
state.locations = state.locations.map((l) =>
l.id === "mars" ? { ...l, discovered: true } : l,
);
renderApp(fakeApi({ getPlayerState: () => state }), "/map");
const canvas = await screen.findByTestId("map-canvas");
await userEvent.click(
within(canvas).getByRole("button", { name: "Tap Ceres Station (locked)" }),
);
const text = screen.getByLabelText("Reachable with").textContent ?? "";
const parts = Object.fromEntries(
text.split(" · ").map((part) => {
const [origin, list] = part.split(": ");
return [origin!, list!.split(", ")];
}),
);
expect(parts["From Mars"]).toContain("Clipper");
expect(parts["From Earth"] ?? []).not.toContain("Clipper");
});
});

View File

@ -1,64 +0,0 @@
import { content } from "@deadhead/content";
import { checkRoute, gameToReal, realToGame } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const MONTH = 30.44 * 24 * 3_600_000;
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const clipper = content.shipModel("clipper")!;
const [earth, mars] = [content.location("earth")!, content.location("mars")!];
/** The first monthly date on which Earth→Mars is (or isn't) in a Clipper's range. */
function dateWhenMars(inRange: boolean): number {
for (let i = 0; i <= 16 * 12; i++) {
const t = gameStart + i * MONTH;
if (checkRoute(clipper, earth, mars, t).ok === inRange) return gameToReal(t, content.config);
}
throw new Error("no such date");
}
async function reachOf(modelName: string, now: number) {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
useClock.setState({ offsetMs: 0, synced: true, now });
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState({ bays: [{ id: "b-earth", locationId: "earth" }] }),
}),
"/market",
);
const card = (await screen.findByRole("button", { name: `Buy ${modelName}` })).closest("li")!;
return within(card).getByLabelText("Reach").textContent ?? "";
}
describe("market roles and reach (Step 10L.7)", () => {
afterEach(() => vi.useRealTimers());
it("shows each model's role", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/market");
for (const model of content.purchasableShipModels) {
expect(await screen.findByText(model.role)).toBeInTheDocument();
}
});
it("lists Mars from Earth for a Clipper only when Mars is in range, and Ceres by hopping", async () => {
const near = await reachOf("Clipper", dateWhenMars(true));
expect(near).toContain("From Earth now: Moon, Mars");
expect(near).toContain("By hopping: Ceres Station");
});
it("puts Mars under other dates for a Clipper when it is out of range", async () => {
const far = await reachOf("Clipper", dateWhenMars(false));
expect(far).toContain("From Earth now: Moon");
expect(far).not.toContain("From Earth now: Moon, Mars");
expect(far).toContain("Other dates: Mars");
expect(far).toContain("By hopping: Ceres Station");
});
it("lists only the Moon for a Lighter", async () => {
const reach = await reachOf("Lighter", dateWhenMars(true));
expect(reach).toBe("Reach From Earth now: Moon");
});
});

View File

@ -1,99 +0,0 @@
import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema";
import { checkRoute, gameToReal, realToGame, wouldStrand } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { pairOptions } from "../src/screens/DispatchSheet";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const DAY = 24 * 3_600_000;
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const clipper = content.shipModel("clipper")!;
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
const discovered = new Set(["earth", "moon", "mars"]);
/** A date on which a Clipper can hop Moon→Mars but couldn't leave Mars (CR-012). */
const NOW = (() => {
for (let d = 0; d < 16 * 365; d += 5) {
const t = gameStart + d * DAY;
if (
checkRoute(clipper, moon, mars, t).ok &&
wouldStrand(clipper, moon, mars, 10, t, discovered, content)
) {
return gameToReal(t, content.config);
}
}
throw new Error("no stranding date");
})();
const STRANDED = "Would be stranded — can't fly out of Mars from there";
/** A crewed Clipper at the Moon, with Mars discovered. */
function state(): PlayerState {
const base = samplePlayerState({ tutorial: null });
return {
...base,
company: { ...base.company, credits: 100_000 },
bays: [{ id: "b1", locationId: "moon" }],
locations: base.locations.map((l) => ({ ...l, discovered: discovered.has(l.id) })),
ships: [
{
id: "s1",
modelId: "clipper",
name: "Swift Heron",
status: "idle",
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("clipper"),
pilotId: "avatar",
},
],
pilots: [{ ...base.pilots[0]!, assignedShipId: "s1", locationId: "moon" }],
};
}
const toMars: Contract = {
id: "c1",
originId: "moon",
destinationId: "mars",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 50_000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
firstContact: false,
};
describe("stranding reason (Step 10L.9)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("marks a would-strand ship and pilot pair with the reason", () => {
const [pair] = pairOptions(state(), toMars, NOW);
expect(pair!.problems).toEqual([STRANDED]);
});
it("disables a would-strand deadhead destination with the reason", async () => {
renderApp(fakeApi({ getPlayerState: state }), "/fleet");
const card = (await screen.findByText("Swift Heron")).closest("li")!;
await userEvent.click(within(card).getByRole("button", { name: /Deadhead/ }));
const dialog = screen.getByRole("dialog", { name: "Deadhead Swift Heron" });
const option = within(dialog).getByRole("button", { name: "Mars" });
expect(option).toBeDisabled();
expect(option).toHaveTextContent(STRANDED);
expect(within(dialog).getByRole("button", { name: "Earth" })).toBeEnabled();
});
});

View File

@ -38,7 +38,7 @@ Living reference for how the code is organised. Update it after any database sch
### Errors
Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window; CR-012 adds `would-strand`: from the destination, at arrival plus `noStrandMarginGameHours`, the ship couldn't reach any other discovered location), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...).
Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...).
## API routes (`packages/schema/src/routes.ts`)

View File

@ -165,7 +165,6 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
- Undiscovered locations are drawn dimmed with a lock. Tapping one shows "Undiscovered — deliver here to unlock" and which ship models can currently reach it from Earth (using the sim range check over purchasable models), for example "Reachable with: Freighter, Bulk Hauler".
- **Test:** Component test: undiscovered locations render as locked; the reachability list for Mars matches the sim range check for the mocked time; discovered locations render normally.
- **Amended by CR-003:** the lock rendering is replaced by Step 10C.7 (desaturated, dimmed sprite with a code-drawn padlock); the tap panel is unchanged.
- **Amended by CR-012:** The reachability text is per origin: "From Mars: Clipper, Freighter · From Earth: Freighter", one part per discovered location with an HQ or idle ship (Earth when there are none) (Step 10L.8).
### Step 10A.17 — Client: something waiting on return
- If goals became ready while the player was away, list them in the "while you were away" modal with a link to claim.

View File

@ -63,7 +63,6 @@ Record these in `DECISIONS.md`.
- If a file name is ambiguous or an expected asset is missing, record it in `DECISIONS.md` and continue: missing assets use the fallback (0.5.33). If more than one file could match the same id, stop and ask the user.
- Add a `sprite` key to each location and ship model in `packages/content` (for example Earth → `body-earth`, Freighter → `ship-freighter`). Add the optional `badge` key to Jupiter Cloud Platform.
- **Test:** A test asserts every location and ship model in content has a sprite key, and every sprite key exists in the manifest. A second test asserts every file named in the manifest exists on disk.
- **Amended by CR-012:** The manifest gains `ship-lighter` and `ship-clipper` (Step 10L.10; the user's art is in place).
### Step 10C.2 — Asset optimization script
- Add a script (using `sharp`) that reads the manifest and writes to a generated folder:

View File

@ -66,7 +66,6 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- Add the five upgrades from §2, and config for cost percent and shop time per level.
- Set the Courier's `fuel-tanks` values so decision 0.5.50 holds (adjust the Courier's per-level bonus or cap, not the Courier's base range, and don't break any Step 3.7 range test).
- **Test:** All upgrades validate. Every upgrade affects a stat that exists on ShipModel. Tests for decision 0.5.49 (all three inequalities). Tests for decision 0.5.50: sampling Earth–Mars distance monthly over two game years, a maxed Courier is in range on at least one sample and out of range on at least one sample; a Courier with fuel tanks at level 2 is never in range.
- **Amended by CR-012:** Caps follow the amended 0.5.49/0.5.50 (this plan's 0.5.67/0.5.68): three more distinctness inequalities, Courier-to-Mars measured from Earth over 16 game years; the Courier's tanks are 450 % per level (Step 10L.5).
### Step 10F.2 — Sim: effective stats
- Add a pure function that returns a ship's effective stats from its model and installed upgrade levels.

View File

@ -1,158 +0,0 @@
# Change Request CR-012 — Lighter and Clipper, Per-Leg Range, Gravity-Well Range
> **Numbering note (added when implemented):** this request arrived titled "CR-009 — Lighter and Clipper, Per-Leg Range, Gravity-Well Range" for "Phase 10I", applying to "CR-001 through CR-008". CR-009 to CR-011 and Phases 10I–10K already existed, so it is stored as **CR-012** and done as **Phase 10L**: every "10I.n" below means **10L.n**. Its decisions 0.5.59–0.5.63 are recorded as **0.5.77 Per-leg range, 0.5.78 Gravity-well range factor, 0.5.79 No-strand rule, 0.5.80 New ships, 0.5.81 Reach targets**. It amends "0.5.49 Models stay distinct" and "0.5.50 Courier to Mars", which in this repo are **0.5.67** and **0.5.68** (from the ship upgrades CR). Its "CR-006 (ship upgrades)" is this repo's **CR-009**, and its "CR-008" (settings) is **CR-011**. The error code `would_strand` is `would-strand`.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10 and CR-001 through CR-008 complete
**Do this:** as **Phase 10I**, after Phase 10H and before Phase 11
**Related docs:** `implementation-plan.md`, `change-request-open-markets.md` (CR-002), `change-request-map-art.md` (CR-003), `change-request-ship-upgrades.md` (CR-006), `game-design-document.md`, `DECISIONS.md`
---
## 1. Why
Three connected changes:
1. **Two new ships between the Courier and the Freighter.** The jump from Courier to Freighter is too big. Instead of two more rungs on a ladder, the new ships are a fork: the **Lighter** works harder close to home (heavy Earth–Moon loads), and the **Clipper** goes farther, faster (the cheapest way to Mars, built for Priority and small high-value cargo).
2. **Range is per leg.** The sim already checks range from wherever the ship departs, but the design targets and several tests say "from Earth". With open markets (CR-002), ships hop: Mars and Ceres are real stepping stones (Mars→Ceres at a good alignment is shorter than Earth→Ceres at its best; Ceres→Jupiter is roughly half of Earth→Jupiter). Targets and tests must describe reach per leg and by hopping.
3. **Gravity-well range factor.** Launching from a low-gravity body stretches a ship's range, so a Moon or Ceres HQ becomes a real staging base for long runs.
A **no-strand rule** is added so hopping can never leave a ship stuck somewhere it can't fly out of.
## 2. The ships
| | Courier | **Lighter** (new) | **Clipper** (new) | Freighter | Bulk Hauler |
|---|---|---|---|---|---|
| Role | Starter | Short-haul heavy lifter | Fast long-legged runner | All-rounder | Long-haul bulk |
| Speed | Fast | Slow (faster than Bulk Hauler) | **Fastest in the game** | Medium | Slowest |
| Capacity | Small | Near Freighter (75–90% of it) | About 1.5× Courier | Large | Largest |
| Takeoff/landing overhead | Low | Low | Low | Normal | High |
| Handling time per tonne | Normal | **Fast** (big cargo doors) | Normal | Normal | Normal |
| Fuel cost per million km | Low | Medium | **High** | Medium | High |
| Landing class | Atmospheric | Atmospheric | Atmospheric | Atmospheric | Atmospheric |
| Price | Cheapest | About 40% of the way from Courier to Freighter | About 60% of the way | — | Highest |
Ids: `lighter`, `clipper`. Sprite ids: `ship-lighter`, `ship-clipper`.
## 3. Range rules
### Gravity-well range factor (per location, in content)
| Location | Range factor |
|---|---|
| Earth | 1.0 |
| Mars | 1.15 |
| Moon | 1.3 |
| Ceres Station | 1.3 |
| Jupiter Cloud Platform | 1.0 (platforms float high above the deep gravity well) |
**Effective range from an origin** = ship's effective range (model plus upgrades, CR-006) × origin's range factor. A leg is in range if its distance at departure is no more than the effective range from that origin.
### Reach targets (base ships, no upgrades)
"Always" = in range on every sampled date. "Some" = in range on at least one sampled date and out of range on at least one. "Never" = out of range on every sampled date. Distances come from the sim; approximate values for reference: Earth–Mars 55–400M km, Mars–Ceres 165–660M km, Earth–Ceres 240–595M km, Ceres–Jupiter 350–1,230M km, Earth–Jupiter 590–970M km, Mars–Jupiter 490–1,040M km.
| Ship | Leg targets | Furthest reach by hopping |
|---|---|---|
| Courier | Earth↔Moon **always**. Earth→Mars and Moon→Mars **never**. | Moon |
| Lighter | Earth↔Moon **always**. Earth→Mars and Moon→Mars **never**. | Moon |
| Clipper | Earth→Mars **some** (in range on at least 30% of dates). Moon→Mars **some**, on more dates than Earth→Mars. Mars→Ceres **some**. Earth→Ceres and Moon→Ceres **never**. Any leg to Jupiter **never**. | Ceres, via Mars |
| Freighter | Earth→Mars **always**. Earth→Ceres **some** (most dates). Ceres→Jupiter **some**. Earth→Jupiter, Moon→Jupiter, and Mars→Jupiter **never**. | Jupiter, via Ceres |
| Bulk Hauler | Earth→Jupiter **always**. Every other leg between the five locations **always**. | Anywhere |
Upgrades (CR-006) may push a ship past a "never" target; that is an intended upgrade payoff. The distinctness caps in §4 still apply.
### No-strand rule
A dispatch (contract or deadhead) is rejected with `would_strand` if, at the arrival time plus a configured margin (default 48 game hours), the ship could not reach **any** other discovered location from the destination with its effective range from that destination. This prevents hopping into a place the ship can't leave.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- **0.5.59 Per-leg range.** Ship range is checked per leg, from the departure location, at departure time. Design targets and tests must name their origin explicitly.
- **0.5.60 Gravity-well range factor.** Each location has a launch range factor (§3). Effective range from an origin = ship effective range × origin factor. This is separate from the existing launch fuel-cost multiplier.
- **0.5.61 No-strand rule.** As §3. New validation reason `would_strand`.
- **0.5.62 New ships.** Lighter and Clipper per §2, purchasable, all atmospheric.
- **0.5.63 Reach targets.** The §3 table is the source of truth for ship range values. Ranges are chosen so every cell holds; adjust ship ranges (not location factors) to satisfy it.
- **0.5.49 (amended) Models stay distinct.** In addition to the existing three caps: a maxed Clipper's capacity is below a base Freighter's; a maxed Courier's speed is below a base Clipper's; a maxed Lighter can't reach Mars from Earth or the Moon on any sampled date. The existing "only Bulk Haulers reach Jupiter at its farthest" cap now reads "from Earth".
- **0.5.50 (amended) Courier to Mars.** The maxed-tanks Courier target is measured **from Earth**. From the Moon it will reach Mars more often; that's fine.
## 5. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
- **Ship stat values are chosen to satisfy tests, not by hand.** Where a test fails, change ship values (range first, then the others) and record the change in `DECISIONS.md`.
- Date-sampling tests sample monthly over **16 game years** (long enough for Ceres–Jupiter alignments to cycle).
- Until the user provides art for the new ships, they use the CR-003 fallback placeholder.
- Update `architecture.md` for schema or API changes and `progress.md` after every step.
- Stop for the user's review at the end of Phase 10I.
## 6. Steps — Phase 10I
### Step 10I.1 — Content: range factors and new ships
- Add a launch range factor to the Location schema and set the §3 values.
- Add the Lighter and Clipper ship models with sprite keys and a one-line role description for each ship model (all five), shown in the Market.
- Add the no-strand margin to GameConfig.
- **Test:** Content validates. Every location has a range factor greater than zero. Both new ships are purchasable and atmospheric. Price ordering: Courier < Lighter < Clipper < Freighter < Bulk Hauler. Speed: Clipper is the fastest model; Bulk Hauler is the slowest; Lighter is slower than Freighter. Capacity: Courier < Clipper < Lighter < Freighter < Bulk Hauler. Lighter handling time per tonne is the lowest of all models. Clipper fuel cost per million km is higher than Courier's and Freighter's. These replace the Step 2.3 ordering tests.
### Step 10I.2 — Sim: effective range from origin
- The range check uses effective range from the origin (decision 0.5.60) everywhere: contract dispatch, deadhead, dispatch-sheet eligibility, and reachability lists.
- **Test:** A ship with base range 100 has effective range 130 from the Moon and 115 from Mars. A leg that fails from Earth passes from the Moon when its distance is between 100 and 130. With all factors set to 1.0 in a test fixture, results match the pre-change range check exactly.
### Step 10I.3 — Sim: no-strand rule
- Add the `would_strand` check (decision 0.5.61) to dispatch validation, after range and landing checks.
- **Test:** A fixture ship whose effective range from the destination can't reach any other discovered location at arrival plus margin is rejected with `would_strand`. The same trip passes once another reachable location is discovered. No base ship on any sampled date is ever stranded by a trip it is allowed to take between the five locations (exhaustive over sampled dates and origin/destination pairs).
### Step 10I.4 — Content tuning: reach table
- Choose ship ranges (and adjust other stats only if needed) so every cell of the §3 reach table holds.
- Rewrite Step 3.7's range tests to name their origin explicitly.
- **Test:** One test per cell of the §3 table using monthly samples over 16 game years, plus the hop tests: a Clipper can't reach Ceres from Earth on any date but can from Mars on some dates; a Freighter can reach Jupiter from Ceres on some dates but never directly from Earth.
### Step 10I.5 — Upgrade caps for the new lineup
- Apply decisions 0.5.49 (amended) and 0.5.50 (amended); set per-model upgrade caps for the Lighter and Clipper as needed.
- **Test:** All distinctness inequalities in 0.5.49 hold, including the three new ones. The maxed-tanks Courier target from Earth still holds; a level-2-tanks Courier never reaches Mars from Earth.
### Step 10I.6 — Schema and server
- Add the `would_strand` error code. The Market and buy-ship endpoint accept the new models. No other server changes.
- **Test:** Buying a Lighter and a Clipper succeeds and places them at the chosen HQ. A dispatch that would strand returns `would_strand` and changes nothing.
### Step 10I.7 — Client: Market shows roles and reach
- Each ship in the Market shows its role line and a "Reach" line computed with the sim: legs it can fly from the player's HQs right now, and its furthest reach by hopping from §3 (for example "From Earth now: Moon, Mars · By hopping: Ceres").
- **Test:** Component test: the Clipper's reach line lists Mars from Earth only on a mocked date when Mars is in range, and always lists Ceres under "By hopping"; the Lighter's lists only the Moon.
### Step 10I.8 — Client: reachability from where ships actually are
- Replace "reachable from Earth" text (map panels, CR-001 Step 10A.16) with per-origin text: for an undiscovered location, list each discovered location that has a player ship or HQ, and which ship models can reach it from there right now, for example "From Mars: Clipper, Freighter · From Earth: Freighter".
- **Test:** Component test with a mocked date and a ship at Mars: Ceres's panel lists "From Mars: Clipper" when Mars→Ceres is in range for a Clipper, and does not list Clipper under Earth.
### Step 10I.9 — Client: stranding reason
- In the dispatch sheet and the deadhead picker, a would-strand option is disabled with "Would be stranded — can't fly out of [destination] from there".
- **Test:** Component test: a would-strand pair or destination is disabled with that text.
### Step 10I.10 — Art
- Add `ship-lighter` and `ship-clipper` to the CR-003 manifest. If the user's files are present, run the asset script; otherwise the fallback placeholder is used and the missing assets are noted in `progress.md`.
- **Test:** The CR-003 manifest tests pass (every ship model has a sprite key, every manifest file exists or is listed as pending in `DECISIONS.md`).
### Step 10I.11 — Plan amendments and regression
- Apply the amendments in §7 to `implementation-plan.md` and earlier change requests.
- Run the full suite.
- **Test:** CI is green. The balance script (once it exists) runs two policies, Lighter-first and Clipper-first for the second ship; both reach "own three ships" within 25% of each other's time. Manual check: buy a Clipper, discover Mars, deadhead or take work to Mars, then take a first-contact contract to Ceres from the Mars board on a date when it's in range.
## 7. Amendments
| Where | Change |
|---|---|
| Plan 0.2 | Ships: 5 models (Courier, Lighter, Clipper, Freighter, Bulk Hauler). |
| Plan 0.5 | Add 0.5.59–0.5.63; amend 0.5.49 and 0.5.50. |
| Plan 2.1 | Location gains launch range factor; ShipModel gains a role line; GameConfig gains the no-strand margin. |
| Plan 2.3 | Ordering tests replaced by 10I.1. |
| Plan 3.7 | Range check uses effective range from origin; tests name origins (10I.2, 10I.4). |
| Plan 3.12 | Validation gains `would_strand` (10I.3). |
| Plan 9.8 | Market shows role and reach (10I.7). |
| Plan 12.1 | Two second-ship policies, Lighter-first and Clipper-first (10I.11). |
| CR-001 10A.16 | Reachability text is per origin (10I.8). |
| CR-003 | Manifest gains `ship-lighter` and `ship-clipper`. |
| CR-006 | Caps amended (10I.5). |
## 8. Out of scope
Refueling stops or fuel stations as separate mechanics, multi-leg routes booked as one dispatch, automatic route planning across hops, orbital transfer windows beyond what real distances already produce, and anything else in `implementation-plan.md` §0.3.

View File

@ -27,7 +27,7 @@ This plan covers the **base game only**: the core loop of taking contracts, disp
|---|---|
| Locations | Earth, Moon, Mars, Ceres Station (belt), Jupiter Cloud Platform |
| Landing classes | Vacuum, Atmospheric (Hardened is deferred) |
| Ships | 5 models: Courier, Lighter, Clipper, Freighter, Bulk Hauler *(CR-012 added the Lighter and Clipper; it was 3 archetypes)* |
| Ships | 3 archetypes: Courier, Freighter, Bulk Hauler |
| Cargo types | Standard, Heavy, High-Value, Dangerous |
| Pilot skills | Long Haul, Heavy Cargo, Expensive Cargo, Dangerous Cargo |
| Phases | Tutorial (contract pilot) → Owner-Operator → small fleet |
@ -80,7 +80,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
22. **Pilot endpoints.** The route catalog includes "unassign pilot", and "hire pilot" takes the bay location to hire at.
23. **Avatar during tutorial.** The avatar is locked to the loaner and cannot be assigned to an owned ship until the tutorial ends.
24. **Repositions need no skills.** A reposition checks range and landing only; no Long Haul or cargo skill is required.
25. **Freighter range** covers Ceres at its farthest from Earth (≈600M km), so it can also reach Jupiter at its closest. *(Superseded by CR-012, decision 0.5.81: a Freighter reaches Ceres from Earth on most dates, not all, and never reaches Jupiter from Earth; it gets there by hopping via Ceres.)* A ship left out of range of every bay waits until distances shrink; this is intended.
25. **Freighter range** covers Ceres at its farthest from Earth (≈600M km), so it can also reach Jupiter at its closest. A ship left out of range of every bay waits until distances shrink; this is intended.
26. **Loaner in player state.** Player state has a `tutorial` object with the loaner's location and whether a run is in flight. The loaner is not in the ships list.
27. **Pilot location storage.** Only unassigned pilots store a location; an assigned pilot's location is derived from their ship.
28. **Git hosting.** The repo is on a self-hosted Gitea instance: `http://192.168.1.107:3000/rory/deadhead-space-freight-tycoon`. CI uses Gitea Actions.
@ -122,8 +122,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
64. **Where and when** (CR-009). An upgrade can be started only on an owned ship that is idle (not in flight, not already in the shop) at a location with a player bay, with tutorial mode off. The Company Courier loaner can't be upgraded.
65. **One level at a time** (CR-009). Each purchase raises one upgrade by exactly one level. A ship can be in the shop for one upgrade at a time.
66. **Shop time** (CR-009). Starting an upgrade deducts the cost, raises the level immediately, and sets the ship's in-shop-until time. The ship can't be dispatched until then (`ship-in-shop`). Because dispatch is blocked, there is nothing to complete later: no background job is needed. The assigned pilot stays assigned and can rest meanwhile.
67. **Models stay distinct** (CR-009). Content caps guarantee: a maxed Courier's capacity is below a base Freighter's; a maxed Bulk Hauler's speed is below a base Freighter's; a maxed Freighter's range is below a base Bulk Hauler's (so only Bulk Haulers reach Jupiter at its farthest). *(Amended by CR-012:)* also a maxed Clipper's capacity is below a base Freighter's; a maxed Courier's speed is below a base Clipper's; a maxed Lighter can't reach Mars from Earth or the Moon on any sampled date; "only Bulk Haulers reach Jupiter at its farthest" is measured from Earth.
68. **Courier to Mars** (CR-009). A Courier with maxed fuel tanks can reach Mars at and around closest approach, but not at Mars's farthest distance. Base and lower-level Couriers still can't reach Mars at all. *(Amended by CR-012:)* measured from Earth; from the Moon it reaches Mars more often, which is fine.
67. **Models stay distinct** (CR-009). Content caps guarantee: a maxed Courier's capacity is below a base Freighter's; a maxed Bulk Hauler's speed is below a base Freighter's; a maxed Freighter's range is below a base Bulk Hauler's (so only Bulk Haulers reach Jupiter at its farthest).
68. **Courier to Mars** (CR-009). A Courier with maxed fuel tanks can reach Mars at and around closest approach, but not at Mars's farthest distance. Base and lower-level Couriers still can't reach Mars at all.
69. **Deduction order** (CR-010). On resolution: pilot pay is taken from the payout first; loan repayment (0.5.32) is calculated on what remains.
70. **Veteran pilot** (CR-010). Accepting the deal also creates the veteran (CR-010 §2): at Earth, unassigned, level 2 with Heavy Cargo 1. The veteran is a normal hired pilot in every respect except how they were acquired.
71. **No accounts yet** (CR-011). Players remain guests. Account linking (email, Apple, Google) stays deferred (§0.3).
@ -132,11 +132,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
74. **New company** (CR-011). Starting a new company deletes all of the player's company data and recreates the starting state exactly as guest creation does (CR-001 tutorial state, new world seed). The player record and backup code stay the same.
75. **Company name** (CR-011). Players can rename their company: 3–32 characters, letters, digits, spaces, and basic punctuation, trimmed. The default name is generated from the world seed.
76. **Display preferences** (CR-011). Clock defaults to the device's locale format. Countdowns default to "real time". Reduced motion defaults to the device's reduced-motion setting and can be overridden.
77. **Per-leg range** (CR-012). Ship range is checked per leg, from the departure location, at departure time. Design targets and tests name their origin explicitly.
78. **Gravity-well range factor** (CR-012). Each location has a launch range factor (Earth 1.0, Mars 1.15, Moon 1.3, Ceres Station 1.3, Jupiter Cloud Platform 1.0). Effective range from an origin = ship effective range × origin factor. Separate from the launch fuel-cost multiplier.
79. **No-strand rule** (CR-012). A dispatch (contract or deadhead) is rejected with `would-strand` if, at arrival plus a configured margin (48 game hours), the ship could not reach any other discovered location from the destination with its effective range from there.
80. **New ships** (CR-012). Lighter (short-haul heavy lifter) and Clipper (fast long-legged runner), purchasable, atmospheric.
81. **Reach targets** (CR-012). CR-012 §3's reach table is the source of truth for ship range values; ship ranges (not location factors) are adjusted so every cell holds.
---
@ -183,7 +178,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Amended by CR-004:** GameConfig gains `dutyLimitGameHours`, `longHaulDutyBonusGameHours` and `restGameHours` (Step 10D.1).
- **Amended by CR-009:** Content gains the Upgrade schema (`upgradeSchema`, `SHIP_STATS`, per-model overrides), the five upgrades, and `upgradeLevels` cost/shop-time config (Step 10I.1).
- **Amended by CR-010:** GameConfig's flat `pilotPayPerDelivery` is replaced by `pilotProfitShare` (0.25); content gains the veteran pilot (Step 10J.1).
- **Amended by CR-012:** Location gains `launchRangeFactor`; ShipModel gains a `role` line and an optional own `handlingGameHoursPerTonne`; GameConfig gains `noStrandMarginGameHours` (Step 10L.1).
### Step 2.2 — Add the five locations
- Add data for Earth, Moon, Mars, Ceres Station, and Jupiter Cloud Platform following the destinations table in the GDD.
@ -198,7 +192,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Pick numbers so that, at default config: a Courier can reach the Moon but not Mars, Ceres or Jupiter, even at their closest; a Freighter can reach Mars and Ceres; only the Bulk Hauler can reach Jupiter at its farthest distance.
- All three ships are atmospheric (decision 0.5.1).
- **Test:** A test validates all ships through the schema and asserts the ordering rules: Courier speed > Freighter speed > Bulk Hauler speed, Bulk Hauler capacity > Freighter > Courier, price Courier < Freighter < Bulk Hauler. (Range rules are tested in Phase 3 once distances exist.)
- **Amended by CR-012:** The Lighter and Clipper join the lineup; these ordering tests are replaced by the CR's orderings (Step 10L.1), and the ranges are set by the reach table (Step 10L.4: Freighter 450, Bulk Hauler 1,250).
### Step 2.4 — Add cargo types and skills
- Cargo types: Standard (no skill), Heavy (Heavy Cargo skill), High-Value (Expensive Cargo skill), Dangerous (Dangerous Cargo skill). Non-standard types pay more per tonne-distance.
@ -251,7 +244,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Add a function that decides whether a ship can fly a route at a given time, returning either "allowed" or a specific reason: out of range, or cannot land at destination (landing class).
- **Test:** A Courier can fly Earth→Moon and Moon→Earth. A Courier is out of range for Mars at its closest. A test-only vacuum ship fixture is rejected at Earth with the cannot-land reason. At Jupiter's farthest point from Earth, only the Bulk Hauler is in range. A Freighter can reach Ceres. (These confirm the Step 2.3 number choices; adjust content if they fail.)
- **Amended by CR-009:** Range checks use effective stats (Step 10I.2).
- **Amended by CR-012:** The range check uses the effective range from the origin (range × the origin's `launchRangeFactor`, `rangeFrom`), and the tests name their origin; a Freighter no longer reaches Jupiter from Earth or Ceres from Earth at its farthest (Steps 10L.2, 10L.4).
### Step 3.8 — Contract model and payout
- Define a Contract shape: id, origin, destination, cargo type, tier, mass, payout, required skill and level, required Long Haul level, offered-at time, expires-at time (end of its board period), first-contact flag.
@ -285,7 +277,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Amended by CR-004:** Validation (contract and deadhead) gains `pilot-resting`, checked before the other pilot checks (Step 10D.3).
- **Amended by CR-007:** Validation gains `too-slow`: a Priority contract's trip must fit its delivery window (Step 10G.4).
- **Amended by CR-009:** Validation uses effective stats (capacity, range, Priority window) and gains `ship-in-shop` (Steps 10I.2, 10I.3).
- **Amended by CR-012:** Validation (contract and deadhead) gains `would-strand` after range and landing: at arrival plus the no-strand margin, the ship must be able to reach another discovered location from the destination (Step 10L.3, decision 0.5.79).
### Step 3.13 — Dispatch record creation
- Add a function that turns a validated dispatch into a Dispatch record: id, ship, pilot, contract (or none for reposition), origin, destination, departure real time, arrival real time (computed from travel time and clock conversion), fuel cost, expected payout, seed.
@ -531,7 +522,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Test:** Component test: buy is disabled when credits are below price; buying calls the endpoint and the new ship appears in Fleet.
- **Amended by CR-002:** Buy opens a "Deliver to which HQ?" bay picker, preselected with one bay (Step 10B.9).
- **Amended by CR-009:** The Market lists the player's upgraded ships of each model with effective stats (Step 10I.7).
- **Amended by CR-012:** Each model shows its role line and a Reach line computed with the sim ("From Earth now: … · Other dates: … · By hopping: …") (Step 10L.7).
### Step 9.9 — Locations screen
- List all locations. Discovered ones show type, landing requirement, current distance from Earth, produces/demands, and a "Lease bay" action with price (or "Bay owned"). Undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
@ -630,12 +620,6 @@ Steps 10K.1–10K.12 are specified in `memory-bank/change-requests/CR-011-settin
---
## Phase 10L — Lighter and Clipper, Per-Leg Range, Gravity-Well Range (CR-012)
Steps 10L.1–10L.11 are specified in `memory-bank/change-requests/CR-012-lighter-clipper-range.md` §6 (written there as 10I.1–10I.11), with its §7 amendments. Done after Phase 10K and before Phase 11; stop for review at the end.
---
## Phase 11 — PWA
### Step 11.1 — Installable PWA
@ -663,7 +647,6 @@ Steps 10L.1–10L.11 are specified in `memory-bank/change-requests/CR-012-lighte
- **Amended by CR-007:** The simulated player considers Priority contracts and skips ones its ships are too slow for. Re-check all timing targets; adjust content values, not thresholds.
- **Amended by CR-009:** The simulated player may buy upgrades: when it can't yet afford its next planned ship, it buys the cheapest available upgrade on its most-used ship, preferring cargo bay and engines. Re-check all timing targets; adjust content values, not thresholds.
- **Amended by CR-010:** Replace "first pilot hired between 30 and 45 minutes" with "second ship bought between 30 and 45 minutes". Add a check that the veteran flies at least one contract in the first session. Re-check all targets with profit-share pay; adjust content values, not thresholds.
- **Amended by CR-012:** The script runs two second-ship policies, Lighter-first and Clipper-first; both must reach "own three ships" within 25 % of each other's time.
### Step 12.2 — Revisit the tutorial economy test
- Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script.

View File

@ -264,21 +264,3 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
- [x] **Step 10K.10 — Client: About and debug info.** Vite bakes in `__APP_VERSION__` (web package version, now 0.10.0) and `__APP_BUILD__` (git short hash, "dev" outside git) — `src/build-info.ts`. Settings → About shows "0.10.0 · build abc1234" and "Copy debug info", which copies version, build, player id, server time, clock offset and the last screen before Settings. Player state gains `playerId`. Test: `about.test.tsx`.
- [x] **Step 10K.11 — Client and server: developer tools (non-production only).** Catalog routes `devAdvanceClock` (`POST /api/test/advance-clock`, `{ advanceMs }`) and `devAddCredits` (`POST /api/test/add-credits`, `{ amount }`), registered with the clock-offset route only when NODE_ENV isn't production (`routes/dev.ts`). Settings → Developer (`screens/DevTools.tsx`): Advance 10 minutes, Advance 1 hour, Add 10,000 credits, Finish all flights (advances to just past the latest arrival); afterwards the client re-syncs its clock and reloads state and boards. Tests: `dev-tools.test.ts` (server, including 404s in production) and `dev-tools.test.tsx` (client).
- [x] **Step 10K.12 — Plan amendments and regression.** CR-011 §6 amendments added to plan steps 4.2, 5.3, 8.1, 8.4, 11.2 and 12.3 (for 12.3–12.6); §0.3 and §0.5.71–76 were set with the CR. Typecheck, lint, full suite with coverage and the web build are green. Scripted manual check against a live server (scratchpad `cr011-smoke.mjs`, two cookie jars as two devices): a renamed company with 50,500 cr was backed up on one and restored on the other (same player, name and credits); starting a new company on the second device showed the fresh company on the first after a refresh; the same backup code still opened it. Screenshot: `docs/screenshots/10k12-settings-{phone,desktop}.png`. Also in this phase: the phone header fix from the user's report. **Phase 10K complete — stopped for review.**
## Phase 10L — Lighter and Clipper, Per-Leg Range, Gravity-Well Range (CR-012)
Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as "CR-009 … Phase 10I"). New decisions recorded as plan §0.5.77–0.5.81; §0.5.67 and §0.5.68 amended.
- [x] **Step 10L.1 — Content: range factors and new ships.** `Location.launchRangeFactor` (Earth 1, Moon 1.3, Mars 1.15, Ceres 1.3, Jupiter 1). `ShipModel.role` (all six) and optional `ShipModel.handlingGameHoursPerTonne` (the sim's effective stats now start from it). New models: **Lighter** (42,400 cr, 1.6M km/h, 80 t, range 10, overhead 2 h, handling 0.012 h/t, fuel 350/M km) and **Clipper** (61,600 cr, 6.5M km/h, 30 t, range 198, overhead 2 h, fuel 600/M km); archetypes `lighter`, `clipper`. Config `noStrandMarginGameHours` 48. Manifest lists `ship-lighter` / `ship-clipper` as pending art. Tests: the CR's orderings replace Step 2.3's (`ships.test.ts`), range factors (`locations.test.ts`), Lighter handling (`effective-stats.test.ts`).
- [x] **Step 10L.2 — Sim: effective range from origin.** `rangeFrom(ship, from)` = range × origin's `launchRangeFactor`; `checkRoute` compares the leg against it (dispatch, reposition, board eligibility and the map all go through it). The dispatch sheet quotes range from the origin. Tests (`range-from.test.ts`): 100 → 130 from the Moon and 115 from Mars; a leg between 1× and 1.3× fails from Earth and passes from the Moon; with every factor 1 the result matches the old check over 16 years of samples.
- [x] **Step 10L.3 — Sim: no-strand rule.** `wouldStrand(stats, origin, destination, mass, departure, discoveredIds, content)`: at arrival plus `noStrandMarginGameHours` (48), can the ship reach any other discovered location from the destination with its range from there? `validateDispatch` (new optional `discoveredIds`; absent = all) and `validateReposition` fail with `would-strand` after range and landing. The server passes the player's discovered ids for contract dispatches; server and web reason maps gained the code (full wording in 10L.6/10L.9). Tests (`no-strand.test.ts`): a fixture heavy-Moon hop is refused, then passes once a reachable location is discovered; the same for a contract; across all six models, every ordered pair and monthly samples over 16 years, an allowed trip always leaves somewhere to fly at arrival plus margin.
- [x] **Step 10L.4 — Content tuning: reach table.** Ranges: Courier 5, Lighter 10, Clipper 198 (unchanged from 10L.1), **Freighter 650 → 450**, **Bulk Hauler 1000 → 1250**. Step 3.7's range tests (`eligibility.test.ts`) now name their origin; the Freighter-to-Ceres-at-farthest and Freighter-to-Jupiter-at-closest tests become "reaches Ceres at its closest, not its farthest" and "never reaches Jupiter from Earth". Tests (`reach-table.test.ts`): one per §3 cell over monthly samples for 16 game years, plus the two hop tests.
- [x] **Step 10L.5 — Upgrade caps for the new lineup.** Courier fuel-tanks override 600 → **450 %** per level (5 → 27.5 → 50 → 72.5M km); the Freighter override stays at 15 % (450 → 652.5). No Lighter or Clipper caps were needed. Tests: the new 0.5.67 inequalities (content `upgrades.test.ts`; sim `upgrade-caps.test.ts`: a maxed Lighter never reaches Mars from Earth or the Moon, only a Bulk Hauler reaches Jupiter from Earth at its farthest even with every ship maxed); `courier-mars.test.ts` now samples 16 years from Earth. The web deadhead-to-Mars test moved to month 176, Mars's closest approach.
- [x] **Step 10L.6 — Schema and server.** `would-strand` is in `ERROR_CODES` (it comes from the sim's failure lists) with a server message; buy-ship already accepts any purchasable model. Tests (`apps/server/test/lighter-clipper.test.ts`): buying a Lighter and a Clipper places them idle at the chosen HQ; a Clipper at the Moon deadheading to Mars on a stranding date gets 409 `would-strand` and credits, ships and flights are unchanged.
- [x] **Step 10L.7 — Client: Market shows roles and reach.** Each Market card shows the model's role and a Reach line from `shipReach` (`apps/web/src/game.ts`, sim `checkRoute`): "From Earth now: Moon, Mars · Other dates: … · By hopping: Ceres Station", with one "From X now" part per HQ location. "Other dates" lists places reachable directly from an HQ but not today; "By hopping" lists places reachable only through other locations (each leg in range on some monthly date over 16 game years). Tests (`market-reach.test.tsx`): roles; the Clipper lists Mars from Earth only on a date when it is in range (otherwise under Other dates) and always lists Ceres by hopping; the Lighter lists only the Moon.
- [x] **Step 10L.8 — Client: reachability from where ships actually are.** An undiscovered location's map panel now reads "From Mars: Clipper, Freighter · From Earth: Freighter": one part per discovered location with an HQ or idle ship (`playerOrigins`, Earth when there are none yet), listing purchasable models in range from there now plus the player's ships there that reach it only thanks to upgrades (`reachersByOrigin` in `map/reach.ts`, replacing the Earth-only helpers). Tests: `map-reach-origins.test.tsx` (a Clipper at Mars on a date Mars→Ceres is in range: Ceres lists Clipper from Mars, not from Earth); `map-locked.test.tsx` now expects "From Earth: …" for a tutorial player.
- [x] **Step 10L.9 — Client: stranding reason.** The dispatch sheet passes the player's discovered ids to `validateDispatch` and explains a stranding pair as "Would be stranded — can't fly out of Mars from there" (`strandedText`); the deadhead picker disables such a destination with the same text. `wouldStrand` now takes a bare model or effective stats. Test (`stranding-reason.test.tsx`, a Clipper at the Moon on a date it could hop to Mars but not leave): the pair's only problem is the stranding text; the Mars deadhead is disabled with it while Earth stays enabled.
- [x] **Step 10L.10 — Art.** The user's `ship-lighter.png` and `ship-clipper.png` (in `public/assets/map/`) are now in the manifest; `pnpm --filter @deadhead/web map-art` wrote their @1x/@2x sprites to `map-gen` (the other outputs regenerated byte-identical). The source-transparency test allows corner alpha up to 2/255, because `ship-lighter.png` has one alpha-1 corner pixel. All manifest tests pass.
- [x] **Step 10L.11 — Plan amendments and regression.** CR-012 §7 applied: plan 0.2 (five models), 2.1, 2.3, 3.7, 3.12, 9.8 and 12.1 (Lighter-first and Clipper-first policies, for when the balance script exists); CR-001 10A.16, CR-003 10C.1 and CR-009 10F.1 notes (decisions 0.5.67, 0.5.68 and 0.5.25 were amended in 10L.4–10L.5). Full suite 938 passing, sim lines 99.75 %. Scripted manual check (temporary server): after the deal, buy a Clipper at Earth, crew it with the veteran, wait for a first-contact Earth→Mars job in range (62–95M km), deliver it (Mars discovered), spend the veteran's points on Long Haul, then take a first-contact Mars→Ceres job (186M km, inside 198 × 1.15) and deliver it (Ceres discovered).
**Phase 10L complete — waiting for review.**

View File

@ -24,8 +24,6 @@ export const gameConfig: GameConfig = {
// Courier price + Earth bay lease.
dealLoanAmount: 5000,
contractBoardPeriodRealMinutes: 10,
// A ship must be able to fly out of its destination two game days after arriving (CR-012).
noStrandMarginGameHours: 48,
// Ship upgrades (CR-009): level 1, 2, 3.
upgradeLevels: [
{ costPercentOfPrice: 10, shopRealMinutes: 10 },

View File

@ -17,7 +17,6 @@ export const locations: Location[] = [
body: "earth",
sprite: "body-earth",
launchCostMultiplier: 3,
launchRangeFactor: 1,
startsDiscovered: true,
bayLeasePrice: 1000,
produces: goodsProducedAt("earth"),
@ -38,7 +37,6 @@ export const locations: Location[] = [
body: "moon",
sprite: "body-moon",
launchCostMultiplier: 0.5,
launchRangeFactor: 1.3,
startsDiscovered: true,
bayLeasePrice: 800,
produces: goodsProducedAt("moon"),
@ -57,7 +55,6 @@ export const locations: Location[] = [
body: "mars",
sprite: "body-mars",
launchCostMultiplier: 1.5,
launchRangeFactor: 1.15,
startsDiscovered: false,
bayLeasePrice: 20000,
produces: goodsProducedAt("mars"),
@ -73,7 +70,6 @@ export const locations: Location[] = [
body: "ceres",
sprite: "body-ceres",
launchCostMultiplier: 0.3,
launchRangeFactor: 1.3,
startsDiscovered: false,
bayLeasePrice: 40000,
produces: goodsProducedAt("ceres-station"),
@ -90,7 +86,6 @@ export const locations: Location[] = [
sprite: "body-jupiter",
badge: "badge-cloud-platform",
launchCostMultiplier: 2,
launchRangeFactor: 1,
startsDiscovered: false,
bayLeasePrice: 80000,
produces: goodsProducedAt("jupiter-cloud-platform"),

View File

@ -21,47 +21,6 @@ export const shipModels: ShipModel[] = [
overheadGameHours: 2,
fuelCostPerMillionKm: 200,
purchasable: true,
role: "Fast and cheap: small loads around Earth and the Moon.",
},
{
id: "lighter",
sprite: "ship-lighter",
name: "Lighter",
archetype: "lighter",
// About 40 % of the way from Courier to Freighter.
price: 42_400,
// Slower than the Freighter, faster than the Bulk Hauler.
cruiseSpeedKmPerGameHour: 1_600_000,
cargoCapacityTonnes: 80,
// Earth and the Moon only, even with maxed tanks (CR-012 §3).
fuelRangeMillionKm: 10,
hullRating: 2,
landingClass: "atmospheric",
overheadGameHours: 2,
// Big cargo doors: the fastest loading in the fleet.
handlingGameHoursPerTonne: 0.012,
fuelCostPerMillionKm: 350,
purchasable: true,
role: "Short-haul heavy lifter: big loads between Earth and the Moon, loaded fast.",
},
{
id: "clipper",
sprite: "ship-clipper",
name: "Clipper",
archetype: "clipper",
// About 60 % of the way from Courier to Freighter.
price: 61_600,
// The fastest ship in the game.
cruiseSpeedKmPerGameHour: 6_500_000,
cargoCapacityTonnes: 30,
// Mars near opposition, Ceres by way of Mars (CR-012 §3; tuned in 10L.4).
fuelRangeMillionKm: 198,
hullRating: 1,
landingClass: "atmospheric",
overheadGameHours: 2,
fuelCostPerMillionKm: 600,
purchasable: true,
role: "Fast long-legged runner: the cheapest way to Mars, built for Priority work.",
},
{
id: "freighter",
@ -72,13 +31,12 @@ export const shipModels: ShipModel[] = [
cruiseSpeedKmPerGameHour: 2_000_000,
cargoCapacityTonnes: 100,
// Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25).
fuelRangeMillionKm: 450,
fuelRangeMillionKm: 650,
hullRating: 2,
landingClass: "atmospheric",
overheadGameHours: 4,
fuelCostPerMillionKm: 400,
purchasable: true,
role: "The all-rounder: big loads to Mars and Ceres, Jupiter by way of Ceres.",
},
{
id: "bulk-hauler",
@ -89,13 +47,12 @@ export const shipModels: ShipModel[] = [
cruiseSpeedKmPerGameHour: 1_200_000,
cargoCapacityTonnes: 400,
// Jupiter at its farthest.
fuelRangeMillionKm: 1250,
fuelRangeMillionKm: 1000,
hullRating: 3,
landingClass: "atmospheric",
overheadGameHours: 8,
fuelCostPerMillionKm: 800,
purchasable: true,
role: "Slow and enormous: the only ship that can fly any leg in the system.",
},
{
// The company's tutorial loaner (decision 0.5.29): not for sale. Faster with
@ -113,6 +70,5 @@ export const shipModels: ShipModel[] = [
overheadGameHours: 0.3,
fuelCostPerMillionKm: 200,
purchasable: false,
role: "Halvorsen's loaner for the company runs.",
},
];

View File

@ -27,9 +27,9 @@ export const upgrades: Upgrade[] = [
percentPerLevel: 20,
maxLevel: 3,
modelOverrides: {
// 5 → 27.5 → 50 → 72.5 M km: Mars from Earth (≈ 59–400 M km) only near closest approach, and only at level 3.
courier: { percentPerLevel: 450 },
// 450 → 652.5 M km at level 3: short of a base Bulk Hauler's 1250 and of Jupiter from Earth at its farthest (≈ 965).
// 5 → 35 → 65 → 95 M km: Mars (≈ 55–400 M km) only near closest approach, and only at level 3.
courier: { percentPerLevel: 600 },
// 650 → 942.5 M km at level 3: still short of a base Bulk Hauler's 1000 (and of Jupiter at its farthest).
freighter: { percentPerLevel: 15 },
},
},

View File

@ -38,11 +38,6 @@ export const locationSchema = z.object({
body: astronomyBodySchema,
/** Multiplies fuel cost for launches from this location. */
launchCostMultiplier: z.number().positive(),
/**
* Multiplies a ship's range for legs launched from here (decision 0.5.78):
* low gravity stretches it. Separate from the fuel-cost multiplier.
*/
launchRangeFactor: z.number().positive(),
startsDiscovered: z.boolean(),
/** Credits. */
bayLeasePrice: nonNegativeInt,
@ -64,13 +59,7 @@ export const locationSchema = z.object({
});
export type Location = z.infer<typeof locationSchema>;
export const shipArchetypeSchema = z.enum([
"courier",
"lighter",
"clipper",
"freighter",
"bulk-hauler",
]);
export const shipArchetypeSchema = z.enum(["courier", "freighter", "bulk-hauler"]);
export type ShipArchetype = z.infer<typeof shipArchetypeSchema>;
export const shipModelSchema = z.object({
@ -91,10 +80,6 @@ export const shipModelSchema = z.object({
fuelCostPerMillionKm: z.number().nonnegative(),
/** False for company-only ships such as the tutorial loaner (decision 0.5.29). */
purchasable: z.boolean(),
/** One line on what the ship is for, shown in the Market (CR-012). */
role: z.string().min(1),
/** Loading time per tonne; defaults to the game-wide `handlingGameHoursPerTonne` (CR-012). */
handlingGameHoursPerTonne: z.number().positive().optional(),
/** Map art asset id (CR-003), e.g. `ship-freighter`. */
sprite: idSchema,
});
@ -213,8 +198,6 @@ export const gameConfigSchema = z
/** Loan taken on with the deal, in credits (decision 0.5.30). */
dealLoanAmount: positiveInt,
contractBoardPeriodRealMinutes: z.number().positive(),
/** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */
noStrandMarginGameHours: z.number().nonnegative(),
/**
* Upgrade levels 1, 2, 3, …: cost as a percent of the ship model's price and
* real minutes in the shop (CR-009).

View File

@ -4,8 +4,8 @@ import { content } from "../src/index";
describe("content bundle", () => {
it("contains every kind of content", () => {
expect(content.locations).toHaveLength(5);
expect(content.shipModels).toHaveLength(6);
expect(content.purchasableShipModels).toHaveLength(5);
expect(content.shipModels).toHaveLength(4);
expect(content.purchasableShipModels).toHaveLength(3);
expect(content.cargoTypes).toHaveLength(4);
expect(content.skills).toHaveLength(4);
expect(content.config.timeCompression).toBe(60);

View File

@ -49,16 +49,4 @@ describe("locations", () => {
for (const sprite of sprites) expect(sprite).toMatch(/^[a-z][a-z-]*$/);
expect(new Set(sprites).size).toBe(sprites.length);
});
it("gives every location a positive launch range factor (CR-012)", () => {
for (const l of locations) expect(l.launchRangeFactor, l.id).toBeGreaterThan(0);
const factor = (id: string) => locations.find((l) => l.id === id)!.launchRangeFactor;
expect([
factor("earth"),
factor("moon"),
factor("mars"),
factor("ceres-station"),
factor("jupiter-cloud-platform"),
]).toEqual([1, 1.3, 1.15, 1.3, 1]);
});
});

View File

@ -11,7 +11,7 @@ describe("loaner ship", () => {
it("is not in the Market's purchasable list", () => {
const ids = content.purchasableShipModels.map((s) => s.id);
expect(ids).not.toContain("company-courier");
expect(ids).toEqual(["courier", "lighter", "clipper", "freighter", "bulk-hauler"]);
expect(ids).toEqual(["courier", "freighter", "bulk-hauler"]);
});
it("is faster with less overhead than the Courier", () => {

View File

@ -18,7 +18,6 @@ const validLocation = {
landingRequirement: "atmospheric",
body: "earth",
launchCostMultiplier: 3,
launchRangeFactor: 1,
startsDiscovered: true,
bayLeasePrice: 5000,
sprite: "body-mars",
@ -53,7 +52,6 @@ const validShipModel = {
overheadGameHours: 2,
fuelCostPerMillionKm: 100,
purchasable: true,
role: "Fast and cheap.",
sprite: "ship-courier",
};
@ -80,7 +78,6 @@ const validGameConfig = {
contractBoardSize: 8,
tutorialBoardSize: 50,
contractBoardPeriodRealMinutes: 60,
noStrandMarginGameHours: 48,
dutyLimitGameHours: 12,
longHaulDutyBonusGameHours: 2,
restGameHours: 60,

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { shipModelSchema, shipModels, gameConfig } from "../src/index";
import { shipModelSchema, shipModels } from "../src/index";
const ship = (id: string) => shipModels.find((s) => s.id === id)!;
@ -10,67 +10,39 @@ describe("ship models", () => {
}
});
// CR-012 §2 replaces the Step 2.3 ordering tests.
const order = (
key: "price" | "cruiseSpeedKmPerGameHour" | "cargoCapacityTonnes",
ids: string[],
) =>
ids
.slice(1)
.forEach((id, i) =>
expect(ship(id)[key], `${ids[i]} < ${id}`).toBeGreaterThan(ship(ids[i]!)[key]),
);
const purchasable = shipModels.filter((m) => m.purchasable);
const handling = (id: string) =>
ship(id).handlingGameHoursPerTonne ?? gameConfig.handlingGameHoursPerTonne;
it("has five purchasable models plus the loaner, all atmospheric", () => {
it("has the three archetypes, all atmospheric", () => {
expect(shipModels.map((s) => s.id)).toEqual([
"courier",
"lighter",
"clipper",
"freighter",
"bulk-hauler",
"company-courier",
]);
for (const model of shipModels) {
expect(model.landingClass).toBe("atmospheric");
expect(model.role.length).toBeGreaterThan(10);
}
expect(ship("lighter").purchasable && ship("clipper").purchasable).toBe(true);
});
it("orders price Courier < Lighter < Clipper < Freighter < Bulk Hauler", () => {
order("price", ["courier", "lighter", "clipper", "freighter", "bulk-hauler"]);
});
it("makes the Clipper fastest, the Bulk Hauler slowest, and the Lighter slower than the Freighter", () => {
const speeds = shipModels.map((m) => m.cruiseSpeedKmPerGameHour);
expect(ship("clipper").cruiseSpeedKmPerGameHour).toBe(Math.max(...speeds));
expect(ship("bulk-hauler").cruiseSpeedKmPerGameHour).toBe(
Math.min(...purchasable.map((m) => m.cruiseSpeedKmPerGameHour)),
it("orders speed Courier > Freighter > Bulk Hauler", () => {
expect(ship("courier").cruiseSpeedKmPerGameHour).toBeGreaterThan(
ship("freighter").cruiseSpeedKmPerGameHour,
);
expect(ship("freighter").cruiseSpeedKmPerGameHour).toBeGreaterThan(
ship("bulk-hauler").cruiseSpeedKmPerGameHour,
);
order("cruiseSpeedKmPerGameHour", ["bulk-hauler", "lighter", "freighter"]);
});
it("orders capacity Courier < Clipper < Lighter < Freighter < Bulk Hauler", () => {
order("cargoCapacityTonnes", ["courier", "clipper", "lighter", "freighter", "bulk-hauler"]);
const lighterShare =
ship("lighter").cargoCapacityTonnes / ship("freighter").cargoCapacityTonnes;
expect(lighterShare).toBeGreaterThanOrEqual(0.75);
expect(lighterShare).toBeLessThanOrEqual(0.9);
it("orders capacity Bulk Hauler > Freighter > Courier", () => {
expect(ship("bulk-hauler").cargoCapacityTonnes).toBeGreaterThan(
ship("freighter").cargoCapacityTonnes,
);
expect(ship("freighter").cargoCapacityTonnes).toBeGreaterThan(
ship("courier").cargoCapacityTonnes,
);
});
it("gives the Lighter the fastest loading and the Clipper costly fuel", () => {
for (const m of shipModels) {
if (m.id !== "lighter") expect(handling("lighter")).toBeLessThan(handling(m.id));
}
expect(ship("clipper").fuelCostPerMillionKm).toBeGreaterThan(
ship("courier").fuelCostPerMillionKm,
);
expect(ship("clipper").fuelCostPerMillionKm).toBeGreaterThan(
ship("freighter").fuelCostPerMillionKm,
);
it("orders price Courier < Freighter < Bulk Hauler", () => {
expect(ship("courier").price).toBeLessThan(ship("freighter").price);
expect(ship("freighter").price).toBeLessThan(ship("bulk-hauler").price);
});
it("gives every ship model its own map sprite key (CR-003)", () => {

View File

@ -13,7 +13,7 @@ import {
const model = (id: string) => shipModels.find((m) => m.id === id)!;
const upgradeFor = (stat: ShipStat) => upgrades.find((u) => u.stat === stat)!;
/** A stat at the upgrade's max level for this model (decision 0.5.63: linear % of base). */
const maxed = (modelId: string, stat: Exclude<ShipStat, "handlingGameHoursPerTonne">) => {
const maxed = (modelId: string, stat: ShipStat & keyof (typeof shipModels)[number]) => {
const upgrade = upgradeFor(stat);
const level = upgradeMaxLevel(upgrade, modelId);
return model(modelId)[stat] * (1 + (upgradePercentPerLevel(upgrade, modelId) * level) / 100);
@ -71,12 +71,5 @@ describe("ship upgrades content (CR-009)", () => {
expect(maxed("freighter", "fuelRangeMillionKm")).toBeLessThan(
model("bulk-hauler").fuelRangeMillionKm,
);
// Amended by CR-012 (the Lighter's Mars cap is a sim test: upgrade-caps.test.ts).
expect(maxed("clipper", "cargoCapacityTonnes")).toBeLessThan(
model("freighter").cargoCapacityTonnes,
);
expect(maxed("courier", "cruiseSpeedKmPerGameHour")).toBeLessThan(
model("clipper").cruiseSpeedKmPerGameHour,
);
});
});

View File

@ -47,11 +47,6 @@ export interface DispatchProposal {
tutorial: boolean;
credits: number;
now: RealTime;
/**
* Locations the player has discovered, for the no-strand rule (decision 0.5.79).
* Absent: every location counts.
*/
discoveredIds?: ReadonlySet<string>;
}
/** Every reason `validateDispatch` can fail, in check order. */
@ -65,7 +60,6 @@ export const DISPATCH_FAILURES = [
"over-capacity",
"cannot-land",
"out-of-range",
"would-strand",
"too-slow",
"cargo-skill-too-low",
"long-haul-too-low",
@ -113,33 +107,6 @@ export function contractTripGameHours(
return travelTimeGameHours(model, distance, contract.massTonnes, content.config);
}
/**
* The no-strand rule (decision 0.5.79): true when, at arrival plus the configured
* margin, the ship could not fly from the destination to any other discovered
* location. `discoveredIds` absent means every location counts.
*/
export function wouldStrand(
/** Effective stats, or a bare model (handling falls back to the game-wide base). */
model: ShipModel,
origin: Location,
destination: Location,
massTonnes: number,
departure: GameTime,
discoveredIds: ReadonlySet<string> | undefined,
content: SimContent,
): boolean {
const { config } = content;
const distance = routeDistance(origin, destination, departure);
const tripHours = travelTimeGameHours(model, distance, massTonnes, config);
const at = departure + (tripHours + config.noStrandMarginGameHours) * MS_PER_HOUR;
return !content.locations.some(
(next) =>
next.id !== destination.id &&
(discoveredIds?.has(next.id) ?? true) &&
checkRoute(model, destination, next, at).ok,
);
}
/** 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;
@ -162,19 +129,6 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
const route = checkRoute(model, origin, destination, departure);
if (!route.ok) return fail(route.reason);
if (
wouldStrand(
model,
origin,
destination,
contract.massTonnes,
departure,
proposal.discoveredIds,
content,
)
) {
return fail("would-strand");
}
// Priority (decision 0.5.56): the trip must fit the delivery window.
if (
@ -298,7 +252,6 @@ export const REPOSITION_FAILURES = [
"location-undiscovered",
"cannot-land",
"out-of-range",
"would-strand",
"insufficient-credits",
] as const;
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
@ -331,9 +284,6 @@ export function validateReposition(
const departure = realToGame(now, content.config);
const route = checkRoute(model, origin, destination, departure);
if (!route.ok) return failWith(route.reason);
if (wouldStrand(model, origin, destination, 0, departure, discoveredIds, content)) {
return failWith("would-strand");
}
if (credits < tripFuelCost(model, origin, destination, departure)) {
return failWith("insufficient-credits");

View File

@ -11,18 +11,7 @@ export function canLandAt(ship: ShipModel, location: Location): boolean {
return LANDING_RANK[ship.landingClass] >= LANDING_RANK[location.landingRequirement];
}
/**
* A ship's range for a leg launched from `from` (decisions 0.5.77, 0.5.78):
* its (effective) range times the origin's launch range factor.
*/
export function rangeFrom(ship: ShipModel, from: Location): number {
return ship.fuelRangeMillionKm * from.launchRangeFactor;
}
/**
* Whether a ship can fly from one location to another at a game time, or why
* not. Range is per leg, from the origin, at departure (decision 0.5.77).
*/
/** Whether a ship can fly from one location to another at a game time, or why not. */
export function checkRoute(
ship: ShipModel,
from: Location,
@ -30,7 +19,7 @@ export function checkRoute(
gameTime: GameTime,
): RouteCheck {
if (!canLandAt(ship, to)) return { ok: false, reason: "cannot-land" };
if (routeDistance(from, to, gameTime) > rangeFrom(ship, from)) {
if (routeDistance(from, to, gameTime) > ship.fuelRangeMillionKm) {
return { ok: false, reason: "out-of-range" };
}
return { ok: true };

View File

@ -14,7 +14,7 @@ export { routeDistance } from "./distance";
export { travelTimeGameHours } from "./travel";
export { fuelCost } from "./fuel";
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
export { canLandAt, checkRoute } from "./eligibility";
export type { RouteCheck } from "./eligibility";
export { contractPayout } from "./contract";
@ -44,7 +44,6 @@ export {
shipStats,
tripFuelCost,
validateDispatch,
wouldStrand,
validateReposition,
} from "./dispatch";
export type {

View File

@ -31,9 +31,7 @@ export function effectiveStats(
): EffectiveStats {
const stats: EffectiveStats = {
...model,
// The model's own loading time if it has one (the Lighter, CR-012), else the game-wide base.
handlingGameHoursPerTonne:
model.handlingGameHoursPerTonne ?? content.config.handlingGameHoursPerTonne,
handlingGameHoursPerTonne: content.config.handlingGameHoursPerTonne,
};
for (const upgrade of content.upgrades) {
const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id));

View File

@ -14,15 +14,14 @@ const withTanks = (level: number): ShipModel => ({
courier.fuelRangeMillionKm * (1 + (upgradePercentPerLevel(tanks, "courier") * level) / 100),
});
/** Monthly samples over 16 game years (CR-012). */
/** Monthly samples over the first two game years. */
const MONTH = 30.44 * 24 * 3_600_000;
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
const samples = Array.from({ length: 25 }, (_, i) => start + i * MONTH);
const inRange = (ship: ShipModel) =>
samples.map((t) => checkRoute(ship, earth, mars, t)).map((r) => r.ok);
// Measured from Earth (decision 0.5.68 as amended by CR-012).
describe("Courier to Mars from Earth (decision 0.5.68)", () => {
describe("Courier to Mars (decision 0.5.68)", () => {
it("reaches Mars with maxed fuel tanks near closest approach, but not at its farthest", () => {
const reach = inRange(withTanks(3));
expect(reach.some(Boolean)).toBe(true);

View File

@ -116,19 +116,3 @@ describe("effective stats (Step 10I.2)", () => {
);
});
});
describe("per-model handling (CR-012)", () => {
it("loads the Lighter faster than other ships, and upgrades scale its own base", () => {
const lighter = content.shipModel("lighter")!;
expect(effectiveStats(lighter, {}, content).handlingGameHoursPerTonne).toBe(
lighter.handlingGameHoursPerTonne,
);
expect(
effectiveStats(lighter, { "handling-gear": 1 }, content).handlingGameHoursPerTonne,
).toBeCloseTo(lighter.handlingGameHoursPerTonne! * 0.85, 9);
expect(travelTimeGameHours(lighter, 0, 50, content.config)).toBeCloseTo(
lighter.overheadGameHours + 50 * lighter.handlingGameHoursPerTonne!,
9,
);
});
});

View File

@ -32,14 +32,13 @@ function extremeTime(to: typeof mars, pick: "closest" | "farthest"): number {
return best;
}
// Origins are named in every test (decision 0.5.77: range is per leg, from its origin).
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 from Earth out of range of Mars, Ceres and Jupiter even at their closest", () => {
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,
@ -56,28 +55,22 @@ describe("route eligibility", () => {
expect(checkRoute(vacuumShip, earth, moon, start)).toEqual({ ok: true });
});
it("lets a Freighter from Earth reach Mars even at its farthest", () => {
expect(checkRoute(freighter, earth, mars, extremeTime(mars, "farthest"))).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 a Freighter from Earth reach Ceres at its closest but not its farthest (CR-012)", () => {
expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "closest"))).toEqual({
ok: true,
});
expect(checkRoute(freighter, earth, ceres, extremeTime(ceres, "farthest")).ok).toBe(false);
});
it("lets only the Bulk Hauler reach Jupiter from Earth at its farthest", () => {
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("keeps a Freighter from Earth out of Jupiter's range even at its closest (CR-012 amends 0.5.25)", () => {
it("lets a Freighter reach Jupiter at its closest (decision 0.5.25)", () => {
expect(checkRoute(freighter, earth, jupiter, extremeTime(jupiter, "closest"))).toEqual({
ok: false,
reason: "out-of-range",
ok: true,
});
});
});

View File

@ -1,134 +0,0 @@
import { content, type Location, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
checkRoute,
effectiveStats,
MS_PER_HOUR,
routeDistance,
travelTimeGameHours,
realToGame,
validateDispatch,
validateReposition,
wouldStrand,
type Contract,
type PilotState,
type RepositionProposal,
type ShipState,
type SimContent,
} from "../src/index";
const loc = (id: string) => content.location(id)!;
const now = Date.parse("2100-01-01T00:30:00Z");
const pilot: PilotState = {
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
};
/**
* Fixture: a short-legged ship and a heavy Moon (factor 0.5), so the ship can
* reach the Moon from Earth but can't climb back out. "moon-station" shares the
* Moon's orbit, so it is always in reach from there.
*/
const heavyMoon: Location = { ...loc("moon"), launchRangeFactor: 0.5 };
const station: Location = { ...heavyMoon, id: "moon-station", name: "Moon Station" };
const fixture: SimContent = {
...content,
locations: [...content.locations.filter((l) => l.id !== "moon"), heavyMoon, station],
shipModels: [
...content.shipModels,
{ ...content.shipModel("courier")!, id: "hopper", fuelRangeMillionKm: 0.45 },
],
};
const hopper: ShipState = { id: "s1", modelId: "hopper", locationId: "earth", inFlight: false };
const proposal: RepositionProposal = {
ship: hopper,
pilot,
destinationId: "moon",
discoveredIds: new Set(["earth", "moon"]),
credits: 100_000,
now,
};
const contract: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
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,
};
describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => {
it("rejects a deadhead that would leave the ship unable to fly anywhere", () => {
expect(validateReposition(proposal, fixture)).toEqual({ ok: false, reason: "would-strand" });
});
it("passes the same trip once a reachable location is discovered", () => {
const discoveredIds = new Set(["earth", "moon", "moon-station"]);
expect(validateReposition({ ...proposal, discoveredIds }, fixture)).toEqual({ ok: true });
});
it("applies to contract dispatches too", () => {
const base = { ship: hopper, pilot, contract, tutorial: false, credits: 100_000, now };
expect(
validateDispatch({ ...base, discoveredIds: new Set(["earth", "moon"]) }, fixture),
).toEqual({ ok: false, reason: "would-strand" });
expect(
validateDispatch(
{ ...base, discoveredIds: new Set(["earth", "moon", "moon-station"]) },
fixture,
),
).toEqual({ ok: true });
});
it("never strands a base ship on a trip it is allowed to take between the five locations", () => {
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const month = 30.44 * 24 * 3_600_000;
const ids = content.locations.map((l) => l.id);
const canLeave = (model: ShipModel, at: Location, t: number) =>
content.locations.some((next) => next.id !== at.id && checkRoute(model, at, next, t).ok);
for (const base of content.shipModels) {
const model = effectiveStats(base, {}, content);
for (const from of ids) {
for (const to of ids) {
if (from === to) continue;
for (let i = 0; i <= 16 * 12; i++) {
const t = start + i * month;
if (!checkRoute(model, loc(from), loc(to), t).ok) continue;
if (wouldStrand(model, loc(from), loc(to), 0, t, undefined, content)) continue;
const hours = travelTimeGameHours(
model,
routeDistance(loc(from), loc(to), t),
0,
content.config,
);
const arrival = t + hours * MS_PER_HOUR;
const label = `${model.id} ${from}→${to} month ${i}`;
expect(
canLeave(
model,
loc(to),
arrival + content.config.noStrandMarginGameHours * MS_PER_HOUR,
),
label,
).toBe(true);
}
}
}
}
});
});

View File

@ -1,46 +0,0 @@
import { content, type Location, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, rangeFrom, realToGame, routeDistance } from "../src/index";
const loc = (id: string) => content.location(id)!;
const courier = content.shipModel("courier")!;
const withRange = (range: number): ShipModel => ({ ...courier, fuelRangeMillionKm: range });
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const MONTH = 30.44 * 24 * 3_600_000;
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
describe("range from the origin (Step 10L.2)", () => {
it("stretches a 100M km range to 130 from the Moon and 115 from Mars", () => {
expect(rangeFrom(withRange(100), loc("earth"))).toBe(100);
expect(rangeFrom(withRange(100), loc("moon"))).toBeCloseTo(130);
expect(rangeFrom(withRange(100), loc("mars"))).toBeCloseTo(115);
});
it("passes a leg from the Moon that fails from Earth when it is between 1× and 1.3× range", () => {
const t = samples[0]!;
const d = routeDistance(loc("moon"), loc("mars"), t);
const ship = withRange(d / 1.2);
expect(routeDistance(loc("earth"), loc("mars"), t)).toBeGreaterThan(ship.fuelRangeMillionKm);
expect(checkRoute(ship, loc("earth"), loc("mars"), t)).toEqual({
ok: false,
reason: "out-of-range",
});
expect(checkRoute(ship, loc("moon"), loc("mars"), t)).toEqual({ ok: true });
});
it("matches the old check exactly when every factor is 1", () => {
const flat = (l: Location): Location => ({ ...l, launchRangeFactor: 1 });
const ids = ["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"];
for (const range of [5, 198, 450, 1250]) {
for (const from of ids) {
for (const to of ids) {
if (from === to) continue;
for (const t of samples) {
const old = routeDistance(loc(from), loc(to), t) <= range;
expect(checkRoute(withRange(range), flat(loc(from)), flat(loc(to)), t).ok).toBe(old);
}
}
}
}
});
});

View File

@ -1,91 +0,0 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, realToGame } from "../src/index";
/** CR-012 §3: reach targets for base ships, monthly over 16 game years (decision 0.5.81). */
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const MONTH = 30.44 * 24 * 3_600_000;
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
const ids = content.locations.map((l) => l.id);
/** Share of sampled dates on which the leg is in range for the base ship. */
function share(modelId: string, fromId: string, toId: string): number {
const ship = content.shipModel(modelId)!;
const from = content.location(fromId)!;
const to = content.location(toId)!;
return samples.filter((t) => checkRoute(ship, from, to, t).ok).length / samples.length;
}
const always = (m: string, from: string, to: string) => expect(share(m, from, to)).toBe(1);
const never = (m: string, from: string, to: string) => expect(share(m, from, to)).toBe(0);
const some = (m: string, from: string, to: string) => {
const s = share(m, from, to);
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
return s;
};
describe("reach table (Step 10L.4)", () => {
for (const model of ["courier", "lighter"]) {
describe(model, () => {
it("flies Earth↔Moon always", () => {
always(model, "earth", "moon");
always(model, "moon", "earth");
});
it("never reaches Mars from Earth", () => never(model, "earth", "mars"));
it("never reaches Mars from the Moon", () => never(model, "moon", "mars"));
});
}
describe("clipper", () => {
it("reaches Mars from Earth on some dates, at least 30 %", () => {
expect(some("clipper", "earth", "mars")).toBeGreaterThanOrEqual(0.3);
});
it("reaches Mars from the Moon on some dates, more than from Earth", () => {
expect(some("clipper", "moon", "mars")).toBeGreaterThan(share("clipper", "earth", "mars"));
});
it("reaches Ceres from Mars on some dates", () => some("clipper", "mars", "ceres-station"));
it("never reaches Ceres from Earth", () => never("clipper", "earth", "ceres-station"));
it("never reaches Ceres from the Moon", () => never("clipper", "moon", "ceres-station"));
it("never reaches Jupiter from anywhere", () => {
for (const from of ids)
if (from !== "jupiter-cloud-platform") {
never("clipper", from, "jupiter-cloud-platform");
}
});
});
describe("freighter", () => {
it("reaches Mars from Earth always", () => always("freighter", "earth", "mars"));
it("reaches Ceres from Earth on most dates", () => {
expect(some("freighter", "earth", "ceres-station")).toBeGreaterThan(0.5);
});
it("reaches Jupiter from Ceres on some dates", () =>
some("freighter", "ceres-station", "jupiter-cloud-platform"));
it("never reaches Jupiter from Earth", () =>
never("freighter", "earth", "jupiter-cloud-platform"));
it("never reaches Jupiter from the Moon", () =>
never("freighter", "moon", "jupiter-cloud-platform"));
it("never reaches Jupiter from Mars", () =>
never("freighter", "mars", "jupiter-cloud-platform"));
});
describe("bulk-hauler", () => {
it("reaches Jupiter from Earth always", () =>
always("bulk-hauler", "earth", "jupiter-cloud-platform"));
it("flies every other leg between the five locations always", () => {
for (const from of ids)
for (const to of ids) if (from !== to) always("bulk-hauler", from, to);
});
});
describe("hops", () => {
it("a Clipper can't reach Ceres from Earth on any date but can from Mars on some", () => {
never("clipper", "earth", "ceres-station");
some("clipper", "mars", "ceres-station");
});
it("a Freighter can reach Jupiter from Ceres on some dates but never directly from Earth", () => {
some("freighter", "ceres-station", "jupiter-cloud-platform");
never("freighter", "earth", "jupiter-cloud-platform");
});
});
});

View File

@ -1,54 +0,0 @@
import { content, upgradeMaxLevel, upgradePercentPerLevel, upgrades } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { checkRoute, effectiveStats, realToGame, routeDistance } from "../src/index";
/** Every upgrade at its highest level for the model (decision 0.5.63). */
const maxedStats = (modelId: string) =>
effectiveStats(
content.shipModel(modelId)!,
Object.fromEntries(upgrades.map((u) => [u.id, upgradeMaxLevel(u, modelId)])),
content,
);
const MONTH = 30.44 * 24 * 3_600_000;
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const samples = Array.from({ length: 16 * 12 + 1 }, (_, i) => start + i * MONTH);
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const mars = content.location("mars")!;
const jupiter = content.location("jupiter-cloud-platform")!;
describe("upgrade caps for the CR-012 lineup (decision 0.5.67)", () => {
it("a maxed Lighter never reaches Mars from Earth or the Moon", () => {
const lighter = maxedStats("lighter");
for (const from of [earth, moon]) {
for (const t of samples) expect(checkRoute(lighter, from, mars, t).ok).toBe(false);
}
});
it("a maxed Clipper carries less than a base Freighter", () => {
expect(maxedStats("clipper").cargoCapacityTonnes).toBeLessThan(
content.shipModel("freighter")!.cargoCapacityTonnes,
);
});
it("a maxed Courier is slower than a base Clipper", () => {
expect(maxedStats("courier").cruiseSpeedKmPerGameHour).toBeLessThan(
content.shipModel("clipper")!.cruiseSpeedKmPerGameHour,
);
});
it("only Bulk Haulers reach Jupiter from Earth at its farthest, even maxed", () => {
const farthest = samples.reduce((a, b) =>
routeDistance(earth, jupiter, b) > routeDistance(earth, jupiter, a) ? b : a,
);
for (const ship of content.shipModels) {
const reaches = checkRoute(maxedStats(ship.id), earth, jupiter, farthest).ok;
expect(reaches, ship.id).toBe(ship.id === "bulk-hauler");
}
});
it("gives the Courier 450 % range per tanks level", () => {
expect(upgradePercentPerLevel(content.upgrade("fuel-tanks")!, "courier")).toBe(450);
});
});