Step 10L.1: range factors, Lighter and Clipper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 01:11:05 -04:00
parent e1ce7ee7d0
commit faa23d2945
15 changed files with 218 additions and 37 deletions

View File

@ -1127,3 +1127,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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).

View File

@ -1,17 +1,61 @@
{
"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" }
"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": null
},
"ship-clipper": {
"kind": "ship",
"file": null
}
}
}

View File

@ -269,7 +269,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
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.
- [ ] Step 10L.1 — Content: range factors and new ships
- [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`).
- [ ] Step 10L.2 — Sim: effective range from origin
- [ ] Step 10L.3 — Sim: no-strand rule
- [ ] Step 10L.4 — Content tuning: reach table

View File

@ -24,6 +24,8 @@ 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,6 +17,7 @@ export const locations: Location[] = [
body: "earth",
sprite: "body-earth",
launchCostMultiplier: 3,
launchRangeFactor: 1,
startsDiscovered: true,
bayLeasePrice: 1000,
produces: goodsProducedAt("earth"),
@ -37,6 +38,7 @@ export const locations: Location[] = [
body: "moon",
sprite: "body-moon",
launchCostMultiplier: 0.5,
launchRangeFactor: 1.3,
startsDiscovered: true,
bayLeasePrice: 800,
produces: goodsProducedAt("moon"),
@ -55,6 +57,7 @@ export const locations: Location[] = [
body: "mars",
sprite: "body-mars",
launchCostMultiplier: 1.5,
launchRangeFactor: 1.15,
startsDiscovered: false,
bayLeasePrice: 20000,
produces: goodsProducedAt("mars"),
@ -70,6 +73,7 @@ export const locations: Location[] = [
body: "ceres",
sprite: "body-ceres",
launchCostMultiplier: 0.3,
launchRangeFactor: 1.3,
startsDiscovered: false,
bayLeasePrice: 40000,
produces: goodsProducedAt("ceres-station"),
@ -86,6 +90,7 @@ 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,6 +21,47 @@ 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",
@ -37,6 +78,7 @@ export const shipModels: ShipModel[] = [
overheadGameHours: 4,
fuelCostPerMillionKm: 400,
purchasable: true,
role: "The all-rounder: big loads to Mars and Ceres, Jupiter by way of Ceres.",
},
{
id: "bulk-hauler",
@ -53,6 +95,7 @@ export const shipModels: ShipModel[] = [
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
@ -70,5 +113,6 @@ export const shipModels: ShipModel[] = [
overheadGameHours: 0.3,
fuelCostPerMillionKm: 200,
purchasable: false,
role: "Halvorsen's loaner for the company runs.",
},
];

View File

@ -38,6 +38,11 @@ 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,
@ -59,7 +64,13 @@ export const locationSchema = z.object({
});
export type Location = z.infer<typeof locationSchema>;
export const shipArchetypeSchema = z.enum(["courier", "freighter", "bulk-hauler"]);
export const shipArchetypeSchema = z.enum([
"courier",
"lighter",
"clipper",
"freighter",
"bulk-hauler",
]);
export type ShipArchetype = z.infer<typeof shipArchetypeSchema>;
export const shipModelSchema = z.object({
@ -80,6 +91,10 @@ 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,
});
@ -198,6 +213,8 @@ 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(4);
expect(content.purchasableShipModels).toHaveLength(3);
expect(content.shipModels).toHaveLength(6);
expect(content.purchasableShipModels).toHaveLength(5);
expect(content.cargoTypes).toHaveLength(4);
expect(content.skills).toHaveLength(4);
expect(content.config.timeCompression).toBe(60);

View File

@ -49,4 +49,16 @@ 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", "freighter", "bulk-hauler"]);
expect(ids).toEqual(["courier", "lighter", "clipper", "freighter", "bulk-hauler"]);
});
it("is faster with less overhead than the Courier", () => {

View File

@ -18,6 +18,7 @@ const validLocation = {
landingRequirement: "atmospheric",
body: "earth",
launchCostMultiplier: 3,
launchRangeFactor: 1,
startsDiscovered: true,
bayLeasePrice: 5000,
sprite: "body-mars",
@ -52,6 +53,7 @@ const validShipModel = {
overheadGameHours: 2,
fuelCostPerMillionKm: 100,
purchasable: true,
role: "Fast and cheap.",
sprite: "ship-courier",
};
@ -78,6 +80,7 @@ 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 } from "../src/index";
import { shipModelSchema, shipModels, gameConfig } from "../src/index";
const ship = (id: string) => shipModels.find((s) => s.id === id)!;
@ -10,39 +10,67 @@ describe("ship models", () => {
}
});
it("has the three archetypes, all atmospheric", () => {
// 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", () => {
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 speed Courier > Freighter > Bulk Hauler", () => {
expect(ship("courier").cruiseSpeedKmPerGameHour).toBeGreaterThan(
ship("freighter").cruiseSpeedKmPerGameHour,
);
expect(ship("freighter").cruiseSpeedKmPerGameHour).toBeGreaterThan(
ship("bulk-hauler").cruiseSpeedKmPerGameHour,
);
it("orders price Courier < Lighter < Clipper < Freighter < Bulk Hauler", () => {
order("price", ["courier", "lighter", "clipper", "freighter", "bulk-hauler"]);
});
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("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)),
);
order("cruiseSpeedKmPerGameHour", ["bulk-hauler", "lighter", "freighter"]);
});
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("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("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("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: ShipStat & keyof (typeof shipModels)[number]) => {
const maxed = (modelId: string, stat: Exclude<ShipStat, "handlingGameHoursPerTonne">) => {
const upgrade = upgradeFor(stat);
const level = upgradeMaxLevel(upgrade, modelId);
return model(modelId)[stat] * (1 + (upgradePercentPerLevel(upgrade, modelId) * level) / 100);

View File

@ -31,7 +31,9 @@ export function effectiveStats(
): EffectiveStats {
const stats: EffectiveStats = {
...model,
handlingGameHoursPerTonne: content.config.handlingGameHoursPerTonne,
// 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,
};
for (const upgrade of content.upgrades) {
const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id));

View File

@ -116,3 +116,19 @@ 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,
);
});
});