Compare commits

..

No commits in common. "f81a8e41efc6c9412ca57e7b8779924aea067d2b" and "81e71e843640dcc62b7de9a4785d9c63856c6b68" have entirely different histories.

112 changed files with 162 additions and 7301 deletions

View File

@ -874,92 +874,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **`contractBoardPeriodRealMinutes` 60 → 10 and `restGameHours` 60 → 10** (user request, after Phase 10D). At 60× compression a rest is now 10 real minutes, exactly one board period, so decision 0.5.46 still holds and its content test (rest in real time ≥ board period) is unchanged and passing.
- **Effects:** boards refresh every 10 real minutes and every contract expires at the end of its period, so offers turn over six times as fast. A rested pilot is back in 10 minutes instead of an hour. This also resolves the earlier note that the CR-004 example expected a short rest.
- **Two tests assumed a 60-minute period** and now derive their timing from config instead: the server's `testContract` helper (expiry = end of the board period) and the board-refresh countdown test. No game code changed.
## CR-006 — Boards That Fit Your Fleet (Phase 10F)
- **Requested by the user** after CR-005: highlight weight a ship can't carry, and offer more jobs that fit the player's ships and pilot skills than the one guaranteed job per band. Stored in `memory-bank/change-requests/`, inserted as Phase 10F before Phase 11; decisions are plan §0.5.52–0.5.53.
## Step 10F.1
- **"Your ship" means the ships at the board's location**, because those are the ones that can take the job now; with none there, the whole fleet (one of them could deadhead in). Ships in flight have no location, so they only count in the fallback.
## Step 10F.3
- **Freed slots go to the largest flyable band first.** With a Courier and a Freighter, the Bulk Hauler band's spare slot becomes a Freighter-band job, since the biggest ship you own earns the most per trip. With one Courier everything lands in the Courier band.
- **A band counts as flyable if some ship can carry its minimum**, and a fit job's mass is capped at the chosen ship's capacity; so a Freighter also gets work from a band whose top it can't carry.
- **Fit destinations are checked at the board's start time**, like the contract's own distance. A route at the edge of a ship's range could drift out during the 10-minute period; dispatch validation still decides.
- **An uncrewed ship counts with no skills**, as if crewed by a fresh hire, so buying a ship immediately shapes the next board.
## Follow-up after Phase 10F: deadheads in the logbook
- **Found while checking deadhead end to end** (user request): a deadhead's "While you were away" entry read "+0 cr +0 XP" in success green. `ResolutionSummary` gains `deadhead` (the dispatch had no contract) and `fuelCost` (paid at departure); the entry now reads "Deadhead · 230 cr fuel" with a flight icon. Tests: `away-summary.test.tsx`, `reposition.test.ts`, `loan.test.ts`.
- **The missing favicon (a harmless 404) is left for later**, by the user's choice; Phase 11's PWA icons are the natural place.
## CR-007 — Cargo Goods, Priority Contracts and Shippers (Phase 10G)
- **Supplied by the user as "CR-005 / Phase 10E" with decisions 0.5.39–0.5.44**, all of which were already taken. Recorded as CR-007, Phase 10G (steps 10G.1–10G.11), decisions 0.5.54–0.5.59; `too_slow` is `too-slow`.
- **Mass: the band wins** (asked; the user chose it). The CR draws mass from each good's range, but boards draw it from ship-size bands, and no Standard good weighs over 60 t, so a Bulk Hauler would lose its unskilled job and CR-006's fit guarantees would break. A contract now picks a good of its class overlapping its band and draws inside both; with no overlap it uses the class's largest-range good and the band's range (a bulk lot). The lightest band has no floor for goods, so sub-5 t goods can appear.
- **Class mix kept by keeping the class-level lists** (not asked: the CR requires it twice and tests it). Switching `produces`/`demands` to goods and deriving classes from them would make Earth produce all four classes and move its mix far beyond ±3 points. Locations keep the old lists as `cargoClassMix` for class selection only.
- **Tutorial contracts are never Priority**: the CR doesn't say, and the tutorial is about learning the loop, not deadlines.
## Step 10G.5
- **Tutorial goods prefer one whose range holds the tier mass** instead of clamping the tier to a random good, so the CR-001 light/medium/full masses (5, 12, 20 t) and the tutorial's timing tests stay exact. Earth→Moon: Mail or Consumer Goods at 5 t, Packaged Food or Consumer Goods at 12 and 20 t; Moon→Earth: Souvenirs or Optical Glass at 5 t, Optical Glass at 12 and 20 t.
## Step 10G.6
- **A backfill migration instead of a database reset.** The CR allows resetting, but contracts are JSONB, so a custom SQL migration can give old rows the new fields and keep saves. It uses a `pg_temp` helper function (drizzle runs all migrations in one session) and touches only contracts without a `goodId`, so rerunning is a no-op.
- **Summaries carry `goodId`, `massTonnes` and `shipper`** for Step 10G.10's "Delivered 6 t Medical Supplies for Meridian Health to the Moon".
## CR-008 — Boards That Feel Fresh (Phase 10H)
- **User report: "the jobs aren't refreshing".** Investigated before changing anything: the server stores a new board every period (the user's dev database had one per 10-minute period, all different), and a real browser left on the board refetched one second after the timer hit zero and showed the new cards. The new boards just look the same for an early player: Earth → Moon runs of three goods, with identical pay for the same good and mass. The user picked three fixes from four offered: visible refresh, per-job pay rate, more early goods (not more open slots).
## Step 10H.4
- **Fit jobs choose a good, not a class.** Picking a class first (from `cargoClassMix`) and then a good meant a skilled pilot could be locked into one class whose only route good didn't fit the ship: a Heavy Cargo 1 Courier at the Moon got nothing but 5–20 t "Lunar Titanium" (a 30–120 t good). Fit jobs now pick among all qualified goods on the route that fit the slot; the board's class mix for non-fit jobs is untouched.
- **No undersized lots.** When every good of the chosen class is too heavy for a slot, the slot carries a Standard good on the route that fits instead (the CR-007 fallback, extended to mass). Goods too light for a big band still make bulk lots, as the user chose for CR-007; the Earth class-mix test (±3 points) still passes.
## CR-009 — Ship Upgrades (Phase 10I)
- **Renumbered on arrival:** the request was titled "CR-006 — Ship Upgrades" for "Phase 10F". CR-006 to CR-008 and Phases 10F–10H already exist, so it is stored as **CR-009** and done as **Phase 10I** (its steps 10F.n are 10I.n).
- **Decision numbering:** the CR's 0.5.45–0.5.50 are recorded as **0.5.63 Effective stats, 0.5.64 Where and when, 0.5.65 One level at a time, 0.5.66 Shop time, 0.5.67 Models stay distinct, 0.5.68 Courier to Mars**.
- **Error codes:** kebab-case, reusing existing codes that mean the same: `ship_busy` → `ship-not-idle`, `insufficient_funds` → `insufficient-funds`, `tutorial_active` → `tutorial-active`, `too_slow` → `too-slow`. New: `not-at-hq`, `ship-in-shop`, `upgrade-maxed`, `not-upgradable`.
- **Fits the current build:** the CR assumes Priority windows and a too-slow reason, which CR-007 added.
## Step 10I.1
- **Courier fuel tanks: +600 % per level** (5 → 35 → 65 → 95 M km). Earth–Mars over the first two game years ranges 82–394 M km (monthly samples), so level 3 reaches Mars only near closest approach and level 2 never does. The Courier's base range (5 M km) and the Step 3.7 range tests are unchanged.
- **Freighter fuel tanks: +15 % per level** (650 → 942.5 M km). At the CR's default +20 % a maxed Freighter would reach 1040 M km, past a base Bulk Hauler's 1000 and Jupiter at its farthest, breaking decision 0.5.67.
- **Overrides cover the percent as well as the max level** (`modelOverrides`), since the CR allows tuning the Courier's per-level bonus.
- **Handling time per tonne stays a game-wide base** (`handlingGameHoursPerTonne`) rather than a ShipModel field: it was a single config value, and upgrades make it per ship through effective stats (10I.2). The "affects a stat that exists" test accepts it as that base; the other four are checked against every ShipModel.
- **Reductions are negative percents** (−15 % overhead and handling per level), so one linear rule covers every upgrade.
- **Upgrade costs and shop times are config** (`upgradeLevels`, one entry per level), so the max level any upgrade may have is bounded by its length (tested).
## Step 10I.2
- **Effective stats are a ShipModel plus handling time** (`EffectiveStats extends ShipModel`), so every function that took a model (route check, travel time, fuel) takes effective stats unchanged. Only the place that looks a ship up changed: `shipStats(ship)` replaces the plain model lookup in dispatch, deadhead and record code.
- **Upgrades ride on `ShipState` as an optional `upgrades` map**; absent means none, so existing callers and tests needed no edits.
- **Levels above a model's max are clamped** in `effectiveStats`, so bad data can never break the "models stay distinct" caps.
- **Boards fit effective capacity:** CR-006's fleet-fitted boards now see an upgraded Cargo Bay. The Priority window's reference ship stays the base model, so windows are the same for every player.
## Step 10I.3
- **Check order:** tutorial, loaner, in flight, in the shop, at an HQ, maxed, affordable. Situation checks come before price, so the reason shown is the one the player must fix first.
- **"At an HQ" is an input (`atHq`)**, not computed from bays in the sim, so the server decides it from the player's bays and the client from state, both with the same rule.
- **Success returns everything the server writes**: the new level, its cost, and in-shop-until (now + that level's shop time).
- **`ship-in-shop` also blocks deadheads**, since a ship in the shop isn't going anywhere. It sits right after `ship-not-idle` in both validators.
- **Costs are rounded to whole credits** like fuel; for current prices all three levels are exact.
## Step 10I.4
- **Ships remember their latest shop job** (`shop_upgrade_id`, `shop_level` with `in_shop_until`) until the player acknowledges it, so the "out of the shop" line (Step 10I.8) knows what finished while they were away without a job table. The API shows it as `shop`, and `inShopUntil` only while the ship is actually in the shop.
- **Acknowledging a finished upgrade reuses `acknowledgeSummaries`** with optional `shipIds`, since both are cleared from the same "While you were away" modal. The request needs at least one id overall (so `dispatchIds` may be empty).
- **Effective stats are sent per ship** (`stats`), computed by the server with the sim, so every screen can show them without recomputing; the client still uses the sim for predictions.
- **Upgrade codes come from the sim's `UPGRADE_FAILURES`**, joining the dispatch and deadhead reasons in the API's error list; the new ones are `not-at-hq`, `ship-in-shop`, `upgrade-maxed` and `not-upgradable`.
## Step 10I.5
- **The upgrade runs in the player's locked transaction** (`withPlayer` locks the company row, Step 6.14), so the funds check and the charge cannot interleave with another purchase: of two simultaneous upgrades the player can afford one of, exactly one succeeds and the balance never goes negative (tested).
- **The level is raised at purchase** (decision 0.5.66). Effective stats change at once, but the ship can't fly until in-shop-until, so there is nothing to finish later.
- **Server messages for upgrade refusals** live with the other validation texts (`UPGRADE_MESSAGES`); an unknown upgrade id is `404 not-found`.

View File

@ -1,36 +0,0 @@
-- CR-007 (Step 10G.6): contracts carry a good, a shipper and Priority fields.
-- Contracts live in JSONB (stored boards and dispatch snapshots), so no columns
-- change. Backfill contracts stored before the change so existing saves keep
-- working: a default good of the contract's class, the origin's first shipper,
-- and no Priority. Rows that already have a good are left alone, so running
-- this again changes nothing.
CREATE FUNCTION pg_temp.cr007_backfill(c jsonb) RETURNS jsonb AS $$
SELECT CASE WHEN c ? 'goodId' THEN c ELSE c || jsonb_build_object(
'goodId', CASE c->>'cargoTypeId'
WHEN 'heavy' THEN 'machine-parts'
WHEN 'high-value' THEN 'electronics'
WHEN 'dangerous' THEN 'industrial-chemicals'
ELSE 'consumer-goods' END,
'shipper', CASE c->>'originId'
WHEN 'moon' THEN 'Shackleton Mining Co.'
WHEN 'mars' THEN 'Jezero Agricultural Co-op'
WHEN 'ceres-station' THEN 'Belt Ore Consolidated'
WHEN 'jupiter-cloud-platform' THEN 'Galilean Fuel Combine'
ELSE 'Pacific Orbital Logistics' END,
'priority', false,
'deliveryWindowGameHours', null
) END
$$ LANGUAGE sql IMMUTABLE;
--> statement-breakpoint
UPDATE "contract_boards"
SET "contracts" = (
SELECT COALESCE(jsonb_agg(pg_temp.cr007_backfill(t.c) ORDER BY t.ord), '[]'::jsonb)
FROM jsonb_array_elements("contracts") WITH ORDINALITY AS t(c, ord)
)
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements("contracts") AS e(c) WHERE NOT (e.c ? 'goodId')
);
--> statement-breakpoint
UPDATE "dispatches"
SET "record" = jsonb_set("record", '{contract}', pg_temp.cr007_backfill("record"->'contract'))
WHERE jsonb_typeof("record"->'contract') = 'object' AND NOT ("record"->'contract' ? 'goodId');

View File

@ -1,11 +0,0 @@
CREATE TABLE "ship_upgrades" (
"ship_id" text NOT NULL,
"upgrade_id" text NOT NULL,
"level" integer NOT NULL,
CONSTRAINT "ship_upgrades_ship_id_upgrade_id_pk" PRIMARY KEY("ship_id","upgrade_id")
);
--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "in_shop_until" bigint;--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "shop_upgrade_id" text;--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "shop_level" integer;--> statement-breakpoint
ALTER TABLE "ship_upgrades" ADD CONSTRAINT "ship_upgrades_ship_id_ships_id_fk" FOREIGN KEY ("ship_id") REFERENCES "public"."ships"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -36,20 +36,6 @@
"when": 1791078648879,
"tag": "0004_cr004_pilot_duty",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1791148094765,
"tag": "0005_cr007_goods",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1791155978006,
"tag": "0006_cr009_ship_upgrades",
"breakpoints": true
}
]
}

View File

@ -65,32 +65,11 @@ export const ships = pgTable(
status: activityStatus("status").notNull().default("idle"),
/** Null while in flight. */
locationId: text("location_id"),
/** In the upgrade shop until this time (decision 0.5.66); null or past: not. */
inShopUntil: time("in_shop_until"),
/**
* The latest shop job (upgrade and level), kept after it finishes until the
* player has seen it ("out of the shop"); null when none or acknowledged.
*/
shopUpgradeId: text("shop_upgrade_id"),
shopLevel: integer("shop_level"),
createdAt: time("created_at").notNull(),
},
(t) => [index("ships_player_idx").on(t.playerId)],
);
/** Installed upgrade levels per ship (CR-009). A missing row is level 0. */
export const shipUpgrades = pgTable(
"ship_upgrades",
{
shipId: text("ship_id")
.notNull()
.references(() => ships.id, { onDelete: "cascade" }),
upgradeId: text("upgrade_id").notNull(),
level: integer("level").notNull(),
},
(t) => [primaryKey({ columns: [t.shipId, t.upgradeId] })],
);
export const pilots = pgTable(
"pilots",
{

View File

@ -6,21 +6,12 @@ import {
generateContractBoard,
generateTutorialBoard,
type Contract,
type FleetMember,
type RealTime,
} from "@deadhead/sim";
import { and, asc, eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
contractBoards,
discoveredLocations,
pilots,
players,
ships,
} from "../db/schema";
import { acceptedContracts, contractBoards, discoveredLocations, players } from "../db/schema";
import { gameError } from "../http/errors";
import { loadUpgradeLevels } from "./upgrades";
export interface BoardView {
contracts: Contract[];
@ -123,9 +114,8 @@ export async function tutorialBoard(
/**
* The contract board (freight market) of any discovered location for the
* current period (Step 6.9, CR-002): no bay needed. Generated on the first
* request in the period from the locations discovered and the fleet owned at
* that moment (CR-006), then stored, so later changes in the same period do
* not change it.
* request in the period from the locations discovered at that moment, then
* stored, so later discoveries in the same period do not change it.
*/
export async function locationBoard(
tx: Queryable,
@ -164,7 +154,6 @@ export async function locationBoard(
discoveredIds: new Set(discovered.map((d) => d.locationId)),
periodIndex,
worldSeed: player!.worldSeed,
fleet: await playerFleet(tx, playerId),
},
content,
);
@ -173,26 +162,3 @@ export async function locationBoard(
);
return boardView(tx, playerId, contracts, periodIndex);
}
/**
* The player's ships with their pilots' skills (none without a pilot), in a
* stable order so the same fleet always gives the same board (decision 0.5.52).
*/
export async function playerFleet(tx: Queryable, playerId: string): Promise<FleetMember[]> {
const rows = await tx
.select({ id: ships.id, modelId: ships.modelId, skills: pilots.skills })
.from(ships)
.leftJoin(pilots, eq(pilots.assignedShipId, ships.id))
.where(eq(ships.playerId, playerId))
.orderBy(asc(ships.createdAt), asc(ships.id));
// Upgrades count: an expanded cargo bay gets bigger fit jobs (CR-009).
const upgrades = await loadUpgradeLevels(
tx,
rows.map((r) => r.id),
);
return rows.map((r) => ({
modelId: r.modelId,
skills: r.skills ?? {},
upgrades: upgrades.get(r.id) ?? {},
}));
}

View File

@ -12,7 +12,6 @@ import {
type PilotState,
type RealTime,
type ShipState,
type UpgradeLevels,
} from "@deadhead/sim";
import { and, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
@ -32,7 +31,6 @@ import { requireCompany, requirePlayer } from "./fleet";
import { toApiDispatch } from "./mappers";
import { VALIDATION_MESSAGES } from "./messages";
import { requireOwnPilot, requireOwnShip } from "./pilots";
import { shipUpgradeLevels } from "./upgrades";
/** The id the tutorial loaner uses in dispatch records (it is not a ship row). */
export const LOANER_SHIP_ID = "loaner";
@ -195,16 +193,13 @@ export async function dispatchTutorial(
return dispatch;
}
export function shipState(row: typeof ships.$inferSelect, upgrades: UpgradeLevels = {}): ShipState {
export function shipState(row: typeof ships.$inferSelect): ShipState {
return {
id: row.id,
modelId: row.modelId,
// An in-flight ship has no location; validation reports it as not idle first.
locationId: row.locationId ?? "",
inFlight: row.status === "in_flight",
// Effective stats and the shop block (CR-009).
upgrades,
inShopUntil: row.inShopUntil,
};
}
@ -231,7 +226,7 @@ export async function dispatchContract(
const company = await requireCompany(tx, playerId);
const proposal = {
ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)),
ship: shipState(ship),
pilot: pilotState(pilot),
contract,
tutorial: false,
@ -275,7 +270,7 @@ export async function dispatchReposition(
.where(eq(discoveredLocations.playerId, playerId));
const proposal = {
ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)),
ship: shipState(ship),
pilot: pilotState(pilot),
destinationId: input.destinationId,
discoveredIds: new Set(discovered.map((d) => d.locationId)),

View File

@ -6,7 +6,6 @@ import { and, count, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, companies, discoveredLocations, players, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { toApiShip } from "./mappers";
import { shipName } from "./names";
export async function requireCompany(tx: Queryable, playerId: string) {
@ -116,9 +115,14 @@ export async function buyShip(
createdAt: now,
})
.returning();
return toApiShip(
ship!,
{ dispatchId: null, pilotId: null, lastArrivalAt: ship!.createdAt, upgrades: {} },
now,
);
return {
id: ship!.id,
modelId: ship!.modelId,
name: ship!.name,
status: "idle",
locationId: ship!.locationId,
dispatchId: null,
pilotId: null,
lastArrivalAt: ship!.createdAt,
};
}

View File

@ -1,15 +1,13 @@
import { content } from "@deadhead/content";
import type { Dispatch, Pilot, Ship } from "@deadhead/schema";
import type { Dispatch, Pilot } from "@deadhead/schema";
import {
dutyLimit,
effectiveStats,
isInShop,
isResting,
pilotLevel,
skillPointsAvailable,
type RealTime,
} from "@deadhead/sim";
import type { dispatches, pilots, ships } from "../db/schema";
import type { dispatches, pilots } from "../db/schema";
/** Database status values use `in_flight`; the API uses `in-flight`. */
export function toApiStatus<S extends "idle" | "in_flight" | "resolved">(
@ -59,42 +57,3 @@ export function toApiPilot(
restEndsAt: resting ? row.restEndsAt : null,
};
}
/** A ship row as the API shows it, with its upgrades and effective stats (CR-009). */
export function toApiShip(
row: typeof ships.$inferSelect,
extra: {
dispatchId: string | null;
pilotId: string | null;
lastArrivalAt: number;
upgrades: Record<string, number>;
},
now: RealTime,
): Ship {
const model = content.shipModel(row.modelId);
if (!model) throw new Error(`Ship ${row.id} has unknown model ${row.modelId}`);
const stats = effectiveStats(model, extra.upgrades, content);
return {
id: row.id,
modelId: row.modelId,
name: row.name,
status: toApiStatus(row.status),
locationId: row.status === "in_flight" ? null : row.locationId,
dispatchId: extra.dispatchId,
pilotId: extra.pilotId,
lastArrivalAt: extra.lastArrivalAt,
upgrades: extra.upgrades,
stats: {
cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: stats.cargoCapacityTonnes,
fuelRangeMillionKm: stats.fuelRangeMillionKm,
overheadGameHours: stats.overheadGameHours,
handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne,
},
inShopUntil: isInShop(row, now) ? row.inShopUntil : null,
shop:
row.shopUpgradeId && row.shopLevel !== null && row.inShopUntil !== null
? { upgradeId: row.shopUpgradeId, level: row.shopLevel, until: row.inShopUntil }
: null,
};
}

View File

@ -1,17 +1,15 @@
import type { DispatchFailure, RepositionFailure, UpgradeFailure } from "@deadhead/sim";
import type { DispatchFailure, RepositionFailure } from "@deadhead/sim";
/** Player-facing text for each sim validation reason. */
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
"ship-not-idle": "That ship is already flying.",
"ship-not-at-origin": "That ship is not at the contract's pickup location.",
"ship-in-shop": "That ship is in the upgrade shop until its work is done.",
"pilot-resting": "That pilot is resting and cannot fly until their rest ends.",
"pilot-not-idle": "That pilot is already flying.",
"pilot-not-assigned": "That pilot is not assigned to that ship.",
"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.",
"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.",
"contract-expired": "This contract has expired.",
@ -19,14 +17,3 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
"already-there": "The ship is already there.",
"location-undiscovered": "You can only fly empty to places you have discovered.",
};
/** Player-facing text for each upgrade refusal (CR-009). */
export const UPGRADE_MESSAGES: Record<UpgradeFailure, string> = {
"tutorial-active": "Finish the company runs before upgrading ships.",
"not-upgradable": "The company loaner can't be upgraded.",
"ship-not-idle": "That ship is flying; upgrade it once it lands at an HQ.",
"ship-in-shop": "That ship is already in the shop.",
"not-at-hq": "Upgrades are installed at an HQ; bring the ship to one of your bays.",
"upgrade-maxed": "That upgrade is already at its highest level.",
"insufficient-funds": "Not enough credits for that upgrade.",
};

View File

@ -17,11 +17,7 @@ import { toApiPilot } from "./mappers";
import { VALIDATION_MESSAGES } from "./messages";
import { pilotName } from "./names";
export async function hasBayAt(
tx: Queryable,
playerId: string,
locationId: string,
): Promise<boolean> {
async function hasBayAt(tx: Queryable, playerId: string, locationId: string): Promise<boolean> {
const [bay] = await tx
.select({ id: bays.id })
.from(bays)

View File

@ -14,8 +14,7 @@ import {
} from "../db/schema";
import { isDealAvailable } from "./deal";
import { goalStates } from "./goals";
import { toApiDispatch, toApiPilot, toApiShip } from "./mappers";
import { loadUpgradeLevels } from "./upgrades";
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
/** Reads everything the client needs about one player. */
export async function loadPlayerState(
@ -57,10 +56,6 @@ export async function loadPlayerState(
const discoveredIds = new Set(discovered.map((d) => d.locationId));
const shipById = new Map(shipRows.map((s) => [s.id, s]));
const activeByShip = new Map(active.filter((d) => d.shipId).map((d) => [d.shipId!, d.id]));
const upgradesByShip = await loadUpgradeLevels(
db,
shipRows.map((s) => s.id),
);
const lastArrivalByShip = new Map(arrivals.map((a) => [a.shipId, a.at]));
const pilotByShip = new Map(
pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]),
@ -73,18 +68,16 @@ export async function loadPlayerState(
credits: company.credits,
loanBalance: company.loanBalance,
},
ships: shipRows.map((s) =>
toApiShip(
s,
{
ships: shipRows.map((s) => ({
id: s.id,
modelId: s.modelId,
name: s.name,
status: toApiStatus(s.status),
locationId: s.status === "in_flight" ? null : s.locationId,
dispatchId: activeByShip.get(s.id) ?? null,
pilotId: pilotByShip.get(s.id) ?? null,
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
upgrades: upgradesByShip.get(s.id) ?? {},
},
now,
),
),
})),
pilots: pilotRows.map((p) =>
toApiPilot(
p,
@ -114,11 +107,6 @@ export async function loadPlayerState(
originId: d.record.originId,
destinationId: d.record.destinationId,
tutorial: d.tutorial,
deadhead: d.record.contract === null,
fuelCost: d.record.fuelCost,
goodId: d.record.contract?.goodId ?? null,
massTonnes: d.record.contract?.massTonnes ?? null,
shipper: d.record.contract?.shipper ?? null,
credits: d.resolution?.credits ?? 0,
loanRepayment: d.resolution?.loanRepayment ?? 0,
pilotXp: d.resolution?.pilotXp ?? 0,

View File

@ -1,101 +0,0 @@
import { content } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import { validateUpgrade, type RealTime, type UpgradeLevels } from "@deadhead/sim";
import { and, eq, inArray, max, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { companies, dispatches, pilots, players, shipUpgrades, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { shipState } from "./dispatch";
import { toApiShip } from "./mappers";
import { UPGRADE_MESSAGES } from "./messages";
import { hasBayAt, requireOwnShip } from "./pilots";
/** Installed upgrade levels for each of the given ships (CR-009). */
export async function loadUpgradeLevels(
tx: Queryable,
shipIds: string[],
): Promise<Map<string, Record<string, number>>> {
const levels = new Map<string, Record<string, number>>();
if (shipIds.length === 0) return levels;
const rows = await tx.select().from(shipUpgrades).where(inArray(shipUpgrades.shipId, shipIds));
for (const row of rows) {
const ship = levels.get(row.shipId) ?? {};
ship[row.upgradeId] = row.level;
levels.set(row.shipId, ship);
}
return levels;
}
/** One ship's installed upgrade levels. */
export async function shipUpgradeLevels(tx: Queryable, shipId: string): Promise<UpgradeLevels> {
return (await loadUpgradeLevels(tx, [shipId])).get(shipId) ?? {};
}
/**
* Starts the next level of an upgrade on a ship (CR-009, decisions 0.5.64–0.5.66),
* inside the caller's player-locked transaction: validate, charge, raise the
* level now, and keep the ship in the shop until the work is done.
*/
export async function startUpgrade(
tx: Queryable,
playerId: string,
input: { shipId: string; upgradeId: string },
now: RealTime,
): Promise<Ship> {
const upgrade = content.upgrade(input.upgradeId);
if (!upgrade) throw gameError("not-found", `No upgrade "${input.upgradeId}".`);
const row = await requireOwnShip(tx, playerId, input.shipId);
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!player || !company) throw new Error(`Player ${playerId} not found`);
const levels = await shipUpgradeLevels(tx, row.id);
const check = validateUpgrade(
{
ship: shipState(row, levels),
upgrade,
atHq: row.locationId !== null && (await hasBayAt(tx, playerId, row.locationId)),
tutorialActive: player.tutorialActive,
credits: company.credits,
now,
},
content,
);
if (!check.ok) throw gameError(check.reason, UPGRADE_MESSAGES[check.reason]);
await tx
.update(companies)
.set({ credits: sql`${companies.credits} - ${check.cost}` })
.where(eq(companies.playerId, playerId));
await tx
.insert(shipUpgrades)
.values({ shipId: row.id, upgradeId: upgrade.id, level: check.level })
.onConflictDoUpdate({
target: [shipUpgrades.shipId, shipUpgrades.upgradeId],
set: { level: check.level },
});
const [updated] = await tx
.update(ships)
.set({ inShopUntil: check.shopUntil, shopUpgradeId: upgrade.id, shopLevel: check.level })
.where(eq(ships.id, row.id))
.returning();
const [pilot] = await tx
.select({ id: pilots.id })
.from(pilots)
.where(eq(pilots.assignedShipId, row.id));
const [arrival] = await tx
.select({ at: max(dispatches.arrivalAt) })
.from(dispatches)
.where(and(eq(dispatches.shipId, row.id), eq(dispatches.status, "resolved")));
return toApiShip(
updated!,
{
dispatchId: null,
pilotId: pilot?.id ?? null,
lastArrivalAt: arrival?.at ?? row.createdAt,
upgrades: { ...levels, [upgrade.id]: check.level },
},
now,
);
}

View File

@ -2,7 +2,6 @@ import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { buyShip, leaseBay } from "../game/fleet";
import { assignPilot, hirePilot, restPilot, spendSkillPoint, unassignPilot } from "../game/pilots";
import { startUpgrade } from "../game/upgrades";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
@ -39,12 +38,6 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void
})),
);
registerRoute(app, routes.startUpgrade, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
ship: await startUpgrade(tx, playerId, input, now),
})),
);
registerRoute(app, routes.restPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await restPilot(tx, playerId, input.pilotId, now),

View File

@ -5,7 +5,7 @@ import {
dispatchResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { boardPeriodMs, contractTripGameHours } from "@deadhead/sim";
import { boardPeriodMs } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { acceptedContracts, companies, dispatches, pilots, ships } from "../src/db/schema";
@ -157,10 +157,6 @@ describe("contract dispatch", () => {
const o = await owner();
const contract = testContract({
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 2,
requiredSkill: { skillId: "heavy-cargo", level: 2 },
});
@ -205,45 +201,3 @@ describe("contract dispatch", () => {
expect(errorCode(await dispatch(o, contract.id))).toBe("contract-already-accepted");
});
});
describe("Priority contracts (CR-007)", () => {
const courier = content.shipModel("courier")!;
/** A Priority Earth→Moon mail job whose window is the Courier's trip plus `slackHours`. */
async function priorityJob(o: Owner, id: string, slackHours: number) {
const { clock } = testApp();
const base = testContract({ id, goodId: "mail", massTonnes: 4, priority: true });
const trip = contractTripGameHours(courier, base, clock.now, content);
const contract = { ...base, deliveryWindowGameHours: trip + slackHours };
await putBoard(database().db, o.guest.playerId, [contract], clock.now);
return contract;
}
it("refuses a ship too slow for the window with too-slow and changes nothing", async () => {
const o = await owner();
const job = await priorityJob(o, "earth:1:0", -0.25);
await expectRejected(o, await dispatch(o, job.id), "too-slow");
});
it("dispatches a ship fast enough for the window", async () => {
const o = await owner();
const job = await priorityJob(o, "earth:1:0", 0.25);
const response = await dispatch(o, job.id);
expect(response.statusCode).toBe(200);
const { dispatch: stored } = dispatchResponseSchema.parse(response.json());
expect(stored.contract).toMatchObject({ priority: true, goodId: "mail" });
});
it("serves generated boards whose contracts all name a known good and a shipper", async () => {
const o = await owner();
const response = await call(testApp().app, o.guest, "GET", "/api/locations/earth/board");
const { contracts } = contractBoardResponseSchema.parse(response.json());
expect(contracts.length).toBeGreaterThan(0);
for (const c of contracts) {
const good = content.good(c.goodId);
expect(good?.name).toBeTruthy();
expect(good!.cargoClassId).toBe(c.cargoTypeId);
expect(content.location("earth")!.shippers).toContain(c.shipper);
}
});
});

View File

@ -100,10 +100,6 @@ export function testContract(overrides: Partial<Contract> = {}): Contract {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,

View File

@ -1,96 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { contractBoardResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import pg from "pg";
import { describe, expect, it } from "vitest";
import { contractBoards, dispatches } from "../src/db/schema";
import { useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
putBoard,
signUpOwner,
testContract,
useTestApp,
} from "./game";
import { testConfig } from "./helpers";
const database = useTestDatabase();
const testApp = useTestApp(database);
const migration = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "../drizzle/0005_cr007_goods.sql"),
"utf8",
);
/** Runs the CR-007 backfill in one session (it uses a pg_temp helper). */
async function backfill() {
const client = new pg.Client({ connectionString: testConfig().databaseUrl });
await client.connect();
try {
for (const statement of migration.split("--> statement-breakpoint")) {
await client.query(statement);
}
} finally {
await client.end();
}
}
/** A contract as stored before CR-007: no good, shipper or Priority fields. */
function legacy(overrides: Parameters<typeof testContract>[0] = {}) {
const { goodId, shipper, priority, deliveryWindowGameHours, ...old } = testContract(overrides);
void [goodId, shipper, priority, deliveryWindowGameHours];
return old as ReturnType<typeof testContract>;
}
describe("CR-007 backfill migration", () => {
it("gives stored boards and in-flight contracts a good, shipper and no Priority, once", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const db = database().db;
await putBoard(
db,
owner.playerId,
[
legacy({ id: "earth:1:0", cargoTypeId: "standard" }),
legacy({ id: "earth:1:1", cargoTypeId: "high-value", originId: "moon" }),
],
clock.now,
);
const record = await insertDispatch(db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: legacy({ cargoTypeId: "dangerous" }),
});
await backfill();
const [board] = await db.select().from(contractBoards);
expect(board!.contracts.map((c) => [c.goodId, c.shipper, c.priority])).toEqual([
["consumer-goods", "Pacific Orbital Logistics", false],
["electronics", "Shackleton Mining Co.", false],
]);
const [row] = await db.select().from(dispatches).where(eq(dispatches.id, record.id));
expect(row!.record.contract).toMatchObject({
goodId: "industrial-chemicals",
priority: false,
deliveryWindowGameHours: null,
});
// A second run changes nothing.
await backfill();
const [again] = await db.select().from(contractBoards);
expect(again!.contracts).toEqual(board!.contracts);
// The API accepts the backfilled rows.
const state = await getState(app, owner);
expect(state.activeDispatches[0]!.contract!.goodId).toBe("industrial-chemicals");
const response = await call(app, owner, "GET", "/api/locations/earth/board");
expect(response.statusCode).toBe(200);
contractBoardResponseSchema.parse(response.json());
});
});

View File

@ -49,7 +49,6 @@ describe("loan repayment on delivery", () => {
loanBalance: 5000 - repayment,
});
expect(state.summaries[0]).toMatchObject({
deadhead: false,
credits: 2000 - repayment,
loanRepayment: repayment,
});

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { boardPeriodMs, validateDispatch } from "@deadhead/sim";
import { boardPeriodMs } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { discoveredLocations } from "../src/db/schema";
import { TEST_NOW, useTestDatabase } from "./db";
@ -76,33 +76,4 @@ describe("location contract boards", () => {
const response = await call(testApp().app, owner, "GET", "/api/locations/pluto/board");
expect(response.statusCode).toBe(404);
});
it("fills a lone Courier's Earth board with jobs it can fly (CR-006)", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const { contracts } = await board(owner, "earth");
const flyable = contracts.filter(
(contract) =>
validateDispatch(
{
ship: { id: owner.shipId, modelId: "courier", locationId: "earth", inFlight: false },
pilot: {
id: owner.avatarId,
isAvatar: true,
assignedShipId: owner.shipId,
inFlight: false,
skills: {},
restEndsAt: null,
},
contract,
tutorial: false,
credits: 1_000_000,
now: clock.now,
},
content,
).ok,
);
expect(contracts).toHaveLength(content.config.contractBoardSize);
expect(flyable.length).toBeGreaterThanOrEqual(5);
});
});

View File

@ -43,7 +43,6 @@ describe("database migrations", () => {
"pilots",
"players",
"session",
"ship_upgrades",
"ships",
"user",
"verification",

View File

@ -35,14 +35,6 @@ describe("deadhead dispatch", () => {
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" });
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 });
expect(state.company.credits).toBe(10_000 - dispatch.fuelCost);
// The logbook marks it as a deadhead with its fuel (no payout to show).
expect(state.summaries).toHaveLength(1);
expect(state.summaries[0]).toMatchObject({
deadhead: true,
fuelCost: dispatch.fuelCost,
credits: 0,
pilotXp: 0,
});
});
it("refuses an undiscovered destination and changes nothing", async () => {

View File

@ -1,163 +0,0 @@
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
shipResponseSchema,
type PlayerState,
} from "@deadhead/schema";
import {
effectiveStats,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
upgradeCost,
} from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
putBoard,
signUpOwner,
testContract,
useTestApp,
type Guest,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const freighterModel = content.shipModel("freighter")!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** An owner whose avatar now crews a newly bought Freighter at the Earth HQ. */
async function ownerWithFreighter() {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, freighterModel.price + 50_000);
const bought = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: owner.earthBayId,
});
const freighter = shipResponseSchema.parse(bought.json()).ship;
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {});
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/assign`, {
shipId: freighter.id,
});
return { owner, freighter };
}
const upgrade = (owner: Guest, shipId: string, upgradeId: string) =>
call(testApp().app, owner, "POST", `/api/ships/${shipId}/upgrades`, { upgradeId });
const shipIn = (state: PlayerState, id: string) => state.ships.find((s) => s.id === id)!;
describe("starting an upgrade (Step 10I.5)", () => {
it("charges, raises the level, keeps the ship in the shop, then flies it faster", async () => {
const { app, clock } = testApp();
const { owner, freighter } = await ownerWithFreighter();
const before = (await getState(app, owner)).company.credits;
const response = await upgrade(owner, freighter.id, "engines");
expect(response.statusCode).toBe(200);
const until = clock.now + 10 * 60_000;
expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({
upgrades: { engines: 1 },
inShopUntil: until,
shop: { upgradeId: "engines", level: 1, until },
});
const state = await getState(app, owner);
expect(state.company.credits).toBe(before - upgradeCost(freighterModel, 1, content.config));
expect(shipIn(state, freighter.id).stats.cruiseSpeedKmPerGameHour).toBeCloseTo(
freighterModel.cruiseSpeedKmPerGameHour * 1.1,
);
const job = testContract({ massTonnes: 60, expiresAt: FAR_FUTURE });
await putBoard(database().db, owner.playerId, [job], clock.now);
const dispatch = () =>
call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: freighter.id,
pilotId: owner.avatarId,
});
const refused = await dispatch();
expect(refused.statusCode).toBe(409);
expect(errorCode(refused)).toBe("ship-in-shop");
clock.now = until;
const flown = await dispatch();
expect(flown.statusCode).toBe(200);
const { dispatch: trip } = dispatchResponseSchema.parse(flown.json());
const distance = routeDistance(
content.location("earth")!,
content.location("moon")!,
realToGame(until, content.config),
);
const hours = travelTimeGameHours(
effectiveStats(freighterModel, { engines: 1 }, content),
distance,
job.massTonnes,
content.config,
);
expect(trip.arrivalAt - trip.departureAt).toBeCloseTo(
(hours * MS_PER_HOUR) / content.config.timeCompression,
-1,
);
});
it("refuses away from an HQ with not-at-hq", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 50_000);
// Fly the Courier to the Moon, where the player has no bay.
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
clock.now = record.arrivalAt;
const credits = (await getState(app, owner)).company.credits;
const response = await upgrade(owner, owner.shipId, "cargo-bay");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("not-at-hq");
expect((await getState(app, owner)).company.credits).toBe(credits);
});
it("refuses an unknown upgrade with not-found", async () => {
const owner = await signUpOwner(testApp().app, database().db);
expect((await upgrade(owner, owner.shipId, "warp-drive")).statusCode).toBe(404);
});
it("makes exactly one of two simultaneous upgrades the player can afford only one of", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const courier = content.shipModel("courier")!;
await setCredits(database().db, owner.playerId, courier.price);
const second = shipResponseSchema.parse(
(
await call(app, owner, "POST", "/api/ships", {
modelId: "courier",
bayId: owner.earthBayId,
})
).json(),
).ship;
const cost = upgradeCost(courier, 1, content.config);
await setCredits(database().db, owner.playerId, cost + cost / 2);
const responses = await Promise.all([
upgrade(owner, owner.shipId, "engines"),
upgrade(owner, second.id, "engines"),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, owner);
expect(state.company.credits).toBe(cost / 2);
expect(state.ships.filter((s) => s.upgrades.engines === 1)).toHaveLength(1);
});
});

View File

@ -1,7 +1,7 @@
import { content } from "@deadhead/content";
import { useState } from "react";
import { useNavigate } from "react-router";
import { formatCredits, locationName, toLocation } from "../format";
import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
import { useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Sheet } from "../ui";
@ -47,19 +47,6 @@ export function AwaySummary() {
{locationName(s.originId)} → {locationName(s.destinationId)}
</span>
</div>
{!s.deadhead && s.goodId && s.massTonnes !== null && (
<p className="mt-1 text-ink-200">
Delivered {s.massTonnes} t {content.good(s.goodId)?.name ?? s.goodId}
{s.shipper && ` for ${s.shipper}`} {toLocation(s.destinationId)}
</p>
)}
{s.deadhead ? (
// An empty flight earns nothing: say what it was and what it cost.
<div className="mt-2 flex items-center gap-1.5 font-display text-ink-300">
<Icon name="send" className="size-4 shrink-0 text-cyan-300" />
Deadhead · {formatCredits(s.fuelCost)} fuel
</div>
) : (
<div className="mt-2 flex gap-4 font-display text-lg font-bold tabular-nums">
<span className={s.credits >= 0 ? "text-emerald-300" : "text-rose-300"}>
{s.credits >= 0 ? "+" : ""}
@ -67,7 +54,6 @@ export function AwaySummary() {
</span>
<span className="text-violet-300">+{s.pilotXp} XP</span>
</div>
)}
{s.loanRepayment > 0 && (
<div className="mt-1 text-ink-400">
{formatCredits(s.loanRepayment)} went to the lender

View File

@ -1,80 +0,0 @@
import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema";
import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim";
import { useState } from "react";
import { cargoName, formatDuration } from "../format";
import { Pill, type Tone } from "../ui";
/** The good's name as shown on a contract: a Priority good may have its own name (CR-007). */
export function goodName(contract: Pick<Contract, "goodId" | "priority">): string {
const good = content.good(contract.goodId);
if (!good) return contract.goodId;
return contract.priority && good.priorityName ? good.priorityName : good.name;
}
/** A delivery window in game hours, e.g. "5h" or "2.5h". */
export function formatWindow(hours: number): string {
const rounded = hours < 10 ? Math.round(hours * 10) / 10 : Math.round(hours);
return `${rounded}h`;
}
/** Real time for a span of game hours, e.g. "5m 12s". */
export function realTimeOf(gameHours: number): string {
return formatDuration((gameHours * MS_PER_HOUR) / content.config.timeCompression);
}
const CLASS_TONES: Record<string, Tone> = {
heavy: "cyan",
"high-value": "emerald",
dangerous: "rose",
};
/** "HIGH-VALUE · T1": the cargo class, with the tier when it has one. */
export function ClassChip({ contract }: { contract: Pick<Contract, "cargoTypeId" | "tier"> }) {
return (
<Pill tone={CLASS_TONES[contract.cargoTypeId] ?? "default"}>
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` · T${contract.tier}`}
</Pill>
);
}
/**
* "PRIORITY · within 5h" (game hours). Tap to switch to real time; hovering
* shows both.
*/
export function PriorityChip({ windowHours }: { windowHours: number }) {
const [real, setReal] = useState(false);
return (
<button
type="button"
onClick={() => setReal(!real)}
title={`Within ${formatWindow(windowHours)} game time, about ${realTimeOf(windowHours)} real time`}
className="rounded-full"
>
<Pill tone="amber" icon="bolt">
Priority · within{" "}
<span className="normal-case">
{real ? realTimeOf(windowHours) : formatWindow(windowHours)}
</span>
</Pill>
</button>
);
}
/**
* True when a Priority contract has idle ships at its origin and none of them
* can make the window (CR-007). Display only; the sim decides at dispatch.
*/
export function tooSlowForAll(state: PlayerState, contract: Contract, now: number): boolean {
if (!contract.priority || contract.deliveryWindowGameHours === null) return false;
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === contract.originId);
if (idle.length === 0) return false;
return idle.every((ship) => {
const model = content.shipModel(ship.modelId);
return (
!model ||
contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours!
);
});
}

View File

@ -38,11 +38,6 @@ export function locationName(id: string): string {
return content.location(id)?.name ?? id;
}
/** A location as a destination in a sentence: "to the Moon", "to Mars". */
export function toLocation(id: string): string {
return id === "moon" ? "to the Moon" : `to ${locationName(id)}`;
}
export function shipModelName(id: string): string {
return content.shipModel(id)?.name ?? id;
}

View File

@ -2,7 +2,6 @@ import { content, type ShipModel } from "@deadhead/content";
import type { Contract, Pilot, PlayerState } from "@deadhead/schema";
import {
checkRoute,
contractTripGameHours,
dutyAfterTrip,
LONG_HAUL_SKILL_ID,
needsRest,
@ -29,20 +28,17 @@ import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Sheet } from "../ui";
import { formatWindow, goodName, PriorityChip } from "../components/Goods";
/** Short player-facing reasons a ship+pilot cannot take a contract. */
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
"ship-not-idle": "Ship is flying",
"ship-not-at-origin": "Not at pickup",
"ship-in-shop": "Ship is in the shop",
"pilot-resting": "Pilot is resting",
"pilot-not-idle": "Pilot is flying",
"pilot-not-assigned": "No pilot assigned",
"over-capacity": "Too heavy",
"cannot-land": "Can't land there",
"out-of-range": "Out of range",
"too-slow": "Too slow for the Priority window",
"cargo-skill-too-low": "Cargo skill too low",
"long-haul-too-low": "Long Haul too low",
"contract-expired": "Contract expired",
@ -85,14 +81,6 @@ export function explainProblems(
: INELIGIBLE_REASONS["cannot-land"],
);
}
// Priority (CR-007): the trip must fit the delivery window.
const window = contract.priority ? contract.deliveryWindowGameHours : null;
if (window !== null) {
const trip = contractTripGameHours(model, contract, now, content);
if (trip > window) {
problems.push(`Too slow — ${formatWindow(trip)} vs ${formatWindow(window)} window`);
}
}
if (contract.requiredSkill) {
const have = pilot.skills[contract.requiredSkill.skillId] ?? 0;
if (have < contract.requiredSkill.level) {
@ -133,8 +121,6 @@ export interface PairOption {
arrivalAt: number;
/** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */
duty: { after: number; limit: number; willRest: boolean } | null;
/** Priority only (CR-007): this ship's trip against the window, in game hours. */
priority: { tripHours: number; windowHours: number } | null;
}
/** A pilot's duty after a trip, with the same sim rules the server applies on arrival. */
@ -213,13 +199,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
netProfit: contract.payout - fuelCost - pilotPay,
arrivalAt: now + tripMs,
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
priority:
contract.priority && contract.deliveryWindowGameHours !== null
? {
tripHours: contractTripGameHours(model, contract, now, content),
windowHours: contract.deliveryWindowGameHours,
}
: null,
};
});
}
@ -255,17 +234,12 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
>
<p aria-label="Job details" className="mt-1 text-sm text-ink-300">
{goodName(contract)} for {contract.shipper} · {cargoName(contract.cargoTypeId)}
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
{formatCredits(contract.payout)}
{" · "}
{requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
</p>
{contract.priority && contract.deliveryWindowGameHours !== null && (
<div className="mt-2">
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
</div>
)}
<div className="mt-3">
<RouteStrip originId={contract.originId} destinationId={contract.destinationId} />
</div>
@ -340,13 +314,6 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
)}
</dl>
)}
{!blocked && option.priority && (
<p aria-label="Priority margin" className="mt-2 text-amber-200">
Priority: {formatWindow(option.priority.tripHours)} trip,{" "}
{formatWindow(option.priority.windowHours - option.priority.tripHours)} to
spare in the {formatWindow(option.priority.windowHours)} window
</p>
)}
{!blocked && option.duty?.willRest && (
<p className="mt-2 text-amber-300">Will need rest on arrival</p>
)}

View File

@ -164,7 +164,6 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
"cannot-land": "Can't land there",
"out-of-range": "Out of range",
"insufficient-credits": "Not enough credits for fuel",
"ship-in-shop": "Ship is in the shop",
"pilot-resting": "Pilot is resting",
"pilot-not-idle": "Pilot is flying",
"pilot-not-assigned": "No pilot assigned",

View File

@ -1,11 +1,18 @@
import type { Contract, PlayerState, Ship } from "@deadhead/schema";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useSearchParams } from "react-router";
import { useApi } from "../api/context";
import { serverNow, useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown";
import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format";
import {
cargoName,
formatClock,
formatCredits,
formatMillionKm,
locationName,
skillName,
} from "../format";
import { distanceAt } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet";
@ -13,52 +20,9 @@ import { DeadheadPicker } from "./Fleet";
import { content } from "@deadhead/content";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Icon, Panel, Pill } from "../ui";
import { ClassChip, goodName, PriorityChip, tooSlowForAll } from "../components/Goods";
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
const seenKey = (locationId: string) => `deadhead:seen-board:${locationId}`;
/** Contract ids on the last board the player saw here, or null if never (decision 0.5.62). */
function readSeen(locationId: string): string[] | null {
try {
const raw = localStorage.getItem(seenKey(locationId));
return raw ? (JSON.parse(raw) as string[]) : null;
} catch {
return null;
}
}
function writeSeen(locationId: string, ids: string[]): void {
try {
localStorage.setItem(seenKey(locationId), JSON.stringify(ids));
} catch {
// Storage blocked: the board works, it just can't tag new contracts.
}
}
/**
* Contracts that weren't on the last board the player saw at this location
* (decision 0.5.62): none on a first visit. Tags stay until the board changes
* again, minus contracts taken in the meantime.
*/
export function useNewContracts(locationId: string, contracts: Contract[] | undefined) {
const [fresh, setFresh] = useState<ReadonlySet<string>>(new Set());
const ids = contracts?.map((c) => c.id) ?? null;
const idsKey = ids?.join(",") ?? null;
useEffect(() => {
if (ids === null) return;
const seen = readSeen(locationId);
const added = seen === null ? [] : ids.filter((id) => !seen.includes(id));
setFresh((previous) =>
added.length > 0 ? new Set(added) : new Set([...previous].filter((id) => ids.includes(id))),
);
writeSeen(locationId, ids);
// `idsKey` stands for `ids` (a new array every render).
}, [locationId, idsKey]);
return fresh;
}
/** Skills a contract needs, e.g. "Heavy Cargo 2, Long Haul 1", or null. */
export function requirementsText(contract: Contract): string | null {
const parts = [
@ -70,18 +34,6 @@ export function requirementsText(contract: Contract): string | null {
return parts.length > 0 ? parts.join(", ") : null;
}
/**
* The most a player's ship can carry for a board at this location: the largest
* ship here, or in the whole fleet when none are here; null with no ships
* (decision 0.5.53).
*/
export function carryLimit(state: PlayerState, locationId: string): number | null {
const here = state.ships.filter((s) => s.locationId === locationId);
const ships = here.length > 0 ? here : state.ships;
if (ships.length === 0) return null;
return Math.max(...ships.map((s) => content.shipModel(s.modelId)?.cargoCapacityTonnes ?? 0));
}
/**
* The location the Contracts screen opens on: where the most recently arrived
* idle ship is, otherwise Earth (CR-002).
@ -155,9 +107,7 @@ function LocationBoard({
onSelect?: (contract: Contract) => void;
}) {
const api = useApi();
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const limit = carryLimit(state, locationId);
const board = useQuery({
queryKey: locationBoardKey(locationId),
queryFn: () => api.call("getContractBoard", { locationId }),
@ -167,7 +117,6 @@ function LocationBoard({
return endsAt === undefined ? false : Math.max(1000, endsAt - serverNow() + 500);
},
});
const fresh = useNewContracts(locationId, board.data?.contracts);
if (board.isPending) return <p className="text-ink-400">Loading contracts…</p>;
if (board.isError) {
@ -190,14 +139,6 @@ function LocationBoard({
<span className="text-cyan-200">
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
{fresh.size > 0 && (
<span
role="status"
className="ml-2 animate-rise rounded-full border border-cyan-400/50 bg-cyan-500/15 px-2 py-0.5 tracking-wider text-cyan-100 normal-case"
>
New contracts posted
</span>
)}
</p>
);
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
@ -220,54 +161,27 @@ function LocationBoard({
<ul className="grid gap-3 sm:grid-cols-2">
{board.data.contracts.map((contract) => {
const requirements = requirementsText(contract);
const tooHeavy = limit !== null && contract.massTonnes > limit;
return (
<Panel
as="li"
key={contract.id}
tone={contract.firstContact ? "amber" : "default"}
feature={contract.firstContact}
className={`flex flex-col gap-2 ${fresh.has(contract.id) ? "animate-rise" : ""}`}
className="flex flex-col gap-2"
>
<div className="flex items-start justify-between gap-2">
<span className="font-display text-sm font-semibold tracking-wide text-ink-300">
<span className="font-display font-bold tracking-wide">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
{contract.firstContact && (
<Pill tone="amber" icon="star" className="ml-2 align-middle">
First contact
</Pill>
)}
</span>
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
{formatCredits(contract.payout)}
</span>
</div>
<div>
<h3 className="font-display text-lg leading-tight font-bold tracking-wide">
{goodName(contract)}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
{fresh.has(contract.id) && (
<Pill tone="cyan" icon="star">
New
</Pill>
)}
<ClassChip contract={contract} />
{contract.priority && contract.deliveryWindowGameHours !== null && (
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
)}
{contract.firstContact && (
<Pill tone="amber" icon="star">
First contact
</Pill>
)}
{tooHeavy && (
<Pill tone="rose" icon="alert">
Too heavy
</Pill>
)}
{tooSlowForAll(state, contract, now) && (
<Pill tone="rose" icon="clock">
Too slow
</Pill>
)}
</div>
</div>
<RouteStrip
originId={contract.originId}
destinationId={contract.destinationId}
@ -276,11 +190,9 @@ function LocationBoard({
<div className="flex items-center gap-1.5 text-sm text-ink-300">
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
<span>
{contract.shipper} ·{" "}
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}>
{contract.massTonnes} t
</span>{" "}
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
</span>
</div>
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">

View File

@ -103,11 +103,11 @@ export function Locations() {
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Produces
</dt>
<dd>{location.cargoClassMix.produces.map(cargoName).join(", ") || "—"}</dd>
<dd>{location.produces.map(cargoName).join(", ") || "—"}</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Demands
</dt>
<dd>{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}</dd>
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
</dl>
<Link
to={`/contracts?location=${location.id}`}

View File

@ -117,8 +117,8 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
</p>
<p className="text-ink-400">
Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} ·
demands {location.cargoClassMix.demands.map(cargoName).join(", ") || "nothing"}
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
{location.demands.map(cargoName).join(", ") || "nothing"}
</p>
<p className={hasBay ? "text-emerald-300" : "text-ink-400"}>
{hasBay ? "Your HQ is here." : "No HQ here."}

View File

@ -3,8 +3,7 @@ import { deliveryXp, loanerModel } from "@deadhead/sim";
import { useQuery } from "@tanstack/react-query";
import { useApi } from "../api/context";
import { useClock } from "../clock/clock";
import { formatCredits, formatDuration, locationName } from "../format";
import { goodName } from "../components/Goods";
import { formatCredits, formatDuration, locationName, cargoName } from "../format";
import { distanceAt, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip";
@ -114,7 +113,7 @@ export function TutorialBoard() {
</span>
<span className="flex items-center gap-1 text-sm whitespace-nowrap text-ink-300">
<Icon name="cargo" className="size-4 text-ink-400" />
{goodName(contract)} · {contract.massTonnes} t
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
</span>
</div>
{scripted && (

View File

@ -2,7 +2,7 @@ import type { PlayerState, Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { tabAttention } from "../src/layout/attention";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const ship = (overrides: Partial<Ship>): Ship => ({
id: "s",
@ -12,7 +12,6 @@ const ship = (overrides: Partial<Ship>): Ship => ({
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null,
...overrides,
});

View File

@ -3,7 +3,7 @@ import type { PlayerState } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
function returningPlayer(acknowledged: boolean): PlayerState {
return samplePlayerState({
@ -18,7 +18,6 @@ function returningPlayer(acknowledged: boolean): PlayerState {
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar",
},
],
@ -33,11 +32,6 @@ function returningPlayer(acknowledged: boolean): PlayerState {
destinationId: "moon",
tutorial: false,
credits: 538,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 230,
pilotXp: 10,
discoveredLocationId: null,

View File

@ -2,7 +2,7 @@ import type { ResolutionSummary, Ship } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const ship: Ship = {
id: "s1",
@ -12,7 +12,6 @@ const ship: Ship = {
locationId: "mars",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar",
};
@ -25,11 +24,6 @@ const summaries: ResolutionSummary[] = [
destinationId: "mars",
tutorial: false,
credits: 51_200,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 32,
discoveredLocationId: "mars",
@ -45,11 +39,6 @@ const summaries: ResolutionSummary[] = [
destinationId: "earth",
tutorial: true,
credits: 240,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,
@ -60,57 +49,6 @@ const summaries: ResolutionSummary[] = [
];
describe("while you were away", () => {
it("names what was delivered, how much, for whom and where (CR-007)", async () => {
const delivery: ResolutionSummary = {
...summaries[0]!,
destinationId: "moon",
goodId: "medical-supplies",
massTonnes: 6,
shipper: "Meridian Health",
discoveredLocationId: null,
};
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({ tutorial: null, ships: [ship], summaries: [delivery] }),
}),
);
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(within(dialog).getAllByRole("listitem")[0]).toHaveTextContent(
"Delivered 6 t Medical Supplies for Meridian Health to the Moon",
);
});
it("shows a deadhead as an empty flight with its fuel, not as a +0 payout", async () => {
const deadhead: ResolutionSummary = {
...summaries[0]!,
dispatchId: "d3",
destinationId: "moon",
deadhead: true,
fuelCost: 230,
goodId: null,
massTonnes: null,
shipper: null,
credits: 0,
pilotXp: 0,
discoveredLocationId: null,
};
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({ tutorial: null, ships: [ship], summaries: [deadhead] }),
}),
);
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
const entry = within(dialog).getAllByRole("listitem")[0]!;
expect(entry).toHaveTextContent("Dusty Mule");
expect(entry).toHaveTextContent("Earth → Moon");
expect(entry).toHaveTextContent("Deadhead · 230 cr fuel");
expect(entry).not.toHaveTextContent("+0 cr");
expect(entry).not.toHaveTextContent("XP");
expect(entry).not.toHaveTextContent("Delivered");
});
it("lists each completed delivery, and dismissing acknowledges them and hides the dialog", async () => {
let acknowledged = false;
const api = fakeApi({

View File

@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatClock } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const period = boardPeriodMs(content.config);
// A quarter of the way into a board period (derived from config, whatever its length).
@ -21,7 +21,6 @@ const courier: Ship = {
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "p1",
};
const rae: Pilot = {
@ -46,10 +45,6 @@ const heavyJob: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 300,
payout: 9000,

View File

@ -1,92 +0,0 @@
import type { Contract, Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { realTimeOf } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.now();
const job = (overrides: Partial<Contract>): Contract => ({
id: "j",
originId: "earth",
destinationId: "moon",
cargoTypeId: "high-value",
goodId: "medical-supplies",
shipper: "Meridian Health",
priority: false,
deliveryWindowGameHours: null,
tier: 1,
massTonnes: 6,
payout: 3_210,
requiredSkill: { skillId: "expensive-cargo", level: 1 },
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 600_000,
firstContact: false,
...overrides,
});
const ship = (modelId: string): Ship => ({
id: modelId,
modelId,
name: `My ${modelId}`,
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
...shipExtras(modelId),
});
function render(contracts: Contract[], ships: Ship[]) {
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }], ships }),
getContractBoard: () => ({ contracts, periodEndsAt: NOW + 600_000 }),
}),
"/contracts?location=earth",
);
}
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
describe("contract card (CR-007)", () => {
it("shows the good, its class and tier, the shipper, mass and requirement", async () => {
render([job({})], [ship("courier")]);
const c = await card("3,210 cr");
expect(within(c).getByRole("heading", { name: "Medical Supplies" })).toBeInTheDocument();
expect(c).toHaveTextContent("High-Value · T1");
expect(c).toHaveTextContent("Meridian Health · 6 t");
expect(c).toHaveTextContent("Needs Expensive Cargo 1");
expect(c).not.toHaveTextContent("Priority");
});
it("shows a Priority window in game hours, and real time on tap", async () => {
render(
[
job({
goodId: "machine-parts",
cargoTypeId: "heavy",
priority: true,
deliveryWindowGameHours: 5,
}),
],
[ship("courier")],
);
const c = await card("3,210 cr");
// A Priority good can have its own name.
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
const chip = within(c).getByRole("button", { name: /Priority · within 5h/ });
await userEvent.click(chip);
expect(chip).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
expect(c).not.toHaveTextContent("Too slow");
});
it("marks a Priority job too slow when no idle ship here can make the window", async () => {
const tight = job({ priority: true, deliveryWindowGameHours: 0.5 });
render([tight], [ship("bulk-hauler")]);
expect(await card("3,210 cr")).toHaveTextContent("Too slow");
});
});

View File

@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const offered = () =>
samplePlayerState({
@ -33,7 +33,6 @@ const afterDeal = () =>
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar",
},
],
@ -83,11 +82,6 @@ describe("the deal", () => {
destinationId: "earth",
tutorial: true,
credits: 96,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,

View File

@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatGameHours } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -18,7 +18,6 @@ const ship = (id: string, name: string, pilotId: string): Ship => ({
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId,
});
@ -45,10 +44,6 @@ const moonJob: Contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 900,

View File

@ -1,117 +0,0 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, Ship } from "@deadhead/schema";
import { contractTripGameHours, priorityWindow } 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 { formatWindow } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const ship = (id: string, name: string, modelId: string, pilotId: string): Ship => ({
id,
name,
modelId,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras(modelId),
pilotId,
});
const pilot = (id: string, name: string, shipId: string): Pilot => ({
id,
name,
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: shipId,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
});
/** Priority mail to the Moon, with the Freighter-reference window the sim would set. */
const rush: Contract = {
id: "c-rush",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "mail",
shipper: "Pacific Orbital Logistics",
priority: true,
deliveryWindowGameHours: priorityWindow(earth, moon, 6, NOW, content),
tier: 0,
massTonnes: 6,
payout: 1_500,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
firstContact: false,
};
const trip = (modelId: string) =>
contractTripGameHours(content.shipModel(modelId)!, rush, NOW, content);
async function openSheet() {
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 10_000, loanBalance: 0 },
bays: [{ id: "b", locationId: "earth" }],
ships: [
ship("s-fast", "Quick Finch", "courier", "p-fast"),
ship("s-slow", "Old Barge", "bulk-hauler", "p-slow"),
],
pilots: [pilot("p-fast", "Ana Ruiz", "s-fast"), pilot("p-slow", "Bo Lind", "s-slow")],
}),
getContractBoard: () => ({ contracts: [rush], periodEndsAt: NOW + 3_600_000 }),
}),
"/contracts?location=earth",
);
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
return screen.getByRole("dialog");
}
const option = (dialog: HTMLElement, shipName: string) =>
within(dialog).getByText(shipName).closest("li")!;
describe("Priority in the dispatch sheet (CR-007)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("enables a ship fast enough for the window and shows its margin", async () => {
const window = rush.deliveryWindowGameHours!;
const fast = option(await openSheet(), "Quick Finch");
expect(within(fast).getByRole("radio")).toBeEnabled();
expect(within(fast).getByLabelText("Priority margin")).toHaveTextContent(
`Priority: ${formatWindow(trip("courier"))} trip, ${formatWindow(window - trip("courier"))} to spare in the ${formatWindow(window)} window`,
);
});
it("disables a too-slow ship with its trip and the window from the sim", async () => {
const window = rush.deliveryWindowGameHours!;
expect(trip("bulk-hauler")).toBeGreaterThan(window);
const slow = option(await openSheet(), "Old Barge");
expect(within(slow).getByRole("radio")).toBeDisabled();
expect(within(slow).getByRole("list", { name: "Problems" })).toHaveTextContent(
`Too slow — ${formatWindow(trip("bulk-hauler"))} vs ${formatWindow(window)} window`,
);
});
});

View File

@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { tutorialHint } from "../src/screens/HQ";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -17,10 +17,6 @@ const job = (
originId: "earth",
destinationId: "moon",
cargoTypeId: skill ? "high-value" : "standard",
goodId: skill ? "medical-supplies" : "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: skill?.level ?? 0,
massTonnes,
payout: 1000,
@ -47,7 +43,6 @@ function afterDeal(): PlayerState {
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar",
},
],

View File

@ -15,7 +15,7 @@ import { useClock } from "../src/clock/clock";
import { formatGameTime } from "../src/clock/GameClock";
import { formatCredits, formatDuration } from "../src/format";
import { formatLocalTime } from "../src/screens/DispatchSheet";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -27,7 +27,6 @@ const ship = (id: string, name: string, modelId: string, pilotId: string | null)
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras(modelId),
pilotId,
});
@ -53,10 +52,6 @@ const marsJob: Contract = {
originId: "earth",
destinationId: "mars",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 15,
payout: 500_000,

View File

@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatCredits, formatDuration } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -36,7 +36,6 @@ const ship = (overrides: Partial<Ship>): Ship => ({
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null,
...overrides,
});

View File

@ -1,75 +0,0 @@
import type { Contract } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const job = (id: string, payout: number): Contract => ({
id,
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "mail",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 4,
payout,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: Date.now(),
expiresAt: Date.now() + 600_000,
firstContact: false,
});
const owner = () => samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }] });
function render(boards: Contract[][], periodMs = 600_000) {
let call = 0;
renderApp(
fakeApi({
getPlayerState: owner,
getContractBoard: () => ({
contracts: boards[Math.min(call++, boards.length - 1)]!,
periodEndsAt: Date.now() + periodMs,
}),
}),
"/contracts?location=earth",
);
}
const remember = (ids: string[]) =>
localStorage.setItem("deadhead:seen-board:earth", JSON.stringify(ids));
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
describe("visible board refresh (CR-008)", () => {
it("tags contracts that weren't on the last board seen here, and says so", async () => {
remember(["old-1", "old-2"]);
render([[job("old-1", 111), job("new-1", 222)]]);
expect(await screen.findByRole("status")).toHaveTextContent("New contracts posted");
expect(within(await card("222 cr")).getByText("New")).toBeInTheDocument();
expect(within(await card("111 cr")).queryByText("New")).not.toBeInTheDocument();
});
it("tags nothing when the board is the one already seen", async () => {
remember(["old-1"]);
render([[job("old-1", 111)]]);
await card("111 cr");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByText("New")).not.toBeInTheDocument();
});
it("tags nothing on a first visit, but remembers the board", async () => {
render([[job("a", 111)]]);
await card("111 cr");
expect(screen.queryByText("New")).not.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem("deadhead:seen-board:earth")!)).toEqual(["a"]);
});
it("announces the new board when the timer rolls over while the page is open", async () => {
render([[job("a", 111)], [job("b", 222)]], 300);
await card("111 cr");
expect(await screen.findByRole("status")).toHaveTextContent("New contracts posted");
expect(within(await card("222 cr")).getByText("New")).toBeInTheDocument();
});
});

View File

@ -4,7 +4,7 @@ import { act, screen, waitFor, 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 { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -16,7 +16,6 @@ const ship = (id: string, status: Ship["status"]): Ship => ({
locationId: status === "idle" ? "earth" : null,
dispatchId: status === "idle" ? null : `d-${id}`,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null,
});
@ -147,11 +146,6 @@ describe("loan on HQ", () => {
destinationId: "moon",
tutorial: false,
credits: 538,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 230,
pilotXp: 10,
discoveredLocationId: null,

View File

@ -3,7 +3,7 @@ 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 { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -12,10 +12,6 @@ const contract = (overrides: Partial<Contract>): Contract => ({
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 50,
payout: 2000,
@ -34,10 +30,6 @@ const boards: Record<string, Contract[]> = {
id: "e2",
destinationId: "mars",
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 2,
requiredSkill: { skillId: "heavy-cargo", level: 2 },
requiredLongHaulLevel: 1,
@ -57,7 +49,6 @@ const ship = (id: string, overrides: Partial<Ship>): Ship => ({
dispatchId: null,
pilotId: null,
lastArrivalAt: NOW - 60_000,
...shipExtras("courier"),
...overrides,
});
@ -142,23 +133,19 @@ describe("location contract boards", () => {
expect(screen.getByRole("button", { name: /Earth/, pressed: true })).toBeInTheDocument();
});
it("shows the good, class and tier, shipper, mass, requirements, expiry and first contact", async () => {
it("shows contract details, requirements, expiry and the first-contact badge", async () => {
renderApp(boardApi(), "/contracts?location=earth");
await screen.findByText("1,999 cr");
const items = screen.getAllByRole("listitem");
const [plain, special] = items;
// CR-007 card: good headline, class chip, shipper · mass · distance.
expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument();
expect(plain).toHaveTextContent("Standard");
expect(plain).toHaveTextContent("Pacific Orbital Logistics · 50 t");
expect(plain).toHaveTextContent("Standard · 50 t");
expect(plain).toHaveTextContent("No skills needed");
expect(plain).toHaveTextContent("expires in 30m 00s");
expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
expect(special).toHaveTextContent("Earth → Mars");
expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument();
expect(special).toHaveTextContent("Heavy · T2");
expect(special).toHaveTextContent("Heavy (tier 2)");
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
expect(special).toHaveTextContent(/M km/);
expect(within(special!).getByText("First contact")).toBeInTheDocument();

View File

@ -4,7 +4,7 @@ import { loadArt, resetArtRegistration, type AssetLoader } from "../src/map/art/
import { artManifest } from "../src/map/art/manifest";
import { sceneVisuals } from "../src/map/art/visuals";
import { buildScene } from "../src/map/scene";
import { samplePlayerState, shipExtras } from "./render";
import { samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -35,7 +35,6 @@ const flying = (id: string, modelId: string, dispatchId: string): Ship => ({
dispatchId,
pilotId: null,
lastArrivalAt: 0,
...shipExtras(modelId),
});
/** Ships of every model in flight, plus the tutorial loaner. */

View File

@ -6,7 +6,7 @@ import { useClock } from "../src/clock/clock";
import { DEFAULT_CAMERA, toScreen } from "../src/map/camera";
import { hitTest } from "../src/map/hitTest";
import { buildScene } from "../src/map/scene";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -42,7 +42,6 @@ function mapState(): PlayerState {
locationId: null,
dispatchId: "d1",
lastArrivalAt: 0,
...shipExtras("freighter"),
pilotId: "p-rae",
},
],

View File

@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { locationOverlays } from "../src/map/overlays";
import { buildScene } from "../src/map/scene";
import { mapStyle } from "../src/map/style";
import { samplePlayerState, shipExtras } from "./render";
import { samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const idleAtMoon = (id: string): Ship => ({
@ -15,7 +15,6 @@ const idleAtMoon = (id: string): Ship => ({
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
});
/** After the deal: an Earth bay, two idle ships at the Moon, Mars and beyond undiscovered. */

View File

@ -2,7 +2,7 @@ import type { Dispatch } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { DEFAULT_CAMERA, toScreen, zoomAt } from "../src/map/camera";
import { buildScene, flightMarker } from "../src/map/scene";
import { samplePlayerState, shipExtras } from "./render";
import { samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -64,7 +64,6 @@ describe("ship markers", () => {
locationId: null,
dispatchId: "d1",
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "p1",
},
{
@ -75,7 +74,6 @@ describe("ship markers", () => {
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null,
},
{
@ -86,7 +84,6 @@ describe("ship markers", () => {
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null,
},
],

View File

@ -3,7 +3,7 @@ import type { Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const courierPrice = content.shipModel("courier")!.price;
const freighterPrice = content.shipModel("freighter")!.price;
@ -47,7 +47,6 @@ describe("market screen", () => {
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("freighter"),
pilotId: null,
};
let owned: Ship[] = [];
@ -103,7 +102,6 @@ describe("market screen", () => {
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
},
}),
});

View File

@ -4,7 +4,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { levelProgress } from "../src/screens/Pilots";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const ship = (overrides: Partial<Ship>): Ship => ({
id: "s",
@ -14,7 +14,6 @@ const ship = (overrides: Partial<Ship>): Ship => ({
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null,
...overrides,
});

View File

@ -1,6 +1,4 @@
import { content } from "@deadhead/content";
import type { PlayerState, RouteName, RouteRequest, RouteResponse, Ship } from "@deadhead/schema";
import { effectiveStats } from "@deadhead/sim";
import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadhead/schema";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router";
@ -34,31 +32,6 @@ export const unauthorized = () => {
};
/** A brand-new tutorial player, as guest sign-up returns it. */
/**
* The CR-009 fields of a ship with no upgrades and not in the shop: its model's
* stats as effective stats. Spread into Ship fixtures, then override as needed.
*/
export function shipExtras(
modelId: string,
upgrades: Record<string, number> = {},
): Pick<Ship, "upgrades" | "stats" | "inShopUntil" | "shop"> {
const model = content.shipModel(modelId);
if (!model) throw new Error(`Unknown ship model ${modelId}`);
const s = effectiveStats(model, upgrades, content);
return {
upgrades,
stats: {
cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: s.cargoCapacityTonnes,
fuelRangeMillionKm: s.fuelRangeMillionKm,
overheadGameHours: s.overheadGameHours,
handlingGameHoursPerTonne: s.handlingGameHoursPerTonne,
},
inShopUntil: null,
shop: null,
};
}
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
return {
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
@ -129,10 +102,6 @@ export function sampleTutorialBoard(
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes,
payout,

View File

@ -33,11 +33,6 @@ const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
destinationId: "moon",
tutorial: false,
credits: 500,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,

View File

@ -2,11 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
// The board remembers what was seen per location (CR-008); start each test fresh.
if (typeof localStorage !== "undefined") localStorage.clear();
});
afterEach(() => cleanup());
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
configure({ asyncUtilTimeout: 3000 });

View File

@ -149,11 +149,6 @@ describe("scripted delivery summary", () => {
destinationId: "moon",
tutorial: true,
credits: 461,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,

View File

@ -1,81 +0,0 @@
import type { Contract, Ship } from "@deadhead/schema";
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { carryLimit } from "../src/screens/LocationBoards";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.now();
const contract = (id: string, massTonnes: number): Contract => ({
id,
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes,
payout: 1000 + massTonnes,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 30 * 60_000,
firstContact: false,
});
const ship = (id: string, overrides: Partial<Ship>): Ship => ({
id,
modelId: "courier",
name: id,
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
...overrides,
});
const owner = (ships: Ship[]) =>
samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }], ships });
const render = (ships: Ship[]) =>
renderApp(
fakeApi({
getPlayerState: () => owner(ships),
getContractBoard: () => ({
contracts: [contract("light", 10), contract("heavy", 50)],
periodEndsAt: NOW + 600_000,
}),
}),
"/contracts?location=earth",
);
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
describe("weight warning (Step 10F.1)", () => {
it("flags a job heavier than every ship at the location", async () => {
render([ship("Dusty Mule", {})]);
expect(await card("1,050 cr")).toHaveTextContent("Too heavy");
expect(await card("1,010 cr")).not.toHaveTextContent("Too heavy");
});
it("uses the ships at the location, not elsewhere", async () => {
render([
ship("Dusty Mule", { locationId: "moon" }),
ship("Big Ox", { modelId: "freighter", locationId: "earth" }),
]);
expect(await card("1,050 cr")).not.toHaveTextContent("Too heavy");
});
it("falls back to the whole fleet when no ship is at the location", () => {
const state = owner([
ship("a", { locationId: "moon" }),
ship("b", { modelId: "freighter", locationId: null, status: "in-flight" }),
]);
expect(carryLimit(state, "earth")).toBe(100);
expect(carryLimit(owner([]), "earth")).toBeNull();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 KiB

View File

@ -25,20 +25,20 @@ Living reference for how the code is organised. Update it after any database sch
|---|---|
| `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits`, `loanBalance` |
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, range, overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) |
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) |
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) |
| `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` |
| `Contract` | mirrors sim `Contract`: route, `cargoTypeId` (class), `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (Priority only, else null), `tier`, `massTonnes` (to 0.1 t under 10 t), `payout`, requirements, `offeredAt`/`expiresAt`, `firstContact`, optional `scriptedId` on a scripted tutorial contract (CR-007) |
| `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) |
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `deadhead`, `fuelCost`, `goodId` / `massTonnes` / `shipper` (null for a deadhead, CR-007), `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` |
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` |
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
### 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), 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, plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...).
## API routes (`packages/schema/src/routes.ts`)
@ -52,7 +52,6 @@ The `routes` catalog is the single source for both server and client. Each entry
| `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes |
| `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes |
| `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes |
| `startUpgrade` | `POST /api/ships/:shipId/upgrades` | `shipId`, `upgradeId` (next level; ship idle at an HQ) | `{ ship }` | yes |
| `buyShip` | `POST /api/ships` | `modelId`, `bayId` (one of the player's bays; the ship is delivered there) | `{ ship }` | yes |
| `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes |
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
@ -60,21 +59,20 @@ The `routes` catalog is the single source for both server and client. Each entry
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
| `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ pilot }` | yes |
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |
## Database (`apps/server/src/db`)
Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/drizzle/`, generated with `pnpm --filter @deadhead/server db:generate` and applied with `db:migrate` (uses `DATABASE_URL`; rerunning is a no-op). Contracts are JSONB snapshots (`contract_boards.contracts`, `dispatches.record`); `0005_cr007_goods` backfills pre-CR-007 contracts with a default good of their class, the origin's first shipper and no Priority. Every game table hangs off `players.id`; times are bigint Unix ms.
Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/drizzle/`, generated with `pnpm --filter @deadhead/server db:generate` and applied with `db:migrate` (uses `DATABASE_URL`; rerunning is a no-op). Every game table hangs off `players.id`; times are bigint Unix ms.
| Table | Purpose | Key constraints |
|---|---|---|
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009) | — |
| `ship_upgrades` | Installed upgrade level per ship and upgrade (CR-009) | primary key (ship, upgrade) |
| `ships` | Owned ships: model, name, status, location (null in flight) | — |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004) | unique `assigned_ship_id` (one pilot per ship) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) |
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |

View File

@ -82,7 +82,6 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
- If a scripted contract is keyed to the upcoming run number, it replaces the medium slot.
- Update the Step 3.10 tests to match.
- **Test:** The board has exactly three contracts with three different masses, all within Company Courier capacity. Same inputs give an identical board. With two runs completed, the "Unmarked crates" contract appears at double pay; with any other count, it does not. The light contract gives more XP per real minute than the full one, and the full one gives more credits per real minute than the light one. The sum of the four slowest possible tutorial trip times is under 6 real minutes.
- **Amended by CR-007:** Tutorial contracts carry named Standard goods made at the loaner's location and wanted at the other end (never Priority); masses stay at the three tiers (Step 10G.5).
### Step 10A.4 — Sim: loan repayment
- Add a pure function that splits a credit amount into repayment and net, given the loan balance and repayment share.

View File

@ -1,75 +0,0 @@
# Change Request CR-006 — Boards That Fit Your Fleet
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10 and CR-001 to CR-005 complete
**Do this:** as **Phase 10F**, after Phase 10E and before Phase 11
**Related docs:** `implementation-plan.md`, `CR-002-open-markets.md`, `DECISIONS.md`
---
## 1. Why
User feedback after CR-005:
> On the jobs list the weight should also be highlighted if your ship can't carry it. But I'd also like there to be more options that do fit your current stats and ship size instead of just one.
Each board has 8 contracts in three mass bands (3 up to a Courier's 20 t, 3 up to a Freighter's 100 t, 2 up to a Bulk Hauler's 400 t). Only the first contract of each band is guaranteed to be Standard cargo to a discovered place. The other two Courier-band jobs are usually skill-gated (High-Value tier 1–5) or first contact out of a Courier's range. A player with one Courier and an unskilled pilot typically has **one** job per board they can fly, and five of the eight contracts are for ships they don't own.
Boards are already generated per player (decision 0.5.21), so generation can look at the player's fleet.
## 2. How it works
| Rule | Detail |
|---|---|
| Fleet | Every ship the player owns, with its assigned pilot's skills (no pilot: no skills, like a fresh hire). Taken when the board is first generated in a period, like the discovered locations. |
| Flyable band | A band is flyable when some ship in the fleet can carry its minimum mass. |
| Preview slots | A band no ship can carry keeps `boardPreviewSlots` contracts (default 1): a look at what a bigger ship would earn. Its other slots move to flyable bands, largest flyable band first, round-robin. The board size does not change. |
| Fit slots | In a flyable band, all but `boardOpenSlotsPerBand` contracts (default 1) are **fit**: built for one ship of the fleet that can carry the band's minimum. Mass is within the band and that ship's capacity; the destination is discovered, reachable (range and landing) and needs no more Long Haul than its pilot has; the cargo is Standard or a skill cargo the pilot has, at a tier up to the pilot's level. Never first contact. |
| Open slots | The remaining contracts in a flyable band are generated as today (skill-gated, first contact or any). |
| No fit possible | If no ship of the fleet can reach any discovered place from this location, the slot is generated as today. |
| No fleet | With an empty fleet (or no fleet input) boards are generated exactly as before. |
Example, one Courier with an unskilled pilot at Earth: Courier band 3 + 2 freed (Freighter band keeps 1) + 1 freed (Hauler band keeps 1) = 6 contracts, **5 fit** (Earth → Moon, 5–20 t, Standard) and 1 open, plus one Freighter and one Bulk Hauler preview.
On the board, a contract's mass is highlighted when none of the player's ships can carry it: the ships at that location, or the whole fleet when none are there.
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply. Update `progress.md` after every step.
- Board generation stays a pure, deterministic sim function: the same inputs (now including the fleet) give the same board.
- The two new numbers are config, not code.
- More flyable jobs means more income per board; the payout balance is Phase 12's job. Note anything that looks off.
## 4. Decisions
Record as plan §0.5.52–0.5.53.
- **0.5.52 Boards fit the fleet.** Location boards are generated with the player's fleet (ship models and pilot skills) as input. Bands the fleet can't carry shrink to a preview; in bands it can, all but a configured number of contracts are ones some ship-and-pilot pair can take.
- **0.5.53 Weight warning.** The board highlights a contract's mass when none of the player's ships at that location (or in the fleet, when none are there) can carry it. Display only.
## 5. Steps — Phase 10F
### Step 10F.1 — Client: weight warning
- On location boards, the mass is shown in rose with a "Too heavy" pill when it exceeds the largest capacity among the player's ships at that location (all ships when none are there). No ships at all: no warning.
- **Test:** a board with a 50 t job and a Courier at the location shows the warning on it and not on a 10 t job; with the Courier elsewhere and a Freighter at the location, no warning on 50 t.
### Step 10F.2 — Content: board config
- GameConfig gains `boardPreviewSlots` (default 1) and `boardOpenSlotsPerBand` (default 1), non-negative integers.
- **Test:** schema accepts and rejects them like other counts; defaults are as listed.
### Step 10F.3 — Sim: fleet-aware boards
- `BoardInput.fleet` (optional): `{ modelId, skills }[]`. Implement §2.
- **Test:** no fleet gives the same board as before (existing tests unchanged); determinism with a fleet; Courier-only fleet at Earth: board size unchanged, at least `bands' total − previews − open` fit contracts, each passing `checkRoute`, `checkPilotEligibility` and capacity for the Courier and pilot; a skilled pilot (High-Value 3) gets High-Value fit jobs at tiers ≤ 3 over many periods; a full fleet keeps the configured band counts.
### Step 10F.4 — Server: pass the fleet
- `locationBoard` reads the player's ships and their assigned pilots' skills and passes them as the fleet.
- **Test:** a player with one Courier gets at least 5 Earth contracts that pass sim `validateDispatch` for that Courier and pilot.
### Step 10F.5 — Regression
- Plan amendments (§6), full suite, coverage, lint, build. Stop for review.
## 6. Amendments to earlier steps
- **Step 3.9 (board generation):** boards take the player's fleet as input (decision 0.5.52).
- **Step 6.9 (location board endpoint):** passes the player's fleet to generation.
- **Step 9.4 (contracts screen):** contracts too heavy for the player's ships are highlighted (decision 0.5.53).

View File

@ -1,204 +0,0 @@
# Change Request CR-007 — Cargo Goods, Priority Contracts, and Shippers
> **Implementation notes (added when implemented).** The user supplied this CR as "CR-005, Phase 10E", written before the UI overhaul (CR-005) and fleet-fitted boards (CR-006) existed. It is recorded and built as **CR-007, Phase 10G**, after Phase 10F.
>
> - **Numbering:** decisions 0.5.39–0.5.44 below are recorded as **0.5.54 Goods, 0.5.55 Tier from goods, 0.5.56 Priority, 0.5.57 Board mix unchanged, 0.5.58 Shippers, 0.5.59 Tutorial goods**. Steps 10E.1–10E.11 are **10G.1–10G.11**. The error code `too_slow` is `too-slow` (the API's kebab-case convention).
> - **Mass: the band wins** (user decision). Boards draw mass from per-ship-size bands (`contractMassBands`, playtest fix and CR-006). A contract picks a good of its class whose mass range overlaps its slot's band and draws mass inside both; if no good of that class overlaps, it uses the class's good with the largest maximum and draws from the band (e.g. a 300 t bulk lot of Packaged Food). The lightest band has no floor for goods, so light goods (Research Samples, Antimatter Traps) still appear. Masses under 10 t are drawn to 0.1 t, heavier ones to whole tonnes.
> - **Class mix:** to keep decision 0.5.57 (and the ±3-point test), class selection still uses the class-level lists. They are kept on each location as `cargoClassMix` (`produces`/`demands` of class ids, unchanged values), while `produces`/`demands` now list goods, derived from the catalog. Baseline before the change (1,000 Earth boards, all discovered, seed 12345, no fleet): **Standard 76.79 %, High-Value 23.21 %, Heavy 0 %, Dangerous 0 %**.
> - **Fleet-fitted boards (CR-006):** fit jobs pick goods too: High-Value and Dangerous goods only up to the pilot's skill, mass within the ship's capacity, and Priority only when the ship meets the window.
> - **Tutorial contracts are never Priority.**
> - **Database:** contracts live in JSONB (stored boards and dispatch records), so no column changes; migration `0005` backfills existing rows with a default good, shipper and no Priority so saves survive.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10 and CR-001 through CR-004 complete
**Do this:** as **Phase 10E**, after Phase 10D and before Phase 11 *(built as Phase 10G, see notes)*
**Related docs:** `implementation-plan.md`, the earlier change requests, `game-design-document.md`, `DECISIONS.md`
---
## 1. Why
Contract cards currently say only "Standard · 14 t". The board already mixes cargo classes well, but nothing says what is being shipped, who is shipping it, or why.
This change:
- Adds a **catalog of named goods** (Medical Supplies, Lunar Titanium, Helium-3 Canisters…). Each good belongs to a cargo class, has its own mass range and pay, and is produced and demanded at specific locations. Contracts carry a good, which is pillar 3 of the GDD: every load goes somewhere for a reason.
- Adds **Priority** contracts: a time-sensitive good that must arrive within a delivery window, paying a bonus. It needs no skill, only a fast enough ship, which gives the Courier a real role.
- Adds **shipper names** as flavor (no mechanics yet; reputation later can use them).
**The existing board mix stays as it is.** The generator keeps choosing cargo classes the way it does now; this change only picks a named good within the chosen class and may mark it Priority.
## 2. Concepts
| Term | Meaning |
|---|---|
| Cargo class | The existing four: Standard, Heavy, High-Value, Dangerous. Classes keep their skill requirements. |
| Good | A named item in the catalog. Has a class, mass range, pay multiplier, origins, destinations, and optional Priority eligibility. |
| Tier | 1–5 for non-Standard classes, the required skill level. High-Value and Dangerous now take their tier from the good; Heavy keeps its current tier rule (see §4). |
| Priority | A modifier on a Priority-eligible good. The contract has a delivery window; the ship's trip time must fit inside it. Pays a bonus. |
| Shipper | A flavor company name per location shown on the card. |
## 3. Goods catalog
Numbers are starting values for balance. All of them live in `packages/content`. Pay × multiplies the class's base pay per tonne per million km.
### Standard (no skill)
| Id | Name | Mass (t) | Pay × | Priority | Produced at | Demanded at |
|---|---|---|---|---|---|---|
| `mail` | Mail & Parcels | 1–8 | 1.2 | Yes | Earth, Mars | Moon, Mars, Ceres, Jupiter, Earth |
| `packaged-food` | Packaged Food | 10–60 | 1.0 | No | Earth | Moon, Mars, Ceres, Jupiter |
| `consumer-goods` | Consumer Goods | 5–40 | 1.0 | No | Earth | Moon, Mars, Ceres, Jupiter |
| `optical-glass` | Lunar Optical Glass | 4–25 | 1.1 | No | Moon | Earth, Mars |
| `moon-souvenirs` | Moon Dust Souvenirs | 1–6 | 0.9 | No | Moon | Earth |
| `hydroponic-produce` | Hydroponic Produce | 5–30 | 1.1 | Yes | Mars | Moon, Ceres, Jupiter |
| `salvaged-components` | Salvaged Components | 5–30 | 0.9 | No | Ceres | Moon, Mars |
| `bioplastics` | Atmospheric Bioplastics | 5–40 | 1.0 | No | Jupiter | Earth, Mars, Ceres |
### High-Value (Expensive Cargo skill)
| Id | Name | Mass (t) | Pay × | Tier | Priority | Produced at | Demanded at |
|---|---|---|---|---|---|---|---|
| `medical-supplies` | Medical Supplies | 1–10 | 1.0 | 1 | Yes | Earth | Moon, Mars, Ceres, Jupiter |
| `electronics` | Electronics | 2–15 | 1.0 | 1 | No | Earth | Moon, Mars, Ceres, Jupiter |
| `seed-stock` | Seed Stock & Embryos | 0.5–4 | 1.3 | 2 | Yes | Earth | Moon, Mars, Ceres |
| `low-g-pharma` | Low-G Pharmaceuticals | 0.5–5 | 1.4 | 2 | Yes | Moon | Earth, Mars |
| `research-samples` | Research Samples | 0.2–3 | 1.5 | 2 | Yes | Moon, Mars, Ceres | Earth |
| `rare-earths` | Martian Rare Earths | 5–30 | 1.4 | 3 | No | Mars | Earth |
| `platinum` | Platinum Ingots | 2–20 | 1.8 | 4 | No | Ceres | Earth |
### Heavy (Heavy Cargo skill)
Tier follows the generator's existing Heavy tier rule.
| Id | Name | Mass (t) | Pay × | Priority | Produced at | Demanded at |
|---|---|---|---|---|---|---|
| `machine-parts` | Machine Parts | 20–90 | 1.0 | Yes ("Emergency Parts") | Earth, Mars | Moon, Mars, Ceres, Jupiter |
| `habitat-prefabs` | Habitat Prefabs | 40–180 | 1.0 | No | Earth | Moon, Mars, Ceres |
| `lunar-titanium` | Lunar Titanium | 30–120 | 1.1 | No | Moon | Earth, Mars |
| `iron-ingots` | Iron Ingots | 50–200 | 0.9 | No | Mars | Moon, Ceres |
| `nickel-iron-ore` | Nickel-Iron Ore | 80–300 | 0.8 | No | Ceres | Earth, Mars, Moon |
| `water-ice` | Water Ice | 60–250 | 0.8 | No | Ceres | Mars, Moon, Jupiter |
### Dangerous (Dangerous Cargo skill)
Tier is the good's hazard class.
| Id | Name | Mass (t) | Pay × | Tier | Priority | Produced at | Demanded at |
|---|---|---|---|---|---|---|---|
| `industrial-chemicals` | Industrial Chemicals | 5–40 | 1.0 | 1 | No | Earth | Moon, Mars, Ceres, Jupiter |
| `liquid-oxygen` | Liquid Oxygen | 20–100 | 1.0 | 1 | No | Moon | Mars, Ceres, Jupiter |
| `mining-charges` | Mining Charges | 2–20 | 1.3 | 2 | No | Earth, Mars | Moon, Ceres |
| `methane-fuel` | Methane Fuel | 20–80 | 1.1 | 2 | No | Mars | Moon, Ceres |
| `liquid-hydrogen` | Liquid Hydrogen | 30–150 | 1.1 | 2 | No | Jupiter | Mars, Ceres, Earth |
| `helium-3` | Helium-3 Canisters | 1–10 | 2.0 | 3 | Yes | Jupiter | Earth, Moon |
| `reactor-cores` | Fusion Reactor Cores | 10–40 | 1.8 | 4 | No | Earth | Ceres, Jupiter |
| `antimatter-traps` | Antimatter Containment Traps | 0.1–1 | 4.0 | 5 | No | Jupiter | Earth |
### Shippers (flavor only)
| Location | Shippers |
|---|---|
| Earth | Pacific Orbital Logistics, Meridian Health, Atlas Heavy Industries, Hollis & Crane Consumer |
| Moon | Shackleton Mining Co., Tranquility Labs, Copernicus Glassworks |
| Mars | Jezero Agricultural Co-op, Red Line Metals, Tharsis Chemical |
| Ceres | Belt Ore Consolidated, Piazzi Station Authority |
| Jupiter Cloud Platform | Galilean Fuel Combine, Zephyr Gas Works |
All names are invented for this game.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5. *(Recorded as 0.5.54–0.5.59.)*
- **0.5.39 Goods.** Contracts carry a good from the catalog. A location's produces and demands lists now name goods, not cargo classes. After the generator picks a contract's cargo class (unchanged logic), it picks a good of that class produced at the origin and demanded at the destination, and draws the mass from that good's range. If no good of that class fits the route, fall back to a Standard good that does; if none does (for example a first-contact destination), use any Standard good produced at the origin. *(Mass: see the implementation notes, the band wins.)*
- **0.5.40 Tier from goods.** For High-Value and Dangerous contracts, the tier is the good's tier, replacing the random tier. Standard stays tier 0. Heavy keeps its current tier rule.
- **0.5.41 Priority.** A Priority-eligible good becomes a Priority contract with the configured chance. Its delivery window is the trip time of the configured reference ship model (default: Freighter) carrying this cargo on this route at offer time, times the window factor (default 1.0). At dispatch, the chosen ship's trip time must be no longer than the window, or validation fails with `too_slow`. Priority pays the configured bonus (default ×1.5). No skill is required.
- **0.5.42 Board mix unchanged.** Class selection, board size, and first-contact chance keep their current logic and config.
- **0.5.43 Shippers.** Each contract gets a shipper name drawn from its origin's list. No mechanics.
- **0.5.44 Tutorial goods.** The tutorial board uses Standard goods produced at the loaner's location and demanded at the other end (Earth→Moon: mail, packaged food, consumer goods; Moon→Earth: optical glass, souvenirs). The scripted "Unmarked crates" contract is unchanged.
## 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`.
- Resetting the dev and test databases is fine (stored boards and contract snapshots change shape). Schema migrations are still required.
- Update existing tests whose expectations change. 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 10E.
## 6. Steps — Phase 10E *(built as 10G)*
### Step 10E.1 — Content: goods catalog
- Add a Good schema: id, name, class, mass min and max, pay multiplier, tier (High-Value and Dangerous only), Priority eligible (with optional Priority display name), produced at, demanded at.
- Add all goods from §3. Change each location's produces and demands lists to good ids (derived from the catalog's produced-at and demanded-at, or kept in sync by a test).
- Add config: Priority chance (default 15% of contracts whose good is Priority-eligible), Priority bonus, Priority reference ship model, Priority window factor. Add the shipper lists.
- **Test:** Every good validates and references existing classes and locations. Mass min ≤ max. High-Value and Dangerous goods have a tier from 1 to 5; Standard and Heavy goods don't. Every location produces at least one Standard good. Earth→Moon and Moon→Earth each have at least two Standard goods. Every location has at least one shipper. Location produces/demands match the catalog.
### Step 10E.2 — Sim: contracts carry goods
- Contract shape gains: good id, shipper name, Priority flag, and delivery window (game hours, Priority only).
- Tier is derived per decision 0.5.40.
- Payout becomes: class base pay × good pay multiplier × tier multiplier × mass × distance × first-contact multiplier (if flagged) × Priority bonus (if flagged).
- **Test:** A High-Value or Dangerous contract's tier equals its good's tier. A Heavy contract's tier follows the existing rule. Doubling the good's pay multiplier doubles payout. A Priority contract pays exactly the bonus times an otherwise identical non-Priority contract.
### Step 10E.3 — Sim: goods, Priority, and shippers in board generation
- Keep the existing class selection. After a class is chosen, pick the good, mass, Priority flag and window, and shipper per decisions 0.5.39, 0.5.41, and 0.5.43.
- Keep everything deterministic from the existing inputs.
- **Test:** Same inputs give identical boards. Every contract's good is produced at the origin and (except first-contact and fallbacks) demanded at the destination, and its mass is within the good's range. Over 1,000 boards from Earth with all locations discovered: the share of each cargo class is within ±3 percentage points of what the pre-change generator produces for the same inputs (record the baseline before changing the generator); Priority appears only on eligible goods and on 10–20% of eligible contracts; every contract has a shipper from its origin's list. First-contact rate is still 5–15%. *(Mass within the good's range except band fallbacks, per the implementation notes.)*
### Step 10E.4 — Sim: Priority validation
- Dispatch validation gains `too_slow`: for a Priority contract, the ship's trip time (with handling time, at departure) must not exceed the delivery window.
- **Test:** On a Priority Earth→Moon contract with the default reference (Freighter), a Courier passes and a Bulk Hauler fails with `too_slow`. A non-Priority contract never fails with `too_slow`.
### Step 10E.5 — Sim: tutorial goods
- The tutorial board picks goods per decision 0.5.44. The three mass tiers from CR-001 still apply, clamped to each good's range.
- **Test:** With the loaner at Earth, every tutorial good is a Standard good produced at Earth and demanded at the Moon; at the Moon, the reverse. "Unmarked crates" still appears on the scripted run.
### Step 10E.6 — Schema and database
- Stored boards and dispatch contract snapshots include good id, shipper, Priority flag, and window.
- Add the `too_slow` error code. API contract shape gains the new fields.
- Update `architecture.md`.
- **Test:** Migrations run on a fresh database and are a no-op the second time. Changed schemas accept valid examples and reject invalid ones.
### Step 10E.7 — Server: pass-through and validation
- Boards and dispatch use the new sim functions; no new endpoints.
- **Test:** A Priority contract dispatched with a too-slow ship returns `too_slow` and changes nothing; with a fast ship it succeeds. A fetched board's contracts all include a good name and shipper.
### Step 10E.8 — Client: contract card redesign
- Card layout, top to bottom:
- Route and payout (as now).
- **Good name** as the headline, with a class chip showing tier (for example "HIGH-VALUE · T1") and, for Priority, a chip "PRIORITY · within 5h" (game hours, with real minutes on tap or hover).
- Shipper · mass · distance.
- Requirement ("No skills needed", "Expensive Cargo 1") and expiry.
- Status badges as now, plus "TOO SLOW" when no idle ship at the origin can meet the window.
- **Test:** Component test: a card shows good name, class and tier chip, shipper, mass, and requirement; a Priority card shows the window chip; a Priority card with only slow idle ships at the origin shows "TOO SLOW".
### Step 10E.9 — Client: Priority in the dispatch sheet
- For a Priority contract, each ship+pilot pair shows its trip time against the window. Pairs that don't fit are disabled with "Too slow — 7h vs 5h window".
- **Test:** Component test: a fast enough pair is enabled and shows its margin; a slow pair is disabled with the correct numbers from the sim.
### Step 10E.10 — Client: goods in summaries
- Delivery summaries name the good and shipper, for example "Delivered 6 t Medical Supplies for Meridian Health to the Moon".
- **Test:** Component test: a seeded summary shows the good, mass, shipper, and destination.
### Step 10E.11 — Plan amendments and regression
- Apply the amendments in §7 to `implementation-plan.md`.
- Run the full suite.
- **Test:** CI is green. Manual check: an Earth board after the CR-001 deal shows the same kind of class mix as before, now with named goods and shippers, and at least one Priority contract appears within a few board refreshes.
## 7. Amendments to `implementation-plan.md`
| Step | Change |
|---|---|
| 0.5.6 | Amended by 0.5.40 (High-Value and Dangerous tiers come from goods). |
| 0.5.17 | Cargo fallback now uses goods (0.5.39). |
| 2.2 | Location produces/demands list good ids (10E.1). |
| 2.4 | Cargo types become cargo classes; goods catalog added (10E.1). |
| 3.8 | Contract shape and payout include goods and Priority (10E.2). |
| 3.9 | Class selection unchanged; goods, Priority, and shippers added (10E.3). |
| 3.12 | Validation gains `too_slow` (10E.4). |
| CR-001 10A.3 | Tutorial board uses named goods (10E.5). |
| 9.4, 9.5 | Card redesign and Priority in dispatch (10E.8, 10E.9). |
| 12.1 | 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. |
## 8. Out of scope
Changes to how the board mixes cargo classes, board filters, Fragile cargo and the Fragile skill, the Urgent Delivery skill, fixed "deliver by" clock deadlines, late-delivery penalties, cargo damage, supply and demand prices that react to deliveries, and shipper reputation. Anything else in `implementation-plan.md` §0.3.

View File

@ -1,54 +0,0 @@
# Change Request CR-008 — Boards That Feel Fresh
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with CR-001 to CR-007 complete
**Do this:** as **Phase 10H**, after Phase 10G and before Phase 11
**Related docs:** `CR-006-boards-that-fit.md`, `CR-007-cargo-goods.md`, `DECISIONS.md`
---
## 1. Why
User report: "The jobs aren't refreshing after you wait. You get the same jobs even after the timer hits zero. Leaving the page and coming back still doesn't refresh and neither does refreshing the page manually."
Investigation: the board **does** refresh. The server stores a new board each period (the user's dev database has one per 10-minute period, with different goods, masses and payouts). A browser left on the board refetched one second after the timer hit zero and showed the new cards; a reload showed the same new board. But the new board looks like the old one:
- An early player (one Courier, Earth and the Moon discovered) is offered almost only Earth → Moon runs of three Standard goods (CR-006 fits the board to the fleet; CR-007 has three Standard goods on that route).
- Payout depends only on good, mass and distance, and the Moon's distance barely changes, so the same good and mass pays the identical amount on every board ("Consumer Goods 16 t · 614 cr" recurs).
- Nothing on screen says the board changed.
The user chose three fixes (asked): show the refresh, vary pay per job, and add more early goods.
## 2. Decisions
Record as plan §0.5.60–0.5.62.
- **0.5.60 Market rate.** Each board contract (not tutorial) gets its own pay rate, drawn uniformly within ±`contractPayVariance` (default 15 %) of 1 and multiplied into its payout. The average payout is unchanged.
- **0.5.61 More early goods.** Five Standard goods join the catalog so early routes vary: Earth → Moon gains Water Recyclers, Spare Parts (Priority-eligible) and Lunar Base Supplies; Moon → Earth gains Crew Personal Effects and Recycled Alloys.
- **0.5.62 Visible refresh.** When the board shown for a location changes (the timer rolls over while open, or the player comes back to a newer board), the screen announces "New contracts posted" and tags each contract that wasn't on the last board the player saw there "New". The last seen board is remembered per location in the browser (a convenience; losing it only loses the tags). On a first visit nothing is tagged.
## 3. Steps — Phase 10H
### Step 10H.1 — Content: pay variance and new goods
- Config `contractPayVariance` (0.15, fraction). Goods per decision 0.5.61:
| Id | Name | Mass (t) | Pay × | Priority | Produced at | Demanded at |
|---|---|---|---|---|---|---|
| `water-recyclers` | Water Recyclers | 3–18 | 1.1 | No | Earth | Moon, Mars, Ceres |
| `spare-parts` | Spare Parts | 1–12 | 1.05 | Yes | Earth, Mars | Moon, Ceres, Jupiter |
| `lunar-base-supplies` | Lunar Base Supplies | 2–20 | 1.0 | No | Earth | Moon |
| `crew-effects` | Crew Personal Effects | 0.5–5 | 1.2 | No | Moon, Mars, Ceres | Earth |
| `recycled-alloys` | Recycled Alloys | 5–30 | 0.95 | No | Moon | Earth, Mars |
- **Test:** content tests pass with the new goods; Earth → Moon has at least five Standard goods and Moon → Earth at least four; the variance is a fraction.
### Step 10H.2 — Sim: per-contract pay rate
- Board contracts (fit and normal) draw a rate in [1 − v, 1 + v] after their other choices; `contractPayout` takes an optional market rate (default 1). Tutorial contracts use 1.
- **Test:** payout scales with the rate; over 1,000 boards the rates stay within bounds and average 1 ± 0.01; the same good and mass on two boards can now pay differently; tutorial payouts are unchanged; the CR-007 class-mix test still passes.
### Step 10H.3 — Client: visible refresh
- Per decision 0.5.62: a `role="status"` notice "New contracts posted" and "New" pills on new contracts (which also rise in), remembered per location in `localStorage` (wrapped in try/catch).
- **Test:** with a remembered earlier board, new contracts are tagged and the notice shows; with the same board remembered, nothing is; on a first visit, nothing is.
### Step 10H.4 — Regression
- Plan amendments (Step 3.8 payout, Step 9.4 board), full suite, coverage, lint, build; manual check across a real rollover. Stop for review.

View File

@ -1,125 +0,0 @@
# Change Request CR-009 — Ship Upgrades
> **Numbering note (added when implemented):** this request arrived titled "CR-006 — Ship Upgrades", for "Phase 10F", applying to "CR-001 through CR-005". CR-006 to CR-008 and Phases 10F–10H already existed, so it is stored as **CR-009** and done as **Phase 10I**: every "10F.n" below means **10I.n**. Its decisions 0.5.45–0.5.50 are recorded as **0.5.63 Effective stats, 0.5.64 Where and when, 0.5.65 One level at a time, 0.5.66 Shop time, 0.5.67 Models stay distinct, 0.5.68 Courier to Mars** (0.5.45–0.5.62 were in use). Error codes use the API's kebab-case and existing codes where one already means the same thing: `not_at_hq` → `not-at-hq`, `ship_busy` → `ship-not-idle`, `ship_in_shop` → `ship-in-shop`, `upgrade_maxed` → `upgrade-maxed`, `insufficient_funds` → `insufficient-funds`, `tutorial_active` → `tutorial-active`, `not_upgradable` → `not-upgradable`; `too_slow` is the existing `too-slow`.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10 and CR-001 through CR-005 complete
**Do this:** as **Phase 10F**, after Phase 10E and before Phase 11
**Related docs:** `implementation-plan.md`, the earlier change requests, `game-design-document.md`, `DECISIONS.md`
---
## 1. Why
A ship's stats are fixed by its model, so the only way to improve the fleet is to buy a different ship. There is nothing to spend money on between ship purchases and no way to make a favorite ship better.
This adds an ATS-style tuning shop:
- Five upgrades, each with **three levels**, each improving one ship stat.
- Upgrades are installed **at an HQ (bay)**, which gives bays a second purpose besides hiring pilots.
- The ship is **out of service** while it's in the shop: start an upgrade, put the phone down, come back to a better ship.
- **Caps keep ship models distinct.** A maxed Courier is still a small, fast ship, never a hauler. One deliberate exception: a Courier with maxed fuel tanks can reach Mars around closest approach, as an ambitious early goal.
## 2. Upgrades
All numbers are starting values in `packages/content`. Percentages are of the ship model's base stat and add up across levels (level 3 engines = +30% speed).
| Upgrade id | Name | Improves | Per level | Max level |
|---|---|---|---|---|
| `engines` | Engine Tuning | Cruise speed | +10% | 3 |
| `cargo-bay` | Cargo Bay Expansion | Cargo capacity | +15% | 3 |
| `fuel-tanks` | Extended Fuel Tanks | Fuel range | +20% | 3 (see 10F.1 for the Courier) |
| `landing-thrusters` | Landing Thrusters | Takeoff/landing overhead | −15% | 3 |
| `handling-gear` | Cargo Handling Gear | Handling time per tonne | −15% | 3 |
| Level | Cost (% of ship model's purchase price) | Time in shop (real minutes) |
|---|---|---|
| 1 | 10% | 10 |
| 2 | 20% | 20 |
| 3 | 35% | 30 |
Hull plating is left out until random events exist, because hull rating has no effect yet.
## 3. 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`.
- Every place that reads a ship's speed, capacity, range, overhead, or handling time must use the ship's **effective stats** (model plus upgrades), never the model's stats directly.
- Resetting the dev and test databases is fine. Schema migrations are still required.
- Update existing tests whose expectations change. 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 10F.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- **0.5.45 Effective stats.** A ship's effective stats are its model's base stats adjusted by its installed upgrade levels. Percentage bonuses are of the base stat and add up linearly.
- **0.5.46 Where and when.** 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.
- **0.5.47 One level at a time.** Each purchase raises one upgrade by exactly one level. A ship can be in the shop for one upgrade at a time.
- **0.5.48 Shop time.** 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.
- **0.5.49 Models stay distinct.** 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).
- **0.5.50 Courier to Mars.** 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.
## 5. Steps — Phase 10F
### Step 10F.1 — Content: upgrades
- Add an Upgrade schema: id, name, stat affected, change per level (percent), max level, and optional per-model max level overrides.
- 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.
### Step 10F.2 — Sim: effective stats
- Add a pure function that returns a ship's effective stats from its model and installed upgrade levels.
- Change travel time, fuel range checks, capacity checks, handling time, and Priority window checks to take effective stats.
- **Test:** With no upgrades, effective stats equal the model's stats exactly, and all existing Phase 3 tests pass unchanged. Each upgrade changes only its own stat. Level 3 engines give exactly +30% speed. A cargo-bay upgrade turns a contract that failed with the too-heavy reason into a valid one. An engines upgrade turns a contract that failed with `too_slow` into a valid one.
### Step 10F.3 — Sim: upgrade validation and cost
- Add pure functions: next-level cost and shop time for a ship and upgrade; validation for starting an upgrade per decisions 0.5.46 and 0.5.47, with reasons: `not_at_hq`, `ship_busy` (in flight), `ship_in_shop`, `upgrade_maxed`, `insufficient_funds`, `tutorial_active`, `not_upgradable` (the loaner).
- Dispatch validation gains `ship_in_shop`.
- **Test:** One test per reason. Cost for level 1 on a Freighter equals 10% of the Freighter's price; level 3 equals 35%. A ship in the shop fails dispatch validation with `ship_in_shop` one second before in-shop-until and passes at it.
### Step 10F.4 — Schema and database
- Add a `ship_upgrades` table (ship, upgrade id, level) and an in-shop-until time on ships.
- Add a "start upgrade" endpoint (ship id, upgrade id) to the route catalog, and the new error codes.
- Player state includes, per ship: installed upgrade levels, effective stats, and in-shop-until.
- Update `architecture.md`.
- **Test:** Migrations run on a fresh database and are a no-op the second time. New and changed schemas accept valid examples and reject invalid ones. The route catalog test still passes.
### Step 10F.5 — Server: start upgrade
- In one transaction with the company row lock (Step 6.14): validate, deduct the cost, raise the level by one, set in-shop-until.
- Dispatch uses effective stats and returns `ship_in_shop` for ships in the shop.
- **Test:** Starting a level 1 engines upgrade on an idle Freighter at the Earth HQ deducts the right cost, sets the level to 1 and the correct in-shop-until. Dispatching it before then fails with `ship_in_shop`; after, it succeeds and its trip time reflects the faster speed. Starting an upgrade at a location without a bay fails with `not_at_hq`. Two simultaneous upgrade requests the player can afford only one of produce exactly one upgrade and no negative balance.
### Step 10F.6 — Client: upgrade shop
- Each ship on the Fleet screen opens a detail view with an **Upgrades** panel listing all five upgrades: current level, next level's effect as before → after (for example "Speed 180 → 198 km/h"), cost, and shop time.
- Disabled upgrades show the reason (not at an HQ, in flight, in the shop, maxed, can't afford).
- Confirming starts the upgrade. Ships in the shop show "In the shop — mm:ss" on their Fleet card.
- **Test:** Component test: before → after values match the sim's effective stats; each disabled reason displays correctly; confirming calls the endpoint; an in-shop ship shows the countdown.
### Step 10F.7 — Client: effective stats everywhere
- Contract card badges (TOO HEAVY, TOO SLOW), the dispatch sheet, the deadhead picker, the Market's ship comparison for owned ships, and map reachability text all use effective stats.
- **Test:** Component test: after a seeded cargo-bay upgrade, a contract that showed TOO HEAVY for that ship no longer does; the deadhead picker enables a destination that only the upgraded range reaches.
### Step 10F.8 — Client: shop in summaries and on HQ
- The "while you were away" modal shows "[Ship] is out of the shop: Engine Tuning 2" for upgrades that finished while the player was away.
- HQ shows ships currently in the shop with countdowns.
- **Test:** Component test with seeded state: the modal line appears for an upgrade that finished since the last visit; HQ lists in-shop ships with correct countdowns.
### Step 10F.9 — Plan amendments and regression
- Apply the amendments in §6 to `implementation-plan.md`, and update `game-design-document.md` §8 (done by the designer; confirm it matches).
- Run the full suite.
- **Test:** CI is green. Manual check: on a fresh account after the CR-001 deal, earn enough to buy Cargo Bay Expansion 1 for the Courier at the Earth HQ, confirm it's unavailable for the shop time, then confirm it can take a contract that was too heavy before.
## 6. Amendments to `implementation-plan.md`
| Step | Change |
|---|---|
| 0.5 | Add decisions 0.5.45–0.5.50. |
| 2.1 | Content gains the Upgrade schema and upgrade cost/time config (10F.1). |
| 3.5, 3.7, 3.12 | Use effective stats; validation gains `ship_in_shop` (10F.2, 10F.3). |
| 5.3 | `ship_upgrades` table and in-shop-until on ships (10F.4). |
| 9.5, 9.6, 9.8 | Effective stats in dispatch, Fleet, and Market; upgrade shop on Fleet (10F.6, 10F.7). |
| 12.1 | 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. |
## 7. Out of scope
Hull plating (until random events exist), selling or removing upgrades, ship resale value, changing a ship's landing class, cosmetic liveries, ship renaming, and anything else in `implementation-plan.md` §0.3.

View File

@ -61,7 +61,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
3. **Tutorial movement.** The avatar and loaner move with each tutorial run. The tutorial board starts at the loaner's current location (Earth→Moon or Moon→Earth). The company pays fuel and pilot pay; the player receives only the tutorial share. *(Amended by CR-001: the board has three contracts of different masses (0.5.31), and the loaner is a dedicated content ship (0.5.29).)*
4. **Tutorial end.** *(Replaced by CR-001.)* The tutorial ends when the player accepts the deal (0.5.30). Buying a ship and leasing an Earth bay no longer end it.
5. **Contract boards are stored.** *(Replaced by CR-002.)* A board exists for every **discovered location**, per player, per period. It is generated on the first request in a period and stored; later requests in that period return the stored board. Every contract expires at the end of its board period.
6. **Cargo tier.** Standard is tier 0. A non-Standard contract has a tier from 1 to 5, and that tier is the required level in the cargo's skill. *Amended by 0.5.55 (CR-007): High-Value and Dangerous tiers come from the good.*
6. **Cargo tier.** Standard is tier 0. A non-Standard contract has a tier from 1 to 5, and that tier is the required level in the cargo's skill.
7. **Long Haul.** A config list of distance cut-offs, one per Long Haul level. The required Long Haul level is the number of cut-offs the route distance exceeds.
8. **Config-driven tuning.** First-contact chance, first-contact multiplier, contract mass range, and Long Haul cut-offs all live in GameConfig. *(Amended after the CR-001 playtest: the single mass range is replaced by per-ship-size mass bands, `contractMassBands`.)*
9. **Game epoch.** Two fields: a real start timestamp and the in-game ephemeris date it maps to. The clock is global for all players.
@ -72,7 +72,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
14. **Ship names** are generated from the world seed. No renaming.
15. **Bays** have no ship limit.
16. **Tutorial purchases.** *(Replaced by CR-001.)* During the tutorial, buying ships, leasing bays and hiring pilots are blocked with a `tutorial-active` error.
17. **Cargo fallback.** If no cargo type is both produced at the origin and demanded at the destination, use Standard. *Amended by 0.5.54 (CR-007): the class fallback uses each location's `cargoClassMix`; the good then falls back to a Standard good on the route, else any Standard good made at the origin.*
17. **Cargo fallback.** If no cargo type is both produced at the origin and demanded at the destination, use Standard.
18. **Testable clock.** The server reads time through an injectable clock. Tests call job handlers directly; E2E tests use a test-only clock-offset endpoint that does not exist in production.
19. **Summaries.** Dispatches have an acknowledged-at column; resolved dispatches without one form the "while you were away" summary.
20. **Same domain.** In production, the client and API share one domain, with the API under `/api`, so the session cookie is first-party (iOS Safari blocks cross-site cookies).
@ -107,23 +107,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
49. **Navigation** (CR-005). Icon tabs: a bottom bar on phones, a left rail from 768 px wide. Tab order and labels are unchanged. Attention badges are decorative (`aria-hidden`), so each tab's accessible name stays its label.
50. **Art on screens** (CR-005). Ships and locations show their content `sprite` from the generated map art as plain images with empty `alt`; the name is always in text beside it. A missing image hides itself.
51. **Presentation only** (CR-005). The overhaul changes no game rules, API calls, copy or accessible names.
52. **Boards fit the fleet** (CR-006). Location boards are generated with the player's fleet (ship models and pilot skills) as input. Bands the fleet can't carry shrink to a preview; in bands it can, all but a configured number of contracts are ones some ship-and-pilot pair can take.
53. **Weight warning** (CR-006). The board highlights a contract's mass when none of the player's ships at that location (or in the fleet, when none are there) can carry it. Display only.
54. **Goods** (CR-007). Contracts carry a named good from the catalog; a location's produces and demands lists name goods. After the generator picks a contract's class (unchanged), it picks a good of that class produced at the origin and demanded at the destination, falling back to a Standard good on the route, else any Standard good produced at the origin. Mass comes from the slot's ship-size band, inside the good's range when they overlap.
55. **Tier from goods** (CR-007). High-Value and Dangerous contracts take their tier from the good; Standard is tier 0; Heavy keeps the random tier.
56. **Priority** (CR-007). A Priority-eligible good becomes Priority with the configured chance. Its delivery window is the reference ship model's trip time for the cargo and route at offer time × the window factor; a ship whose trip time exceeds it fails dispatch with `too-slow`. Priority pays the configured bonus; no skill is required.
57. **Board mix unchanged** (CR-007). Class selection (by each location's class-level `cargoClassMix`), board size and first-contact chance keep their logic and config.
58. **Shippers** (CR-007). Each contract gets a shipper name from its origin's list. Flavor only.
59. **Tutorial goods** (CR-007). Tutorial contracts use Standard goods produced at the loaner's location and demanded at the other end; never Priority. The scripted "Unmarked crates" contract is unchanged.
60. **Market rate** (CR-008). Each board contract (not tutorial) gets its own pay rate, uniform within ±`contractPayVariance` of 1, multiplied into its payout; the average is unchanged.
61. **More early goods** (CR-008). Water Recyclers, Spare Parts and Lunar Base Supplies (Earth → Moon), Crew Personal Effects and Recycled Alloys (Moon → Earth) join the catalog.
62. **Visible refresh** (CR-008). When a location's board changes, the screen announces "New contracts posted" and tags contracts not on the last board the player saw there "New" (remembered per location in the browser; nothing tagged on a first visit).
63. **Effective stats** (CR-009). A ship's effective stats are its model's base stats adjusted by its installed upgrade levels. Percentage bonuses are of the base stat and add up linearly.
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).
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.
---
@ -173,7 +156,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Add data for Earth, Moon, Mars, Ceres Station, and Jupiter Cloud Platform following the destinations table in the GDD.
- Earth and Moon start discovered. Earth is atmospheric with the highest launch multiplier; Moon is vacuum with a low multiplier; Mars is atmospheric; Ceres is a belt station (vacuum); Jupiter Cloud Platform is a cloud platform requiring atmospheric landing.
- **Test:** A test loads all locations through the schema without errors, asserts exactly five locations, asserts exactly Earth and Moon start discovered, and asserts every cargo id referenced in produces/demands exists.
- **Amended by CR-007:** `produces`/`demands` list good ids (derived from the goods catalog); the old class lists are kept as `cargoClassMix`, and each location has `shippers` (Step 10G.1).
### Step 2.3 — Add the three ship models
- Courier: fastest, smallest hold, short range, atmospheric, lowest price.
@ -187,7 +169,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- 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.
- Skills: Long Haul, Heavy Cargo, Expensive Cargo, Dangerous Cargo, each max level 5.
- **Test:** A test validates all entries, asserts every cargo type's required skill exists, and asserts each non-standard cargo pays more than Standard.
- **Amended by CR-007:** Cargo types are the four cargo classes; a goods catalog (`data/goods.ts`) adds named goods with class, mass range, pay multiplier, tier (High-Value and Dangerous), Priority eligibility and where they're made and wanted (Step 10G.1).
### Step 2.5 — Add the default game config
- Set time compression to 60 (1 real minute = 1 game hour).
@ -237,8 +218,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- 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.
- Add a payout function: base pay per tonne per million km × tier multiplier × mass × distance, times the first-contact multiplier when flagged. Payout is fixed at offer time using the distance at that moment (decision 0.5.11).
- **Test:** Payout scales linearly with mass and distance. Dangerous cargo pays more than Standard for identical mass and route. A first-contact contract pays more than an otherwise identical normal contract.
- **Amended by CR-007:** Contracts carry `goodId`, `shipper`, `priority` and `deliveryWindowGameHours`; payout includes the good's pay multiplier and the Priority bonus (Step 10G.2).
- **Amended by CR-008:** Board contracts multiply their payout by a market rate within ±`contractPayVariance` of 1 (tutorial contracts don't) (Step 10H.2).
### Step 3.9 — Contract board generation
- Add a function that generates a bay's contract board from: the bay's location, the set of discovered locations, the board period index (real time ÷ board period), and the player's world seed.
@ -246,8 +225,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- **Test:** The same inputs always produce an identical board. A different period index produces a different board. No contract has origin equal to destination. Over 1,000 generated boards, first-contact contracts are between 5% and 15% of all contracts and every one targets an undiscovered location.
- **Amended after the CR-001 playtest:** contract mass comes from per-ship-size bands (`contractMassBands`, e.g. 3 × 5–20 t, 3 × 21–100 t, 2 × 101–400 t), and the first contract in each band is Standard cargo to a discovered location, so every ship size always has a job a new pilot can fly.
- **Amended by CR-002:** Input is a location, not a bay: boards are generated for any discovered location (Step 10B.1).
- **Amended by CR-006:** `BoardInput` takes the player's fleet (ship models and pilot skills); bands the fleet can't carry shrink to a preview and flyable bands are mostly jobs the fleet can take (decision 0.5.52, Step 10F.3).
- **Amended by CR-007:** Class selection unchanged; each contract then gets a good (mass from its band, inside the good's range when they overlap), maybe Priority with a window, and a shipper (Step 10G.3).
### Step 3.10 — Tutorial contract board
- Add a separate generator for the tutorial "company board": Earth↔Moon only, Standard cargo only, payout reduced by the tutorial payout share, always flown with the company loaner Courier. Contracts start at the loaner's current location: Earth→Moon when it is at Earth, Moon→Earth when it is at the Moon.
@ -263,7 +240,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Return "valid" or the first failing reason.
- **Test:** One test per failure reason, each constructing a scenario that triggers exactly that reason. One test for a fully valid dispatch.
- **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).
### 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.
@ -385,7 +361,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Return the contract board for one of the player's bays for the current period. On the first request in a period, generate it with the sim using the player's discovered locations at that moment and store it in contract_boards; later requests in the period return the stored board. Exclude already-accepted contracts. Contracts from earlier periods are never shown.
- **Test:** A bay board is returned for an owned bay. Requesting a board for a location without a player bay fails. Two requests in the same period return the same board, even if a location is discovered between them. After the period rolls over, a new board is returned, and dispatching a contract from the old board fails as expired.
- **Amended by CR-002:** `GET /api/locations/:locationId/board` serves any discovered location, no bay needed; undiscovered is `location-undiscovered` (Step 10B.4, decision 0.5.36).
- **Amended by CR-006:** The endpoint passes the player's fleet (ships and assigned pilots' skills) to board generation (Step 10F.4).
### Step 6.10 — Contract dispatch
- Dispatching a contract runs sim validation, deducts fuel cost, marks the contract accepted, and stores the dispatch record. Ship and pilot become in-flight.
@ -474,16 +449,12 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- After tutorial, the Contracts screen has a selector for the player's bays and shows each bay's board. Each contract shows route, cargo type, mass, distance, payout, required skill, expiry countdown, and a first-contact badge where applicable.
- **Test:** Component test switches bays and shows the right board. A first-contact contract displays its badge.
- **Amended by CR-002:** The selector lists every discovered location with an idle-ship badge, defaulting to the most recently arrived idle ship's location, else Earth; Fleet gets "Find work here" (Step 10B.7).
- **Amended by CR-006:** A contract's mass is highlighted, with a "Too heavy" tag, when none of the player's ships at the location (or in the fleet, when none are there) can carry it (decision 0.5.53, Step 10F.1).
- **Amended by CR-007:** Cards headline the good with class/tier, Priority and Too slow chips, and show shipper · mass · distance (Step 10G.8).
- **Amended by CR-008:** When a location's board changes, the screen says "New contracts posted" and tags contracts not on the last board seen there "New" (Step 10H.3).
### Step 9.5 — Dispatch flow
- Selecting a contract opens a dispatch sheet: choose an eligible ship+pilot pair at the origin. Show trip time, fuel cost, net profit, and ETA in both game time and real time. Ineligible pairs are listed but disabled with the reason shown (out of range, can't land, skill too low, too heavy).
- Confirm sends the dispatch; on error, show the server's message.
- **Test:** Component test: an eligible pair shows correct numbers from the sim; an ineligible pair shows the correct reason; confirming calls dispatch; a server error message is displayed.
- **Amended by CR-004:** The dispatch sheet disables resting pilots with a countdown and shows duty after the trip, warning "Will need rest on arrival" (Step 10D.7).
- **Amended by CR-007:** For a Priority contract each pair shows its trip against the window; too-slow pairs are disabled with the numbers (Step 10G.9).
### Step 9.6 — Fleet screen
- List all ships: name, model, location or route, assigned pilot, and for in-flight ships a progress bar and countdown. Idle ships at a bay location have a "Reposition" action to another bay location.
@ -561,30 +532,6 @@ Steps 10E.1–10E.9 are specified in `memory-bank/change-requests/CR-005-ui-over
---
## Phase 10F — Boards That Fit Your Fleet (CR-006)
Steps 10F.1–10F.5 are specified in `memory-bank/change-requests/CR-006-boards-that-fit.md` §5, with its §6 amendments to earlier steps. Done after Phase 10E and before Phase 11; stop for review at the end.
---
## Phase 10G — Cargo Goods, Priority Contracts and Shippers (CR-007)
Steps 10G.1–10G.11 are specified in `memory-bank/change-requests/CR-007-cargo-goods.md` §6 (written there as 10E.1–10E.11), with its §7 amendments to earlier steps. Done after Phase 10F and before Phase 11; stop for review at the end.
---
## Phase 10H — Boards That Feel Fresh (CR-008)
Steps 10H.1–10H.4 are specified in `memory-bank/change-requests/CR-008-fresh-boards.md` §3. Done after Phase 10G and before Phase 11; stop for review at the end.
---
## Phase 10I — Ship Upgrades (CR-009)
Steps 10I.1–10I.9 are specified in `memory-bank/change-requests/CR-009-ship-upgrades.md` §5 (written there as 10F.1–10F.9), with its §6 amendments to earlier steps. Done after Phase 10H and before Phase 11; stop for review at the end.
---
## Phase 11 — PWA
### Step 11.1 — Installable PWA
@ -608,7 +555,6 @@ Steps 10I.1–10I.9 are specified in `memory-bank/change-requests/CR-009-ship-up
- **Amended by CR-001:** Change the thresholds to: deal accepted under 10 real minutes; first pilot hired between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds.
- **Amended by CR-002:** The simulated player picks up contracts at whatever location its ships are at, and only deadheads when no eligible contract exists there. Add a check: across the whole run, no ship is ever idle with no eligible contract **and** no reachable deadhead destination.
- **Amended by CR-004:** The simulated player follows rest rules: it rests a pilot when no eligible contract exists at its location and deadheading isn't better. Re-check all CR-001 timing targets with rest in place; adjust content values, not thresholds. Add a check that no pilot is ever dispatched while resting.
- **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.
### 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

@ -188,50 +188,3 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde
- [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
- [x] **Step 10E.8 — Locations, map panels, start screen and dialogs.** Locations ("Known space"): planet-art cards (two columns from `md`), emerald tone and an HQ pill where you have a bay, distance in cyan, specs on an inset `dl`, View contracts as a button, Lease bay as a full-width primary; undiscovered places dimmed and greyed with a padlock. Map: Reset view and the tap panels restyled (glass panel with the location's or ship's art, icon close button, View contracts as a button; a side card from `md`). Start screen is a title screen: gradient DEADHEAD wordmark, the Sun, Earth, Moon and a Courier around it, and a large Start button. "While you were away" and the deal are on `Sheet` ("Logbook" with big emerald credits and violet XP, discoveries with a star, goals panel with a success button; the deal as an amber "Incoming transmission" with the Courier's art and a large "Shake on it"). The last `slate-*` classes are gone from `apps/web/src`. Existing locations, map, onboarding, away and deal tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
- [x] **Step 10E.9 — Regression and review.** CR-005 §6 amendments added to plan steps 8.1 (the CR said 8.2; the layout is 8.1, corrected) and 11.1. Typecheck, lint, full suite with coverage (100 files, 680 tests) and the web build are green. The main JS chunk grew from 496 KB to 523 KB, past Vite's 500 KB warning; splitting it is left for Phase 11/13. **Review screenshots** (palette-quantized PNGs, 6 MB total): `docs/screenshots/10e9-final-{hq,map,contracts,dispatch,fleet,pilots,market,locations}-midgame-{desktop,phone}.png`, `10e9-final-{hq,contracts,map}-tutorial-{desktop,phone}.png` and `10e9-final-start-new-{desktop,phone}.png`. Phone shots are full-page captures, so the fixed bottom navigation appears partway down the image. **Manual check for the user:** the look on a real phone, and tap targets in the 7-tab bar on the narrowest phones. **Phase 10E complete — stopped for review.**
## Phase 10F — Boards That Fit Your Fleet (CR-006)
Spec: `memory-bank/change-requests/CR-006-boards-that-fit.md`. New decisions recorded as plan §0.5.52–0.5.53.
- [x] **Step 10F.1 — Client: weight warning.** `carryLimit(state, locationId)` (`LocationBoards.tsx`): the largest capacity among the player's ships at the location, else the whole fleet, else null. Contract cards show the mass in rose and a rose "Too heavy" pill when it is over that limit. Test: `weight-warning.test.tsx`.
- [x] **Step 10F.2 — Content: board config.** GameConfig gains `boardPreviewSlots` (1) and `boardOpenSlotsPerBand` (1), non-negative integers. Test: `schemas.test.ts` (missing-field coverage plus a negative/fractional rejection test).
- [x] **Step 10F.3 — Sim: fleet-aware boards.** `BoardInput.fleet?: FleetMember[]` (`{ modelId, skills }`, exported). `planSlots` (board.ts): with no usable fleet, the old plan (each band's count, first slot guaranteed); otherwise bands no ship can carry keep `boardPreviewSlots` (their first is guaranteed), freed slots go to flyable bands largest-first round-robin, and flyable bands are fit slots but for `boardOpenSlotsPerBand` open ones. `fitJob` picks a crew that can carry the band (random start, then the next), a discovered destination it can reach and land at with its Long Haul, cargo the origin produces and the destination demands that the pilot is skilled for (else Standard), tier 1..pilot level, mass up to the ship's capacity; never first contact. A fit slot no crew can fill falls back to a guaranteed slot. No fleet: identical boards and RNG order (existing tests unchanged). Test: `board-fleet.test.ts` (empty fleet = old board; determinism; a lone Courier gets ≥ 5 flyable jobs on every one of 200 boards plus a preview per bigger band; skill cargo never above the pilot's level; a full fleet keeps band counts with fit jobs per band; expiry).
- [x] **Step 10F.4 — Server: pass the fleet.** `playerFleet(tx, playerId)` (`game/boards.ts`): the player's ships left-joined to their assigned pilots' skills, ordered by creation then id; `locationBoard` passes it as `fleet` when generating a board. Like discoveries, the fleet is read once per (player, location, period) and the stored board doesn't change afterwards. Test: `location-board.test.ts` — a new owner with one Courier gets ≥ 5 Earth contracts that pass sim `validateDispatch` for that Courier and the avatar.
- [x] **Step 10F.5 — Regression.** CR-006 §6 amendments added to plan steps 3.9, 6.9 and 9.4. Typecheck, lint, full suite with coverage (102 files, 693 tests; `board.ts` 100 % lines) and the web build are green. **Manual check:** a fresh owner with one Courier at Earth (`docs/screenshots/10f5-lone-courier-earth-board-desktop.png`) sees 5 Standard Earth→Moon jobs of 8–20 t, 1 open High-Value tier 5 job and 2 bigger-ship previews (37 t, 366 t) marked "Too heavy"; before, the same player typically had one job they could fly. **Balance note for Phase 12:** a lone Courier can now chain several Moon runs per 10-minute board, so early income per real hour rises; watch the payout curve and the loan payoff time. **Phase 10F complete — stopped for review.**
## Phase 10G — Cargo Goods, Priority Contracts and Shippers (CR-007)
Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / Phase 10E"; renumbered, see its implementation notes). New decisions recorded as plan §0.5.54–0.5.59. Baseline class mix before the change (1,000 Earth boards, all discovered): Standard 76.79 %, High-Value 23.21 %.
- [x] **Step 10G.1 — Content: goods catalog.** `goodSchema` (id, name, `cargoClassId`, `massTonnes {min,max}`, `payMultiplier`, optional `tier` 1–5, `priorityEligible`, optional `priorityName`, `producedAt`, `demandedAt`; min ≤ max refinement) and `data/goods.ts` with all 29 goods from CR-007 §3, plus `goodsProducedAt`/`goodsDemandedAt`. Locations: `produces`/`demands` now derive from the catalog (good ids); the old class lists are kept as `cargoClassMix` (decision 0.5.57) and the sim's class selection and the UI's produces/demands now read them, so nothing visible changes yet; `shippers` added (≥ 1 each). Config: `priorityChance` 0.15, `priorityPayoutMultiplier` 1.5, `priorityReferenceShipModelId` "freighter", `priorityWindowFactor` 1. Bundle: `goods`, `good(id)`. Tests: `goods.test.ts` (schema, references, tiers by class, Standard good everywhere, ≥ 2 Standard goods each way Earth↔Moon, location lists in sync, Priority config), schema tests for Good and the new fields.
- [x] **Step 10G.2 — Sim: contracts carry goods.** `Contract` gains `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (null unless Priority); the API `contractSchema` mirrors them now (the schema package's type-parity test requires it), so test fixtures across packages gained a good of their class. `contractPayout` = class base × good `payMultiplier` × tierMultiplier^tier × mass × distance × first-contact × Priority bonus. `buildContract` takes a good (the class comes from it) plus shipper and Priority fields; `tierFor` (0.5.55: Standard 0, High-Value/Dangerous the good's tier, Heavy the random roll or up to a fit pilot's level); `routeGoods` (0.5.54 fallback chain). Interim choice until 10G.3: the first route good in catalog order, the origin's first shipper, never Priority; fit jobs only use goods whose tier the pilot meets. `SimContent` gains `goods`. Tests: `contract.test.ts` (tiers from goods, Heavy 1–5, ×2 pay multiplier doubles payout, Priority = bonus × normal).
- [x] **Step 10G.3 — Sim: goods, Priority and shippers in board generation.** Class selection is unchanged (by `cargoClassMix`). Then `chooseGood` (band wins): a random good of the class on the route whose mass range overlaps the slot's band (the lightest band has no floor) with mass in both and within a fit ship's capacity; else the largest-range good as a bulk lot from the band. Masses under 10 t to 0.1 t, else whole tonnes. `rollPriority`: eligible goods become Priority with `priorityChance`; window = `priorityWindow` (reference model's trip time at offer × factor); a fit job is Priority only if its own ship makes the window. Shipper: random from the origin's list. Measured over 1,000 Earth boards (all discovered, seed 12345): Standard 76.04 % (baseline 76.79), High-Value 23.96 % (23.21); Priority on 13.6 % of eligible contracts. Bulk lots make Packaged Food and Electronics the most common goods in the big bands. Tests: `board-goods.test.ts` (determinism with and without a fleet; goods made at the origin and wanted at the destination except when no Standard good goes that way; mass rules; class mix ±3 points; Priority 10–20 % of eligible, only eligible goods, window = reference trip; shippers from the origin's list; `chooseGood` overlap, bulk and capacity cases). `board.test.ts` / `board-fleet.test.ts`: the lightest band's floor is 0 for goods.
- [x] **Step 10G.4 — Sim: Priority validation.** `too-slow` joins `DISPATCH_FAILURES` after the route checks: a Priority contract fails when `contractTripGameHours` (cruise + overhead + handling, distance at departure; exported) exceeds `deliveryWindowGameHours`. Server message and the dispatch sheet's short reason added (the API error code follows from the sim's list). `priorityWindow` exported. Tests: `priority.test.ts` (Priority mail Earth→Moon with the Freighter reference: Courier and Freighter pass, Bulk Hauler fails `too-slow`; the trip is measured like the window; a normal contract never fails as too slow); `board-fleet.test.ts` now also requires fit Priority jobs to be within the crew's reach.
- [x] **Step 10G.5 — Sim: tutorial goods.** Each tutorial slot carries a Standard good made at the loaner's location and wanted at the other end, chosen (seeded from location, run and period) among goods whose range holds the CR-001 tier mass, else clamped to a good's range; a random shipper from the origin; never Priority. On both routes some good holds 5, 12 and 20 t, so tutorial masses are unchanged; payouts now include the good's pay multiplier (e.g. Mail ×1.2). "Unmarked crates" is unchanged. Test: `tutorial.test.ts` (goods per direction over 6 runs × 20 periods, never Priority, shippers, masses 5/12/20; the existing scripted-run test still passes).
- [x] **Step 10G.6 — Schema and database.** API `Contract` (since 10G.2) carries `goodId`, `shipper`, `priority`, `deliveryWindowGameHours`; `ResolutionSummary` gains `goodId`, `massTonnes`, `shipper` (null for a deadhead); `too-slow` is an API error code via the sim's list. Contracts are JSONB, so no columns change: migration `0005_cr007_goods` (custom SQL) backfills stored boards and dispatch snapshots without a good (default good of the class, origin's first shipper, no Priority) and skips rows that have one. Dev and screenshot databases migrated (all 48 stored dev contracts now carry a good). `architecture.md` updated. Tests: `goods-backfill.test.ts` (legacy board and in-flight contract are filled, a second run changes nothing, the API then serves them), `migrations.test.ts` unchanged, schema cases for Priority windows, empty goods and summary goods.
- [x] **Step 10G.7 — Server: pass-through and validation.** No server code changes were needed beyond 10G.4's message and 10G.6's summary fields: boards come from the sim and dispatch validation returns the sim's `too-slow`. Tests (`contract-dispatch.test.ts`): a Priority job whose window is shorter than the Courier's trip is refused with `too-slow` and changes nothing; with a window the Courier makes, it dispatches and the stored contract keeps its Priority good; every generated Earth contract names a catalog good of its class and a shipper from Earth's list.
- [x] **Step 10G.8 — Client: contract card redesign.** New `components/Goods.tsx`: `goodName` (a Priority good may use its `priorityName`, e.g. Emergency Parts), `ClassChip` ("High-Value · T1", toned by class), `PriorityChip` ("Priority · within 5h" in game hours; tap switches to real time, hover shows both), `tooSlowForAll` (idle ships at the origin exist and none makes the window, via sim `contractTripGameHours`), `formatWindow`, `realTimeOf`. Location board cards, top to bottom: route and payout; the good as an `h3` headline with class, Priority, First contact, Too heavy and Too slow chips; route strip; shipper · mass · distance; requirement and expiry with Dispatch. Tutorial cards show the good's name instead of the class. Tests: `contract-card.test.tsx` (good, class/tier chip, shipper, mass, requirement; Priority chip in game hours and real time on tap with the Priority name; Too slow with only a Bulk Hauler idle); `location-boards.test.tsx` updated to the new card.
- [x] **Step 10G.9 — Client: Priority in the dispatch sheet.** `explainProblems` adds "Too slow — 7h vs 5h window" (sim `contractTripGameHours` vs the window) so a slow pair is disabled with the numbers; `PairOption.priority` carries each pair's trip and the window, and eligible pairs show "Priority: 2.1h trip, 1.4h to spare in the 3.5h window". The sheet's job details now start with the good and shipper ("Mail & Parcels for Pacific Orbital Logistics · Standard · 6 t …") and Priority jobs show the window chip. Test: `dispatch-priority.test.tsx` (Courier enabled with its margin, Bulk Hauler disabled with "Too slow — …", numbers from the sim).
- [x] **Step 10G.10 — Client: goods in summaries.** Each delivery in "While you were away" says "Delivered 6 t Medical Supplies for Meridian Health to the Moon" (from the summary's `goodId`, `massTonnes`, `shipper`; new `toLocation` gives "to the Moon" / "to Mars"); deadheads keep their fuel line. Test: `away-summary.test.tsx` (the seeded delivery line; a deadhead shows no "Delivered").
- [x] **Step 10G.11 — Plan amendments and regression.** CR-007 §7 amendments added to plan §0.5.6 and §0.5.17 and steps 2.2, 2.4, 3.8, 3.9, 3.12, 9.4, 9.5 and 12.1, and to CR-001 Step 10A.3. Typecheck, lint, full suite with coverage (108 files, 753 tests; sim lines 100 %) and the web build are green. **Manual check:** a fresh owner after the deal with one Courier at Earth: every contract names a good and an Earth shipper (Mail & Parcels, Consumer Goods, Packaged Food; bulk Packaged Food lots flagged Too heavy), and a Priority job appeared on the first board (Mail & Parcels, 7.7 t, window 4.3 h; the Courier makes it in 2.3 h with 2.1 h to spare). The Priority chip keeps its unit in normal case ("within 4.3h"). Screenshots: `docs/screenshots/10g11-goods-contracts-solo-{desktop,phone}.png`, `10g11-priority-dispatch-solo-{desktop,phone}.png`. **Balance notes for Phase 12:** with "the band wins", bulk lots make Packaged Food and Electronics the usual big-band goods; shippers are random per origin, so e.g. Hollis & Crane Consumer can ship Mail. **Phase 10G complete — stopped for review.**
## Phase 10H — Boards That Feel Fresh (CR-008)
Spec: `memory-bank/change-requests/CR-008-fresh-boards.md`. New decisions recorded as plan §0.5.60–0.5.62.
- [x] **Step 10H.1 — Content: pay variance and new goods.** Config `contractPayVariance` 0.15. Five Standard goods (decision 0.5.61): Water Recyclers, Spare Parts (Priority-eligible) and Lunar Base Supplies Earth → Moon; Crew Personal Effects and Recycled Alloys Moon → Earth (locations' goods lists update from the catalog). Earth → Moon now has 6 Standard goods, Moon → Earth 4. All existing tests still pass (tutorial masses unchanged: goods on both routes still hold 5/12/20 t). Test: `goods.test.ts` (route variety, variance is a fraction).
- [x] **Step 10H.2 — Sim: per-contract pay rate.** `PayoutInput.marketRate` (default 1) multiplies the payout; `payRate(rng, content)` draws 1 ± `contractPayVariance` uniformly as the last draw of every board contract (fit and normal); `buildContract` takes `payRate` (tutorial boards pass none, so tutorial payouts are unchanged). Tests: `pay-rate.test.ts` (payout scales with the rate; 1,000 Earth boards: rates within ±15 %, mean within 1 ± 0.01; the same good, mass and route now pays different amounts); the tutorial and CR-007 class-mix tests still pass.
- [x] **Step 10H.3 — Client: visible refresh.** `useNewContracts(locationId, contracts)` (`LocationBoards.tsx`) compares the board with the last one seen at that location (`localStorage` key `deadhead:seen-board:<id>`, read and written in try/catch), then remembers it. Contracts not seen before get a cyan "New" pill and rise in; the refresh line shows a `role="status"` "New contracts posted". Tags stay until the board changes again (contracts taken in between drop out); nothing is tagged on a first visit. The test setup clears `localStorage` after each test. Test: `fresh-board.test.tsx` (remembered older board → tags and notice; same board → none; first visit → none but remembered; rollover while open → notice and tags on the new board).
- [x] **Step 10H.4 — Regression, and a fix found by the user.** Plan amendments for steps 3.8 and 9.4. Manual check across a real rollover (`docs/screenshots/10h4-board-rollover-desktop.png`): one second after the timer hit zero the open page showed "New contracts posted" and tagged every new card New. **User report after restarting:** "the jobs are the same". Their Moon boards had six of eight jobs as Lunar Titanium every period: a fit job picked a class first, and for a Heavy Cargo 1 pilot at the Moon that was only Heavy, whose one route good (Lunar Titanium, 30–120 t) became an undersized bulk lot for the Courier; open jobs did the same. Fix (`board.ts`): fit jobs choose among every good on the route the crew is qualified for and whose range fits the slot and ship (`fitsSlot`), falling back to the class choice only when none does; `slotGoods` swaps a class whose goods are all too heavy for the slot for Standard goods on the route (goods too light for a big band still become bulk lots). With the user's save the next Moon boards carry Optical Glass, Souvenirs, Crew Personal Effects and Recycled Alloys. Test: `board-fleet.test.ts` (the user's Courier at the Moon: ≥ 5 flyable jobs per board, every flyable job's mass inside its good's range, no Lunar Titanium, ≥ 3 distinct goods per board on average); the class-mix test still passes. Full suite (110 files, 763 tests) green. **Phase 10H complete — stopped for review.**
## Phase 10I — Ship Upgrades (CR-009)
Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006 … Phase 10F"). New decisions recorded as plan §0.5.63–0.5.68.
- [x] **Step 10I.1 — Content: upgrades.** `upgradeSchema` (id, name, `stat` from `SHIP_STATS`, `percentPerLevel`, `maxLevel`, optional `modelOverrides`) and `data/upgrades.ts`: engines +10 %, cargo-bay +15 %, fuel-tanks +20 % (Courier +600 %, Freighter +15 %), landing-thrusters −15 %, handling-gear −15 %, all 3 levels; `upgradePercentPerLevel` / `upgradeMaxLevel`; `content.upgrades` / `content.upgrade(id)`. Config `upgradeLevels`: 10/20/35 % of price, 10/20/30 real minutes. Tests: `content/test/upgrades.test.ts` (validity, stats exist, decision 0.5.67), `sim/test/courier-mars.test.ts` (decision 0.5.68 over monthly samples for two game years).
- [x] **Step 10I.2 — Sim: effective stats.** `sim/src/upgrades.ts`: `effectiveStats(model, levels, content)` → `EffectiveStats` (the model with upgraded stats plus its own `handlingGameHoursPerTonne`). `ShipState.upgrades` (optional) and `shipStats(ship, content)` feed every check in `validateDispatch`, `validateReposition` and dispatch records (capacity, range, travel time, Priority window). `travelTimeGameHours` uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (`FleetMember.upgrades`). `SimContent` gains `upgrades`. Test: `effective-stats.test.ts`; all earlier sim tests unchanged.
- [x] **Step 10I.3 — Sim: upgrade validation and cost.** `upgradeCost(model, level)` (whole credits, % of price), `upgradeShopMs(level)`, `nextUpgradeLevel`, `isInShop(ship, now)` and `validateUpgrade` with `UPGRADE_FAILURES` in check order: `tutorial-active`, `not-upgradable`, `ship-not-idle`, `ship-in-shop`, `not-at-hq`, `upgrade-maxed`, `insufficient-funds`; success returns the level, cost and in-shop-until. `ShipState.inShopUntil`; dispatch and deadhead validation fail `ship-in-shop` right after `ship-not-idle`. Test: `upgrade-validation.test.ts`.
- [x] **Step 10I.4 — Schema and database.** Migration `0006_cr009_ship_upgrades`: `ship_upgrades` (ship, upgrade, level; PK ship+upgrade), and on `ships` `in_shop_until`, `shop_upgrade_id`, `shop_level`. API `Ship` gains `upgrades`, `stats` (effective), `inShopUntil`, `shop`; one server mapper `toApiShip` (player state and purchases). Route `startUpgrade` (`POST /api/ships/:shipId/upgrades`). Upgrade failure reasons join the API error codes. `acknowledgeSummaries` takes optional `shipIds`. Web test helper `shipExtras(modelId)`. Dev database migrated.
- [x] **Step 10I.5 — Server: start upgrade.** `startUpgrade` (`game/upgrades.ts`) runs inside `withPlayer` (company row lock): sim `validateUpgrade` (at an HQ = a bay where the ship is), charge, upsert `ship_upgrades`, set in-shop-until and the shop job; returns the ship. Unknown upgrade → `not-found`. Dispatch and deadhead pass the ship's upgrades and in-shop time to the sim (effective stats, `ship-in-shop`); fleet-fitted boards use effective capacity. Test: `upgrades.test.ts` (engines on a Freighter: cost, level, shop time, refused then faster trip; `not-at-hq` at the Moon; two simultaneous upgrades → one).
- [ ] Step 10I.6 — Client: upgrade shop
- [ ] Step 10I.7 — Client: effective stats everywhere
- [ ] Step 10I.8 — Client: shop in summaries and on HQ
- [ ] Step 10I.9 — Plan amendments and regression

View File

@ -1,10 +1,8 @@
import { cargoTypes, skills } from "./data/cargo";
import { gameConfig } from "./data/config";
import { goods } from "./data/goods";
import { locations } from "./data/locations";
import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding";
import { shipModels } from "./data/ships";
import { upgrades } from "./data/upgrades";
/** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */
function byId<T extends { id: string }>(items: readonly T[]): (id: string) => T | undefined {
@ -19,18 +17,14 @@ export const content = {
/** Ship models players can buy (excludes company ships such as the loaner). */
purchasableShipModels: shipModels.filter((s) => s.purchasable),
cargoTypes,
goods,
skills,
config: gameConfig,
scriptedTutorialContracts,
onboardingGoals,
upgrades,
location: byId(locations),
shipModel: byId(shipModels),
cargoType: byId(cargoTypes),
good: byId(goods),
skill: byId(skills),
onboardingGoal: byId(onboardingGoals),
upgrade: byId(upgrades),
};
export type Content = typeof content;

View File

@ -24,12 +24,6 @@ export const gameConfig: GameConfig = {
// Courier price + Earth bay lease.
dealLoanAmount: 5000,
contractBoardPeriodRealMinutes: 10,
// Ship upgrades (CR-009): level 1, 2, 3.
upgradeLevels: [
{ costPercentOfPrice: 10, shopRealMinutes: 10 },
{ costPercentOfPrice: 20, shopRealMinutes: 20 },
{ costPercentOfPrice: 35, shopRealMinutes: 30 },
],
// Duty and rest (CR-004): about three Moon round trips, then rest.
dutyLimitGameHours: 12,
longHaulDutyBonusGameHours: 2,
@ -38,14 +32,6 @@ export const gameConfig: GameConfig = {
restGameHours: 10,
firstContactChance: 0.1,
firstContactPayoutMultiplier: 2,
// Each board contract pays its own market rate, ±15% (CR-008).
contractPayVariance: 0.15,
// Priority contracts (CR-007): 15% of eligible goods, ×1.5 pay, a window as
// long as a Freighter's trip.
priorityChance: 0.15,
priorityPayoutMultiplier: 1.5,
priorityReferenceShipModelId: "freighter",
priorityWindowFactor: 1,
// One band per ship size, so every board has work for every ship (playtest fix):
// up to a Courier's 20 t, a Freighter's 100 t, and a Bulk Hauler's 400 t.
contractMassBands: [
@ -53,10 +39,6 @@ export const gameConfig: GameConfig = {
{ min: 21, max: 100, count: 3 },
{ min: 101, max: 400, count: 2 },
],
// Boards that fit the fleet (CR-006): a band no ship can carry keeps one
// preview job; flyable bands keep one open job, the rest fit a ship and pilot.
boardPreviewSlots: 1,
boardOpenSlotsPerBand: 1,
// Mars at its closest needs no Long Haul; Jupiter at its farthest needs level 5.
longHaulCutoffsMillionKm: [100, 250, 400, 600, 800],
xpPerDelivery: { base: 10, perMillionKm: 0.2, perCargoTier: 5 },

View File

@ -1,390 +0,0 @@
import type { Good } from "../schemas";
const EARTH = "earth";
const MOON = "moon";
const MARS = "mars";
const CERES = "ceres-station";
const JUPITER = "jupiter-cloud-platform";
/**
* The goods catalog (CR-007, decision 0.5.54). Pay multipliers scale the
* class's base pay per tonne per million km. High-Value and Dangerous goods
* carry their own tier (0.5.55); Standard and Heavy goods don't.
*/
export const goods: Good[] = [
// Standard: no skill.
{
id: "mail",
name: "Mail & Parcels",
cargoClassId: "standard",
massTonnes: { min: 1, max: 8 },
payMultiplier: 1.2,
priorityEligible: true,
producedAt: [EARTH, MARS],
demandedAt: [MOON, MARS, CERES, JUPITER, EARTH],
},
{
id: "packaged-food",
name: "Packaged Food",
cargoClassId: "standard",
massTonnes: { min: 10, max: 60 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "consumer-goods",
name: "Consumer Goods",
cargoClassId: "standard",
massTonnes: { min: 5, max: 40 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "optical-glass",
name: "Lunar Optical Glass",
cargoClassId: "standard",
massTonnes: { min: 4, max: 25 },
payMultiplier: 1.1,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [EARTH, MARS],
},
{
id: "moon-souvenirs",
name: "Moon Dust Souvenirs",
cargoClassId: "standard",
massTonnes: { min: 1, max: 6 },
payMultiplier: 0.9,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [EARTH],
},
{
id: "hydroponic-produce",
name: "Hydroponic Produce",
cargoClassId: "standard",
massTonnes: { min: 5, max: 30 },
payMultiplier: 1.1,
priorityEligible: true,
producedAt: [MARS],
demandedAt: [MOON, CERES, JUPITER],
},
{
id: "salvaged-components",
name: "Salvaged Components",
cargoClassId: "standard",
massTonnes: { min: 5, max: 30 },
payMultiplier: 0.9,
priorityEligible: false,
producedAt: [CERES],
demandedAt: [MOON, MARS],
},
{
id: "bioplastics",
name: "Atmospheric Bioplastics",
cargoClassId: "standard",
massTonnes: { min: 5, max: 40 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [JUPITER],
demandedAt: [EARTH, MARS, CERES],
},
// More early Standard goods (CR-008, decision 0.5.61).
{
id: "water-recyclers",
name: "Water Recyclers",
cargoClassId: "standard",
massTonnes: { min: 3, max: 18 },
payMultiplier: 1.1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES],
},
{
id: "spare-parts",
name: "Spare Parts",
cargoClassId: "standard",
massTonnes: { min: 1, max: 12 },
payMultiplier: 1.05,
priorityEligible: true,
producedAt: [EARTH, MARS],
demandedAt: [MOON, CERES, JUPITER],
},
{
id: "lunar-base-supplies",
name: "Lunar Base Supplies",
cargoClassId: "standard",
massTonnes: { min: 2, max: 20 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON],
},
{
id: "crew-effects",
name: "Crew Personal Effects",
cargoClassId: "standard",
massTonnes: { min: 0.5, max: 5 },
payMultiplier: 1.2,
priorityEligible: false,
producedAt: [MOON, MARS, CERES],
demandedAt: [EARTH],
},
{
id: "recycled-alloys",
name: "Recycled Alloys",
cargoClassId: "standard",
massTonnes: { min: 5, max: 30 },
payMultiplier: 0.95,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [EARTH, MARS],
},
// High-Value: Expensive Cargo skill; tier from the good.
{
id: "medical-supplies",
name: "Medical Supplies",
cargoClassId: "high-value",
massTonnes: { min: 1, max: 10 },
payMultiplier: 1,
tier: 1,
priorityEligible: true,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "electronics",
name: "Electronics",
cargoClassId: "high-value",
massTonnes: { min: 2, max: 15 },
payMultiplier: 1,
tier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "seed-stock",
name: "Seed Stock & Embryos",
cargoClassId: "high-value",
massTonnes: { min: 0.5, max: 4 },
payMultiplier: 1.3,
tier: 2,
priorityEligible: true,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES],
},
{
id: "low-g-pharma",
name: "Low-G Pharmaceuticals",
cargoClassId: "high-value",
massTonnes: { min: 0.5, max: 5 },
payMultiplier: 1.4,
tier: 2,
priorityEligible: true,
producedAt: [MOON],
demandedAt: [EARTH, MARS],
},
{
id: "research-samples",
name: "Research Samples",
cargoClassId: "high-value",
massTonnes: { min: 0.2, max: 3 },
payMultiplier: 1.5,
tier: 2,
priorityEligible: true,
producedAt: [MOON, MARS, CERES],
demandedAt: [EARTH],
},
{
id: "rare-earths",
name: "Martian Rare Earths",
cargoClassId: "high-value",
massTonnes: { min: 5, max: 30 },
payMultiplier: 1.4,
tier: 3,
priorityEligible: false,
producedAt: [MARS],
demandedAt: [EARTH],
},
{
id: "platinum",
name: "Platinum Ingots",
cargoClassId: "high-value",
massTonnes: { min: 2, max: 20 },
payMultiplier: 1.8,
tier: 4,
priorityEligible: false,
producedAt: [CERES],
demandedAt: [EARTH],
},
// Heavy: Heavy Cargo skill; tier follows the generator's Heavy rule.
{
id: "machine-parts",
name: "Machine Parts",
cargoClassId: "heavy",
massTonnes: { min: 20, max: 90 },
payMultiplier: 1,
priorityEligible: true,
priorityName: "Emergency Parts",
producedAt: [EARTH, MARS],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "habitat-prefabs",
name: "Habitat Prefabs",
cargoClassId: "heavy",
massTonnes: { min: 40, max: 180 },
payMultiplier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES],
},
{
id: "lunar-titanium",
name: "Lunar Titanium",
cargoClassId: "heavy",
massTonnes: { min: 30, max: 120 },
payMultiplier: 1.1,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [EARTH, MARS],
},
{
id: "iron-ingots",
name: "Iron Ingots",
cargoClassId: "heavy",
massTonnes: { min: 50, max: 200 },
payMultiplier: 0.9,
priorityEligible: false,
producedAt: [MARS],
demandedAt: [MOON, CERES],
},
{
id: "nickel-iron-ore",
name: "Nickel-Iron Ore",
cargoClassId: "heavy",
massTonnes: { min: 80, max: 300 },
payMultiplier: 0.8,
priorityEligible: false,
producedAt: [CERES],
demandedAt: [EARTH, MARS, MOON],
},
{
id: "water-ice",
name: "Water Ice",
cargoClassId: "heavy",
massTonnes: { min: 60, max: 250 },
payMultiplier: 0.8,
priorityEligible: false,
producedAt: [CERES],
demandedAt: [MARS, MOON, JUPITER],
},
// Dangerous: Dangerous Cargo skill; tier is the hazard class.
{
id: "industrial-chemicals",
name: "Industrial Chemicals",
cargoClassId: "dangerous",
massTonnes: { min: 5, max: 40 },
payMultiplier: 1,
tier: 1,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [MOON, MARS, CERES, JUPITER],
},
{
id: "liquid-oxygen",
name: "Liquid Oxygen",
cargoClassId: "dangerous",
massTonnes: { min: 20, max: 100 },
payMultiplier: 1,
tier: 1,
priorityEligible: false,
producedAt: [MOON],
demandedAt: [MARS, CERES, JUPITER],
},
{
id: "mining-charges",
name: "Mining Charges",
cargoClassId: "dangerous",
massTonnes: { min: 2, max: 20 },
payMultiplier: 1.3,
tier: 2,
priorityEligible: false,
producedAt: [EARTH, MARS],
demandedAt: [MOON, CERES],
},
{
id: "methane-fuel",
name: "Methane Fuel",
cargoClassId: "dangerous",
massTonnes: { min: 20, max: 80 },
payMultiplier: 1.1,
tier: 2,
priorityEligible: false,
producedAt: [MARS],
demandedAt: [MOON, CERES],
},
{
id: "liquid-hydrogen",
name: "Liquid Hydrogen",
cargoClassId: "dangerous",
massTonnes: { min: 30, max: 150 },
payMultiplier: 1.1,
tier: 2,
priorityEligible: false,
producedAt: [JUPITER],
demandedAt: [MARS, CERES, EARTH],
},
{
id: "helium-3",
name: "Helium-3 Canisters",
cargoClassId: "dangerous",
massTonnes: { min: 1, max: 10 },
payMultiplier: 2,
tier: 3,
priorityEligible: true,
producedAt: [JUPITER],
demandedAt: [EARTH, MOON],
},
{
id: "reactor-cores",
name: "Fusion Reactor Cores",
cargoClassId: "dangerous",
massTonnes: { min: 10, max: 40 },
payMultiplier: 1.8,
tier: 4,
priorityEligible: false,
producedAt: [EARTH],
demandedAt: [CERES, JUPITER],
},
{
id: "antimatter-traps",
name: "Antimatter Containment Traps",
cargoClassId: "dangerous",
massTonnes: { min: 0.1, max: 1 },
payMultiplier: 4,
tier: 5,
priorityEligible: false,
producedAt: [JUPITER],
demandedAt: [EARTH],
},
];
/** Good ids produced at a location, in catalog order (a location's `produces`). */
export function goodsProducedAt(locationId: string): string[] {
return goods.filter((g) => g.producedAt.includes(locationId)).map((g) => g.id);
}
/** Good ids demanded at a location, in catalog order (a location's `demands`). */
export function goodsDemandedAt(locationId: string): string[] {
return goods.filter((g) => g.demandedAt.includes(locationId)).map((g) => g.id);
}

View File

@ -1,12 +1,9 @@
import type { Location } from "../schemas";
import { goodsDemandedAt, goodsProducedAt } from "./goods";
/**
* Base-game destinations (GDD §9). Cargo flows: Earth and Mars make goods, the
* Moon and Ceres mine ore (heavy), Jupiter refines fuel (dangerous); remote
* outposts import high-value equipment. `produces`/`demands` come from the
* goods catalog (CR-007); `cargoClassMix` keeps the class-level flows that set
* each board's class mix (decision 0.5.57).
* outposts import high-value equipment.
*/
export const locations: Location[] = [
{
@ -19,15 +16,8 @@ export const locations: Location[] = [
launchCostMultiplier: 3,
startsDiscovered: true,
bayLeasePrice: 1000,
produces: goodsProducedAt("earth"),
demands: goodsDemandedAt("earth"),
cargoClassMix: { produces: ["standard", "high-value"], demands: ["heavy", "dangerous"] },
shippers: [
"Pacific Orbital Logistics",
"Meridian Health",
"Atlas Heavy Industries",
"Hollis & Crane Consumer",
],
produces: ["standard", "high-value"],
demands: ["heavy", "dangerous"],
},
{
id: "moon",
@ -39,14 +29,9 @@ export const locations: Location[] = [
launchCostMultiplier: 0.5,
startsDiscovered: true,
bayLeasePrice: 800,
produces: goodsProducedAt("moon"),
demands: goodsDemandedAt("moon"),
cargoClassMix: {
produces: ["standard", "heavy"],
demands: ["standard", "high-value", "dangerous"],
},
shippers: ["Shackleton Mining Co.", "Tranquility Labs", "Copernicus Glassworks"],
},
{
id: "mars",
name: "Mars",
@ -57,10 +42,8 @@ export const locations: Location[] = [
launchCostMultiplier: 1.5,
startsDiscovered: false,
bayLeasePrice: 20000,
produces: goodsProducedAt("mars"),
demands: goodsDemandedAt("mars"),
cargoClassMix: { produces: ["standard", "high-value"], demands: ["heavy", "dangerous"] },
shippers: ["Jezero Agricultural Co-op", "Red Line Metals", "Tharsis Chemical"],
produces: ["standard", "high-value"],
demands: ["heavy", "dangerous"],
},
{
id: "ceres-station",
@ -72,10 +55,8 @@ export const locations: Location[] = [
launchCostMultiplier: 0.3,
startsDiscovered: false,
bayLeasePrice: 40000,
produces: goodsProducedAt("ceres-station"),
demands: goodsDemandedAt("ceres-station"),
cargoClassMix: { produces: ["heavy"], demands: ["standard", "high-value", "dangerous"] },
shippers: ["Belt Ore Consolidated", "Piazzi Station Authority"],
produces: ["heavy"],
demands: ["standard", "high-value", "dangerous"],
},
{
id: "jupiter-cloud-platform",
@ -88,9 +69,7 @@ export const locations: Location[] = [
launchCostMultiplier: 2,
startsDiscovered: false,
bayLeasePrice: 80000,
produces: goodsProducedAt("jupiter-cloud-platform"),
demands: goodsDemandedAt("jupiter-cloud-platform"),
cargoClassMix: { produces: ["dangerous"], demands: ["standard", "high-value", "heavy"] },
shippers: ["Galilean Fuel Combine", "Zephyr Gas Works"],
produces: ["dangerous"],
demands: ["standard", "high-value", "heavy"],
},
];

View File

@ -1,60 +0,0 @@
import type { Upgrade } from "../schemas";
/**
* Ship upgrades (CR-009 §2). Percentages are of the model's base stat and add
* up across levels. Overrides keep models distinct (decision 0.5.67) and give a
* maxed Courier a reach to Mars near closest approach only (decision 0.5.68).
*/
export const upgrades: Upgrade[] = [
{
id: "engines",
name: "Engine Tuning",
stat: "cruiseSpeedKmPerGameHour",
percentPerLevel: 10,
maxLevel: 3,
},
{
id: "cargo-bay",
name: "Cargo Bay Expansion",
stat: "cargoCapacityTonnes",
percentPerLevel: 15,
maxLevel: 3,
},
{
id: "fuel-tanks",
name: "Extended Fuel Tanks",
stat: "fuelRangeMillionKm",
percentPerLevel: 20,
maxLevel: 3,
modelOverrides: {
// 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 },
},
},
{
id: "landing-thrusters",
name: "Landing Thrusters",
stat: "overheadGameHours",
percentPerLevel: -15,
maxLevel: 3,
},
{
id: "handling-gear",
name: "Cargo Handling Gear",
stat: "handlingGameHoursPerTonne",
percentPerLevel: -15,
maxLevel: 3,
},
];
/** An upgrade's percent per level for a ship model, after overrides. */
export function upgradePercentPerLevel(upgrade: Upgrade, modelId: string): number {
return upgrade.modelOverrides?.[modelId]?.percentPerLevel ?? upgrade.percentPerLevel;
}
/** An upgrade's highest level for a ship model, after overrides. */
export function upgradeMaxLevel(upgrade: Upgrade, modelId: string): number {
return upgrade.modelOverrides?.[modelId]?.maxLevel ?? upgrade.maxLevel;
}

View File

@ -2,8 +2,6 @@ export * from "./schemas";
export * from "./data/locations";
export * from "./data/ships";
export * from "./data/cargo";
export * from "./data/goods";
export * from "./data/config";
export * from "./data/onboarding";
export * from "./data/upgrades";
export * from "./bundle";

View File

@ -41,17 +41,10 @@ export const locationSchema = z.object({
startsDiscovered: z.boolean(),
/** Credits. */
bayLeasePrice: nonNegativeInt,
/** Good ids made here (CR-007), derived from the goods catalog. */
/** Cargo type ids. */
produces: z.array(idSchema),
/** Good ids wanted here (CR-007), derived from the goods catalog. */
/** Cargo type ids. */
demands: z.array(idSchema),
/**
* Cargo class ids that steer a board's class mix (decision 0.5.57): a
* contract's class is one produced here and demanded at its destination.
*/
cargoClassMix: z.object({ produces: z.array(idSchema), demands: z.array(idSchema) }),
/** Flavor company names for contracts from here (decision 0.5.58). */
shippers: z.array(z.string().min(1)).min(1),
/** Map art asset id (CR-003), e.g. `body-earth`; see the web app's art manifest. */
sprite: idSchema,
/** Optional map badge asset id drawn on the body's edge (CR-003). */
@ -85,39 +78,6 @@ export const shipModelSchema = z.object({
});
export type ShipModel = z.infer<typeof shipModelSchema>;
/**
* Ship stats an upgrade can change (CR-009). The first four are ShipModel
* fields; handling time per tonne is a game-wide base (`handlingGameHoursPerTonne`)
* that becomes per ship once upgraded.
*/
export const SHIP_STATS = [
"cruiseSpeedKmPerGameHour",
"cargoCapacityTonnes",
"fuelRangeMillionKm",
"overheadGameHours",
"handlingGameHoursPerTonne",
] as const;
export const shipStatSchema = z.enum(SHIP_STATS);
export type ShipStat = z.infer<typeof shipStatSchema>;
/** One upgrade (decision 0.5.63): a stat change per level, as a percent of the base stat. */
export const upgradeSchema = z.object({
id: idSchema,
name: z.string().min(1),
stat: shipStatSchema,
/** Percent of the model's base stat per level; negative lowers it (e.g. −15 overhead). */
percentPerLevel: z.number().refine((p) => p !== 0, "An upgrade must change its stat"),
maxLevel: positiveInt,
/** Per ship model overrides (decisions 0.5.67, 0.5.68). */
modelOverrides: z
.record(
idSchema,
z.object({ percentPerLevel: z.number().optional(), maxLevel: positiveInt.optional() }),
)
.optional(),
});
export type Upgrade = z.infer<typeof upgradeSchema>;
export const cargoTypeSchema = z.object({
id: idSchema,
name: z.string().min(1),
@ -130,30 +90,6 @@ export const cargoTypeSchema = z.object({
});
export type CargoType = z.infer<typeof cargoTypeSchema>;
/** A named good (CR-007, decision 0.5.54). */
export const goodSchema = z
.object({
id: idSchema,
name: z.string().min(1),
/** Cargo class (cargo type) id. */
cargoClassId: idSchema,
massTonnes: z.object({ min: z.number().positive(), max: z.number().positive() }),
/** Multiplies the class's base pay. */
payMultiplier: z.number().positive(),
/** High-Value and Dangerous goods only: the required skill level (decision 0.5.55). */
tier: z.number().int().min(1).max(5).optional(),
priorityEligible: z.boolean(),
/** Shown instead of the name on a Priority contract, e.g. "Emergency Parts". */
priorityName: z.string().min(1).optional(),
producedAt: z.array(idSchema).min(1),
demandedAt: z.array(idSchema).min(1),
})
.refine((g) => g.massTonnes.min <= g.massTonnes.max, {
message: "Mass min must not exceed max",
path: ["massTonnes"],
});
export type Good = z.infer<typeof goodSchema>;
export const skillSchema = z.object({
id: idSchema,
name: z.string().min(1),
@ -198,18 +134,6 @@ export const gameConfigSchema = z
/** Loan taken on with the deal, in credits (decision 0.5.30). */
dealLoanAmount: positiveInt,
contractBoardPeriodRealMinutes: z.number().positive(),
/**
* Upgrade levels 1, 2, 3, …: cost as a percent of the ship model's price and
* real minutes in the shop (CR-009).
*/
upgradeLevels: z
.array(
z.object({
costPercentOfPrice: z.number().positive(),
shopRealMinutes: z.number().positive(),
}),
)
.min(1),
/** Game hours a pilot may fly before mandatory rest, at Long Haul 0 (decision 0.5.43). */
dutyLimitGameHours: z.number().positive(),
/** Extra duty game hours per Long Haul level (decision 0.5.43). */
@ -218,16 +142,6 @@ export const gameConfigSchema = z
restGameHours: z.number().positive(),
firstContactChance: fraction,
firstContactPayoutMultiplier: z.number().positive(),
/** Each board contract's pay rate varies within ± this share of 1 (decision 0.5.60). */
contractPayVariance: fraction,
/** Share of Priority-eligible contracts that become Priority (decision 0.5.56). */
priorityChance: fraction,
/** Payout multiplier for a Priority contract. */
priorityPayoutMultiplier: z.number().positive(),
/** Ship model whose trip time sets a Priority contract's delivery window. */
priorityReferenceShipModelId: idSchema,
/** Multiplies the reference trip time to give the window. */
priorityWindowFactor: z.number().positive(),
/**
* Contract mass bands, one per ship size class: each board has `count`
* contracts with a whole-tonne mass in [min, max]. Counts add up to the
@ -244,13 +158,6 @@ export const gameConfigSchema = z
.refine((band) => band.min <= band.max, { message: "min must not exceed max" }),
)
.min(1),
/**
* Contracts kept in a mass band the player's fleet can't carry: a preview
* of bigger ships' work (decision 0.5.52). The rest move to flyable bands.
*/
boardPreviewSlots: nonNegativeInt,
/** Contracts per flyable band left open (skill-gated, first contact) rather than fit (0.5.52). */
boardOpenSlotsPerBand: nonNegativeInt,
/** One cut-off per Long Haul level (decision 0.5.7). */
longHaulCutoffsMillionKm: increasingList,
xpPerDelivery: z.object({

View File

@ -1,79 +0,0 @@
import { describe, expect, it } from "vitest";
import {
cargoTypes,
goods,
goodSchema,
goodsDemandedAt,
goodsProducedAt,
locations,
shipModels,
} from "../src/index";
import { gameConfig } from "../src/data/config";
const classIds = cargoTypes.map((c) => c.id);
const locationIds = locations.map((l) => l.id);
const standardOn = (from: string, to: string) =>
goods.filter(
(g) =>
g.cargoClassId === "standard" && g.producedAt.includes(from) && g.demandedAt.includes(to),
);
describe("goods catalog (CR-007)", () => {
it("has unique ids and every good passes the schema", () => {
expect(new Set(goods.map((g) => g.id)).size).toBe(goods.length);
for (const good of goods) expect(() => goodSchema.parse(good)).not.toThrow();
});
it("references existing classes and locations, with min ≤ max", () => {
for (const good of goods) {
expect(classIds).toContain(good.cargoClassId);
for (const id of [...good.producedAt, ...good.demandedAt]) expect(locationIds).toContain(id);
expect(good.massTonnes.min).toBeLessThanOrEqual(good.massTonnes.max);
}
});
it("gives High-Value and Dangerous goods a tier 1–5, and Standard and Heavy goods none", () => {
for (const good of goods) {
if (good.cargoClassId === "high-value" || good.cargoClassId === "dangerous") {
expect(good.tier).toBeGreaterThanOrEqual(1);
expect(good.tier).toBeLessThanOrEqual(5);
} else {
expect(good.tier).toBeUndefined();
}
}
});
it("has every location produce at least one Standard good", () => {
for (const id of locationIds) {
expect(goods.some((g) => g.cargoClassId === "standard" && g.producedAt.includes(id))).toBe(
true,
);
}
});
it("has at least two Standard goods each way between Earth and the Moon (tutorial)", () => {
expect(standardOn("earth", "moon").length).toBeGreaterThanOrEqual(2);
expect(standardOn("moon", "earth").length).toBeGreaterThanOrEqual(2);
});
it("offers variety on the early route: ≥ 5 Standard goods Earth→Moon, ≥ 4 back (CR-008)", () => {
expect(standardOn("earth", "moon").length).toBeGreaterThanOrEqual(5);
expect(standardOn("moon", "earth").length).toBeGreaterThanOrEqual(4);
expect(gameConfig.contractPayVariance).toBeGreaterThan(0);
expect(gameConfig.contractPayVariance).toBeLessThan(1);
});
it("keeps each location's produces and demands in sync with the catalog", () => {
for (const location of locations) {
expect(location.produces).toEqual(goodsProducedAt(location.id));
expect(location.demands).toEqual(goodsDemandedAt(location.id));
}
});
it("sets Priority to a real ship model and sensible numbers", () => {
expect(shipModels.map((s) => s.id)).toContain(gameConfig.priorityReferenceShipModelId);
expect(gameConfig.priorityChance).toBe(0.15);
expect(gameConfig.priorityPayoutMultiplier).toBeGreaterThan(1);
expect(goods.some((g) => g.priorityEligible)).toBe(true);
});
});

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { cargoTypes, goods, locationSchema, locations } from "../src/index";
import { cargoTypes, locationSchema, locations } from "../src/index";
const cargoTypeIds = cargoTypes.map((c) => c.id);
@ -20,21 +20,14 @@ describe("locations", () => {
expect(discovered.sort()).toEqual(["earth", "moon"]);
});
it("references only existing goods, and cargo classes in its class mix", () => {
const goodIds = goods.map((g) => g.id);
it("references only existing cargo types", () => {
for (const location of locations) {
for (const goodId of [...location.produces, ...location.demands]) {
expect(goodIds).toContain(goodId);
for (const cargoId of [...location.produces, ...location.demands]) {
expect(cargoTypeIds).toContain(cargoId);
}
const { produces, demands } = location.cargoClassMix;
for (const cargoId of [...produces, ...demands]) expect(cargoTypeIds).toContain(cargoId);
}
});
it("has at least one shipper per location (CR-007)", () => {
for (const location of locations) expect(location.shippers.length).toBeGreaterThan(0);
});
it("gives Earth the highest launch multiplier", () => {
const earth = locations.find((l) => l.id === "earth")!;
for (const location of locations) {

View File

@ -3,7 +3,6 @@ import type { z } from "zod";
import {
cargoTypeSchema,
gameConfigSchema,
goodSchema,
locationSchema,
onboardingGoalSchema,
scriptedTutorialContractSchema,
@ -21,22 +20,8 @@ const validLocation = {
startsDiscovered: true,
bayLeasePrice: 5000,
sprite: "body-mars",
produces: ["mail"],
demands: ["lunar-titanium"],
cargoClassMix: { produces: ["standard"], demands: ["heavy"] },
shippers: ["Pacific Orbital Logistics"],
};
/** Optional fields (tier, priorityName) are left out so every key here is required. */
const validGood = {
id: "mail",
name: "Mail & Parcels",
cargoClassId: "standard",
massTonnes: { min: 1, max: 8 },
payMultiplier: 1.2,
priorityEligible: true,
producedAt: ["earth"],
demandedAt: ["moon"],
produces: ["standard"],
demands: ["heavy"],
};
const validShipModel = {
@ -83,17 +68,10 @@ const validGameConfig = {
restGameHours: 60,
firstContactChance: 0.1,
firstContactPayoutMultiplier: 2,
contractPayVariance: 0.15,
priorityChance: 0.15,
priorityPayoutMultiplier: 1.5,
priorityReferenceShipModelId: "freighter",
priorityWindowFactor: 1,
contractMassBands: [
{ min: 1, max: 20, count: 4 },
{ min: 21, max: 100, count: 4 },
],
boardPreviewSlots: 1,
boardOpenSlotsPerBand: 1,
longHaulCutoffsMillionKm: [100, 200, 300, 400, 500],
xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 },
pilotLevelXpThresholds: [100, 250, 500],
@ -104,7 +82,6 @@ const validGameConfig = {
tutorialRunsBeforeDeal: 4,
tutorialMassTonnes: { light: 5, medium: 12, full: 20 },
handlingGameHoursPerTonne: 0.02,
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],
loanRepaymentShare: 0.3,
dealLoanAmount: 5000,
};
@ -129,7 +106,6 @@ const cases: [string, z.ZodType, Record<string, unknown>][] = [
["Location", locationSchema, validLocation],
["ShipModel", shipModelSchema, validShipModel],
["CargoType", cargoTypeSchema, validCargoType],
["Good", goodSchema, validGood],
["Skill", skillSchema, validSkill],
["GameConfig", gameConfigSchema, validGameConfig],
["ScriptedTutorialContract", scriptedTutorialContractSchema, validScripted],
@ -148,19 +124,6 @@ describe.each(cases)("%s schema", (_name, schema, valid) => {
});
});
describe("Good refinements (CR-007)", () => {
it("rejects a mass range whose min exceeds its max", () => {
const good = { ...validGood, massTonnes: { min: 9, max: 8 } };
expect(goodSchema.safeParse(good).success).toBe(false);
});
it("rejects a tier outside 1–5 and a shipperless location", () => {
expect(goodSchema.safeParse({ ...validGood, tier: 6 }).success).toBe(false);
expect(goodSchema.safeParse({ ...validGood, tier: 0 }).success).toBe(false);
expect(locationSchema.safeParse({ ...validLocation, shippers: [] }).success).toBe(false);
});
});
describe("GameConfig refinements", () => {
it("rejects tutorial mass tiers that do not increase", () => {
const config = { ...validGameConfig, tutorialMassTonnes: { light: 12, medium: 12, full: 20 } };
@ -188,20 +151,6 @@ describe("GameConfig refinements", () => {
expect(gameConfigSchema.safeParse(config).success).toBe(false);
});
it("rejects negative or fractional board slot counts (CR-006)", () => {
for (const bad of [-1, 0.5]) {
expect(
gameConfigSchema.safeParse({ ...validGameConfig, boardPreviewSlots: bad }).success,
).toBe(false);
expect(
gameConfigSchema.safeParse({ ...validGameConfig, boardOpenSlotsPerBand: bad }).success,
).toBe(false);
}
expect(
gameConfigSchema.safeParse({ ...validGameConfig, boardOpenSlotsPerBand: 0 }).success,
).toBe(true);
});
it("rejects Long Haul cut-offs that are not increasing", () => {
const config = { ...validGameConfig, longHaulCutoffsMillionKm: [100, 100, 300] };
expect(gameConfigSchema.safeParse(config).success).toBe(false);

View File

@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import {
content,
gameConfig,
shipModels,
upgradeMaxLevel,
upgradePercentPerLevel,
upgradeSchema,
upgrades,
type ShipStat,
} from "../src/index";
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 upgrade = upgradeFor(stat);
const level = upgradeMaxLevel(upgrade, modelId);
return model(modelId)[stat] * (1 + (upgradePercentPerLevel(upgrade, modelId) * level) / 100);
};
describe("ship upgrades content (CR-009)", () => {
it("has the five upgrades, all valid, three levels each", () => {
for (const upgrade of upgrades) expect(upgradeSchema.safeParse(upgrade).success).toBe(true);
expect(upgrades.map((u) => u.id)).toEqual([
"engines",
"cargo-bay",
"fuel-tanks",
"landing-thrusters",
"handling-gear",
]);
for (const upgrade of upgrades) expect(upgrade.maxLevel).toBe(3);
expect(content.upgrade("engines")?.name).toBe("Engine Tuning");
});
it("only changes stats ships have", () => {
for (const upgrade of upgrades) {
if (upgrade.stat === "handlingGameHoursPerTonne") {
// A game-wide base until upgraded.
expect(gameConfig.handlingGameHoursPerTonne).toBeGreaterThan(0);
} else {
for (const m of shipModels)
expect(m, `${m.id}.${upgrade.stat}`).toHaveProperty(upgrade.stat);
}
for (const id of Object.keys(upgrade.modelOverrides ?? {})) {
expect(content.shipModel(id), id).toBeDefined();
}
}
});
it("prices levels at 10/20/35 % of the ship and 10/20/30 minutes in the shop", () => {
expect(gameConfig.upgradeLevels).toEqual([
{ costPercentOfPrice: 10, shopRealMinutes: 10 },
{ costPercentOfPrice: 20, shopRealMinutes: 20 },
{ costPercentOfPrice: 35, shopRealMinutes: 30 },
]);
for (const upgrade of upgrades) {
for (const m of shipModels) {
expect(upgradeMaxLevel(upgrade, m.id)).toBeLessThanOrEqual(gameConfig.upgradeLevels.length);
}
}
});
it("keeps models distinct (decision 0.5.67)", () => {
expect(maxed("courier", "cargoCapacityTonnes")).toBeLessThan(
model("freighter").cargoCapacityTonnes,
);
expect(maxed("bulk-hauler", "cruiseSpeedKmPerGameHour")).toBeLessThan(
model("freighter").cruiseSpeedKmPerGameHour,
);
expect(maxed("freighter", "fuelRangeMillionKm")).toBeLessThan(
model("bulk-hauler").fuelRangeMillionKm,
);
});
});

View File

@ -27,16 +27,6 @@ export const companySchema = z.object({
});
export type Company = z.infer<typeof companySchema>;
/** A ship's stats after upgrades (CR-009). */
export const effectiveStatsSchema = z.object({
cruiseSpeedKmPerGameHour: z.number().positive(),
cargoCapacityTonnes: z.number().positive(),
fuelRangeMillionKm: z.number().positive(),
overheadGameHours: z.number().nonnegative(),
handlingGameHoursPerTonne: z.number().nonnegative(),
});
export type EffectiveShipStats = z.infer<typeof effectiveStatsSchema>;
export const shipSchema = z.object({
id: idSchema,
modelId: idSchema,
@ -52,19 +42,6 @@ export const shipSchema = z.object({
* delivery if it has not flown. Picks the default Contracts location (CR-002).
*/
lastArrivalAt: timeSchema,
/** Installed upgrade levels by upgrade id; a missing upgrade is level 0 (CR-009). */
upgrades: z.record(z.string(), z.number().int().positive()),
/** The model's stats after upgrades (decision 0.5.63). */
stats: effectiveStatsSchema,
/** In the upgrade shop until then; null when not in the shop now. */
inShopUntil: timeSchema.nullable(),
/**
* The latest shop job, until the player acknowledges it: in progress while
* `until` is in the future, finished ("out of the shop") once it has passed.
*/
shop: z
.object({ upgradeId: idSchema, level: z.number().int().positive(), until: timeSchema })
.nullable(),
});
export type Ship = z.infer<typeof shipSchema>;
@ -108,13 +85,6 @@ export const contractSchema = z.object({
originId: idSchema,
destinationId: idSchema,
cargoTypeId: idSchema,
/** The named good (CR-007). */
goodId: idSchema,
/** Flavor: the shipping company (decision 0.5.58). */
shipper: z.string().min(1),
priority: z.boolean(),
/** Priority only: the longest allowed trip in game hours (decision 0.5.56). */
deliveryWindowGameHours: z.number().positive().nullable(),
tier: z.number().int().min(0),
massTonnes: z.number().positive(),
payout: credits,
@ -156,14 +126,6 @@ export const resolutionSummarySchema = z.object({
originId: idSchema,
destinationId: idSchema,
tutorial: z.boolean(),
/** An empty reposition flight: no cargo, no pay (CR-002). */
deadhead: z.boolean(),
/** Fuel paid at departure. */
fuelCost: credits.nonnegative(),
/** What was delivered and for whom (CR-007); null for a deadhead. */
goodId: idSchema.nullable(),
massTonnes: z.number().positive().nullable(),
shipper: z.string().min(1).nullable(),
/** Credits added after pilot pay and loan repayment. */
credits,
/** Credits the lender took from this payout (decision 0.5.32). */

View File

@ -1,10 +1,8 @@
import {
DISPATCH_FAILURES,
REPOSITION_FAILURES,
UPGRADE_FAILURES,
type DispatchFailure,
type RepositionFailure,
type UpgradeFailure,
} from "@deadhead/sim";
import { z } from "zod";
@ -37,17 +35,11 @@ export const SERVER_ERROR_CODES = [
"goal-already-claimed",
] as const;
export type ErrorCode =
DispatchFailure | RepositionFailure | UpgradeFailure | (typeof SERVER_ERROR_CODES)[number];
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];
/** Every machine-readable error code. Sim validation reasons are used unchanged. */
export const ERROR_CODES = [
...new Set<ErrorCode>([
...DISPATCH_FAILURES,
...REPOSITION_FAILURES,
...UPGRADE_FAILURES,
...SERVER_ERROR_CODES,
]),
...new Set<ErrorCode>([...DISPATCH_FAILURES, ...REPOSITION_FAILURES, ...SERVER_ERROR_CODES]),
] as [ErrorCode, ...ErrorCode[]];
export const errorCodeSchema = z.enum(ERROR_CODES);

View File

@ -49,9 +49,6 @@ export const dispatchResponseSchema = z.object({ dispatch: dispatchSchema });
export const buyShipRequestSchema = z.object({ modelId: idSchema, bayId: idSchema });
export const shipResponseSchema = z.object({ ship: shipSchema });
/** CR-009: start the next level of an upgrade on a ship at an HQ. */
export const startUpgradeRequestSchema = z.object({ shipId: idSchema, upgradeId: idSchema });
export const leaseBayRequestSchema = z.object({ locationId: idSchema });
export const bayResponseSchema = z.object({ bay: baySchema });
@ -64,15 +61,9 @@ export const restPilotRequestSchema = z.object({ pilotId: idSchema });
export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema });
export const pilotResponseSchema = z.object({ pilot: pilotSchema });
export const acknowledgeSummariesRequestSchema = z
.object({
dispatchIds: z.array(idSchema),
/** Ships whose finished upgrade the player has now seen (CR-009). */
shipIds: z.array(idSchema).optional(),
})
.refine((r) => r.dispatchIds.length + (r.shipIds?.length ?? 0) > 0, {
message: "Acknowledge at least one summary or ship",
});
export const acknowledgeSummariesRequestSchema = z.object({
dispatchIds: z.array(idSchema).min(1),
});
export const acknowledgeSummariesResponseSchema = z.object({
acknowledged: z.number().int().nonnegative(),
});

View File

@ -21,7 +21,6 @@ import {
shipResponseSchema,
spendSkillPointRequestSchema,
restPilotRequestSchema,
startUpgradeRequestSchema,
unassignPilotRequestSchema,
} from "./requests";
@ -88,13 +87,6 @@ export const routes = {
response: shipResponseSchema,
auth: true,
},
startUpgrade: {
method: "POST",
path: "/api/ships/:shipId/upgrades",
request: startUpgradeRequestSchema,
response: shipResponseSchema,
auth: true,
},
leaseBay: {
method: "POST",
path: "/api/bays",

View File

@ -20,7 +20,6 @@ describe("route catalog", () => {
"assignPilot",
"unassignPilot",
"restPilot",
"startUpgrade",
"spendSkillPoint",
"acknowledgeSummaries",
"acceptDeal",

View File

@ -9,7 +9,6 @@ import type { z } from "zod";
import {
acknowledgeSummariesRequestSchema,
acknowledgeSummariesResponseSchema,
startUpgradeRequestSchema,
assignPilotRequestSchema,
baySchema,
claimGoalRequestSchema,
@ -57,16 +56,6 @@ const ship = {
dispatchId: null,
pilotId: "p1",
lastArrivalAt: 4_102_444_800_000,
upgrades: { "cargo-bay": 1 },
stats: {
cruiseSpeedKmPerGameHour: 3_000_000,
cargoCapacityTonnes: 23,
fuelRangeMillionKm: 5,
overheadGameHours: 2,
handlingGameHoursPerTonne: 0.02,
},
inShopUntil: null,
shop: { upgradeId: "cargo-bay", level: 1, until: 4_102_444_000_000 },
};
const pilot = {
id: "p1",
@ -91,10 +80,6 @@ const contract = {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 12,
payout: 480,
@ -129,11 +114,6 @@ const summary = {
destinationId: "earth",
tutorial: true,
credits: 240,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,
@ -169,24 +149,6 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
],
["company", companySchema, company, { ...company, credits: 1.5 }],
["ship", shipSchema, ship, { ...ship, status: "lost" }],
[
"ship in the shop",
shipSchema,
{ ...ship, inShopUntil: 4_102_445_000_000 },
{ ...ship, upgrades: { engines: 0 } },
],
[
"ship stats",
shipSchema,
{ ...ship, shop: null },
{ ...ship, stats: { ...ship.stats, cargoCapacityTonnes: 0 } },
],
[
"start upgrade request",
startUpgradeRequestSchema,
{ shipId: "s1", upgradeId: "engines" },
{ shipId: "s1" },
],
["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }],
[
"resting pilot",
@ -199,19 +161,6 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
["bay", baySchema, bay, { id: "b1" }],
["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
["contract", contractSchema, contract, { ...contract, tier: -1 }],
[
"priority contract (CR-007)",
contractSchema,
{ ...contract, priority: true, deliveryWindowGameHours: 4.5 },
{ ...contract, priority: true, deliveryWindowGameHours: -1 },
],
["contract without a good", contractSchema, contract, { ...contract, goodId: "" }],
[
"delivery summary goods (CR-007)",
resolutionSummarySchema,
{ ...summary, goodId: null, massTonnes: null, shipper: null },
{ ...summary, massTonnes: 0 },
],
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
[
@ -288,12 +237,6 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
{ dispatchIds: ["d0"] },
{ dispatchIds: [] },
],
[
"acknowledge a finished upgrade",
acknowledgeSummariesRequestSchema,
{ dispatchIds: [], shipIds: ["s1"] },
{ dispatchIds: [], shipIds: [] },
],
["acknowledge summaries response", acknowledgeSummariesResponseSchema, { acknowledged: 1 }, {}],
];

View File

@ -1,31 +1,23 @@
import type {
CargoType,
GameConfig,
Good,
Location,
ScriptedTutorialContract,
ShipModel,
Skill,
Upgrade,
} from "@deadhead/content";
import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
import { contractPayout, type Contract } from "./contract";
import { routeDistance } from "./distance";
import { checkRoute } from "./eligibility";
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
import { createRng, deriveSeed, type Rng, type Seed } from "./rng";
import { travelTimeGameHours } from "./travel";
import { effectiveStats, type UpgradeLevels } from "./upgrades";
/** The static data the sim needs. The `content` bundle satisfies this. */
export interface SimContent {
locations: readonly Location[];
cargoTypes: readonly CargoType[];
goods: readonly Good[];
skills: readonly Skill[];
shipModels: readonly ShipModel[];
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
upgrades: readonly Upgrade[];
config: GameConfig;
}
@ -49,344 +41,52 @@ export function standardCargo(content: SimContent): CargoType {
return standard;
}
/** One of the player's ships, with its pilot's skills (none without a pilot). */
export interface FleetMember {
modelId: string;
skills: SkillLevels;
/** Installed upgrades: boards fit the ship's effective capacity (CR-009). */
upgrades?: UpgradeLevels;
}
export interface BoardInput {
/** The location whose freight market this is; every contract starts here (CR-002). */
location: Location;
discoveredIds: ReadonlySet<string>;
periodIndex: number;
worldSeed: Seed;
/** The player's ships (CR-006). Absent or empty: a board that ignores the fleet. */
fleet?: readonly FleetMember[];
}
type MassBand = GameConfig["contractMassBands"][number];
type SlotKind = "guaranteed" | "open" | "fit";
interface Crew {
model: ShipModel;
skills: SkillLevels;
}
/**
* Which contracts a board has, band by band (decision 0.5.52). Without a crew
* every band keeps its count and opens with a guaranteed job. With one, a band
* no ship can carry shrinks to a preview, its other slots go to the flyable
* bands (largest first, round-robin), and flyable bands are fit jobs but for
* `boardOpenSlotsPerBand` open ones.
*/
function planSlots(crews: readonly Crew[], config: GameConfig) {
const bands = config.contractMassBands;
const flyable = bands.map((band) => crews.some((c) => c.model.cargoCapacityTonnes >= band.min));
if (!flyable.some(Boolean)) {
return bands.flatMap((band) =>
Array.from({ length: band.count }, (_, k) => ({
band,
kind: (k === 0 ? "guaranteed" : "open") as SlotKind,
})),
);
}
const counts = bands.map((band, i) =>
flyable[i] ? band.count : Math.min(band.count, config.boardPreviewSlots),
);
const targets = bands
.map((_, i) => i)
.filter((i) => flyable[i])
.reverse();
let freed = bands.reduce((sum, band, i) => sum + band.count - counts[i]!, 0);
for (let k = 0; freed > 0; k++, freed--) counts[targets[k % targets.length]!]!++;
return bands.flatMap((band, i) => {
const fit = flyable[i] ? Math.max(0, counts[i]! - config.boardOpenSlotsPerBand) : 0;
return Array.from({ length: counts[i]! }, (_, k) => ({
band,
kind: (k < fit ? "fit" : !flyable[i] && k === 0 ? "guaranteed" : "open") as SlotKind,
}));
});
}
/**
* A job one crew can take (decision 0.5.52): within its capacity, to a
* discovered place it can reach and land at with its Long Haul, with cargo its
* pilot is skilled for. Crews are tried from a random start; null if none fits.
*/
function fitJob(
rng: Rng,
band: MassBand,
crews: readonly Crew[],
origin: Location,
discovered: readonly Location[],
offeredAt: RealTime,
content: SimContent,
) {
const gameTime = realToGame(offeredAt, content.config);
const able = crews.filter((c) => c.model.cargoCapacityTonnes >= band.min);
if (able.length === 0) return null;
const start = rng.int(0, able.length - 1);
for (let k = 0; k < able.length; k++) {
const { model, skills } = able[(start + k) % able.length]!;
const reachable = discovered.filter(
(d) =>
checkRoute(model, origin, d, gameTime).ok &&
requiredLongHaulLevel(routeDistance(origin, d, gameTime), content.config) <=
skillLevel(skills, LONG_HAUL_SKILL_ID),
);
if (reachable.length === 0) continue;
const destination = rng.pick(reachable);
// Only goods this pilot may carry: a good's own tier must not exceed their skill.
const qualified = (g: Good) => {
const skillId = cargoClassOf(g, content).requiredSkillId;
return skillId === null || skillLevel(skills, skillId) >= (g.tier ?? 1);
};
// Any good on this route the crew is qualified for and the ship can carry
// (CR-008 fix): picking a class first left e.g. a Heavy-skilled Courier at the
// Moon with nothing but undersized Lunar Titanium lots.
const fitting = content.goods.filter(
(g) =>
g.producedAt.includes(origin.id) &&
g.demandedAt.includes(destination.id) &&
qualified(g) &&
fitsSlot(g, band, content, model.cargoCapacityTonnes),
);
let candidates = fitting;
if (candidates.length === 0) {
// Nothing on the route fits: a class from the class mix, as a bulk lot if need be.
const skilled = content.cargoTypes.filter(
(c) =>
origin.cargoClassMix.produces.includes(c.id) &&
destination.cargoClassMix.demands.includes(c.id) &&
(c.requiredSkillId === null || skillLevel(skills, c.requiredSkillId) >= 1),
);
const cargoType = skilled.length > 0 ? rng.pick(skilled) : standardCargo(content);
candidates = slotGoods(
cargoType,
origin,
destination,
band,
content,
qualified,
model.cargoCapacityTonnes,
);
}
const { good, massTonnes } = chooseGood(
rng,
candidates,
band,
content,
model.cargoCapacityTonnes,
);
const skillId = cargoClassOf(good, content).requiredSkillId;
const tier = tierFor(rng, good, content, skillId ? skillLevel(skills, skillId) : undefined);
return { destination, good, tier, massTonnes, model };
}
return null;
}
/** A mass in [lo, hi]: to 0.1 t under 10 t, whole tonnes from there (CR-007). */
function drawMass(rng: Rng, lo: number, hi: number): number {
const raw = lo + rng.next() * (hi - lo);
const rounded = raw < 10 ? Math.round(raw * 10) / 10 : Math.round(raw);
return Math.min(hi, Math.max(lo, rounded));
}
/**
* A good and its mass for a slot (CR-007, "the band wins"): a good whose mass
* range overlaps the slot's band (the lightest band has no floor), with mass in
* both and within `capacity`; with no overlap, the candidate with the largest
* range and a mass from the band, a bulk lot.
*/
/** The mass range a good can take in a slot (the lightest band has no floor), within `capacity`. */
function slotRange(good: Good, band: MassBand, content: SimContent, capacity: number) {
const lightest = Math.min(...content.config.contractMassBands.map((b) => b.min));
const floor = band.min === lightest ? 0 : band.min;
return {
lo: Math.max(floor, good.massTonnes.min),
hi: Math.min(band.max, capacity, good.massTonnes.max),
};
}
/**
* Goods for a slot of this class (decision 0.5.54): the class's goods on the
* route. If every one of them is too heavy for the slot (band and ship),
* Standard goods on the route that fit instead (CR-008 fix: no 8 t lots of a
* 30 t+ good). Goods too light for a big band stay, and `chooseGood` makes a
* bulk lot of them ("the band wins").
*/
export function slotGoods(
cargoType: CargoType,
origin: Location,
destination: Location,
band: MassBand,
content: SimContent,
allow: (good: Good) => boolean = () => true,
capacity = Infinity,
): Good[] {
const goods = routeGoods(cargoType, origin, destination, content, allow);
const ceiling = Math.min(band.max, capacity);
if (!goods.every((g) => g.massTonnes.min > ceiling)) return goods;
const standard = routeGoods(standardCargo(content), origin, destination, content, allow);
return standard.some((g) => fitsSlot(g, band, content, capacity)) ? standard : goods;
}
/** Whether a good's own mass range fits the slot's band and the ship. */
export function fitsSlot(good: Good, band: MassBand, content: SimContent, capacity = Infinity) {
const { lo, hi } = slotRange(good, band, content, capacity);
return lo <= hi;
}
export function chooseGood(
rng: Rng,
candidates: readonly Good[],
band: MassBand,
content: SimContent,
capacity = Infinity,
): { good: Good; massTonnes: number } {
const ceiling = Math.min(band.max, capacity);
const overlapping = candidates.filter((g) => fitsSlot(g, band, content, capacity));
if (overlapping.length > 0) {
const good = rng.pick(overlapping);
const { lo, hi } = slotRange(good, band, content, capacity);
return { good, massTonnes: drawMass(rng, lo, hi) };
}
const good = candidates.reduce((a, b) => (b.massTonnes.max > a.massTonnes.max ? b : a));
return { good, massTonnes: drawMass(rng, band.min, ceiling) };
}
/**
* A Priority contract's delivery window (decision 0.5.56): the reference
* ship's trip time for this mass and route at offer time × the window factor.
*/
export function priorityWindow(
origin: Location,
destination: Location,
massTonnes: number,
offeredAt: RealTime,
content: SimContent,
): number {
const { config } = content;
const reference = content.shipModels.find((s) => s.id === config.priorityReferenceShipModelId);
if (!reference) throw new Error(`Unknown ship model ${config.priorityReferenceShipModelId}`);
const distance = routeDistance(origin, destination, realToGame(offeredAt, config));
return travelTimeGameHours(reference, distance, massTonnes, config) * config.priorityWindowFactor;
}
/**
* Rolls Priority for an eligible good (decision 0.5.56). A fit job is Priority
* only when its own ship (`model`) makes the window.
*/
function rollPriority(
rng: Rng,
args: {
good: Good;
origin: Location;
destination: Location;
massTonnes: number;
offeredAt: RealTime;
model?: ShipModel;
},
content: SimContent,
): { priority: boolean; deliveryWindowGameHours: number | null } {
const none = { priority: false, deliveryWindowGameHours: null };
if (!args.good.priorityEligible || rng.next() >= content.config.priorityChance) return none;
const window = priorityWindow(
args.origin,
args.destination,
args.massTonnes,
args.offeredAt,
content,
);
if (args.model) {
const distance = routeDistance(
args.origin,
args.destination,
realToGame(args.offeredAt, content.config),
);
const trip = travelTimeGameHours(args.model, distance, args.massTonnes, content.config);
if (trip > window) return none;
}
return { priority: true, deliveryWindowGameHours: window };
}
/** A board contract's market rate: uniform within ± `contractPayVariance` of 1 (decision 0.5.60). */
export function payRate(rng: Rng, content: SimContent): number {
return 1 + content.config.contractPayVariance * (2 * rng.next() - 1);
}
export function cargoClassOf(good: Good, content: SimContent): CargoType {
const cargoType = content.cargoTypes.find((c) => c.id === good.cargoClassId);
if (!cargoType) throw new Error(`Unknown cargo class ${good.cargoClassId}`);
return cargoType;
}
/**
* A contract's tier (decision 0.5.55): Standard is 0, High-Value and Dangerous
* take the good's tier, and Heavy rolls 1 to `maxTier` (the skill's maximum,
* or a pilot's level for a fit job).
*/
export function tierFor(rng: Rng, good: Good, content: SimContent, maxTier?: number): number {
const cargoType = cargoClassOf(good, content);
if (cargoType.requiredSkillId === null) return 0;
if (good.tier !== undefined) return good.tier;
return maxTier === undefined ? pickTier(rng, cargoType, content) : rng.int(1, maxTier);
}
/** Builds one contract from a chosen route, good, tier and mass. */
/** Builds one contract from a chosen route, cargo, tier and mass. */
export function buildContract(
args: {
id: string;
origin: Location;
destination: Location;
good: Good;
cargoType: CargoType;
tier: number;
massTonnes: number;
firstContact: boolean;
periodIndex: number;
shipper: string;
priority: boolean;
deliveryWindowGameHours: number | null;
/** Market rate (decision 0.5.60); 1 when absent, as on the tutorial board. */
payRate?: number;
},
content: SimContent,
): Contract {
const { config } = content;
const cargoType = cargoClassOf(args.good, content);
const offeredAt = args.periodIndex * boardPeriodMs(config);
const distance = routeDistance(args.origin, args.destination, realToGame(offeredAt, config));
return {
id: args.id,
originId: args.origin.id,
destinationId: args.destination.id,
cargoTypeId: cargoType.id,
goodId: args.good.id,
shipper: args.shipper,
priority: args.priority,
deliveryWindowGameHours: args.priority ? args.deliveryWindowGameHours : null,
cargoTypeId: args.cargoType.id,
tier: args.tier,
massTonnes: args.massTonnes,
payout: contractPayout(
{
cargoType,
payMultiplier: args.good.payMultiplier,
cargoType: args.cargoType,
tier: args.tier,
massTonnes: args.massTonnes,
distanceMillionKm: distance,
firstContact: args.firstContact,
priority: args.priority,
marketRate: args.payRate ?? 1,
},
config,
),
requiredSkill:
cargoType.requiredSkillId === null
args.cargoType.requiredSkillId === null
? null
: { skillId: cargoType.requiredSkillId, level: args.tier },
: { skillId: args.cargoType.requiredSkillId, level: args.tier },
requiredLongHaulLevel: requiredLongHaulLevel(distance, config),
offeredAt,
expiresAt: offeredAt + boardPeriodMs(config),
@ -394,38 +94,9 @@ export function buildContract(
};
}
/**
* The goods a contract of this class can carry on this route (decision 0.5.54):
* that class's goods made at the origin and wanted at the destination; else
* Standard goods on the route; else any Standard good made at the origin.
* `allow` filters out goods a crew can't take (fit jobs).
*/
export function routeGoods(
cargoType: CargoType,
origin: Location,
destination: Location,
content: SimContent,
allow: (good: Good) => boolean = () => true,
): Good[] {
const made = content.goods.filter((g) => g.producedAt.includes(origin.id) && allow(g));
const onRoute = made.filter((g) => g.demandedAt.includes(destination.id));
const ofClass = onRoute.filter((g) => g.cargoClassId === cargoType.id);
if (ofClass.length > 0) return ofClass;
const standardId = standardCargo(content).id;
const standardOnRoute = onRoute.filter((g) => g.cargoClassId === standardId);
if (standardOnRoute.length > 0) return standardOnRoute;
const anyStandard = content.goods.filter(
(g) => g.producedAt.includes(origin.id) && g.cargoClassId === standardId,
);
if (anyStandard.length === 0) throw new Error(`${origin.id} makes no Standard good`);
return anyStandard;
}
function pickCargo(rng: Rng, origin: Location, destination: Location, content: SimContent) {
const matching = content.cargoTypes.filter(
(c) =>
origin.cargoClassMix.produces.includes(c.id) &&
destination.cargoClassMix.demands.includes(c.id),
(c) => origin.produces.includes(c.id) && destination.demands.includes(c.id),
);
// Decision 0.5.17: fall back to Standard when no produced cargo is demanded.
return matching.length > 0 ? rng.pick(matching) : standardCargo(content);
@ -441,8 +112,8 @@ function pickTier(rng: Rng, cargoType: CargoType, content: SimContent): number {
/**
* Generates a location's contract board for one period (any discovered
* location, bay or not, CR-002): `contractMassBands` decide how
* many contracts fall in each ship-size mass band, shaped by the player's fleet
* when given (CR-006). Same inputs always give the same board.
* many contracts fall in each ship-size mass band. Same inputs always give the
* same board.
*/
export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] {
const { location: origin, discoveredIds, periodIndex, worldSeed } = input;
@ -453,38 +124,14 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
const discovered = others.filter((l) => discoveredIds.has(l.id));
const undiscovered = others.filter((l) => !discoveredIds.has(l.id));
const crews: Crew[] = (input.fleet ?? []).flatMap((f) => {
const model = content.shipModels.find((m) => m.id === f.modelId);
return model
? [{ model: effectiveStats(model, f.upgrades ?? {}, content), skills: f.skills }]
: [];
});
const offeredAt = periodIndex * boardPeriodMs(config);
// One slot per contract, band by band. A guaranteed slot is Standard cargo
// (no skill) to a discovered place, so every ship size always has something
// a new pilot can fly; fit slots are built for the player's own ships.
return planSlots(crews, config).map(({ band, kind }, i) => {
const id = `${origin.id}:${periodIndex}:${i}`;
const fit =
kind === "fit" ? fitJob(rng, band, crews, origin, discovered, offeredAt, content) : null;
if (fit) {
const { model, ...job } = fit;
return buildContract(
{
id,
origin,
...job,
firstContact: false,
periodIndex,
...rollPriority(rng, { ...job, origin, offeredAt, model }, content),
shipper: rng.pick(origin.shippers),
payRate: payRate(rng, content),
},
content,
// One slot per contract, band by band. The first slot of each band is a
// guaranteed job: Standard cargo (no skill) to a discovered place, so every
// ship size always has something a new pilot can fly.
const slots = config.contractMassBands.flatMap((band) =>
Array.from({ length: band.count }, (_, k) => ({ band, guaranteed: k === 0 })),
);
}
const guaranteed = kind !== "open";
return slots.map(({ band, guaranteed }, i) => {
const plain = guaranteed && discovered.length > 0;
const firstContact =
!plain &&
@ -492,25 +139,16 @@ export function generateContractBoard(input: BoardInput, content: SimContent): C
(discovered.length === 0 || rng.next() < config.firstContactChance);
const destination = rng.pick(firstContact ? undiscovered : discovered);
const cargoType = plain ? standardCargo(content) : pickCargo(rng, origin, destination, content);
const { good, massTonnes } = chooseGood(
rng,
slotGoods(cargoType, origin, destination, band, content),
band,
content,
);
return buildContract(
{
id,
id: `${origin.id}:${periodIndex}:${i}`,
origin,
destination,
good,
tier: tierFor(rng, good, content),
massTonnes,
cargoType,
tier: pickTier(rng, cargoType, content),
massTonnes: rng.int(band.min, band.max),
firstContact,
periodIndex,
...rollPriority(rng, { good, origin, destination, massTonnes, offeredAt }, content),
shipper: rng.pick(origin.shippers),
payRate: payRate(rng, content),
},
content,
);

View File

@ -5,20 +5,8 @@ export interface Contract {
id: string;
originId: string;
destinationId: string;
/** The cargo class (decision 0.5.6). */
cargoTypeId: string;
/** The named good (CR-007, decision 0.5.54); its class is `cargoTypeId`. */
goodId: string;
/** Flavor: the company shipping it, from the origin's list (decision 0.5.58). */
shipper: string;
/** A Priority contract must arrive within its delivery window (decision 0.5.56). */
priority: boolean;
/** Priority only: the longest trip allowed, in game hours; null otherwise. */
deliveryWindowGameHours: number | null;
/**
* 0 for Standard; 1–5 otherwise, and equal to the required cargo-skill level
* (decision 0.5.6). High-Value and Dangerous take it from the good (0.5.55).
*/
/** 0 for Standard; 1–5 otherwise, and equal to the required cargo-skill level (decision 0.5.6). */
tier: number;
massTonnes: number;
/** Credits, fixed at offer time (decision 0.5.11). */
@ -36,33 +24,24 @@ export interface Contract {
export interface PayoutInput {
cargoType: CargoType;
/** The good's pay multiplier (CR-007). */
payMultiplier: number;
tier: number;
massTonnes: number;
/** Route distance at offer time. */
distanceMillionKm: number;
firstContact: boolean;
priority: boolean;
/** This contract's market rate around 1 (decision 0.5.60); 1 when absent. */
marketRate?: number;
}
/**
* Payout in whole credits: class base pay per tonne per million km × the good's
* pay multiplier × tierMultiplier^tier × mass × distance, × the first-contact
* multiplier and the Priority bonus when flagged (CR-007), × its market rate (CR-008).
* Payout in whole credits: base pay per tonne per million km × tierMultiplier^tier
* × mass × distance, × the first-contact multiplier when flagged.
*/
export function contractPayout(input: PayoutInput, config: GameConfig): number {
const { cargoType, payMultiplier, tier, massTonnes, distanceMillionKm, firstContact } = input;
const { cargoType, tier, massTonnes, distanceMillionKm, firstContact } = input;
const pay =
cargoType.basePayPerTonnePerMillionKm *
payMultiplier *
cargoType.tierPayMultiplier ** tier *
massTonnes *
distanceMillionKm *
(firstContact ? config.firstContactPayoutMultiplier : 1) *
(input.priority ? config.priorityPayoutMultiplier : 1) *
(input.marketRate ?? 1);
(firstContact ? config.firstContactPayoutMultiplier : 1);
return Math.round(pay);
}

View File

@ -9,22 +9,12 @@ import { fuelCost } from "./fuel";
import { checkPilotEligibility, type SkillLevels } from "./pilot";
import { deriveSeed, type Seed } from "./rng";
import { travelTimeGameHours } from "./travel";
import { effectiveStats, isInShop, type EffectiveStats, type UpgradeLevels } from "./upgrades";
export interface ShipState {
id: string;
modelId: string;
locationId: string;
inFlight: boolean;
/** Installed upgrades (CR-009); absent means none. */
upgrades?: UpgradeLevels;
/** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */
inShopUntil?: RealTime | null;
}
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
export function shipStats(ship: ShipState, content: SimContent): EffectiveStats {
return effectiveStats(findShipModel(ship.modelId, content), ship.upgrades ?? {}, content);
}
export interface PilotState {
@ -52,7 +42,6 @@ export interface DispatchProposal {
/** Every reason `validateDispatch` can fail, in check order. */
export const DISPATCH_FAILURES = [
"ship-not-idle",
"ship-in-shop",
"ship-not-at-origin",
"pilot-resting",
"pilot-not-idle",
@ -60,7 +49,6 @@ export const DISPATCH_FAILURES = [
"over-capacity",
"cannot-land",
"out-of-range",
"too-slow",
"cargo-skill-too-low",
"long-haul-too-low",
"contract-expired",
@ -94,29 +82,15 @@ export function tripFuelCost(
const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason });
/** A ship's trip time for a contract, departing now: cruise, overhead and handling (game hours). */
export function contractTripGameHours(
model: ShipModel,
contract: Contract,
now: RealTime,
content: SimContent,
): number {
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
const distance = routeDistance(origin, destination, realToGame(now, content.config));
return travelTimeGameHours(model, distance, contract.massTonnes, content.config);
}
/** Validates a proposed contract dispatch, returning the first failing reason. */
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
const { ship, pilot, contract, tutorial, credits, now } = proposal;
const model = shipStats(ship, content);
const model = findShipModel(ship.modelId, content);
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
const departure = realToGame(now, content.config);
if (ship.inFlight) return fail("ship-not-idle");
if (isInShop(ship, now)) return fail("ship-in-shop");
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
// Rest blocks flying only (decision 0.5.44); checked before the other pilot checks.
if (isResting(pilot.restEndsAt, now)) return fail("pilot-resting");
@ -130,15 +104,6 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
const route = checkRoute(model, origin, destination, departure);
if (!route.ok) return fail(route.reason);
// Priority (decision 0.5.56): the trip must fit the delivery window.
if (
contract.priority &&
contract.deliveryWindowGameHours !== null &&
contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours
) {
return fail("too-slow");
}
const pilotCheck = checkPilotEligibility(pilot.skills, contract);
if (!pilotCheck.ok) return fail(pilotCheck.reason);
@ -197,7 +162,7 @@ function buildRecord(
): DispatchRecord {
const { id, ship, pilot, contract, tutorial, now, worldSeed } = args;
const { config } = content;
const model = shipStats(ship, content);
const model = findShipModel(ship.modelId, content);
const origin = findLocation(args.originId, content);
const destination = findLocation(args.destinationId, content);
const departure = realToGame(now, config);
@ -244,7 +209,6 @@ export interface RepositionProposal {
/** Every reason `validateReposition` can fail, in check order. */
export const REPOSITION_FAILURES = [
"ship-not-idle",
"ship-in-shop",
"pilot-resting",
"pilot-not-idle",
"pilot-not-assigned",
@ -271,14 +235,13 @@ export function validateReposition(
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
if (ship.inFlight) return failWith("ship-not-idle");
if (isInShop(ship, now)) return failWith("ship-in-shop");
if (isResting(pilot.restEndsAt, now)) return failWith("pilot-resting");
if (pilot.inFlight) return failWith("pilot-not-idle");
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
if (destinationId === ship.locationId) return failWith("already-there");
if (!discoveredIds.has(destinationId)) return failWith("location-undiscovered");
const model = shipStats(ship, content);
const model = findShipModel(ship.modelId, content);
const origin = findLocation(ship.locationId, content);
const destination = findLocation(destinationId, content);
const departure = realToGame(now, content.config);

View File

@ -26,8 +26,7 @@ export {
generateContractBoard,
requiredLongHaulLevel,
} from "./board";
export type { BoardInput, FleetMember, SimContent } from "./board";
export { priorityWindow } from "./board";
export type { BoardInput, SimContent } from "./board";
export { generateTutorialBoard, loanerModel } from "./tutorial";
export type { TutorialBoardInput, TutorialLocationId } from "./tutorial";
@ -38,10 +37,8 @@ export type { PilotCheck, SkillLevels } from "./pilot";
export {
DISPATCH_FAILURES,
REPOSITION_FAILURES,
contractTripGameHours,
createDispatchRecord,
createRepositionRecord,
shipStats,
tripFuelCost,
validateDispatch,
validateReposition,
@ -74,20 +71,3 @@ export { resolveDispatch } from "./resolution";
export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";
export {
UPGRADE_FAILURES,
effectiveStats,
isInShop,
nextUpgradeLevel,
upgradeCost,
upgradeShopMs,
validateUpgrade,
} from "./upgrades";
export type {
EffectiveStats,
UpgradeCheck,
UpgradeFailure,
UpgradeLevels,
UpgradeProposal,
} from "./upgrades";

View File

@ -7,14 +7,12 @@ import type { GameConfig, ShipModel } from "@deadhead/content";
* carry zero mass.
*/
export function travelTimeGameHours(
/** Effective stats carry the ship's own handling time (decision 0.5.63); a bare model uses the game-wide base. */
ship: ShipModel & { handlingGameHoursPerTonne?: number },
ship: ShipModel,
distanceMillionKm: number,
massTonnes: number,
config: GameConfig,
): number {
const cruiseHours = (distanceMillionKm * 1e6) / ship.cruiseSpeedKmPerGameHour;
const handlingHours =
massTonnes * (ship.handlingGameHoursPerTonne ?? config.handlingGameHoursPerTonne);
const handlingHours = massTonnes * config.handlingGameHoursPerTonne;
return cruiseHours + ship.overheadGameHours + handlingHours;
}

View File

@ -1,6 +1,5 @@
import type { Location, ShipModel } from "@deadhead/content";
import { buildContract, routeGoods, standardCargo, type SimContent } from "./board";
import { createRng, deriveSeed } from "./rng";
import { buildContract, standardCargo, type SimContent } from "./board";
import type { Contract } from "./contract";
/** The tutorial loaner: the content's company ship (decision 0.5.29). */
@ -32,9 +31,7 @@ function location(id: string, content: SimContent): Location {
* from the loaner's location to the other end of the Earth–Moon route, at the
* light, medium and full mass tiers, each paying the tutorial share. A scripted
* contract for the upcoming run (decision 0.5.33) replaces the medium slot.
* Each carries a Standard good made here and wanted there, preferring one whose
* range holds the tier's mass, else clamped to the good's range (0.5.59).
* Never Priority. The board is fully determined by its inputs.
* The board is fully determined by its inputs.
*/
export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] {
const { loanerLocationId, runsCompleted, periodIndex } = input;
@ -44,33 +41,23 @@ export function generateTutorialBoard(input: TutorialBoardInput, content: SimCon
const tiers = config.tutorialMassTonnes;
const run = runsCompleted + 1;
const scripted = content.scriptedTutorialContracts.find((s) => s.run === run);
const rng = createRng(deriveSeed(0, `tutorial:${loanerLocationId}:${run}:${periodIndex}`));
const slot = (i: number, tierMass: number): Contract => {
const slot = (i: number, massTonnes: number): Contract => {
const special = i === 1 ? scripted : undefined;
const cargoType = special
? content.cargoTypes.find((c) => c.id === special.cargoTypeId)
: standardCargo(content);
if (!cargoType) throw new Error(`Unknown cargo ${special?.cargoTypeId}`);
const goods = routeGoods(cargoType, origin, destination, content);
const holding = goods.filter(
(g) => g.massTonnes.min <= tierMass && tierMass <= g.massTonnes.max,
);
const good = rng.pick(holding.length > 0 ? holding : goods);
const massTonnes = Math.min(good.massTonnes.max, Math.max(good.massTonnes.min, tierMass));
const contract = buildContract(
{
id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
origin,
destination,
good,
cargoType,
tier: 0,
massTonnes,
firstContact: false,
periodIndex,
shipper: rng.pick(origin.shippers),
priority: false,
deliveryWindowGameHours: null,
},
content,
);

View File

@ -1,125 +0,0 @@
import {
upgradeMaxLevel,
upgradePercentPerLevel,
type GameConfig,
type ShipModel,
type Upgrade,
} from "@deadhead/content";
import { MS_PER_MINUTE, type RealTime } from "./clock";
import type { ShipState } from "./dispatch";
/** Installed upgrade levels by upgrade id; a missing upgrade is level 0. */
export type UpgradeLevels = Readonly<Record<string, number>>;
/**
* A ship's stats after upgrades (decision 0.5.63): its model with the four
* upgradable model stats adjusted, plus its own handling time per tonne.
*/
export interface EffectiveStats extends ShipModel {
handlingGameHoursPerTonne: number;
}
/**
* Applies installed upgrades to a model's base stats: each level adds the
* upgrade's percent (after model overrides) of the base stat, linearly.
* Levels above the model's max for an upgrade are clamped.
*/
export function effectiveStats(
model: ShipModel,
levels: UpgradeLevels,
content: { upgrades: readonly Upgrade[]; config: GameConfig },
): EffectiveStats {
const stats: EffectiveStats = {
...model,
handlingGameHoursPerTonne: content.config.handlingGameHoursPerTonne,
};
for (const upgrade of content.upgrades) {
const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id));
if (level <= 0) continue;
const base = stats[upgrade.stat];
stats[upgrade.stat] = base * (1 + (upgradePercentPerLevel(upgrade, model.id) * level) / 100);
}
return stats;
}
/** Whether a ship is in the upgrade shop at real time `now` (until, not at, in-shop-until). */
export function isInShop(ship: Pick<ShipState, "inShopUntil">, now: RealTime): boolean {
return ship.inShopUntil != null && now < ship.inShopUntil;
}
/** The next level an upgrade would reach on a ship, or null when it is maxed. */
export function nextUpgradeLevel(
upgrade: Upgrade,
modelId: string,
levels: UpgradeLevels,
): number | null {
const next = (levels[upgrade.id] ?? 0) + 1;
return next > upgradeMaxLevel(upgrade, modelId) ? null : next;
}
/** Cost of an upgrade level: a percent of the ship model's price, in whole credits. */
export function upgradeCost(model: ShipModel, level: number, config: GameConfig): number {
const entry = config.upgradeLevels[level - 1];
if (!entry) throw new Error(`No upgrade level ${level}`);
return Math.round((model.price * entry.costPercentOfPrice) / 100);
}
/** Real milliseconds a ship spends in the shop for an upgrade level. */
export function upgradeShopMs(level: number, config: GameConfig): number {
const entry = config.upgradeLevels[level - 1];
if (!entry) throw new Error(`No upgrade level ${level}`);
return entry.shopRealMinutes * MS_PER_MINUTE;
}
/** Every reason `validateUpgrade` can fail, in check order (decisions 0.5.64, 0.5.65). */
export const UPGRADE_FAILURES = [
"tutorial-active",
"not-upgradable",
"ship-not-idle",
"ship-in-shop",
"not-at-hq",
"upgrade-maxed",
"insufficient-funds",
] as const;
export type UpgradeFailure = (typeof UPGRADE_FAILURES)[number];
export interface UpgradeProposal {
ship: ShipState;
upgrade: Upgrade;
/** Whether the player has a bay where the ship is. */
atHq: boolean;
tutorialActive: boolean;
credits: number;
now: RealTime;
}
export type UpgradeCheck =
| { ok: true; level: number; cost: number; shopUntil: RealTime }
| { ok: false; reason: UpgradeFailure };
/**
* Validates starting one level of an upgrade (decisions 0.5.64–0.5.66): tutorial
* over, a purchasable model (not the loaner), idle and out of the shop, at an HQ,
* not maxed, affordable. On success, gives the new level, its cost and when the
* ship leaves the shop.
*/
export function validateUpgrade(
proposal: UpgradeProposal,
content: { shipModels: readonly ShipModel[]; config: GameConfig },
): UpgradeCheck {
const { ship, upgrade, atHq, tutorialActive, credits, now } = proposal;
const fail = (reason: UpgradeFailure): UpgradeCheck => ({ ok: false, reason });
const model = content.shipModels.find((m) => m.id === ship.modelId);
if (!model) throw new Error(`Unknown ship model ${ship.modelId}`);
if (tutorialActive) return fail("tutorial-active");
if (!model.purchasable) return fail("not-upgradable");
if (ship.inFlight) return fail("ship-not-idle");
if (isInShop(ship, now)) return fail("ship-in-shop");
if (!atHq) return fail("not-at-hq");
const level = nextUpgradeLevel(upgrade, model.id, ship.upgrades ?? {});
if (level === null) return fail("upgrade-maxed");
const cost = upgradeCost(model, level, content.config);
if (credits < cost) return fail("insufficient-funds");
return { ok: true, level, cost, shopUntil: now + upgradeShopMs(level, content.config) };
}

View File

@ -1,142 +0,0 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
boardPeriodMs,
checkPilotEligibility,
checkRoute,
contractTripGameHours,
generateContractBoard,
realToGame,
type BoardInput,
type Contract,
type FleetMember,
} from "../src/index";
const earth = content.location("earth")!;
const discoveredIds = new Set(["earth", "moon", "mars"]);
const base: BoardInput = { location: earth, discoveredIds, periodIndex: 500, worldSeed: 777 };
const { config } = content;
const bands = config.contractMassBands;
/** Whether this crew can fly the contract (the sim's own route and pilot rules). */
function fits(contract: Contract, member: FleetMember): boolean {
const model = content.shipModel(member.modelId)!;
const gameTime = realToGame(contract.offeredAt, config);
return (
!contract.firstContact &&
contract.massTonnes <= model.cargoCapacityTonnes &&
checkRoute(model, earth, content.location(contract.destinationId)!, gameTime).ok &&
checkPilotEligibility(member.skills, contract).ok &&
// Priority (CR-007): the ship must make the delivery window.
(!contract.priority ||
contractTripGameHours(model, contract, contract.offeredAt, content) <=
contract.deliveryWindowGameHours!)
);
}
const floorOf = (band: { min: number }) =>
band.min === Math.min(...bands.map((b) => b.min)) ? 0 : band.min;
const courier: FleetMember = { modelId: "courier", skills: {} };
describe("boards that fit the fleet (CR-006)", () => {
it("ignores an empty fleet: the same board as with no fleet", () => {
expect(generateContractBoard({ ...base, fleet: [] }, content)).toEqual(
generateContractBoard(base, content),
);
});
it("is deterministic with a fleet", () => {
const input = { ...base, fleet: [courier] };
expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content));
});
it("gives a lone Courier mostly jobs it can fly, keeping one preview per bigger band", () => {
const previews = (bands.length - 1) * config.boardPreviewSlots;
const expectedFit = config.contractBoardSize - previews - config.boardOpenSlotsPerBand;
for (let p = 0; p < 200; p++) {
const board = generateContractBoard({ ...base, periodIndex: p, fleet: [courier] }, content);
expect(board).toHaveLength(config.contractBoardSize);
expect(board.filter((c) => fits(c, courier)).length).toBeGreaterThanOrEqual(expectedFit);
// Each band the Courier can't carry still shows a preview.
for (const band of bands.slice(1)) {
expect(board.some((c) => c.massTonnes >= band.min && c.massTonnes <= band.max)).toBe(true);
}
}
});
it("offers skill cargo up to the pilot's level, never above", () => {
const skilled: FleetMember = { modelId: "courier", skills: { "expensive-cargo": 3 } };
let skillJobs = 0;
for (let p = 0; p < 200; p++) {
const board = generateContractBoard({ ...base, periodIndex: p, fleet: [skilled] }, content);
for (const c of board.filter((c) => fits(c, skilled))) {
if (c.requiredSkill) {
skillJobs++;
expect(c.requiredSkill.level).toBeLessThanOrEqual(3);
}
}
}
expect(skillJobs).toBeGreaterThan(0);
});
it("keeps the configured band counts when the fleet covers every band", () => {
const fleet: FleetMember[] = ["courier", "freighter", "bulk-hauler"].map((modelId) => ({
modelId,
skills: {},
}));
const board = generateContractBoard({ ...base, fleet }, content);
let slot = 0;
for (const band of bands) {
for (let k = 0; k < band.count; k++, slot++) {
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(floorOf(band));
expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max);
}
}
for (const [i, band] of bands.entries()) {
const member = fleet[i]!;
const inBand = board.filter((c) => c.massTonnes >= floorOf(band) && c.massTonnes <= band.max);
const fitting = inBand.filter((c) => fits(c, member));
expect(fitting.length).toBeGreaterThanOrEqual(band.count - config.boardOpenSlotsPerBand);
}
});
it("expires fit jobs with the board period", () => {
const end = (base.periodIndex + 1) * boardPeriodMs(config);
for (const c of generateContractBoard({ ...base, fleet: [courier] }, content)) {
expect(c.expiresAt).toBe(end);
}
});
});
describe("fit jobs carry varied goods (CR-008 fix)", () => {
// The user's save: one Courier at the Moon, pilot Heavy Cargo 1 and Long Haul 1.
const pilotCourier: FleetMember = {
modelId: "courier",
skills: { "heavy-cargo": 1, "long-haul": 1 },
};
const moonInput: BoardInput = {
location: content.location("moon")!,
discoveredIds: new Set(["earth", "moon"]),
periodIndex: 0,
worldSeed: 4242,
fleet: [pilotCourier],
};
it("offers the Courier several goods it can really carry, not undersized Lunar Titanium", () => {
let distinct = 0;
for (let p = 0; p < 100; p++) {
const board = generateContractBoard({ ...moonInput, periodIndex: p }, content);
const flyable = board.filter((c) => fits(c, pilotCourier));
expect(flyable.length).toBeGreaterThanOrEqual(5);
for (const c of flyable) {
const { min, max } = content.good(c.goodId)!.massTonnes;
expect(c.massTonnes).toBeGreaterThanOrEqual(min);
expect(c.massTonnes).toBeLessThanOrEqual(max);
expect(c.goodId).not.toBe("lunar-titanium");
}
distinct += new Set(flyable.map((c) => c.goodId)).size;
}
expect(distinct / 100).toBeGreaterThanOrEqual(3);
});
});

View File

@ -1,161 +0,0 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { chooseGood, priorityWindow, routeGoods } from "../src/board";
import { createRng, generateContractBoard, type BoardInput, type Contract } from "../src/index";
const { config } = content;
const bands = config.contractMassBands;
const lightest = Math.min(...bands.map((b) => b.min));
const all = new Set(content.locations.map((l) => l.id));
const loc = (id: string) => content.location(id)!;
const earthInput: BoardInput = {
location: loc("earth"),
discoveredIds: all,
periodIndex: 0,
worldSeed: 12345,
};
/** Pre-change class shares, 1,000 Earth boards, all discovered, seed 12345 (CR-007 §6). */
const BASELINE: Record<string, number> = { standard: 76.79, "high-value": 23.21 };
function boards(input: BoardInput, count: number): Contract[][] {
return Array.from({ length: count }, (_, p) =>
generateContractBoard({ ...input, periodIndex: p }, content),
);
}
describe("goods in board generation (CR-007)", () => {
it("gives identical boards for identical inputs, fleet or not", () => {
expect(generateContractBoard(earthInput, content)).toEqual(
generateContractBoard(earthInput, content),
);
const fleet = [{ modelId: "courier", skills: { "expensive-cargo": 2 } }];
expect(generateContractBoard({ ...earthInput, fleet }, content)).toEqual(
generateContractBoard({ ...earthInput, fleet }, content),
);
});
it("ships goods made at the origin to where they're wanted, unless no good fits the route", () => {
for (const origin of ["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"]) {
for (const board of boards({ ...earthInput, location: loc(origin) }, 100)) {
for (const c of board) {
const good = content.good(c.goodId)!;
expect(good.producedAt).toContain(origin);
expect(good.cargoClassId).toBe(c.cargoTypeId);
if (!good.demandedAt.includes(c.destinationId)) {
// Fallback: no Standard good goes this way.
const onRoute = routeGoods(
content.cargoType("standard")!,
loc(origin),
loc(c.destinationId),
content,
);
expect(onRoute.some((g) => g.demandedAt.includes(c.destinationId))).toBe(false);
}
}
}
}
});
it("draws mass inside the good's range and the slot's band, or makes a bulk lot from the band", () => {
for (const board of boards(earthInput, 300)) {
let slot = 0;
for (const band of bands) {
for (let k = 0; k < band.count; k++, slot++) {
const c = board[slot]!;
const { min, max } = content.good(c.goodId)!.massTonnes;
const floor = band.min === lightest ? 0 : band.min;
expect(c.massTonnes).toBeLessThanOrEqual(band.max);
const overlaps = Math.max(floor, min) <= Math.min(band.max, max);
if (overlaps) {
expect(c.massTonnes).toBeGreaterThanOrEqual(Math.max(floor, min));
expect(c.massTonnes).toBeLessThanOrEqual(max);
} else {
expect(c.massTonnes).toBeGreaterThanOrEqual(band.min);
}
}
}
}
});
it("keeps each cargo class within ±3 points of the pre-change mix from Earth", () => {
const counts: Record<string, number> = {};
let total = 0;
for (const board of boards(earthInput, 1000)) {
for (const c of board) {
counts[c.cargoTypeId] = (counts[c.cargoTypeId] ?? 0) + 1;
total++;
}
}
for (const cls of content.cargoTypes.map((c) => c.id)) {
const share = ((counts[cls] ?? 0) / total) * 100;
expect(Math.abs(share - (BASELINE[cls] ?? 0))).toBeLessThanOrEqual(3);
}
});
it("makes 10–20% of eligible contracts Priority, only on eligible goods, with the reference window", () => {
let eligible = 0;
let priority = 0;
for (const board of boards(earthInput, 1000)) {
for (const c of board) {
const good = content.good(c.goodId)!;
if (good.priorityEligible) eligible++;
if (!c.priority) {
expect(c.deliveryWindowGameHours).toBeNull();
continue;
}
priority++;
expect(good.priorityEligible).toBe(true);
expect(c.deliveryWindowGameHours).toBeCloseTo(
priorityWindow(loc(c.originId), loc(c.destinationId), c.massTonnes, c.offeredAt, content),
);
}
}
expect(eligible).toBeGreaterThan(100);
expect(priority / eligible).toBeGreaterThanOrEqual(0.1);
expect(priority / eligible).toBeLessThanOrEqual(0.2);
});
it("names a shipper from the origin's list on every contract", () => {
for (const origin of ["earth", "moon", "mars"]) {
const shippers = loc(origin).shippers;
for (const board of boards({ ...earthInput, location: loc(origin) }, 50)) {
for (const c of board) expect(shippers).toContain(c.shipper);
}
}
});
});
describe("choosing a good for a slot (the band wins)", () => {
const rng = createRng(7);
const standard = content.cargoType("standard")!;
const earthToMoon = routeGoods(standard, loc("earth"), loc("moon"), content);
it("picks an overlapping good and a mass inside both ranges", () => {
for (let i = 0; i < 200; i++) {
const { good, massTonnes } = chooseGood(rng, earthToMoon, bands[1]!, content);
expect(massTonnes).toBeGreaterThanOrEqual(Math.max(bands[1]!.min, good.massTonnes.min));
expect(massTonnes).toBeLessThanOrEqual(Math.min(bands[1]!.max, good.massTonnes.max));
}
});
it("makes a bulk lot of the largest-range good when none overlaps the band", () => {
const heavyBand = bands[bands.length - 1]!;
const { good, massTonnes } = chooseGood(rng, earthToMoon, heavyBand, content);
const largest = Math.max(...earthToMoon.map((g) => g.massTonnes.max));
expect(good.massTonnes.max).toBe(largest);
expect(massTonnes).toBeGreaterThanOrEqual(heavyBand.min);
expect(massTonnes).toBeLessThanOrEqual(heavyBand.max);
});
it("respects a ship's capacity and lets light goods into the lightest band", () => {
const light = bands.find((b) => b.min === lightest)!;
let below = 0;
for (let i = 0; i < 300; i++) {
const { massTonnes } = chooseGood(rng, earthToMoon, light, content, 12);
expect(massTonnes).toBeLessThanOrEqual(12);
if (massTonnes < light.min) below++;
}
expect(below).toBeGreaterThan(0);
});
});

View File

@ -13,10 +13,6 @@ const discoveredIds = new Set(["earth", "moon"]);
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
const input: BoardInput = { location: earth, discoveredIds, periodIndex, worldSeed: 12345 };
/** A band's lower bound for goods: the lightest band has none (CR-007, light goods). */
const floorOf = (band: { min: number }) =>
band.min === Math.min(...content.config.contractMassBands.map((b) => b.min)) ? 0 : band.min;
describe("contract board generation", () => {
it("produces an identical board from the same inputs", () => {
expect(generateContractBoard(input, content)).toEqual(generateContractBoard(input, content));
@ -59,7 +55,7 @@ describe("contract board generation", () => {
expect(c.requiredSkill?.level).toBe(c.tier);
}
const bands = content.config.contractMassBands;
expect(c.massTonnes).toBeGreaterThan(0);
expect(c.massTonnes).toBeGreaterThanOrEqual(Math.min(...bands.map((b) => b.min)));
expect(c.massTonnes).toBeLessThanOrEqual(Math.max(...bands.map((b) => b.max)));
}
}
@ -70,7 +66,7 @@ describe("contract board generation", () => {
let slot = 0;
for (const band of content.config.contractMassBands) {
for (let k = 0; k < band.count; k++, slot++) {
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(floorOf(band));
expect(board[slot]!.massTonnes).toBeGreaterThanOrEqual(band.min);
expect(board[slot]!.massTonnes).toBeLessThanOrEqual(band.max);
}
}

View File

@ -1,7 +1,6 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { tierFor } from "../src/board";
import { contractPayout, createRng, type PayoutInput } from "../src/index";
import { contractPayout, type PayoutInput } from "../src/index";
const config = content.config;
const standard = content.cargoType("standard")!;
@ -9,12 +8,10 @@ const dangerous = content.cargoType("dangerous")!;
const base: PayoutInput = {
cargoType: standard,
payMultiplier: 1,
tier: 0,
massTonnes: 10,
distanceMillionKm: 100,
firstContact: false,
priority: false,
};
describe("contract payout", () => {
@ -53,35 +50,3 @@ describe("contract payout", () => {
);
});
});
describe("contracts carry goods (CR-007)", () => {
const rng = createRng(42);
it("takes the tier of a High-Value or Dangerous contract from its good", () => {
for (const good of content.goods.filter(
(g) => g.cargoClassId === "high-value" || g.cargoClassId === "dangerous",
)) {
expect(tierFor(rng, good, content)).toBe(good.tier);
}
});
it("keeps the random 1–5 tier for Heavy goods and tier 0 for Standard", () => {
const parts = content.good("machine-parts")!;
const seen = new Set<number>();
for (let i = 0; i < 200; i++) seen.add(tierFor(rng, parts, content));
expect([...seen].sort()).toEqual([1, 2, 3, 4, 5]);
expect(tierFor(rng, content.good("mail")!, content)).toBe(0);
});
it("doubles the payout when the good's pay multiplier doubles", () => {
expect(contractPayout({ ...base, payMultiplier: 2 }, config)).toBe(
2 * contractPayout(base, config),
);
});
it("pays a Priority contract exactly the bonus times an identical normal one", () => {
expect(contractPayout({ ...base, priority: true }, config)).toBe(
contractPayout(base, config) * config.priorityPayoutMultiplier,
);
});
});

Some files were not shown because too many files have changed in this diff Show More