Step 10F.4: location boards use the player's fleet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
215f380e80
commit
82acf0e637
|
|
@ -6,11 +6,19 @@ import {
|
||||||
generateContractBoard,
|
generateContractBoard,
|
||||||
generateTutorialBoard,
|
generateTutorialBoard,
|
||||||
type Contract,
|
type Contract,
|
||||||
|
type FleetMember,
|
||||||
type RealTime,
|
type RealTime,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
import type { Queryable } from "../db/client";
|
import type { Queryable } from "../db/client";
|
||||||
import { acceptedContracts, contractBoards, discoveredLocations, players } from "../db/schema";
|
import {
|
||||||
|
acceptedContracts,
|
||||||
|
contractBoards,
|
||||||
|
discoveredLocations,
|
||||||
|
pilots,
|
||||||
|
players,
|
||||||
|
ships,
|
||||||
|
} from "../db/schema";
|
||||||
import { gameError } from "../http/errors";
|
import { gameError } from "../http/errors";
|
||||||
|
|
||||||
export interface BoardView {
|
export interface BoardView {
|
||||||
|
|
@ -114,8 +122,9 @@ export async function tutorialBoard(
|
||||||
/**
|
/**
|
||||||
* The contract board (freight market) of any discovered location for the
|
* 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
|
* current period (Step 6.9, CR-002): no bay needed. Generated on the first
|
||||||
* request in the period from the locations discovered at that moment, then
|
* request in the period from the locations discovered and the fleet owned at
|
||||||
* stored, so later discoveries in the same period do not change it.
|
* that moment (CR-006), then stored, so later changes in the same period do
|
||||||
|
* not change it.
|
||||||
*/
|
*/
|
||||||
export async function locationBoard(
|
export async function locationBoard(
|
||||||
tx: Queryable,
|
tx: Queryable,
|
||||||
|
|
@ -154,6 +163,7 @@ export async function locationBoard(
|
||||||
discoveredIds: new Set(discovered.map((d) => d.locationId)),
|
discoveredIds: new Set(discovered.map((d) => d.locationId)),
|
||||||
periodIndex,
|
periodIndex,
|
||||||
worldSeed: player!.worldSeed,
|
worldSeed: player!.worldSeed,
|
||||||
|
fleet: await playerFleet(tx, playerId),
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
|
|
@ -162,3 +172,17 @@ export async function locationBoard(
|
||||||
);
|
);
|
||||||
return boardView(tx, playerId, contracts, periodIndex);
|
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({ 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));
|
||||||
|
return rows.map((r) => ({ modelId: r.modelId, skills: r.skills ?? {} }));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
|
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
|
||||||
import { boardPeriodMs } from "@deadhead/sim";
|
import { boardPeriodMs, validateDispatch } from "@deadhead/sim";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { discoveredLocations } from "../src/db/schema";
|
import { discoveredLocations } from "../src/db/schema";
|
||||||
import { TEST_NOW, useTestDatabase } from "./db";
|
import { TEST_NOW, useTestDatabase } from "./db";
|
||||||
|
|
@ -76,4 +76,33 @@ describe("location contract boards", () => {
|
||||||
const response = await call(testApp().app, owner, "GET", "/api/locations/pluto/board");
|
const response = await call(testApp().app, owner, "GET", "/api/locations/pluto/board");
|
||||||
expect(response.statusCode).toBe(404);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -196,3 +196,4 @@ Spec: `memory-bank/change-requests/CR-006-boards-that-fit.md`. New decisions rec
|
||||||
- [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.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.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.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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue