Step 10B.3: key contract boards by location

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 14:49:05 -04:00
parent 553983590e
commit b3d4975111
11 changed files with 1391 additions and 14 deletions

View File

@ -627,3 +627,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Deadhead checks, in order:** ship idle, pilot idle, pilot assigned to the ship, destination not the current location (`already-there`), destination discovered (`location-undiscovered`), landing, range, fuel credits. Bays play no part. - **Deadhead checks, in order:** ship idle, pilot idle, pilot assigned to the ship, destination not the current location (`already-there`), destination discovered (`location-undiscovered`), landing, range, fuel credits. Bays play no part.
- **`no-bay-at-destination` is gone** from sim reasons and API error codes; `location-undiscovered` (already a server code) is now also a sim reason. - **`no-bay-at-destination` is gone** from sim reasons and API error codes; `location-undiscovered` (already a server code) is now also a sim reason.
- **The server change landed in this step** (it must pass `discoveredIds` to compile), so the Step 6.11 "no bay at destination" test became "undiscovered destination" here; Step 10B.5 adds the stranded-ship scenario. - **The server change landed in this step** (it must pass `discoveredIds` to compile), so the Step 6.11 "no bay at destination" test became "undiscovered destination" here; Step 10B.5 adds the stranded-ship scenario.
## Step 10B.3
- **The board key was already per location:** (player, kind, location, period, tutorial run) since CR-001. This step renames the `board_kind` value `bay` to `location`, so the stored data says what it is.
- **The migration relabels existing `bay` rows as `location`** in one `UPDATE` while the column is temporarily text. That keeps the user's development database usable without a reset; it is a one-line relabel, not a data migration of board contents.

View File

@ -0,0 +1,5 @@
ALTER TABLE "contract_boards" ALTER COLUMN "kind" SET DATA TYPE text;--> statement-breakpoint
UPDATE "contract_boards" SET "kind" = 'location' WHERE "kind" = 'bay';--> statement-breakpoint
DROP TYPE "public"."board_kind";--> statement-breakpoint
CREATE TYPE "public"."board_kind" AS ENUM('location', 'tutorial');--> statement-breakpoint
ALTER TABLE "contract_boards" ALTER COLUMN "kind" SET DATA TYPE "public"."board_kind" USING "kind"::"public"."board_kind";

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,13 @@
"when": 1791039630016, "when": 1791039630016,
"tag": "0002_cr001_faster_start", "tag": "0002_cr001_faster_start",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1791053200611,
"tag": "0003_cr002_location_boards",
"breakpoints": true
} }
] ]
} }

View File

@ -18,7 +18,7 @@ const time = (name: string) => bigint(name, { mode: "number" });
export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]); export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]);
export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]); export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]);
export const boardKind = pgEnum("board_kind", ["bay", "tutorial"]); export const boardKind = pgEnum("board_kind", ["location", "tutorial"]);
export const players = pgTable("players", { export const players = pgTable("players", {
id: text("id").primaryKey(), id: text("id").primaryKey(),

View File

@ -33,10 +33,10 @@ export async function getOrCreateBoard(
tx: Queryable, tx: Queryable,
key: { key: {
playerId: string; playerId: string;
kind: "bay" | "tutorial"; kind: "location" | "tutorial";
locationId: string; locationId: string;
periodIndex: number; periodIndex: number;
/** Tutorial boards: runs completed (CR-001). Bay boards: 0. */ /** Tutorial boards: runs completed (CR-001). Location boards: 0. */
tutorialRun?: number; tutorialRun?: number;
}, },
generate: () => Contract[] | Promise<Contract[]>, generate: () => Contract[] | Promise<Contract[]>,
@ -140,7 +140,7 @@ export async function bayBoard(
const periodIndex = boardPeriodIndex(now, content.config); const periodIndex = boardPeriodIndex(now, content.config);
const contracts = await getOrCreateBoard( const contracts = await getOrCreateBoard(
tx, tx,
{ playerId, kind: "bay", locationId, periodIndex }, { playerId, kind: "location", locationId, periodIndex },
async () => { async () => {
const discovered = await tx const discovered = await tx
.select({ locationId: discoveredLocations.locationId }) .select({ locationId: discoveredLocations.locationId })

View File

@ -39,7 +39,7 @@ export const LOANER_SHIP_ID = "loaner";
export async function findBoardContract( export async function findBoardContract(
tx: Queryable, tx: Queryable,
playerId: string, playerId: string,
kind: "bay" | "tutorial", kind: "location" | "tutorial",
contractId: string, contractId: string,
): Promise<{ contract: Contract; periodIndex: number } | undefined> { ): Promise<{ contract: Contract; periodIndex: number } | undefined> {
const [board] = await tx const [board] = await tx
@ -60,7 +60,7 @@ export async function findBoardContract(
export async function claimableContract( export async function claimableContract(
tx: Queryable, tx: Queryable,
playerId: string, playerId: string,
kind: "bay" | "tutorial", kind: "location" | "tutorial",
contractId: string, contractId: string,
): Promise<{ contract: Contract; periodIndex: number }> { ): Promise<{ contract: Contract; periodIndex: number }> {
const found = await findBoardContract(tx, playerId, kind, contractId); const found = await findBoardContract(tx, playerId, kind, contractId);
@ -212,7 +212,12 @@ export async function dispatchContract(
input: { contractId: string; shipId: string; pilotId: string }, input: { contractId: string; shipId: string; pilotId: string },
now: RealTime, now: RealTime,
): Promise<Dispatch> { ): Promise<Dispatch> {
const { contract, periodIndex } = await claimableContract(tx, playerId, "bay", input.contractId); const { contract, periodIndex } = await claimableContract(
tx,
playerId,
"location",
input.contractId,
);
const ship = await requireOwnShip(tx, playerId, input.shipId); const ship = await requireOwnShip(tx, playerId, input.shipId);
const pilot = await requireOwnPilot(tx, playerId, input.pilotId); const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId); const player = await requirePlayer(tx, playerId);

View File

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { useTestDatabase } from "./db";
import { putBoard, signUp, testContract, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("contract boards keyed by location (CR-002)", () => {
it("lets one player have boards at two locations in the same period", async () => {
const { db } = database();
const guest = await signUp(testApp().app);
await putBoard(db, guest.playerId, [testContract()], undefined, "earth");
await putBoard(
db,
guest.playerId,
[testContract({ originId: "moon", destinationId: "earth" })],
undefined,
"moon",
);
});
it("rejects a second board for the same player, location and period", async () => {
const { db } = database();
const guest = await signUp(testApp().app);
await putBoard(db, guest.playerId, [testContract()], undefined, "moon");
await expect(
putBoard(db, guest.playerId, [testContract()], undefined, "moon"),
).rejects.toThrow();
});
});

View File

@ -186,13 +186,19 @@ export async function insertDispatch(
return record; return record;
} }
/** Stores a hand-made Earth bay board for the current period. */ /** Stores a hand-made location board (Earth by default) for the period containing `now`. */
export async function putBoard(db: Db, playerId: string, contracts: Contract[], now = TEST_NOW) { export async function putBoard(
db: Db,
playerId: string,
contracts: Contract[],
now = TEST_NOW,
locationId = "earth",
) {
await db.insert(contractBoards).values({ await db.insert(contractBoards).values({
id: randomUUID(), id: randomUUID(),
playerId, playerId,
kind: "bay", kind: "location",
locationId: "earth", locationId,
periodIndex: boardPeriodIndex(now, content.config), periodIndex: boardPeriodIndex(now, content.config),
contracts, contracts,
createdAt: now, createdAt: now,

View File

@ -75,7 +75,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) | | `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) | | `bays` | Leased bays | unique (`player_id`, `location_id`) |
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) | | `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
| `contract_boards` | Stored bay boards and tutorial boards per period (tutorial boards also per run) (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) | | `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) |
| `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) | | `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) |
| `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) | | `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) |
| `claimed_goals` | Onboarding goals whose reward was claimed | pk (`player_id`, `goal_id`) | | `claimed_goals` | Onboarding goals whose reward was claimed | pk (`player_id`, `goal_id`) |
@ -106,7 +106,7 @@ const time = (name: string) => bigint(name, { mode: "number" });
export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]); export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]);
export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]); export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]);
export const boardKind = pgEnum("board_kind", ["bay", "tutorial"]); export const boardKind = pgEnum("board_kind", ["location", "tutorial"]);
export const players = pgTable("players", { export const players = pgTable("players", {
id: text("id").primaryKey(), id: text("id").primaryKey(),

View File

@ -136,7 +136,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record
- [x] **Step 10B.1 — Sim: boards at any location.** `BoardInput.location` (was `origin`) is the board's location; no bay notion in sim board generation. Moon board test added. - [x] **Step 10B.1 — Sim: boards at any location.** `BoardInput.location` (was `origin`) is the board's location; no bay notion in sim board generation. Moon board test added.
- [x] **Step 10B.2 — Sim: deadhead validation.** `validateReposition` takes `discoveredIds` (not bays): any origin, destination discovered (`location-undiscovered`) and not the current location, range and landing. `REPOSITION_FAILURES` swaps `no-bay-at-destination` for `location-undiscovered`. Server passes the player's discovered locations. - [x] **Step 10B.2 — Sim: deadhead validation.** `validateReposition` takes `discoveredIds` (not bays): any origin, destination discovered (`location-undiscovered`) and not the current location, range and landing. `REPOSITION_FAILURES` swaps `no-bay-at-destination` for `location-undiscovered`. Server passes the player's discovered locations.
- [ ] Step 10B.3 — Database: boards keyed by location - [x] **Step 10B.3 — Database: boards keyed by location.** Migration `0003_cr002_location_boards`: `board_kind` is now `location` / `tutorial` (old `bay` rows relabelled); unique key (player, kind, location, period, tutorial run) means one board per player, location and period. Test: two locations coexist, a duplicate is rejected.
- [ ] Step 10B.4 — Server: board endpoint for any discovered location - [ ] Step 10B.4 — Server: board endpoint for any discovered location
- [ ] Step 10B.5 — Server: no more stranded ships - [ ] Step 10B.5 — Server: no more stranded ships
- [ ] Step 10B.6 — Server: ship delivery to a bay - [ ] Step 10B.6 — Server: ship delivery to a bay