Step 10G.6: goods in API summaries, too-slow code and backfill migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 17:12:51 -04:00
parent 46dd3777c9
commit 5be66372d7
16 changed files with 1528 additions and 4 deletions

View File

@ -905,3 +905,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## 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".

View File

@ -0,0 +1,36 @@
-- 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');

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -109,6 +109,9 @@ export async function loadPlayerState(
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

@ -0,0 +1,96 @@
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

@ -34,6 +34,9 @@ function returningPlayer(acknowledged: boolean): PlayerState {
credits: 538,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 230,
pilotXp: 10,
discoveredLocationId: null,

View File

@ -26,6 +26,9 @@ const summaries: ResolutionSummary[] = [
credits: 51_200,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 32,
discoveredLocationId: "mars",
@ -43,6 +46,9 @@ const summaries: ResolutionSummary[] = [
credits: 240,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,
@ -60,6 +66,9 @@ describe("while you were away", () => {
destinationId: "moon",
deadhead: true,
fuelCost: 230,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
credits: 0,
pilotXp: 0,
discoveredLocationId: null,

View File

@ -84,6 +84,9 @@ describe("the deal", () => {
credits: 96,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,

View File

@ -148,6 +148,9 @@ describe("loan on HQ", () => {
credits: 538,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 230,
pilotXp: 10,
discoveredLocationId: null,

View File

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

View File

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

View File

@ -29,16 +29,16 @@ Living reference for how the code is organised. Update it after any database sch
| `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` (optional `scriptedId` on a scripted tutorial contract) |
| `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) |
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `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` |
| `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, plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...).
Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...).
## API routes (`packages/schema/src/routes.ts`)
@ -66,7 +66,7 @@ The `routes` catalog is the single source for both server and client. Each entry
## 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). 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). 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.
| Table | Purpose | Key constraints |
|---|---|---|

View File

@ -207,3 +207,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 /
- [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.

View File

@ -137,6 +137,10 @@ export const resolutionSummarySchema = z.object({
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

@ -120,6 +120,9 @@ const summary = {
credits: 240,
deadhead: false,
fuelCost: 0,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,
@ -167,6 +170,19 @@ 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 }],
[