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:
parent
46dd3777c9
commit
5be66372d7
|
|
@ -905,3 +905,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
## Step 10G.5
|
## 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.
|
- **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".
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -36,6 +36,13 @@
|
||||||
"when": 1791078648879,
|
"when": 1791078648879,
|
||||||
"tag": "0004_cr004_pilot_duty",
|
"tag": "0004_cr004_pilot_duty",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791148094765,
|
||||||
|
"tag": "0005_cr007_goods",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +109,9 @@ export async function loadPlayerState(
|
||||||
tutorial: d.tutorial,
|
tutorial: d.tutorial,
|
||||||
deadhead: d.record.contract === null,
|
deadhead: d.record.contract === null,
|
||||||
fuelCost: d.record.fuelCost,
|
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,
|
credits: d.resolution?.credits ?? 0,
|
||||||
loanRepayment: d.resolution?.loanRepayment ?? 0,
|
loanRepayment: d.resolution?.loanRepayment ?? 0,
|
||||||
pilotXp: d.resolution?.pilotXp ?? 0,
|
pilotXp: d.resolution?.pilotXp ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -34,6 +34,9 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
||||||
credits: 538,
|
credits: 538,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
loanRepayment: 230,
|
loanRepayment: 230,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ const summaries: ResolutionSummary[] = [
|
||||||
credits: 51_200,
|
credits: 51_200,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotXp: 32,
|
pilotXp: 32,
|
||||||
discoveredLocationId: "mars",
|
discoveredLocationId: "mars",
|
||||||
|
|
@ -43,6 +46,9 @@ const summaries: ResolutionSummary[] = [
|
||||||
credits: 240,
|
credits: 240,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
|
@ -60,6 +66,9 @@ describe("while you were away", () => {
|
||||||
destinationId: "moon",
|
destinationId: "moon",
|
||||||
deadhead: true,
|
deadhead: true,
|
||||||
fuelCost: 230,
|
fuelCost: 230,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
credits: 0,
|
credits: 0,
|
||||||
pilotXp: 0,
|
pilotXp: 0,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ describe("the deal", () => {
|
||||||
credits: 96,
|
credits: 96,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,9 @@ describe("loan on HQ", () => {
|
||||||
credits: 538,
|
credits: 538,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
loanRepayment: 230,
|
loanRepayment: 230,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
|
||||||
credits: 500,
|
credits: 500,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,9 @@ describe("scripted delivery summary", () => {
|
||||||
credits: 461,
|
credits: 461,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| `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` |
|
| `Bay` | `id`, `locationId` |
|
||||||
| `LocationState` | `id`, `discovered` |
|
| `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` |
|
| `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` |
|
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
|
||||||
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
||||||
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
||||||
|
|
||||||
### Errors
|
### 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`)
|
## 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`)
|
## 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 |
|
| Table | Purpose | Key constraints |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,10 @@ export const resolutionSummarySchema = z.object({
|
||||||
deadhead: z.boolean(),
|
deadhead: z.boolean(),
|
||||||
/** Fuel paid at departure. */
|
/** Fuel paid at departure. */
|
||||||
fuelCost: credits.nonnegative(),
|
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 added after pilot pay and loan repayment. */
|
||||||
credits,
|
credits,
|
||||||
/** Credits the lender took from this payout (decision 0.5.32). */
|
/** Credits the lender took from this payout (decision 0.5.32). */
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,9 @@ const summary = {
|
||||||
credits: 240,
|
credits: 240,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
massTonnes: 12,
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
|
@ -167,6 +170,19 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
||||||
["bay", baySchema, bay, { id: "b1" }],
|
["bay", baySchema, bay, { id: "b1" }],
|
||||||
["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
|
["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
|
||||||
["contract", contractSchema, contract, { ...contract, tier: -1 }],
|
["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" }],
|
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
|
||||||
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
|
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
|
||||||
[
|
[
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue