Step 10B.3: key contract boards by location
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
553983590e
commit
b3d4975111
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue