Compare commits
11 Commits
c36d1edf55
...
f7698166bb
| Author | SHA1 | Date |
|---|---|---|
|
|
f7698166bb | |
|
|
3a3bb84c29 | |
|
|
2ba6721200 | |
|
|
962c0601c8 | |
|
|
61781aa790 | |
|
|
924151d1a8 | |
|
|
a5803d31f5 | |
|
|
b3d4975111 | |
|
|
553983590e | |
|
|
dff2c576cf | |
|
|
ea37ce8ced |
62
DECISIONS.md
|
|
@ -611,3 +611,65 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **The first contract of each band is a guaranteed job:** Standard cargo (no skill) to an already-discovered location, never first contact. A new pilot has no skill points (tutorial XP does not reach level 1), so without this a Courier-sized job could still need a skill. The other slots are drawn as before. First contact now applies to 5 of 8 slots, about 6% of contracts, inside the 5–15% the Step 3.9 test expects.
|
||||
- **Boards list contracts band by band** (small to large), so a new player sees Courier-sized work first.
|
||||
- **Existing stored boards are unchanged** until their period ends (at most an hour); new boards use the bands.
|
||||
|
||||
## CR-002 — Open Markets, Bays as HQs (Phase 10B)
|
||||
|
||||
- **Change request CR-002 is stored in `memory-bank/change-requests/`** and inserted as Phase 10B, before Phase 11.
|
||||
- **Decision numbering:** the CR's new 0.5.27 (Ship delivery) and 0.5.28 (Undiscovered locations) are recorded as **0.5.35** and **0.5.36**, since 27–34 were already in use. Decision 0.5.5 is replaced and the glossary gains "Contract board" and "Deadhead".
|
||||
- **Error code:** the CR's `location_undiscovered` is the existing `location-undiscovered`.
|
||||
|
||||
## Step 10B.1
|
||||
|
||||
- **The board generator's input is `location`** (the freight market's location). Sim never knew about bays for boards; the old field name `origin` was the only "bay-board" naming, and comments now say "location board". Generation rules and seeds (`board:<location>:<period>`) are unchanged.
|
||||
|
||||
## Step 10B.2
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Step 10B.4
|
||||
|
||||
- **The route keeps its name, `getContractBoard`, with a new path `/api/locations/:locationId/board`.** The client builds requests from the catalog, so it follows automatically.
|
||||
- **Check order:** unknown location `404 not-found`, then undiscovered `409 location-undiscovered` (decision 0.5.36). Bays are not consulted.
|
||||
- **Discovery by a delivery shows up on the same request:** the endpoint runs inside `withPlayer`, which resolves due arrivals first.
|
||||
|
||||
## Step 10B.5
|
||||
|
||||
- **No server code changed beyond a comment:** contract dispatch already validated only the ship, pilot, contract and credits, and the contract lookup searches every stored location board. The step is the regression test for the stranded-ship bug.
|
||||
- **The test picks the Moon→Earth Courier job from the real generated board** (destination Earth, 20 t or less, no skill), relying on the guaranteed first slot of the smallest mass band, not a hand-placed contract.
|
||||
|
||||
## Step 10B.6
|
||||
|
||||
- **Another player's bay id is `404 not-found` ("You have no such bay")**, the same as an id that does not exist, so ids of other players' bays are not confirmed. It is checked after the model and before any credits are spent.
|
||||
- **`SHIPYARD_LOCATION_ID` is gone:** the delivery location is always the chosen bay's.
|
||||
- **Interim client:** the Market buys for the player's first bay (always Earth today) so the build stays green; Step 10B.9 replaces this with the "Deliver to which HQ?" picker.
|
||||
|
||||
## Step 10B.7
|
||||
|
||||
- **`Ship.lastArrivalAt` is never null:** the latest resolved arrival of that ship, else its delivery time (`created_at`). A newly bought ship counts as having just arrived at its bay, which is what the default-location rule wants.
|
||||
- **The selector is a wrapping row of location buttons (`aria-pressed`), not a `<select>`**, because each entry carries an idle-ship badge; a native option cannot hold one. Locations without idle ships show no badge.
|
||||
- **The URL is the source of truth for the selection** (`?location=`, replacing `?bay=`), so "Find work here" and the map link work even when Contracts is already open. An undiscovered or unknown id falls back to the default.
|
||||
- **A hint shows when no idle ship is at the selected location**, pointing to deadheading from the Fleet. The old "no bays yet" message is gone: every player can see Earth's market.
|
||||
|
||||
## Step 10B.8
|
||||
|
||||
- **The picker runs `validateReposition` per destination**, so the client and server agree on what is possible. Fuel is `tripFuelCost` and time is `tripRealMs` with zero cargo, the same inputs the sim's reposition record uses.
|
||||
- **The action shows on every idle ship, crewed or not.** An uncrewed ship's options are all disabled with "No pilot assigned", which tells the player what to fix rather than hiding the action.
|
||||
- **Disabled destinations still show fuel and time**, so the player can see how far off they are.
|
||||
|
||||
## Step 10B.9
|
||||
|
||||
- **The delivery picker is a dialog with radio buttons and a "Buy for N cr" confirm**, so buying always takes two taps, even with one bay (preselected). The confirm is disabled until a bay is chosen.
|
||||
- **"Bay owned" now reads "Your HQ is here"**, and the map panel says "Your HQ is here." or "No HQ here."; the player-facing word for a bay is HQ, while the action keeps "Lease bay" as in the CR.
|
||||
- **The success message names the delivery location** ("… is waiting at Moon.") instead of always Earth.
|
||||
|
||||
## Step 10B.10
|
||||
|
||||
- **GDD §12 ("Each bay has one contract board") was also corrected**, since it contradicted §10; the CR named only §4 and §10.
|
||||
- **The manual check was also scripted** against a live dev server with the test clock: after the deal, a Courier flies Earth→Moon and then takes a Moon→Earth job from the Moon board with no Moon bay. The user's own manual check on a device is still the review gate.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -22,6 +22,13 @@
|
|||
"when": 1791039630016,
|
||||
"tag": "0002_cr001_faster_start",
|
||||
"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 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", {
|
||||
id: text("id").primaryKey(),
|
||||
|
|
|
|||
|
|
@ -10,13 +10,7 @@ import {
|
|||
} from "@deadhead/sim";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import {
|
||||
acceptedContracts,
|
||||
bays,
|
||||
contractBoards,
|
||||
discoveredLocations,
|
||||
players,
|
||||
} from "../db/schema";
|
||||
import { acceptedContracts, contractBoards, discoveredLocations, players } from "../db/schema";
|
||||
import { gameError } from "../http/errors";
|
||||
|
||||
export interface BoardView {
|
||||
|
|
@ -33,10 +27,10 @@ export async function getOrCreateBoard(
|
|||
tx: Queryable,
|
||||
key: {
|
||||
playerId: string;
|
||||
kind: "bay" | "tutorial";
|
||||
kind: "location" | "tutorial";
|
||||
locationId: string;
|
||||
periodIndex: number;
|
||||
/** Tutorial boards: runs completed (CR-001). Bay boards: 0. */
|
||||
/** Tutorial boards: runs completed (CR-001). Location boards: 0. */
|
||||
tutorialRun?: number;
|
||||
},
|
||||
generate: () => Contract[] | Promise<Contract[]>,
|
||||
|
|
@ -118,29 +112,37 @@ export async function tutorialBoard(
|
|||
}
|
||||
|
||||
/**
|
||||
* The contract board of one of the player's bays for the current period
|
||||
* (Step 6.9). Generated on the first request in the period from the locations
|
||||
* discovered at that moment, then stored, so later discoveries in the same
|
||||
* period do not change it.
|
||||
* 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
|
||||
* request in the period from the locations discovered at that moment, then
|
||||
* stored, so later discoveries in the same period do not change it.
|
||||
*/
|
||||
export async function bayBoard(
|
||||
export async function locationBoard(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
locationId: string,
|
||||
now: RealTime,
|
||||
): Promise<BoardView> {
|
||||
const [bay] = await tx
|
||||
.select({ id: bays.id })
|
||||
.from(bays)
|
||||
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId)));
|
||||
const origin = content.location(locationId);
|
||||
if (!bay || !origin) throw gameError("no-bay-at-location", "You have no bay there.");
|
||||
if (!origin) throw gameError("not-found", `No location "${locationId}".`);
|
||||
const [known] = await tx
|
||||
.select({ locationId: discoveredLocations.locationId })
|
||||
.from(discoveredLocations)
|
||||
.where(
|
||||
and(
|
||||
eq(discoveredLocations.playerId, playerId),
|
||||
eq(discoveredLocations.locationId, locationId),
|
||||
),
|
||||
);
|
||||
if (!known) {
|
||||
throw gameError("location-undiscovered", `Deliver to ${origin.name} to open its market.`);
|
||||
}
|
||||
|
||||
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
|
||||
const periodIndex = boardPeriodIndex(now, content.config);
|
||||
const contracts = await getOrCreateBoard(
|
||||
tx,
|
||||
{ playerId, kind: "bay", locationId, periodIndex },
|
||||
{ playerId, kind: "location", locationId, periodIndex },
|
||||
async () => {
|
||||
const discovered = await tx
|
||||
.select({ locationId: discoveredLocations.locationId })
|
||||
|
|
@ -148,7 +150,7 @@ export async function bayBoard(
|
|||
.where(eq(discoveredLocations.playerId, playerId));
|
||||
return generateContractBoard(
|
||||
{
|
||||
origin,
|
||||
location: origin,
|
||||
discoveredIds: new Set(discovered.map((d) => d.locationId)),
|
||||
periodIndex,
|
||||
worldSeed: player!.worldSeed,
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import { and, eq, sql } from "drizzle-orm";
|
|||
import type { Queryable } from "../db/client";
|
||||
import {
|
||||
acceptedContracts,
|
||||
bays,
|
||||
companies,
|
||||
contractBoards,
|
||||
discoveredLocations,
|
||||
dispatches,
|
||||
pilots,
|
||||
players,
|
||||
|
|
@ -39,7 +39,7 @@ export const LOANER_SHIP_ID = "loaner";
|
|||
export async function findBoardContract(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
kind: "bay" | "tutorial",
|
||||
kind: "location" | "tutorial",
|
||||
contractId: string,
|
||||
): Promise<{ contract: Contract; periodIndex: number } | undefined> {
|
||||
const [board] = await tx
|
||||
|
|
@ -60,7 +60,7 @@ export async function findBoardContract(
|
|||
export async function claimableContract(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
kind: "bay" | "tutorial",
|
||||
kind: "location" | "tutorial",
|
||||
contractId: string,
|
||||
): Promise<{ contract: Contract; periodIndex: number }> {
|
||||
const found = await findBoardContract(tx, playerId, kind, contractId);
|
||||
|
|
@ -203,7 +203,8 @@ export function shipState(row: typeof ships.$inferSelect): ShipState {
|
|||
}
|
||||
|
||||
/**
|
||||
* Dispatches an owned ship and its pilot on a bay-board contract (Step 6.10):
|
||||
* Dispatches an owned ship and its pilot on a location-board contract, from
|
||||
* wherever the ship is (Steps 6.10, 10B.5; no bay involved):
|
||||
* sim validation, fuel charged, contract accepted, ship and pilot in flight.
|
||||
*/
|
||||
export async function dispatchContract(
|
||||
|
|
@ -212,7 +213,12 @@ export async function dispatchContract(
|
|||
input: { contractId: string; shipId: string; pilotId: string },
|
||||
now: RealTime,
|
||||
): 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 pilot = await requireOwnPilot(tx, playerId, input.pilotId);
|
||||
const player = await requirePlayer(tx, playerId);
|
||||
|
|
@ -246,7 +252,7 @@ export async function setInFlight(tx: Queryable, shipId: string, pilotId: string
|
|||
.where(eq(pilots.id, pilotId));
|
||||
}
|
||||
|
||||
/** Flies a ship and its pilot empty to another player bay, paying fuel only (Step 6.11). */
|
||||
/** Deadhead: flies a ship and its pilot empty to another discovered location, paying fuel only (Steps 6.11, 10B.2). */
|
||||
export async function dispatchReposition(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
|
|
@ -257,16 +263,16 @@ export async function dispatchReposition(
|
|||
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
|
||||
const player = await requirePlayer(tx, playerId);
|
||||
const company = await requireCompany(tx, playerId);
|
||||
const bayRows = await tx
|
||||
.select({ locationId: bays.locationId })
|
||||
.from(bays)
|
||||
.where(eq(bays.playerId, playerId));
|
||||
const discovered = await tx
|
||||
.select({ locationId: discoveredLocations.locationId })
|
||||
.from(discoveredLocations)
|
||||
.where(eq(discoveredLocations.playerId, playerId));
|
||||
|
||||
const proposal = {
|
||||
ship: shipState(ship),
|
||||
pilot: pilotState(pilot),
|
||||
destinationId: input.destinationId,
|
||||
bayLocationIds: new Set(bayRows.map((b) => b.locationId)),
|
||||
discoveredIds: new Set(discovered.map((d) => d.locationId)),
|
||||
credits: company.credits,
|
||||
now,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ import { bays, companies, discoveredLocations, players, ships } from "../db/sche
|
|||
import { gameError } from "../http/errors";
|
||||
import { shipName } from "./names";
|
||||
|
||||
/** Where newly bought ships appear. */
|
||||
export const SHIPYARD_LOCATION_ID = "earth";
|
||||
|
||||
export async function requireCompany(tx: Queryable, playerId: string) {
|
||||
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
|
||||
if (!company) throw new Error(`Player ${playerId} has no company`);
|
||||
|
|
@ -84,16 +81,25 @@ export async function leaseBay(
|
|||
return { id: bay!.id, locationId: bay!.locationId };
|
||||
}
|
||||
|
||||
/** Buys a ship model: it appears idle at Earth with no pilot (Step 6.5). */
|
||||
/**
|
||||
* Buys a ship model, delivered idle and uncrewed to one of the player's bays
|
||||
* (Steps 6.5, 10B.6; decision 0.5.35).
|
||||
*/
|
||||
export async function buyShip(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
modelId: string,
|
||||
input: { modelId: string; bayId: string },
|
||||
now: RealTime,
|
||||
): Promise<Ship> {
|
||||
const { modelId } = input;
|
||||
await requireTutorialOver(tx, playerId, "buying a ship");
|
||||
const model = content.shipModel(modelId);
|
||||
if (!model?.purchasable) throw gameError("not-found", `No ship model "${modelId}" for sale.`);
|
||||
const [bay] = await tx
|
||||
.select()
|
||||
.from(bays)
|
||||
.where(and(eq(bays.playerId, playerId), eq(bays.id, input.bayId)));
|
||||
if (!bay) throw gameError("not-found", "You have no such bay.");
|
||||
await spend(tx, playerId, model.price, `a ${model.name}`);
|
||||
|
||||
const player = await requirePlayer(tx, playerId);
|
||||
|
|
@ -105,7 +111,7 @@ export async function buyShip(
|
|||
playerId,
|
||||
modelId,
|
||||
name: shipName(player.worldSeed, owned!.n),
|
||||
locationId: SHIPYARD_LOCATION_ID,
|
||||
locationId: bay.locationId,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
|
@ -117,5 +123,6 @@ export async function buyShip(
|
|||
locationId: ship!.locationId,
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: ship!.createdAt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,5 +14,5 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
|
|||
"contract-expired": "This contract has expired.",
|
||||
"insufficient-credits": "Not enough credits for fuel.",
|
||||
"already-there": "The ship is already there.",
|
||||
"no-bay-at-destination": "You have no bay at that destination.",
|
||||
"location-undiscovered": "You can only fly empty to places you have discovered.",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { and, eq, isNull, max } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import {
|
||||
bays,
|
||||
|
|
@ -32,6 +32,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
.select()
|
||||
.from(dispatches)
|
||||
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "in_flight")));
|
||||
const arrivals = await db
|
||||
.select({ shipId: dispatches.shipId, at: max(dispatches.arrivalAt) })
|
||||
.from(dispatches)
|
||||
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "resolved")))
|
||||
.groupBy(dispatches.shipId);
|
||||
const unacknowledged = await db
|
||||
.select()
|
||||
.from(dispatches)
|
||||
|
|
@ -46,6 +51,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
const discoveredIds = new Set(discovered.map((d) => d.locationId));
|
||||
const shipById = new Map(shipRows.map((s) => [s.id, s]));
|
||||
const activeByShip = new Map(active.filter((d) => d.shipId).map((d) => [d.shipId!, d.id]));
|
||||
const lastArrivalByShip = new Map(arrivals.map((a) => [a.shipId, a.at]));
|
||||
const pilotByShip = new Map(
|
||||
pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]),
|
||||
);
|
||||
|
|
@ -65,6 +71,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
locationId: s.status === "in_flight" ? null : s.locationId,
|
||||
dispatchId: activeByShip.get(s.id) ?? null,
|
||||
pilotId: pilotByShip.get(s.id) ?? null,
|
||||
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
|
||||
})),
|
||||
pilots: pilotRows.map((p) =>
|
||||
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { routes } from "@deadhead/schema";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { bayBoard, tutorialBoard } from "../game/boards";
|
||||
import { locationBoard, tutorialBoard } from "../game/boards";
|
||||
import { registerRoute } from "../http/route";
|
||||
import type { AppContext } from "./context";
|
||||
import { withPlayer } from "./with-player";
|
||||
|
|
@ -12,6 +12,6 @@ export function registerBoardRoutes(app: FastifyInstance, ctx: AppContext): void
|
|||
);
|
||||
|
||||
registerRoute(app, routes.getContractBoard, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, (tx, now) => bayBoard(tx, playerId, input.locationId, now)),
|
||||
withPlayer(ctx, playerId, (tx, now) => locationBoard(tx, playerId, input.locationId, now)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { withPlayer } from "./with-player";
|
|||
export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
registerRoute(app, routes.buyShip, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
ship: await buyShip(tx, playerId, input.modelId, now),
|
||||
ship: await buyShip(tx, playerId, input, now),
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ async function dispatched() {
|
|||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
const guest = await signUp(app);
|
||||
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
await setCredits(db, guest.playerId, 10_000);
|
||||
const contract = testContract();
|
||||
await putBoard(db, guest.playerId, [contract]);
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
|
||||
import { boardPeriodMs } from "@deadhead/sim";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discoveredLocations } from "../src/db/schema";
|
||||
import { makeOwner, TEST_NOW, useTestDatabase } from "./db";
|
||||
import { call, signUp, useTestApp, type Guest } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
async function owner(): Promise<Guest> {
|
||||
const guest = await signUp(testApp().app);
|
||||
await makeOwner(database().db, guest.playerId, guest.avatarId);
|
||||
return guest;
|
||||
}
|
||||
|
||||
async function earthBoard(guest: Guest) {
|
||||
const response = await call(testApp().app, guest, "GET", "/api/bays/earth/board");
|
||||
expect(response.statusCode).toBe(200);
|
||||
return contractBoardResponseSchema.parse(response.json());
|
||||
}
|
||||
|
||||
describe("bay contract board", () => {
|
||||
it("returns a board of the configured size for an owned bay", async () => {
|
||||
const guest = await owner();
|
||||
const { contracts, periodEndsAt } = await earthBoard(guest);
|
||||
expect(contracts).toHaveLength(content.config.contractBoardSize);
|
||||
for (const c of contracts) expect(c.originId).toBe("earth");
|
||||
expect(periodEndsAt).toBeGreaterThan(testApp().clock.now);
|
||||
});
|
||||
|
||||
it("refuses a location where the player has no bay", async () => {
|
||||
const { app } = testApp();
|
||||
const guest = await owner();
|
||||
const response = await call(app, guest, "GET", "/api/bays/moon/board");
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("no-bay-at-location");
|
||||
});
|
||||
|
||||
it("returns the same stored board within a period, even after a discovery", async () => {
|
||||
const { clock } = testApp();
|
||||
const guest = await owner();
|
||||
const first = await earthBoard(guest);
|
||||
await database()
|
||||
.db.insert(discoveredLocations)
|
||||
.values(
|
||||
["mars", "ceres-station", "jupiter-cloud-platform"].map((locationId) => ({
|
||||
playerId: guest.playerId,
|
||||
locationId,
|
||||
discoveredAt: TEST_NOW,
|
||||
})),
|
||||
);
|
||||
clock.advance(boardPeriodMs(content.config) / 2 - 1);
|
||||
expect((await earthBoard(guest)).contracts).toEqual(first.contracts);
|
||||
});
|
||||
|
||||
it("returns a new board after the period rolls over", async () => {
|
||||
const { clock } = testApp();
|
||||
const guest = await owner();
|
||||
const first = await earthBoard(guest);
|
||||
clock.now = first.periodEndsAt;
|
||||
const next = await earthBoard(guest);
|
||||
expect(next.periodEndsAt).toBe(first.periodEndsAt + boardPeriodMs(content.config));
|
||||
expect(next.contracts.map((c) => c.id)).not.toEqual(first.contracts.map((c) => c.id));
|
||||
for (const c of next.contracts) expect(c.expiresAt).toBe(next.periodEndsAt);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { content } from "@deadhead/content";
|
||||
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bays } from "../src/db/schema";
|
||||
import { shipName } from "../src/game/names";
|
||||
import { setCredits, useTestDatabase } from "./db";
|
||||
import { setCredits, TEST_NOW, useTestDatabase } from "./db";
|
||||
import { call, getState, signUp, signUpOwner, useTestApp } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
|
|
@ -12,13 +14,16 @@ const errorCode = (response: { json(): unknown }) =>
|
|||
errorResponseSchema.parse(response.json()).error.code;
|
||||
|
||||
describe("buy a ship", () => {
|
||||
it("deducts the price and adds an idle, uncrewed ship at Earth", async () => {
|
||||
it("deducts the price and adds an idle, uncrewed ship at the Earth bay", async () => {
|
||||
const { app } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const price = content.shipModel("courier")!.price;
|
||||
await setCredits(database().db, owner.playerId, price + 500);
|
||||
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" });
|
||||
const response = await call(app, owner, "POST", "/api/ships", {
|
||||
modelId: "courier",
|
||||
bayId: owner.earthBayId,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const { ship } = shipResponseSchema.parse(response.json());
|
||||
expect(ship).toMatchObject({
|
||||
|
|
@ -41,7 +46,10 @@ describe("buy a ship", () => {
|
|||
const price = content.shipModel("freighter")!.price;
|
||||
await setCredits(database().db, owner.playerId, price - 1);
|
||||
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "freighter" });
|
||||
const response = await call(app, owner, "POST", "/api/ships", {
|
||||
modelId: "freighter",
|
||||
bayId: owner.earthBayId,
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorCode(response)).toBe("insufficient-funds");
|
||||
|
||||
|
|
@ -55,7 +63,10 @@ describe("buy a ship", () => {
|
|||
const guest = await signUp(app);
|
||||
await setCredits(database().db, guest.playerId, 1_000_000);
|
||||
|
||||
const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
|
||||
const response = await call(app, guest, "POST", "/api/ships", {
|
||||
modelId: "courier",
|
||||
bayId: "any",
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorCode(response)).toBe("tutorial-active");
|
||||
|
||||
|
|
@ -68,19 +79,76 @@ describe("buy a ship", () => {
|
|||
const { app } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, owner.playerId, 1_000_000);
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "company-courier" });
|
||||
const response = await call(app, owner, "POST", "/api/ships", {
|
||||
modelId: "company-courier",
|
||||
bayId: owner.earthBayId,
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an unknown model", async () => {
|
||||
const { app } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "x-wing" });
|
||||
const response = await call(app, owner, "POST", "/api/ships", {
|
||||
modelId: "x-wing",
|
||||
bayId: owner.earthBayId,
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(errorCode(response)).toBe("not-found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ship delivery to a bay (CR-002)", () => {
|
||||
it("delivers a Freighter bought for the Moon bay to the Moon", async () => {
|
||||
const { app } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const moonBayId = randomUUID();
|
||||
await database()
|
||||
.db.insert(bays)
|
||||
.values({ id: moonBayId, playerId: owner.playerId, locationId: "moon", createdAt: TEST_NOW });
|
||||
await setCredits(database().db, owner.playerId, content.shipModel("freighter")!.price);
|
||||
|
||||
const response = await call(app, owner, "POST", "/api/ships", {
|
||||
modelId: "freighter",
|
||||
bayId: moonBayId,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({
|
||||
locationId: "moon",
|
||||
status: "idle",
|
||||
pilotId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a purchase with no bay id as a validation error", async () => {
|
||||
const { app } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, owner.playerId, 1_000_000);
|
||||
const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(errorCode(response)).toBe("validation-error");
|
||||
expect((await getState(app, owner)).company.credits).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("refuses another player's bay and changes nothing", async () => {
|
||||
const { app } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const other = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, owner.playerId, 1_000_000);
|
||||
|
||||
const response = await call(app, owner, "POST", "/api/ships", {
|
||||
modelId: "courier",
|
||||
bayId: other.earthBayId,
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(errorCode(response)).toBe("not-found");
|
||||
const state = await getState(app, owner);
|
||||
expect(state.company.credits).toBe(1_000_000);
|
||||
expect(state.ships).toHaveLength(1);
|
||||
expect((await getState(app, other)).ships).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ship names", () => {
|
||||
it("are stable for a world seed and differ between ships", () => {
|
||||
expect(shipName(42, 0)).toBe(shipName(42, 0));
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ describe("expired contract cleanup", () => {
|
|||
it("deletes old boards and accepted contracts and keeps current ones", async () => {
|
||||
const { db } = database();
|
||||
const guest = await signUp(testApp().app);
|
||||
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
const period = boardPeriodMs(content.config);
|
||||
const current = boardPeriodIndex(TEST_NOW, content.config);
|
||||
const old = current - BOARD_RETENTION_PERIODS - 1;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ describe("concurrency and money safety", () => {
|
|||
await setCredits(database().db, guest.playerId, price + price / 2);
|
||||
|
||||
const responses = await Promise.all([
|
||||
call(app, guest, "POST", "/api/ships", { modelId: "courier" }),
|
||||
call(app, guest, "POST", "/api/ships", { modelId: "courier" }),
|
||||
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
|
||||
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
|
||||
]);
|
||||
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ describe("concurrency and money safety", () => {
|
|||
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 8 }, () =>
|
||||
call(app, guest, "POST", "/api/ships", { modelId: "courier" }),
|
||||
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
|
||||
),
|
||||
);
|
||||
expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3);
|
||||
|
|
@ -46,7 +46,7 @@ describe("concurrency and money safety", () => {
|
|||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
const guest = await signUp(app);
|
||||
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
await setCredits(db, guest.playerId, 10_000);
|
||||
const contract = testContract();
|
||||
await putBoard(db, guest.playerId, [contract]);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface Owner {
|
|||
/** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */
|
||||
async function owner(): Promise<Owner> {
|
||||
const guest = await signUp(testApp().app);
|
||||
const shipId = await makeOwner(database().db, guest.playerId, guest.avatarId);
|
||||
const { shipId } = await makeOwner(database().db, guest.playerId, guest.avatarId);
|
||||
await setCredits(database().db, guest.playerId, 10_000);
|
||||
return { guest, shipId };
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ describe("contract dispatch", () => {
|
|||
expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id });
|
||||
expect(state.pilots[0]!.status).toBe("in-flight");
|
||||
const board = contractBoardResponseSchema.parse(
|
||||
(await call(app, o.guest, "GET", "/api/bays/earth/board")).json(),
|
||||
(await call(app, o.guest, "GET", "/api/locations/earth/board")).json(),
|
||||
);
|
||||
expect(board.contracts).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export async function seedPlayer(
|
|||
});
|
||||
if (options.credits !== undefined) await setCredits(db, player.playerId, options.credits);
|
||||
if (state === "tutorial") return player;
|
||||
return { ...player, shipId: await makeOwner(db, player.playerId, player.avatarId) };
|
||||
return { ...player, ...(await makeOwner(db, player.playerId, player.avatarId)) };
|
||||
}
|
||||
|
||||
export async function setCredits(db: Db, playerId: string, credits: number): Promise<void> {
|
||||
|
|
@ -85,10 +85,15 @@ export async function setCredits(db: Db, playerId: string, credits: number): Pro
|
|||
|
||||
/**
|
||||
* Turns a tutorial player into an owner: tutorial over, one Courier at Earth
|
||||
* crewed by the avatar, an Earth bay. Returns the ship id.
|
||||
* crewed by the avatar, an Earth bay. Returns the ship and bay ids.
|
||||
*/
|
||||
export async function makeOwner(db: Db, playerId: string, avatarId: string): Promise<string> {
|
||||
export async function makeOwner(
|
||||
db: Db,
|
||||
playerId: string,
|
||||
avatarId: string,
|
||||
): Promise<{ shipId: string; earthBayId: string }> {
|
||||
const shipId = randomUUID();
|
||||
const earthBayId = randomUUID();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(players)
|
||||
|
|
@ -108,7 +113,7 @@ export async function makeOwner(db: Db, playerId: string, avatarId: string): Pro
|
|||
.where(eq(pilots.id, avatarId));
|
||||
await tx
|
||||
.insert(bays)
|
||||
.values({ id: randomUUID(), playerId, locationId: "earth", createdAt: TEST_NOW });
|
||||
.values({ id: earthBayId, playerId, locationId: "earth", createdAt: TEST_NOW });
|
||||
});
|
||||
return shipId;
|
||||
return { shipId, earthBayId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,13 +186,19 @@ export async function insertDispatch(
|
|||
return record;
|
||||
}
|
||||
|
||||
/** Stores a hand-made Earth bay board for the current period. */
|
||||
export async function putBoard(db: Db, playerId: string, contracts: Contract[], now = TEST_NOW) {
|
||||
/** 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,
|
||||
locationId = "earth",
|
||||
) {
|
||||
await db.insert(contractBoards).values({
|
||||
id: randomUUID(),
|
||||
playerId,
|
||||
kind: "bay",
|
||||
locationId: "earth",
|
||||
kind: "location",
|
||||
locationId,
|
||||
periodIndex: boardPeriodIndex(now, content.config),
|
||||
contracts,
|
||||
createdAt: now,
|
||||
|
|
@ -203,8 +209,7 @@ export async function putBoard(db: Db, playerId: string, contracts: Contract[],
|
|||
export async function signUpOwner(
|
||||
app: TestApp["app"],
|
||||
db: Db,
|
||||
): Promise<Guest & { shipId: string }> {
|
||||
): Promise<Guest & { shipId: string; earthBayId: string }> {
|
||||
const guest = await signUp(app);
|
||||
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
return { ...guest, shipId };
|
||||
return { ...guest, ...(await makeOwner(db, guest.playerId, guest.avatarId)) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { TEST_NOW, useTestDatabase } from "./db";
|
||||
import { getState, insertDispatch, signUpOwner, testContract, useTestApp } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
describe("ship lastArrivalAt (CR-002)", () => {
|
||||
it("is the delivery time before a flight and the latest arrival after flights", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(TEST_NOW);
|
||||
|
||||
const out = await insertDispatch(database().db, {
|
||||
playerId: owner.playerId,
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
departAt: clock.now,
|
||||
contract: testContract(),
|
||||
});
|
||||
clock.now = out.arrivalAt;
|
||||
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(out.arrivalAt);
|
||||
|
||||
const back = await insertDispatch(database().db, {
|
||||
playerId: owner.playerId,
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
departAt: clock.now,
|
||||
contract: testContract({ originId: "moon", destinationId: "earth" }),
|
||||
});
|
||||
clock.now = back.arrivalAt + 1000;
|
||||
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(back.arrivalAt);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
|
||||
import { boardPeriodMs } from "@deadhead/sim";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discoveredLocations } from "../src/db/schema";
|
||||
import { TEST_NOW, useTestDatabase } from "./db";
|
||||
import { call, insertDispatch, signUpOwner, testContract, useTestApp, type Guest } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
async function board(guest: Guest, locationId: string) {
|
||||
const response = await call(testApp().app, guest, "GET", `/api/locations/${locationId}/board`);
|
||||
expect(response.statusCode).toBe(200);
|
||||
return contractBoardResponseSchema.parse(response.json());
|
||||
}
|
||||
|
||||
describe("location contract boards", () => {
|
||||
it("returns the Moon's board for a player with no Moon bay", async () => {
|
||||
const owner = await signUpOwner(testApp().app, database().db);
|
||||
const { contracts } = await board(owner, "moon");
|
||||
expect(contracts).toHaveLength(content.config.contractBoardSize);
|
||||
for (const c of contracts) expect(c.originId).toBe("moon");
|
||||
});
|
||||
|
||||
it("returns the Earth board of a player with an Earth bay too", async () => {
|
||||
const owner = await signUpOwner(testApp().app, database().db);
|
||||
const { contracts, periodEndsAt } = await board(owner, "earth");
|
||||
expect(contracts).toHaveLength(content.config.contractBoardSize);
|
||||
expect(periodEndsAt).toBeGreaterThan(testApp().clock.now);
|
||||
});
|
||||
|
||||
it("returns the same stored board within a period, even after a discovery", async () => {
|
||||
const { clock } = testApp();
|
||||
const owner = await signUpOwner(testApp().app, database().db);
|
||||
const first = await board(owner, "moon");
|
||||
await database()
|
||||
.db.insert(discoveredLocations)
|
||||
.values({ playerId: owner.playerId, locationId: "mars", discoveredAt: TEST_NOW });
|
||||
clock.advance(boardPeriodMs(content.config) / 2 - 1);
|
||||
expect((await board(owner, "moon")).contracts).toEqual(first.contracts);
|
||||
});
|
||||
|
||||
it("returns a new board after the period rolls over", async () => {
|
||||
const { clock } = testApp();
|
||||
const owner = await signUpOwner(testApp().app, database().db);
|
||||
const first = await board(owner, "earth");
|
||||
clock.now = first.periodEndsAt;
|
||||
const next = await board(owner, "earth");
|
||||
expect(next.periodEndsAt).toBe(first.periodEndsAt + boardPeriodMs(content.config));
|
||||
expect(next.contracts.map((c) => c.id)).not.toEqual(first.contracts.map((c) => c.id));
|
||||
});
|
||||
|
||||
it("refuses Mars before it is discovered, and opens it after a delivery discovers it", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
const before = await call(app, owner, "GET", "/api/locations/mars/board");
|
||||
expect(before.statusCode).toBe(409);
|
||||
expect(errorResponseSchema.parse(before.json()).error.code).toBe("location-undiscovered");
|
||||
|
||||
// A delivery to Mars (placed directly; the Courier's range is not the point here).
|
||||
const record = await insertDispatch(database().db, {
|
||||
playerId: owner.playerId,
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
departAt: clock.now,
|
||||
contract: testContract({ destinationId: "mars" }),
|
||||
});
|
||||
clock.now = record.arrivalAt;
|
||||
const { contracts } = await board(owner, "mars");
|
||||
for (const c of contracts) expect(c.originId).toBe("mars");
|
||||
});
|
||||
|
||||
it("returns not-found for an unknown location", async () => {
|
||||
const owner = await signUpOwner(testApp().app, database().db);
|
||||
const response = await call(testApp().app, owner, "GET", "/api/locations/pluto/board");
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
|
@ -18,7 +18,7 @@ async function ownerWithMoonBay() {
|
|||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
const guest = await signUp(app);
|
||||
const earthShipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
const { shipId: earthShipId } = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
await setCredits(db, guest.playerId, 100_000);
|
||||
await db.insert(bays).values({
|
||||
id: randomUUID(),
|
||||
|
|
|
|||
|
|
@ -1,38 +1,26 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { dispatchResponseSchema, errorResponseSchema } from "@deadhead/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bays } from "../src/db/schema";
|
||||
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
|
||||
import { call, getState, signUp, useTestApp } from "./game";
|
||||
import { setCredits, useTestDatabase } from "./db";
|
||||
import { call, getState, signUpOwner, useTestApp } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
async function ownerWithMoonBay(withMoonBay = true) {
|
||||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
const guest = await signUp(app);
|
||||
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
await setCredits(db, guest.playerId, 10_000);
|
||||
if (withMoonBay) {
|
||||
await db.insert(bays).values({
|
||||
id: randomUUID(),
|
||||
playerId: guest.playerId,
|
||||
locationId: "moon",
|
||||
createdAt: TEST_NOW,
|
||||
});
|
||||
}
|
||||
return { guest, shipId };
|
||||
/** An owner with only the Earth bay, a crewed Courier at Earth and 10,000 credits. */
|
||||
async function owner() {
|
||||
const guest = await signUpOwner(testApp().app, database().db);
|
||||
await setCredits(database().db, guest.playerId, 10_000);
|
||||
return guest;
|
||||
}
|
||||
|
||||
describe("reposition dispatch", () => {
|
||||
it("flies the ship empty to another bay, costing only fuel", async () => {
|
||||
describe("deadhead dispatch", () => {
|
||||
it("flies the ship empty to a discovered location without a bay, costing only fuel", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const { guest, shipId } = await ownerWithMoonBay();
|
||||
const guest = await owner();
|
||||
|
||||
const response = await call(app, guest, "POST", "/api/dispatches", {
|
||||
kind: "reposition",
|
||||
shipId,
|
||||
shipId: guest.shipId,
|
||||
pilotId: guest.avatarId,
|
||||
destinationId: "moon",
|
||||
});
|
||||
|
|
@ -43,22 +31,23 @@ describe("reposition dispatch", () => {
|
|||
|
||||
clock.now = dispatch.arrivalAt;
|
||||
const state = await getState(app, guest);
|
||||
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
|
||||
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" });
|
||||
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 });
|
||||
expect(state.company.credits).toBe(10_000 - dispatch.fuelCost);
|
||||
});
|
||||
|
||||
it("refuses a destination without a player bay and changes nothing", async () => {
|
||||
it("refuses an undiscovered destination and changes nothing", async () => {
|
||||
const { app } = testApp();
|
||||
const { guest, shipId } = await ownerWithMoonBay(false);
|
||||
const guest = await owner();
|
||||
const response = await call(app, guest, "POST", "/api/dispatches", {
|
||||
kind: "reposition",
|
||||
shipId,
|
||||
shipId: guest.shipId,
|
||||
pilotId: guest.avatarId,
|
||||
destinationId: "moon",
|
||||
destinationId: "mars",
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("no-bay-at-destination");
|
||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("location-undiscovered");
|
||||
const state = await getState(app, guest);
|
||||
expect(state.company.credits).toBe(10_000);
|
||||
expect(state.ships[0]!.status).toBe("idle");
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const testApp = useTestApp(database);
|
|||
async function owner() {
|
||||
const { app } = testApp();
|
||||
const guest = await signUp(app);
|
||||
const shipId = await makeOwner(database().db, guest.playerId, guest.avatarId);
|
||||
const { shipId } = await makeOwner(database().db, guest.playerId, guest.avatarId);
|
||||
await setCredits(database().db, guest.playerId, 1000);
|
||||
return { guest, shipId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe("full player state", () => {
|
|||
const { app, clock } = testApp();
|
||||
const { db } = database();
|
||||
const guest = await signUp(app);
|
||||
const flyingShipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
const { shipId: flyingShipId } = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
await setCredits(db, guest.playerId, 9000);
|
||||
|
||||
// A second ship at the Moon crewed by a hired pilot with some XP and a skill.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
contractBoardResponseSchema,
|
||||
dispatchResponseSchema,
|
||||
type Contract,
|
||||
} from "@deadhead/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setCredits, useTestDatabase } from "./db";
|
||||
import {
|
||||
call,
|
||||
getState,
|
||||
insertDispatch,
|
||||
signUpOwner,
|
||||
testContract,
|
||||
useTestApp,
|
||||
type Guest,
|
||||
} from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
/** An owner whose Courier has just delivered Earth→Moon; there is no Moon bay. */
|
||||
async function landedAtMoon(): Promise<Guest & { shipId: string }> {
|
||||
const { app, clock } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
await setCredits(database().db, owner.playerId, 5000);
|
||||
const record = await insertDispatch(database().db, {
|
||||
playerId: owner.playerId,
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
departAt: clock.now,
|
||||
contract: testContract({ payout: 1000 }),
|
||||
});
|
||||
clock.now = record.arrivalAt;
|
||||
const state = await getState(app, owner);
|
||||
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" });
|
||||
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** A Moon→Earth job the Courier can fly with no skills. */
|
||||
function courierJobHome(contracts: Contract[]): Contract {
|
||||
const job = contracts.find(
|
||||
(c) =>
|
||||
c.destinationId === "earth" &&
|
||||
c.massTonnes <= 20 &&
|
||||
c.requiredSkill === null &&
|
||||
c.requiredLongHaulLevel === 0,
|
||||
);
|
||||
if (!job) throw new Error("no Courier job home on the Moon board");
|
||||
return job;
|
||||
}
|
||||
|
||||
describe("no more stranded ships (the original bug)", () => {
|
||||
it("lets a ship that delivered to the Moon, with no Moon bay, take a Moon→Earth job home", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const owner = await landedAtMoon();
|
||||
|
||||
const board = contractBoardResponseSchema.parse(
|
||||
(await call(app, owner, "GET", "/api/locations/moon/board")).json(),
|
||||
);
|
||||
const job = courierJobHome(board.contracts);
|
||||
const response = await call(app, owner, "POST", "/api/dispatches", {
|
||||
kind: "contract",
|
||||
contractId: job.id,
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const { dispatch } = dispatchResponseSchema.parse(response.json());
|
||||
|
||||
clock.now = dispatch.arrivalAt;
|
||||
const state = await getState(app, owner);
|
||||
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "earth" });
|
||||
expect(state.summaries.find((s) => s.dispatchId === dispatch.id)).toMatchObject({
|
||||
originId: "moon",
|
||||
destinationId: "earth",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the same ship deadhead Moon→Earth instead", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const owner = await landedAtMoon();
|
||||
const response = await call(app, owner, "POST", "/api/dispatches", {
|
||||
kind: "reposition",
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
destinationId: "earth",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const { dispatch } = dispatchResponseSchema.parse(response.json());
|
||||
clock.now = dispatch.arrivalAt;
|
||||
expect((await getState(app, owner)).ships[0]).toMatchObject({
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@ describe("acknowledging summaries", () => {
|
|||
const { app, clock } = testApp();
|
||||
const { db } = database();
|
||||
const guest = await signUp(app);
|
||||
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
await setCredits(db, guest.playerId, 1000);
|
||||
const record = await insertDispatch(db, {
|
||||
playerId: guest.playerId,
|
||||
|
|
@ -47,7 +47,7 @@ describe("acknowledging summaries", () => {
|
|||
const { app, clock } = testApp();
|
||||
const { db } = database();
|
||||
const owner = await signUp(app);
|
||||
const shipId = await makeOwner(db, owner.playerId, owner.avatarId);
|
||||
const { shipId } = await makeOwner(db, owner.playerId, owner.avatarId);
|
||||
const record = await insertDispatch(db, {
|
||||
playerId: owner.playerId,
|
||||
shipId,
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 861 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -1,18 +1,18 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import { useState } from "react";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { BayBoards } from "./BayBoards";
|
||||
import { LocationBoards } from "./LocationBoards";
|
||||
import { DispatchSheet } from "./DispatchSheet";
|
||||
import { TutorialBoard } from "./TutorialBoard";
|
||||
|
||||
/** Contracts: the tutorial board during the tutorial, bay boards afterwards. */
|
||||
/** Contracts: the tutorial board during the tutorial, location boards afterwards. */
|
||||
export function Contracts() {
|
||||
const state = useLoadedPlayer();
|
||||
const [selected, setSelected] = useState<Contract | null>(null);
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Contracts</h1>
|
||||
{state.tutorial ? <TutorialBoard /> : <BayBoards onSelect={setSelected} />}
|
||||
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
||||
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { useState } from "react";
|
|||
import { useClock } from "../clock/clock";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format";
|
||||
import { requirementsText } from "./BayBoards";
|
||||
import { requirementsText } from "./LocationBoards";
|
||||
import { location, shipModel, tripRealMs } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, Ship } from "@deadhead/schema";
|
||||
import { dispatchProgress } from "@deadhead/sim";
|
||||
import {
|
||||
dispatchProgress,
|
||||
realToGame,
|
||||
tripFuelCost,
|
||||
validateReposition,
|
||||
type RepositionFailure,
|
||||
} from "@deadhead/sim";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { locationName, shipModelName } from "../format";
|
||||
import { formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||
import { location, shipModel, tripRealMs } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
/** All ships, their pilots and flights; idle ships at a bay can reposition (Step 9.6). */
|
||||
/** All ships, their pilots and flights; any idle ship can deadhead (Steps 9.6, 10B.8). */
|
||||
export function Fleet() {
|
||||
const state = useLoadedPlayer();
|
||||
const [repositioning, setRepositioning] = useState<Ship | null>(null);
|
||||
const [deadheading, setDeadheading] = useState<Ship | null>(null);
|
||||
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
|
|
@ -31,20 +39,12 @@ export function Fleet() {
|
|||
ship={ship}
|
||||
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
|
||||
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
|
||||
canReposition={
|
||||
ship.status === "idle" &&
|
||||
ship.pilotId !== null &&
|
||||
state.bays.some((b) => b.locationId === ship.locationId) &&
|
||||
state.bays.length > 1
|
||||
}
|
||||
onReposition={() => setRepositioning(ship)}
|
||||
onDeadhead={() => setDeadheading(ship)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{repositioning && (
|
||||
<RepositionPicker ship={repositioning} onClose={() => setRepositioning(null)} />
|
||||
)}
|
||||
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,8 +53,7 @@ function ShipRow(props: {
|
|||
ship: Ship;
|
||||
dispatch: Dispatch | undefined;
|
||||
pilotName: string | null;
|
||||
canReposition: boolean;
|
||||
onReposition: () => void;
|
||||
onDeadhead: () => void;
|
||||
}) {
|
||||
const { ship, dispatch, pilotName } = props;
|
||||
const now = useClock((s) => s.now);
|
||||
|
|
@ -90,57 +89,122 @@ function ShipRow(props: {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{props.canReposition && (
|
||||
{ship.status === "idle" && ship.locationId && (
|
||||
<Link
|
||||
to={`/contracts?location=${ship.locationId}`}
|
||||
className="mt-2 mr-2 inline-block rounded-md border border-slate-700 px-3 py-1 text-sm"
|
||||
>
|
||||
Find work here
|
||||
</Link>
|
||||
)}
|
||||
{ship.status === "idle" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onReposition}
|
||||
onClick={props.onDeadhead}
|
||||
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
|
||||
>
|
||||
Reposition
|
||||
Deadhead (fly empty)
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Choose another bay to fly an idle ship to, empty (fuel only). */
|
||||
function RepositionPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||
/** Why a deadhead destination is unavailable, in plain words. */
|
||||
const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
|
||||
"cannot-land": "Can't land there",
|
||||
"out-of-range": "Out of range",
|
||||
"insufficient-credits": "Not enough credits for fuel",
|
||||
"pilot-not-idle": "Pilot is flying",
|
||||
"pilot-not-assigned": "No pilot assigned",
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly an idle ship empty to any other discovered location, paying fuel only
|
||||
* (CR-002). The sim decides which destinations are possible, as the server will.
|
||||
*/
|
||||
function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
const reposition = useGameAction("createDispatch");
|
||||
const destinations = state.bays.filter((b) => b.locationId !== ship.locationId);
|
||||
const model = shipModel(ship.modelId);
|
||||
const origin = ship.locationId!;
|
||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
|
||||
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
||||
const discoveredIds = new Set(discovered);
|
||||
const gameNow = realToGame(now, content.config);
|
||||
|
||||
const options = discovered
|
||||
.filter((id) => id !== origin)
|
||||
.map((id) => {
|
||||
const check = validateReposition(
|
||||
{
|
||||
ship: { id: ship.id, modelId: ship.modelId, locationId: origin, inFlight: false },
|
||||
pilot: {
|
||||
id: pilot?.id ?? "",
|
||||
isAvatar: pilot?.isAvatar ?? false,
|
||||
assignedShipId: pilot ? ship.id : null,
|
||||
inFlight: pilot?.status === "in-flight",
|
||||
skills: pilot?.skills ?? {},
|
||||
},
|
||||
destinationId: id,
|
||||
discoveredIds,
|
||||
credits: state.company.credits,
|
||||
now,
|
||||
},
|
||||
content,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
problem: check.ok ? null : (DEADHEAD_REASONS[check.reason] ?? check.reason),
|
||||
fuelCost: tripFuelCost(model, location(origin), location(id), gameNow),
|
||||
tripMs: tripRealMs(model, origin, id, 0, now),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reposition-title"
|
||||
aria-labelledby="deadhead-title"
|
||||
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
||||
>
|
||||
<h2 id="reposition-title" className="text-lg font-semibold">
|
||||
Reposition {ship.name}
|
||||
<h2 id="deadhead-title" className="text-lg font-semibold">
|
||||
Deadhead {ship.name}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400">Fly empty to another bay. You pay fuel only.</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Fly empty from {locationName(origin)} to another place you know. You pay fuel only.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{destinations.map((bay) => (
|
||||
<li key={bay.id}>
|
||||
{options.map((option) => (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={reposition.isPending}
|
||||
aria-label={locationName(option.id)}
|
||||
disabled={option.problem !== null || reposition.isPending}
|
||||
onClick={() =>
|
||||
reposition.mutate(
|
||||
{
|
||||
kind: "reposition",
|
||||
shipId: ship.id,
|
||||
pilotId: ship.pilotId!,
|
||||
destinationId: bay.locationId,
|
||||
pilotId: pilot!.id,
|
||||
destinationId: option.id,
|
||||
},
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
}
|
||||
className="w-full rounded-lg border border-slate-700 p-3 text-left"
|
||||
className="w-full rounded-lg border border-slate-700 p-3 text-left disabled:opacity-50"
|
||||
>
|
||||
{locationName(bay.locationId)}
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium">{locationName(option.id)}</span>
|
||||
<span className="text-sm tabular-nums text-slate-300">
|
||||
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
|
||||
</span>
|
||||
</span>
|
||||
{option.problem && (
|
||||
<span className="mt-1 block text-sm text-red-400">{option.problem}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import type { Contract, PlayerState } from "@deadhead/schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useApi } from "../api/context";
|
||||
import { useClock } from "../clock/clock";
|
||||
|
|
@ -9,7 +8,7 @@ import { cargoName, formatCredits, formatMillionKm, locationName, skillName } fr
|
|||
import { distanceAt } from "../game";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
|
||||
export const bayBoardKey = (locationId: string) => ["board", "bay", locationId] as const;
|
||||
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
||||
|
||||
/** Skills a contract needs, e.g. "Heavy Cargo 2, Long Haul 1", or null. */
|
||||
export function requirementsText(contract: Contract): string | null {
|
||||
|
|
@ -22,47 +21,65 @@ export function requirementsText(contract: Contract): string | null {
|
|||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
/** Contract boards for the player's bays (Step 9.4). */
|
||||
export function BayBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
// `?bay=moon` (e.g. from the map) preselects that bay.
|
||||
const [params] = useSearchParams();
|
||||
const [selected, setSelected] = useState(params.get("bay") ?? state.bays[0]?.locationId);
|
||||
const bayId = state.bays.some((b) => b.locationId === selected)
|
||||
? selected
|
||||
: state.bays[0]?.locationId;
|
||||
/**
|
||||
* The location the Contracts screen opens on: where the most recently arrived
|
||||
* idle ship is, otherwise Earth (CR-002).
|
||||
*/
|
||||
export function defaultBoardLocation(state: PlayerState): string {
|
||||
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId);
|
||||
idle.sort((a, b) => b.lastArrivalAt - a.lastArrivalAt);
|
||||
return idle[0]?.locationId ?? "earth";
|
||||
}
|
||||
|
||||
if (!bayId) {
|
||||
return (
|
||||
<p className="text-sm text-slate-400">
|
||||
You have no bays yet. Lease one on the Places screen to get contracts.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
/** Freight markets of every discovered location, no bay needed (Steps 9.4, 10B.7). */
|
||||
export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
// `?location=moon` (from the map or Fleet) selects that location.
|
||||
const [params, setParams] = useSearchParams();
|
||||
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
||||
const requested = params.get("location");
|
||||
const locationId =
|
||||
requested && discovered.includes(requested) ? requested : defaultBoardLocation(state);
|
||||
const idleAt = (id: string) =>
|
||||
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-400">Bay</span>
|
||||
<select
|
||||
aria-label="Bay"
|
||||
value={bayId}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
||||
>
|
||||
{state.bays.map((bay) => (
|
||||
<option key={bay.id} value={bay.locationId}>
|
||||
{locationName(bay.locationId)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<BayBoard locationId={bayId} onSelect={onSelect} />
|
||||
<div role="group" aria-label="Location" className="flex flex-wrap gap-2">
|
||||
{discovered.map((id) => {
|
||||
const idle = idleAt(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={id === locationId}
|
||||
onClick={() => setParams({ location: id }, { replace: true })}
|
||||
className="flex items-center gap-1.5 rounded-full border border-slate-700 px-3 py-1 text-sm aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-200"
|
||||
>
|
||||
{locationName(id)}
|
||||
{idle > 0 && (
|
||||
<span
|
||||
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
|
||||
className="rounded-full bg-sky-500/25 px-1.5 text-xs tabular-nums text-sky-200"
|
||||
>
|
||||
{idle}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{idleAt(locationId) === 0 && (
|
||||
<p className="text-sm text-slate-400">
|
||||
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
|
||||
</p>
|
||||
)}
|
||||
<LocationBoard locationId={locationId} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BayBoard({
|
||||
function LocationBoard({
|
||||
locationId,
|
||||
onSelect,
|
||||
}: {
|
||||
|
|
@ -72,7 +89,7 @@ function BayBoard({
|
|||
const api = useApi();
|
||||
const now = useClock((s) => s.now);
|
||||
const board = useQuery({
|
||||
queryKey: bayBoardKey(locationId),
|
||||
queryKey: locationBoardKey(locationId),
|
||||
queryFn: () => api.call("getContractBoard", { locationId }),
|
||||
});
|
||||
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { content, type Location } from "@deadhead/content";
|
||||
import { Link } from "react-router";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
||||
import { distanceAt } from "../game";
|
||||
|
|
@ -10,9 +11,13 @@ const TYPE_LABELS: Record<Location["type"], string> = {
|
|||
"cloud-platform": "Cloud platform",
|
||||
"belt-station": "Belt station",
|
||||
};
|
||||
/** What a bay is for (CR-002: bays are HQs, not access to contracts). */
|
||||
export const HQ_EXPLANATION =
|
||||
"Open an HQ here: hire pilots and take delivery of new ships at this location.";
|
||||
|
||||
const LANDING_LABELS = { vacuum: "Any ship", atmospheric: "Atmospheric", hardened: "Hardened" };
|
||||
|
||||
/** Every location; details and bay leasing only once discovered (Step 9.9). */
|
||||
/** Every location; details, contracts and HQ leasing once discovered (Steps 9.9, 10B.9). */
|
||||
export function Locations() {
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
|
|
@ -59,18 +64,27 @@ export function Locations() {
|
|||
<dt className="text-slate-400">Demands</dt>
|
||||
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
|
||||
</dl>
|
||||
<Link
|
||||
to={`/contracts?location=${location.id}`}
|
||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||
>
|
||||
View contracts
|
||||
</Link>
|
||||
{hasBay ? (
|
||||
<p className="mt-2 font-medium text-emerald-400">Bay owned</p>
|
||||
<p className="mt-2 font-medium text-emerald-400">Your HQ is here</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Lease bay at ${location.name}`}
|
||||
disabled={!affordable || lease.isPending}
|
||||
onClick={() => lease.mutate({ locationId: location.id })}
|
||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Lease bay ({formatCredits(location.bayLeasePrice)})
|
||||
</button>
|
||||
<>
|
||||
<p className="mt-2 text-slate-400">{HQ_EXPLANATION}</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Lease bay at ${location.name}`}
|
||||
disabled={!affordable || lease.isPending}
|
||||
onClick={() => lease.mutate({ locationId: location.id })}
|
||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Lease bay ({formatCredits(location.bayLeasePrice)})
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ function Panel({
|
|||
);
|
||||
}
|
||||
|
||||
/** A tapped location: details if discovered, and a link to its board if there is a bay. */
|
||||
/** A tapped location: details and a link to its contracts once discovered (CR-002). */
|
||||
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
|
|
@ -95,16 +95,13 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
|||
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
||||
{location.demands.map(cargoName).join(", ") || "nothing"}
|
||||
</p>
|
||||
{hasBay ? (
|
||||
<Link
|
||||
to={`/contracts?bay=${location.id}`}
|
||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||
>
|
||||
View contract board
|
||||
</Link>
|
||||
) : (
|
||||
<p className="mt-2 text-slate-400">No bay here yet.</p>
|
||||
)}
|
||||
<Link
|
||||
to={`/contracts?location=${location.id}`}
|
||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||
>
|
||||
View contracts
|
||||
</Link>
|
||||
<p className="mt-1 text-slate-400">{hasBay ? "Your HQ is here." : "No HQ here."}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { formatCredits, formatMillionKm } from "../format";
|
||||
import { content, type ShipModel } from "@deadhead/content";
|
||||
import { useState } from "react";
|
||||
import { formatCredits, formatMillionKm, locationName } from "../format";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
|
||||
|
||||
/** Ship models for sale; new ships appear at Earth (Step 9.8). */
|
||||
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
|
||||
export function Market() {
|
||||
const state = useLoadedPlayer();
|
||||
const buy = useGameAction("buyShip");
|
||||
const [buying, setBuying] = useState<ShipModel | null>(null);
|
||||
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Market</h1>
|
||||
<p className="text-sm text-slate-400">New ships are delivered to Earth with no pilot.</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
New ships are delivered with no pilot to one of your HQs.
|
||||
</p>
|
||||
{buy.isError && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{errorMessage(buy.error)}
|
||||
|
|
@ -20,7 +24,7 @@ export function Market() {
|
|||
)}
|
||||
{buy.isSuccess && (
|
||||
<p role="status" className="text-sm text-emerald-400">
|
||||
{buy.data.ship.name} is waiting at Earth.
|
||||
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
|
|
@ -45,8 +49,8 @@ export function Market() {
|
|||
<button
|
||||
type="button"
|
||||
aria-label={`Buy ${model.name}`}
|
||||
disabled={!affordable || buy.isPending}
|
||||
onClick={() => buy.mutate({ modelId: model.id })}
|
||||
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
||||
onClick={() => setBuying(model)}
|
||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
{affordable ? "Buy" : "Not enough credits"}
|
||||
|
|
@ -55,6 +59,77 @@ export function Market() {
|
|||
);
|
||||
})}
|
||||
</ul>
|
||||
{buying && (
|
||||
<DeliveryPicker
|
||||
model={buying}
|
||||
pending={buy.isPending}
|
||||
onBuy={(bayId) =>
|
||||
buy.mutate({ modelId: buying.id, bayId }, { onSettled: () => setBuying(null) })
|
||||
}
|
||||
onClose={() => setBuying(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Deliver to which HQ?": choose the bay the new ship appears at (decision 0.5.35). */
|
||||
function DeliveryPicker(props: {
|
||||
model: ShipModel;
|
||||
pending: boolean;
|
||||
onBuy: (bayId: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const state = useLoadedPlayer();
|
||||
const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delivery-title"
|
||||
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
||||
>
|
||||
<h2 id="delivery-title" className="text-lg font-semibold">
|
||||
Deliver to which HQ?
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Your {props.model.name} will wait there, idle and without a pilot.
|
||||
</p>
|
||||
<fieldset className="mt-3 space-y-2">
|
||||
<legend className="sr-only">HQ</legend>
|
||||
{state.bays.map((bay) => (
|
||||
<label
|
||||
key={bay.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-700 p-3 text-sm"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="hq"
|
||||
checked={bayId === bay.id}
|
||||
onChange={() => setBayId(bay.id)}
|
||||
/>
|
||||
{locationName(bay.locationId)}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bayId === null || props.pending}
|
||||
onClick={() => bayId && props.onBuy(bayId)}
|
||||
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-40"
|
||||
>
|
||||
Buy for {formatCredits(props.model.price)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
className="mt-2 w-full rounded-lg border border-slate-700 py-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe("typed API client", () => {
|
|||
it("puts path parameters in GET URLs", async () => {
|
||||
const fetch = mockFetch(200, { contracts: [], periodEndsAt: 7_200_000 });
|
||||
await createApiClient({ fetch }).call("getContractBoard", { locationId: "moon" });
|
||||
expect(fetch.mock.calls[0]![0]).toBe("/api/bays/moon/board");
|
||||
expect(fetch.mock.calls[0]![0]).toBe("/api/locations/moon/board");
|
||||
});
|
||||
|
||||
it("throws the server's error code and message", async () => {
|
||||
|
|
@ -60,7 +60,7 @@ describe("typed API client", () => {
|
|||
error: { code: "insufficient-funds", message: "Not enough credits." },
|
||||
});
|
||||
const error = await createApiClient({ fetch })
|
||||
.call("buyShip", { modelId: "courier" })
|
||||
.call("buyShip", { modelId: "courier", bayId: "b1" })
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
|||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const ship: Ship = {
|
|||
status: "idle",
|
||||
locationId: "mars",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "avatar",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const contract = (overrides: Partial<Contract>): Contract => ({
|
||||
id: "c",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
tier: 0,
|
||||
massTonnes: 50,
|
||||
payout: 2000,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: NOW,
|
||||
expiresAt: NOW + 30 * 60_000,
|
||||
firstContact: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const boards: Record<string, Contract[]> = {
|
||||
earth: [
|
||||
contract({ id: "e1", payout: 1999 }),
|
||||
contract({
|
||||
id: "e2",
|
||||
destinationId: "mars",
|
||||
cargoTypeId: "heavy",
|
||||
tier: 2,
|
||||
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
||||
requiredLongHaulLevel: 1,
|
||||
firstContact: true,
|
||||
payout: 88_000,
|
||||
}),
|
||||
],
|
||||
moon: [contract({ id: "m1", originId: "moon", destinationId: "earth", payout: 1234 })],
|
||||
};
|
||||
|
||||
const owner = () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [
|
||||
{ id: "b1", locationId: "earth" },
|
||||
{ id: "b2", locationId: "moon" },
|
||||
],
|
||||
});
|
||||
|
||||
describe("bay contract boards", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows the first bay's board, and switching bays shows that bay's board", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: owner,
|
||||
getContractBoard: ({ locationId }) => ({
|
||||
contracts: boards[locationId] ?? [],
|
||||
periodEndsAt: NOW + 30 * 60_000,
|
||||
}),
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Bay"), "moon");
|
||||
expect(await screen.findByText("1,234 cr")).toBeInTheDocument();
|
||||
expect(screen.getByText("Moon → Earth")).toBeInTheDocument();
|
||||
expect(screen.queryByText("1,999 cr")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contract details, requirements, expiry and the first-contact badge", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: owner,
|
||||
getContractBoard: () => ({ contracts: boards.earth!, periodEndsAt: NOW + 30 * 60_000 }),
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
|
||||
const items = await screen.findAllByRole("listitem");
|
||||
const [plain, special] = items;
|
||||
expect(plain).toHaveTextContent("Standard · 50 t");
|
||||
expect(plain).toHaveTextContent("No skills needed");
|
||||
expect(plain).toHaveTextContent("expires in 30m 00s");
|
||||
expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
|
||||
|
||||
expect(special).toHaveTextContent("Earth → Mars");
|
||||
expect(special).toHaveTextContent("Heavy (tier 2)");
|
||||
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
|
||||
expect(special).toHaveTextContent(/M km/);
|
||||
expect(within(special!).getByText("First contact")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains how to get contracts when the player has no bays", async () => {
|
||||
renderApp(
|
||||
fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }),
|
||||
"/contracts",
|
||||
);
|
||||
expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -32,6 +32,7 @@ const afterDeal = () =>
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ function afterDeal(): PlayerState {
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string | null)
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, PlayerState, Ship } from "@deadhead/schema";
|
||||
import { dispatchProgress } from "@deadhead/sim";
|
||||
import { createRepositionRecord, dispatchProgress } from "@deadhead/sim";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { formatCredits, formatDuration } from "../src/format";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
|
@ -33,6 +35,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
|
@ -105,33 +108,79 @@ describe("fleet screen", () => {
|
|||
expect(idle).toHaveTextContent("Captain");
|
||||
});
|
||||
|
||||
it("repositions to a destination chosen from the player's other bays only", async () => {
|
||||
const api = fakeApi({ getPlayerState: () => fleetState() });
|
||||
/** A crewed Courier idle at the Moon with no Moon bay; Mars discovered but out of range. */
|
||||
const atMoon = () =>
|
||||
fleetState({
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
locations: [
|
||||
{ id: "earth", discovered: true },
|
||||
{ id: "moon", discovered: true },
|
||||
{ id: "mars", discovered: true },
|
||||
{ id: "ceres-station", discovered: false },
|
||||
{ id: "jupiter-cloud-platform", discovered: false },
|
||||
],
|
||||
company: { id: "c", name: "D", credits: 10_000, loanBalance: 0 },
|
||||
ships: [
|
||||
ship({ id: "s-idle", name: "Dusty Mule", locationId: "moon", pilotId: "avatar" }),
|
||||
fleetState().ships[1]!,
|
||||
],
|
||||
});
|
||||
|
||||
it("lets an idle ship with no bay where it is deadhead to a discovered location", async () => {
|
||||
const api = fakeApi({ getPlayerState: atMoon });
|
||||
renderApp(api, "/fleet");
|
||||
const idle = (await screen.findByText("Dusty Mule")).closest("li")!;
|
||||
await userEvent.click(within(idle).getByRole("button", { name: "Reposition" }));
|
||||
await userEvent.click(within(idle).getByRole("button", { name: "Deadhead (fly empty)" }));
|
||||
|
||||
const picker = screen.getByRole("dialog", { name: "Reposition Dusty Mule" });
|
||||
const picker = screen.getByRole("dialog", { name: "Deadhead Dusty Mule" });
|
||||
const options = within(picker)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
.filter((t) => t !== "Cancel");
|
||||
expect(options).toEqual(["Moon", "Mars"]);
|
||||
.map((b) => b.getAttribute("aria-label"))
|
||||
.filter((label) => label !== null);
|
||||
expect(options).toEqual(["Earth", "Mars"]);
|
||||
|
||||
await userEvent.click(within(picker).getByRole("button", { name: "Moon" }));
|
||||
const mars = within(picker).getByRole("button", { name: "Mars" });
|
||||
expect(mars).toBeDisabled();
|
||||
expect(mars).toHaveTextContent("Out of range");
|
||||
|
||||
const earth = within(picker).getByRole("button", { name: "Earth" });
|
||||
const record = createRepositionRecord(
|
||||
{
|
||||
id: "r",
|
||||
ship: { id: "s-idle", modelId: "courier", locationId: "moon", inFlight: false },
|
||||
pilot: {
|
||||
id: "avatar",
|
||||
isAvatar: true,
|
||||
assignedShipId: "s-idle",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
},
|
||||
destinationId: "earth",
|
||||
now: NOW,
|
||||
worldSeed: 1,
|
||||
},
|
||||
content,
|
||||
);
|
||||
expect(earth).toBeEnabled();
|
||||
expect(earth).toHaveTextContent(`${formatCredits(record.fuelCost)} fuel`);
|
||||
expect(earth).toHaveTextContent(formatDuration(record.arrivalAt - record.departureAt));
|
||||
|
||||
await userEvent.click(earth);
|
||||
await waitFor(() =>
|
||||
expect(api.call).toHaveBeenCalledWith("createDispatch", {
|
||||
kind: "reposition",
|
||||
shipId: "s-idle",
|
||||
pilotId: "avatar",
|
||||
destinationId: "moon",
|
||||
destinationId: "earth",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer reposition to in-flight ships", async () => {
|
||||
it("does not offer a deadhead to in-flight ships", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
|
||||
const flying = (await screen.findByText("Slow Ox")).closest("li")!;
|
||||
expect(within(flying).queryByRole("button", { name: "Reposition" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(flying).queryByRole("button", { name: "Deadhead (fly empty)" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const ship = (id: string, status: Ship["status"]): Ship => ({
|
|||
status,
|
||||
locationId: status === "idle" ? "earth" : null,
|
||||
dispatchId: status === "idle" ? null : `d-${id}`,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import type { Contract, Ship } from "@deadhead/schema";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const contract = (overrides: Partial<Contract>): Contract => ({
|
||||
id: "c",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
tier: 0,
|
||||
massTonnes: 50,
|
||||
payout: 2000,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: NOW,
|
||||
expiresAt: NOW + 30 * 60_000,
|
||||
firstContact: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const boards: Record<string, Contract[]> = {
|
||||
earth: [
|
||||
contract({ id: "e1", payout: 1999 }),
|
||||
contract({
|
||||
id: "e2",
|
||||
destinationId: "mars",
|
||||
cargoTypeId: "heavy",
|
||||
tier: 2,
|
||||
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
||||
requiredLongHaulLevel: 1,
|
||||
firstContact: true,
|
||||
payout: 88_000,
|
||||
}),
|
||||
],
|
||||
moon: [contract({ id: "m1", originId: "moon", destinationId: "earth", payout: 1234 })],
|
||||
};
|
||||
|
||||
const ship = (id: string, overrides: Partial<Ship>): Ship => ({
|
||||
id,
|
||||
modelId: "courier",
|
||||
name: id,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: NOW - 60_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Only an Earth bay; one idle ship at Earth, two at the Moon (the newest arrival). */
|
||||
const owner = () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
ships: [
|
||||
ship("s1", { locationId: "earth", lastArrivalAt: NOW - 120_000 }),
|
||||
ship("s2", { locationId: "moon", lastArrivalAt: NOW - 1000 }),
|
||||
ship("s3", { locationId: "moon", lastArrivalAt: NOW - 90_000 }),
|
||||
ship("s4", { status: "in-flight", locationId: null, dispatchId: "d", lastArrivalAt: NOW }),
|
||||
],
|
||||
});
|
||||
|
||||
const boardApi = () =>
|
||||
fakeApi({
|
||||
getPlayerState: owner,
|
||||
getContractBoard: ({ locationId }) => ({
|
||||
contracts: boards[locationId] ?? [],
|
||||
periodEndsAt: NOW + 30 * 60_000,
|
||||
}),
|
||||
});
|
||||
|
||||
describe("location contract boards", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("lists every discovered location, none undiscovered, with idle-ship badges", async () => {
|
||||
renderApp(boardApi(), "/contracts");
|
||||
const group = await screen.findByRole("group", { name: "Location" });
|
||||
const names = within(group)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent?.replace(/[0-9]+$/, ""));
|
||||
expect(names).toEqual(["Earth", "Moon"]);
|
||||
expect(within(group).getByRole("button", { name: /Earth/ })).toContainElement(
|
||||
within(group).getByLabelText("1 idle ship"),
|
||||
);
|
||||
expect(within(group).getByRole("button", { name: /Moon/ })).toContainElement(
|
||||
within(group).getByLabelText("2 idle ships"),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens on the most recently arrived idle ship's location, and switches on tap", async () => {
|
||||
renderApp(boardApi(), "/contracts");
|
||||
expect(await screen.findByText("1,234 cr")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Earth/ }));
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to Earth when no ship is idle", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () => samplePlayerState({ tutorial: null }),
|
||||
getContractBoard: ({ locationId }) => ({
|
||||
contracts: boards[locationId] ?? [],
|
||||
periodEndsAt: NOW + 30 * 60_000,
|
||||
}),
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No idle ships here/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the location named in the address", async () => {
|
||||
renderApp(boardApi(), "/contracts?location=earth");
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("'Find work here' on the Fleet opens that ship's location board", async () => {
|
||||
renderApp(boardApi(), "/fleet");
|
||||
const row = (await screen.findByText("s1")).closest("li")!;
|
||||
await userEvent.click(within(row).getByRole("link", { name: "Find work here" }));
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Earth/, pressed: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contract details, requirements, expiry and the first-contact badge", async () => {
|
||||
renderApp(boardApi(), "/contracts?location=earth");
|
||||
|
||||
await screen.findByText("1,999 cr");
|
||||
const items = screen.getAllByRole("listitem");
|
||||
const [plain, special] = items;
|
||||
expect(plain).toHaveTextContent("Standard · 50 t");
|
||||
expect(plain).toHaveTextContent("No skills needed");
|
||||
expect(plain).toHaveTextContent("expires in 30m 00s");
|
||||
expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
|
||||
|
||||
expect(special).toHaveTextContent("Earth → Mars");
|
||||
expect(special).toHaveTextContent("Heavy (tier 2)");
|
||||
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
|
||||
expect(special).toHaveTextContent(/M km/);
|
||||
expect(within(special!).getByText("First contact")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { content } from "@deadhead/content";
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HQ_EXPLANATION } from "../src/screens/Locations";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const item = (name: string) => screen.getByText(name).closest("li")!;
|
||||
|
|
@ -27,7 +28,7 @@ describe("locations screen", () => {
|
|||
expect(item("Earth")).toHaveTextContent("Home");
|
||||
});
|
||||
|
||||
it("offers leasing only at discovered locations without a bay", async () => {
|
||||
it("offers leasing, with the HQ explanation, only at discovered locations without a bay", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
|
|
@ -38,8 +39,12 @@ describe("locations screen", () => {
|
|||
renderApp(api, "/locations");
|
||||
await screen.findByText("Moon");
|
||||
|
||||
expect(item("Earth")).toHaveTextContent("Bay owned");
|
||||
expect(item("Earth")).toHaveTextContent("Your HQ is here");
|
||||
expect(within(item("Earth")).queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(item("Earth")).not.toHaveTextContent(HQ_EXPLANATION);
|
||||
expect(item("Moon")).toHaveTextContent(
|
||||
"Open an HQ here: hire pilots and take delivery of new ships at this location.",
|
||||
);
|
||||
|
||||
const moonPrice = content.location("moon")!.bayLeasePrice;
|
||||
const lease = within(item("Moon")).getByRole("button", { name: "Lease bay at Moon" });
|
||||
|
|
@ -50,6 +55,26 @@ describe("locations screen", () => {
|
|||
expect(screen.queryByRole("button", { name: "Lease bay at Mars" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links every discovered location to its contracts, with or without a bay", async () => {
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => samplePlayerState({ bays: [{ id: "b1", locationId: "earth" }] }),
|
||||
}),
|
||||
"/locations",
|
||||
);
|
||||
await screen.findByText("Moon");
|
||||
for (const [name, id] of [
|
||||
["Earth", "earth"],
|
||||
["Moon", "moon"],
|
||||
] as const) {
|
||||
expect(within(item(name)).getByRole("link", { name: "View contracts" })).toHaveAttribute(
|
||||
"href",
|
||||
`/contracts?location=${id}`,
|
||||
);
|
||||
}
|
||||
expect(within(item("Mars")).queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables leasing the player cannot afford", async () => {
|
||||
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations");
|
||||
expect(await screen.findByRole("button", { name: "Lease bay at Earth" })).toBeDisabled();
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ function mapState(): PlayerState {
|
|||
status: "in-flight",
|
||||
locationId: null,
|
||||
dispatchId: "d1",
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "p-rae",
|
||||
},
|
||||
],
|
||||
|
|
@ -98,24 +99,28 @@ describe("map panels", () => {
|
|||
return screen.findByTestId("map-canvas");
|
||||
};
|
||||
|
||||
it("tapping a location with a bay shows its details and a link to its contract board", async () => {
|
||||
it("tapping a location with a bay shows its details, its contracts link and the HQ", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||
const panel = screen.getByRole("dialog", { name: "Moon" });
|
||||
expect(panel).toHaveTextContent("Airless body");
|
||||
expect(panel).toHaveTextContent(/from Earth/);
|
||||
expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute(
|
||||
expect(within(panel).getByRole("link", { name: "View contracts" })).toHaveAttribute(
|
||||
"href",
|
||||
"/contracts?bay=moon",
|
||||
"/contracts?location=moon",
|
||||
);
|
||||
expect(panel).toHaveTextContent("Your HQ is here.");
|
||||
});
|
||||
|
||||
it("tapping a discovered location without a bay shows no board link", async () => {
|
||||
it("tapping a discovered location without a bay still links to its contracts", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
|
||||
const panel = screen.getByRole("dialog", { name: "Earth" });
|
||||
expect(panel).toHaveTextContent("No bay here yet.");
|
||||
expect(within(panel).queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(panel).toHaveTextContent("No HQ here.");
|
||||
expect(within(panel).getByRole("link", { name: "View contracts" })).toHaveAttribute(
|
||||
"href",
|
||||
"/contracts?location=earth",
|
||||
);
|
||||
});
|
||||
|
||||
it("tapping an undiscovered location hides its details", async () => {
|
||||
|
|
@ -146,10 +151,10 @@ describe("map panels", () => {
|
|||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the board link opens Contracts with that bay selected", async () => {
|
||||
it("the board link opens Contracts with that location selected", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||
await userEvent.click(screen.getByRole("link", { name: "View contract board" }));
|
||||
expect(await screen.findByLabelText("Bay")).toHaveValue("moon");
|
||||
await userEvent.click(screen.getByRole("link", { name: "View contracts" }));
|
||||
expect(await screen.findByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ describe("ship markers", () => {
|
|||
status: "in-flight",
|
||||
locationId: null,
|
||||
dispatchId: "d1",
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "p1",
|
||||
},
|
||||
{
|
||||
|
|
@ -70,6 +71,7 @@ describe("ship markers", () => {
|
|||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
},
|
||||
{
|
||||
|
|
@ -79,6 +81,7 @@ describe("ship markers", () => {
|
|||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe("market screen", () => {
|
|||
fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
bays: [{ id: "b-earth", locationId: "earth" }],
|
||||
company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 },
|
||||
}),
|
||||
}),
|
||||
|
|
@ -45,6 +46,7 @@ describe("market screen", () => {
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
};
|
||||
let owned: Ship[] = [];
|
||||
|
|
@ -52,6 +54,7 @@ describe("market screen", () => {
|
|||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [{ id: "b-earth", locationId: "earth" }],
|
||||
ships: owned,
|
||||
company: {
|
||||
id: "c",
|
||||
|
|
@ -67,11 +70,51 @@ describe("market screen", () => {
|
|||
});
|
||||
renderApp(api, "/market");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Buy Freighter" }));
|
||||
expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "freighter" });
|
||||
const picker = screen.getByRole("dialog", { name: "Deliver to which HQ?" });
|
||||
expect(within(picker).getByRole("radio", { name: "Earth" })).toBeChecked();
|
||||
await userEvent.click(within(picker).getByRole("button", { name: /Buy for/ }));
|
||||
expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "freighter", bayId: "b-earth" });
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Lucky Heron is waiting at Earth.");
|
||||
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
|
||||
expect(await screen.findByText("Lucky Heron")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks which HQ to deliver to and sends the chosen bay", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [
|
||||
{ id: "b-earth", locationId: "earth" },
|
||||
{ id: "b-moon", locationId: "moon" },
|
||||
],
|
||||
company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 },
|
||||
}),
|
||||
buyShip: ({ bayId }) => ({
|
||||
ship: {
|
||||
id: "s-new",
|
||||
modelId: "courier",
|
||||
name: "Moon Moth",
|
||||
status: "idle",
|
||||
locationId: bayId === "b-moon" ? "moon" : "earth",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
renderApp(api, "/market");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Buy Courier" }));
|
||||
const picker = screen.getByRole("dialog", { name: "Deliver to which HQ?" });
|
||||
const confirm = within(picker).getByRole("button", { name: /Buy for/ });
|
||||
expect(confirm).toBeDisabled();
|
||||
expect(within(picker).getByRole("radio", { name: "Earth" })).not.toBeChecked();
|
||||
|
||||
await userEvent.click(within(picker).getByRole("radio", { name: "Moon" }));
|
||||
await userEvent.click(confirm);
|
||||
expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "courier", bayId: "b-moon" });
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Moon Moth is waiting at Moon.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
|
|||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -96,9 +96,9 @@ describe("empty states", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("Contracts: no bays explains how to get contracts", async () => {
|
||||
it("Contracts: no idle ships explains how to take the jobs", async () => {
|
||||
show("/contracts");
|
||||
expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/No idle ships here/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Contracts: an empty bay board says to check back", async () => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ Living reference for how the code is organised. Update it after any database sch
|
|||
|---|---|
|
||||
| `ServerTime` | `realTime`, `gameTime` |
|
||||
| `Company` | `id`, `name`, `credits`, `loanBalance` |
|
||||
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` |
|
||||
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) |
|
||||
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
|
||||
| `Bay` | `id`, `locationId` |
|
||||
| `LocationState` | `id`, `discovered` |
|
||||
|
|
@ -49,10 +49,10 @@ The `routes` catalog is the single source for both server and client. Each entry
|
|||
| `getServerTime` | `GET /api/time` | — | `ServerTime` | no |
|
||||
| `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` + session cookie | no |
|
||||
| `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes |
|
||||
| `getContractBoard` | `GET /api/bays/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes |
|
||||
| `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes |
|
||||
| `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes |
|
||||
| `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes |
|
||||
| `buyShip` | `POST /api/ships` | `modelId` | `{ ship }` | yes |
|
||||
| `buyShip` | `POST /api/ships` | `modelId`, `bayId` (one of the player's bays; the ship is delivered there) | `{ ship }` | yes |
|
||||
| `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes |
|
||||
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
|
||||
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
|
||||
|
|
@ -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) |
|
||||
| `bays` | Leased bays | unique (`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`) |
|
||||
| `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`) |
|
||||
|
|
@ -106,7 +106,7 @@ const time = (name: string) => bigint(name, { mode: "number" });
|
|||
|
||||
export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]);
|
||||
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", {
|
||||
id: text("id").primaryKey(),
|
||||
|
|
@ -450,7 +450,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
|||
| `src/App.tsx` | Clock sync; loading / start screen / routed layout |
|
||||
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
|
||||
| `src/routes.ts` | Screen list shared by router and navigation |
|
||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
||||
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
|
||||
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
# Change Request CR-002 — Open Markets, Bays as HQs
|
||||
|
||||
**Project:** Deadhead: Space Freight Tycoon
|
||||
**Applies to:** a build with Phases 1–10 and CR-001 (Faster Start) complete
|
||||
**Do this:** as **Phase 10B**, after Phase 10A and before Phase 11
|
||||
**Related docs:** `implementation-plan.md`, `change-request-faster-start.md`, `game-design-document.md`, `DECISIONS.md`
|
||||
|
||||
> **Numbering note (added when implemented):** the plan's §0.5 already used 27 (pilot location storage), 28 (Git hosting) and 29–34 (CR-001). The new decisions below numbered 0.5.27 and 0.5.28 are recorded as **0.5.35 Ship delivery** and **0.5.36 Undiscovered locations**. The error code `location_undiscovered` is the existing `location-undiscovered`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why
|
||||
|
||||
Contracts are currently only offered at locations where the player has a bay, and repositioning is only allowed to and from bay locations. A ship that delivers to a location without a bay (for example the Moon) is stranded: there is no contract board there, and it cannot reposition home.
|
||||
|
||||
The fix follows the American/Euro Truck Simulator model:
|
||||
|
||||
- **Every discovered location has a contract board (the freight market).** Any idle ship can pick up work wherever it is, which makes backhaul work.
|
||||
- **Bays are HQs (like ATS garages).** You need a bay to hire pilots there, to take delivery of newly bought ships there, and to unassign pilots there. Bays no longer control where contracts are available.
|
||||
- **Discovery still gates expansion.** An undiscovered location has no board until the player delivers there.
|
||||
|
||||
## 2. Rules for this change
|
||||
|
||||
- All rules in `implementation-plan.md` §0.1 still apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
|
||||
- **Do not write data migrations for old contract boards.** Resetting the dev and test databases is fine. Schema migrations are still required.
|
||||
- Update existing tests whose expectations change. Do not delete a test unless §5 says the step is replaced.
|
||||
- Update `architecture.md` for every schema or API change and `progress.md` after every step.
|
||||
- Stop for the user's review at the end of Phase 10B.
|
||||
|
||||
## 3. Decision changes
|
||||
|
||||
Record these in `DECISIONS.md` and in `implementation-plan.md` §0.4 and §0.5.
|
||||
|
||||
**Glossary changes (§0.4)**
|
||||
- **Bay** → A docking bay the player leases at a discovered location. It is an HQ: pilots are hired there, newly bought ships are delivered there, and pilots can be unassigned there. A bay does **not** control where contracts are offered.
|
||||
- **Contract board** (new) → The freight market at a discovered location. Every discovered location has one per player per board period, whether or not the player has a bay there.
|
||||
- **Deadhead** (new) → A reposition: a ship flying empty to another location. Fuel only, no payout, no XP.
|
||||
|
||||
**Replaced**
|
||||
- **0.5.5 (Contract boards are stored)** → A board exists for every **discovered location**, per player, per period. It is generated on the first request in a period and stored; later requests in that period return the stored board. Every contract expires at the end of its board period.
|
||||
- **Step 3.14 rule (reposition only to a bay)** → An idle ship can deadhead from **any** location to **any discovered** location other than its current one, subject to the normal range and landing checks.
|
||||
|
||||
**New**
|
||||
- **0.5.27 Ship delivery.** Buying a ship requires choosing one of the player's bays. The new ship appears there, idle and uncrewed.
|
||||
- **0.5.28 Undiscovered locations.** Requesting the board of an undiscovered location fails with a `location_undiscovered` error. Deadheading to an undiscovered location is not allowed; only a contract delivery discovers a location.
|
||||
|
||||
**Unchanged (confirm, don't modify)**
|
||||
- **0.5.2 Pilot locations.** Hiring happens at a player bay; unassigning is allowed only at a location with a player bay.
|
||||
- **CR-001 deal.** The deal still includes the Earth bay.
|
||||
|
||||
## 4. Steps — Phase 10B
|
||||
|
||||
### Step 10B.1 — Sim: boards at any location
|
||||
- Change the contract board generator's input from "the bay's location" to "the board's location". No other generation rules change.
|
||||
- Rename any sim types or functions that say "bay board" to "location board".
|
||||
- **Test:** The Step 3.9 tests still pass with a location input. Generating a board for the Moon with no bay data supplied anywhere works and is deterministic.
|
||||
|
||||
### Step 10B.2 — Sim: deadhead validation
|
||||
- Reposition validation allows any origin. The destination must be discovered, must differ from the origin, and must pass the range and landing checks. The bay requirement is removed.
|
||||
- Update the Step 3.14 tests.
|
||||
- **Test:** A Courier idle at the Moon with no Moon bay can deadhead to Earth. Deadheading to an undiscovered location fails with `location_undiscovered`. Deadheading to the ship's own location fails. Deadheading out of range fails with the out-of-range reason. A deadhead record still has zero payout and zero XP.
|
||||
|
||||
### Step 10B.3 — Database: boards keyed by location
|
||||
- Change `contract_boards` to be keyed by player, location, and board period (the tutorial board keeps its own key).
|
||||
- Update `architecture.md`.
|
||||
- **Test:** Migrations run on a fresh database and are a no-op the second time. Two boards for the same player and period at different locations can coexist; a second board for the same player, location, and period is rejected by a unique constraint.
|
||||
|
||||
### Step 10B.4 — Server: board endpoint for any discovered location
|
||||
- Replace "get contract board for a bay" with "get contract board for a location" in the route catalog and server. Any discovered location works; an undiscovered location returns `location_undiscovered`.
|
||||
- Update the Step 6.9 tests.
|
||||
- **Test:** The Moon's board is returned for a player with no Moon bay. Two requests in the same period return the same board. Requesting Mars's board before discovery fails with `location_undiscovered`; after a delivery discovers Mars, it succeeds.
|
||||
|
||||
### Step 10B.5 — Server: no more stranded ships
|
||||
- Contract dispatch works from any discovered location where the ship is idle (it already validates that the ship is at the contract's origin; confirm no bay check remains).
|
||||
- Deadhead dispatch uses the Step 10B.2 rules.
|
||||
- Update the Step 6.10 and 6.11 tests.
|
||||
- **Test (the original bug):** A ship delivers Earth→Moon with no Moon bay. After arrival, the Moon's board is available, a Moon→Earth contract can be dispatched with that ship, and it resolves normally. Separately, the same ship can instead deadhead Moon→Earth.
|
||||
|
||||
### Step 10B.6 — Server: ship delivery to a bay
|
||||
- Buying a ship takes a bay id. The ship appears idle and uncrewed at that bay's location. Buying without a bay, or with a bay the player doesn't own, fails.
|
||||
- Update the Step 6.5 tests.
|
||||
- **Test:** With bays at Earth and the Moon, buying a Freighter for the Moon bay places it at the Moon. Buying with no bay id fails with a validation error. Buying with another player's bay id fails and changes nothing.
|
||||
|
||||
### Step 10B.7 — Client: Contracts screen by location
|
||||
- The Contracts screen's selector lists all discovered locations instead of bays. Each entry shows a badge with the number of idle ships there.
|
||||
- Default selection: the location of the most recently arrived idle ship; otherwise Earth.
|
||||
- Fleet screen: each idle ship gets a "Find work here" action that opens its location's board.
|
||||
- Update the Step 9.4 tests.
|
||||
- **Test:** Component test: the selector lists every discovered location and no undiscovered ones; idle-ship badges match state; the default is the most recently arrived idle ship's location; "Find work here" opens the right board.
|
||||
|
||||
### Step 10B.8 — Client: deadhead action
|
||||
- Rename the Fleet screen's "Reposition" action to "Deadhead (fly empty)". It is available for every idle ship. The destination picker lists discovered locations; ones out of range or not landable are shown disabled with the reason. Show fuel cost and trip time for each.
|
||||
- Update the Step 9.6 tests.
|
||||
- **Test:** Component test: an idle ship at the Moon with no Moon bay shows the action; the picker excludes undiscovered locations and the ship's current location; an out-of-range destination is disabled with its reason; fuel and time match the sim.
|
||||
|
||||
### Step 10B.9 — Client: bays as HQs
|
||||
- Market screen: "Buy" opens a bay picker ("Deliver to which HQ?"). With only one bay, preselect it.
|
||||
- Locations screen and map panels: every discovered location has a "View contracts" link. The "Lease bay" action explains what a bay does: "Open an HQ here: hire pilots and take delivery of new ships at this location."
|
||||
- Update the Step 9.8, 9.9, and 10.3 tests.
|
||||
- **Test:** Component test: Buy shows the bay picker and sends the chosen bay id; with one bay it is preselected. Every discovered location shows "View contracts", with or without a bay. The lease action shows the HQ explanation.
|
||||
|
||||
### Step 10B.10 — Plan amendments and regression
|
||||
- Apply the amendments in §5 to `implementation-plan.md`, and update `game-design-document.md` §4 and §10 to match §1 of this document.
|
||||
- Run the full suite.
|
||||
- **Test:** CI is green. Manual check: on a fresh account after the CR-001 deal, deliver to the Moon, pick up a Moon→Earth contract with the same ship, and deliver it back to Earth without leasing a Moon bay.
|
||||
|
||||
## 5. Amendments to `implementation-plan.md`
|
||||
|
||||
| Step | Change |
|
||||
|---|---|
|
||||
| 0.4, 0.5 | Apply §3 of this document. |
|
||||
| 3.9 | Input is a location, not a bay (10B.1). |
|
||||
| 3.14 | Deadhead rules replace reposition-to-bay (10B.2). |
|
||||
| 5.3 | `contract_boards` keyed by player, location, and period (10B.3). |
|
||||
| 6.5 | Ship purchase takes a bay id (10B.6). |
|
||||
| 6.9 | Boards for any discovered location (10B.4). |
|
||||
| 6.10, 6.11 | No bay checks on dispatch; deadhead rules (10B.5). |
|
||||
| 9.4 | Selector lists discovered locations (10B.7). |
|
||||
| 9.6 | "Deadhead (fly empty)" for every idle ship (10B.8). |
|
||||
| 9.8, 9.9, 10.3 | Bay picker on purchase; "View contracts" everywhere; HQ explanation (10B.9). |
|
||||
| 12.1 | The simulated player picks up contracts at whatever location its ships are at, and only deadheads when no eligible contract exists there. Add a check: across the whole run, no ship is ever idle with no eligible contract **and** no reachable deadhead destination. |
|
||||
| 12.5 | After Mars is discovered, also confirm Mars's contract board is available without a Mars bay. |
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
Garage-style bay capacity or slot limits, bay upgrades, recurring bay rent, pilots that pick their own jobs from their home bay (ATS-style AI drivers), and anything else in `implementation-plan.md` §0.3.
|
||||
|
|
@ -29,10 +29,10 @@ The game is about **logistics decisions**: which jobs to take, which ship and pi
|
|||
|
||||
## 4. Core Loop
|
||||
|
||||
1. Browse contracts at a docking bay.
|
||||
1. Browse the freight market (contract board) wherever an idle ship is. Every discovered location has one.
|
||||
2. Choose a ship and pilot, set the throttle, and dispatch.
|
||||
3. Ship travels in real time (continues while offline).
|
||||
4. Delivery completes: payout, pilot XP, possible new destination discovered.
|
||||
4. Delivery completes: payout, pilot XP, possible new destination discovered. The ship takes a backhaul from where it landed, or deadheads (flies empty, fuel only) somewhere better.
|
||||
5. Reinvest: ships, bays, pilots, pilot skills.
|
||||
|
||||
## 5. Progression
|
||||
|
|
@ -128,9 +128,10 @@ Ship tiers should feel like upgrading from a box truck to a top-end rig.
|
|||
|
||||
## 10. Docking Bays & Discovery
|
||||
|
||||
- **Docking bays** are rented at a location and act as the player's base there: a place to pick up contracts and house ships.
|
||||
- A location must be **discovered** before a bay can be rented there. Discovery happens by **completing a delivery to that location**.
|
||||
- Contracts to undiscovered destinations appear occasionally at existing bays as well-paying "first contact" or survey jobs, giving players a path to expand.
|
||||
- **Every discovered location has a contract board** (its freight market), like the job markets in American/Euro Truck Simulator. Any idle ship can pick up work wherever it is, so backhauls are always possible and a ship is never stranded.
|
||||
- **Docking bays are HQs** (like ATS garages), rented at a discovered location. A bay is where pilots are hired, where newly bought ships are delivered, and where pilots can be unassigned. A bay does **not** control where contracts are offered.
|
||||
- A location must be **discovered** before it has a board, a bay can be rented there, or ships can deadhead there. Discovery happens only by **completing a delivery to that location**.
|
||||
- Contracts to undiscovered destinations appear occasionally on discovered locations' boards as well-paying "first contact" or survey jobs, giving players a path to expand.
|
||||
|
||||
## 11. Pilots
|
||||
|
||||
|
|
@ -162,7 +163,7 @@ Pilots are hired, assigned to ships, and gain **XP** from deliveries. XP is spen
|
|||
### Contract Generation
|
||||
- Every destination **produces** some goods and **needs** others. Contracts are generated from these flows.
|
||||
- Backhaul matters: the best routes avoid flying home empty.
|
||||
- Each bay has one contract board per refresh period. Contracts expire when the period ends.
|
||||
- Each discovered location has one contract board per player per refresh period. Contracts expire when the period ends.
|
||||
- Payout is fixed when a contract is offered; travel time and fuel depend on the distance at departure.
|
||||
|
||||
### Payout Factors
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ Random events, reputation, fuel price variation, throttle setting, multi-leg rou
|
|||
|
||||
- **Location** — a place a ship can land (planet, moon, station, platform).
|
||||
- **Discovered** — a location the player has delivered to at least once (Earth and Moon start discovered).
|
||||
- **Bay** — a docking bay the player rents at a discovered location. Contracts are offered only at bays (plus the tutorial board).
|
||||
- **Bay** — a docking bay the player leases at a discovered location. It is an HQ: pilots are hired there, newly bought ships are delivered there, and pilots can be unassigned there. A bay does **not** control where contracts are offered. *(Changed by CR-002.)*
|
||||
- **Contract board** — the freight market at a discovered location. Every discovered location has one per player per board period, whether or not the player has a bay there. *(CR-002.)*
|
||||
- **Deadhead** — a reposition: a ship flying empty to another location. Fuel only, no payout, no XP. *(CR-002.)*
|
||||
- **Contract** — a job: cargo of a type and mass, from origin to destination, with a payout and skill requirements.
|
||||
- **Dispatch** — a ship + pilot sent on a trip, either carrying a contract or repositioning empty.
|
||||
- **Game time** — the in-world clock, derived from real time × compression factor.
|
||||
|
|
@ -58,7 +60,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
2. **Pilot locations.** An assigned pilot is wherever their ship is. An unassigned pilot stays where they were unassigned. Pilots travel only by flying a ship. Unassigning is allowed only at a location with a player bay. New hires appear at a player bay the player chooses.
|
||||
3. **Tutorial movement.** The avatar and loaner move with each tutorial run. The tutorial board starts at the loaner's current location (Earth→Moon or Moon→Earth). The company pays fuel and pilot pay; the player receives only the tutorial share. *(Amended by CR-001: the board has three contracts of different masses (0.5.31), and the loaner is a dedicated content ship (0.5.29).)*
|
||||
4. **Tutorial end.** *(Replaced by CR-001.)* The tutorial ends when the player accepts the deal (0.5.30). Buying a ship and leasing an Earth bay no longer end it.
|
||||
5. **Contract boards are stored.** A bay's board is generated on the first request in a period and stored. Later requests in that period return the stored board, so a discovery mid-period does not change it. Every contract expires at the end of its board period; nothing carries over.
|
||||
5. **Contract boards are stored.** *(Replaced by CR-002.)* A board exists for every **discovered location**, per player, per period. It is generated on the first request in a period and stored; later requests in that period return the stored board. Every contract expires at the end of its board period.
|
||||
6. **Cargo tier.** Standard is tier 0. A non-Standard contract has a tier from 1 to 5, and that tier is the required level in the cargo's skill.
|
||||
7. **Long Haul.** A config list of distance cut-offs, one per Long Haul level. The required Long Haul level is the number of cut-offs the route distance exceeds.
|
||||
8. **Config-driven tuning.** First-contact chance, first-contact multiplier, contract mass range, and Long Haul cut-offs all live in GameConfig. *(Amended after the CR-001 playtest: the single mass range is replaced by per-ship-size mass bands, `contractMassBands`.)*
|
||||
|
|
@ -88,6 +90,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
32. **Loan** (CR-001). The company has a loan balance. When a dispatch resolves with positive credits (after pilot pay), the lender takes min(balance, credits × repayment share). The player can pay down any amount up to the balance at any time. No interest, no missed payments, no fail state.
|
||||
33. **Scripted tutorial contracts** (CR-001). Content can define special tutorial contracts keyed to a tutorial run number. A scripted contract replaces one board slot on that run and can carry a custom name, pay multiplier, and a flavor message shown after delivery.
|
||||
34. **Onboarding goals** (CR-001). A fixed, ordered list of goals in content, each with a condition evaluated from player state and a one-time credit reward the player claims. Goals appear after the tutorial ends.
|
||||
35. **Ship delivery** (CR-002). Buying a ship requires choosing one of the player's bays. The new ship appears there, idle and uncrewed.
|
||||
36. **Undiscovered locations** (CR-002). Requesting the board of an undiscovered location fails with a `location-undiscovered` error. Deadheading to an undiscovered location is not allowed; only a contract delivery discovers a location.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -204,6 +208,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- Rules: the board has the configured number of contracts; destinations are mostly discovered locations; cargo type is chosen from types the origin produces and the destination demands, falling back to Standard if none match; the configured first-contact chance decides whether a contract targets a random undiscovered location instead (flagged first-contact); mass comes from the configured range; non-Standard contracts get a tier from 1 to 5, which is their required skill level; required Long Haul level comes from the distance cut-offs; every contract expires at the end of the board period.
|
||||
- **Test:** The same inputs always produce an identical board. A different period index produces a different board. No contract has origin equal to destination. Over 1,000 generated boards, first-contact contracts are between 5% and 15% of all contracts and every one targets an undiscovered location.
|
||||
- **Amended after the CR-001 playtest:** contract mass comes from per-ship-size bands (`contractMassBands`, e.g. 3 × 5–20 t, 3 × 21–100 t, 2 × 101–400 t), and the first contract in each band is Standard cargo to a discovered location, so every ship size always has a job a new pilot can fly.
|
||||
- **Amended by CR-002:** Input is a location, not a bay: boards are generated for any discovered location (Step 10B.1).
|
||||
|
||||
### Step 3.10 — Tutorial contract board
|
||||
- Add a separate generator for the tutorial "company board": Earth↔Moon only, Standard cargo only, payout reduced by the tutorial payout share, always flown with the company loaner Courier. Contracts start at the loaner's current location: Earth→Moon when it is at Earth, Moon→Earth when it is at the Moon.
|
||||
|
|
@ -226,6 +231,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
### Step 3.14 — Reposition dispatch
|
||||
- Allow a dispatch with no contract to any discovered location that has a player bay, paying fuel only and earning no payout or XP.
|
||||
- **Test:** A reposition record has zero payout and zero XP. A reposition to a location without a player bay fails validation.
|
||||
- **Amended by CR-002:** Deadhead rules replace reposition-to-bay: from wherever the ship is to any other discovered location, range and landing only, `location-undiscovered` otherwise (Step 10B.2).
|
||||
|
||||
### Step 3.15 — In-flight position and progress
|
||||
- Add functions that return, for a dispatch and a real time: progress (0 to 1, clamped) and an interpolated 2D position along a straight line from the origin's position at departure to the destination's position at arrival.
|
||||
|
|
@ -273,6 +279,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- Using Drizzle, define tables: players (with world seed), companies, ships, pilots (with current location and an is-avatar flag), bays, discovered_locations, contract_boards (one stored board per bay per period, plus tutorial boards per period per loaner location), accepted_contracts, dispatches. Dispatches store the full record including a snapshot of the contract, plus a status (in_flight or resolved), a resolved-at time, and an acknowledged-at time.
|
||||
- Add a migration command.
|
||||
- **Test:** Running migrations against the empty test database succeeds; running them a second time is a no-op.
|
||||
- **Amended by CR-002:** `contract_boards` is keyed by player, kind (`location` / `tutorial`), location and period (plus tutorial run); one board per player, location and period (Step 10B.3).
|
||||
|
||||
### Step 5.4 — Test database harness
|
||||
- Add a test helper that resets the test database between integration tests and can seed a player in a given state (tutorial, owner with one ship, etc.).
|
||||
|
|
@ -314,6 +321,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- Allow buying any ship model if the player has enough credits. A new ship appears idle at Earth with no pilot assigned.
|
||||
- **Test:** Buying with enough credits deducts the price and adds the ship. Buying without enough credits returns the insufficient-funds error and changes nothing.
|
||||
- **Amended by CR-001:** Blocked during the tutorial with `tutorial-active` (Step 10A.7, decision 0.5.16 as replaced).
|
||||
- **Amended by CR-002:** `buyShip` takes a `bayId`; the ship appears at that bay's location. No bay id is a validation error; another player's bay is `not-found` (Step 10B.6, decision 0.5.35).
|
||||
|
||||
### Step 6.6 — Lease a bay
|
||||
- Allow leasing a bay at any discovered location where the player does not already have one, for that location's one-time lease price.
|
||||
|
|
@ -333,14 +341,17 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
### Step 6.9 — Bay contract board endpoint
|
||||
- Return the contract board for one of the player's bays for the current period. On the first request in a period, generate it with the sim using the player's discovered locations at that moment and store it in contract_boards; later requests in the period return the stored board. Exclude already-accepted contracts. Contracts from earlier periods are never shown.
|
||||
- **Test:** A bay board is returned for an owned bay. Requesting a board for a location without a player bay fails. Two requests in the same period return the same board, even if a location is discovered between them. After the period rolls over, a new board is returned, and dispatching a contract from the old board fails as expired.
|
||||
- **Amended by CR-002:** `GET /api/locations/:locationId/board` serves any discovered location, no bay needed; undiscovered is `location-undiscovered` (Step 10B.4, decision 0.5.36).
|
||||
|
||||
### Step 6.10 — Contract dispatch
|
||||
- Dispatching a contract runs sim validation, deducts fuel cost, marks the contract accepted, and stores the dispatch record. Ship and pilot become in-flight.
|
||||
- **Test:** One integration test per sim validation failure confirms the matching error code is returned and nothing changes. A valid dispatch deducts fuel, stores the record, and makes the contract disappear from the board.
|
||||
- **Amended by CR-002:** No bay checks: any idle ship can take a contract from its current location's board (Step 10B.5).
|
||||
|
||||
### Step 6.11 — Reposition dispatch
|
||||
- Dispatching with no contract to a discovered location with a player bay deducts fuel and moves the ship and pilot with no payout.
|
||||
- **Test:** A reposition succeeds and, after arrival, the ship is at the new location with credits reduced only by fuel.
|
||||
- **Amended by CR-002:** Deadhead rules (Step 3.14 as amended); bays play no part (Step 10B.5).
|
||||
|
||||
### Step 6.12 — Spend skill points
|
||||
- Allow spending an available skill point on a skill below max level.
|
||||
|
|
@ -417,6 +428,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
### Step 9.4 — Bay contract boards
|
||||
- After tutorial, the Contracts screen has a selector for the player's bays and shows each bay's board. Each contract shows route, cargo type, mass, distance, payout, required skill, expiry countdown, and a first-contact badge where applicable.
|
||||
- **Test:** Component test switches bays and shows the right board. A first-contact contract displays its badge.
|
||||
- **Amended by CR-002:** The selector lists every discovered location with an idle-ship badge, defaulting to the most recently arrived idle ship's location, else Earth; Fleet gets "Find work here" (Step 10B.7).
|
||||
|
||||
### Step 9.5 — Dispatch flow
|
||||
- Selecting a contract opens a dispatch sheet: choose an eligible ship+pilot pair at the origin. Show trip time, fuel cost, net profit, and ETA in both game time and real time. Ineligible pairs are listed but disabled with the reason shown (out of range, can't land, skill too low, too heavy).
|
||||
|
|
@ -426,6 +438,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
### Step 9.6 — Fleet screen
|
||||
- List all ships: name, model, location or route, assigned pilot, and for in-flight ships a progress bar and countdown. Idle ships at a bay location have a "Reposition" action to another bay location.
|
||||
- **Test:** Component test: an in-flight ship's progress bar matches sim progress for the mocked time. Reposition opens a destination picker listing only bay locations.
|
||||
- **Amended by CR-002:** "Reposition" becomes "Deadhead (fly empty)" for every idle ship, with fuel, time and disabled-with-reason destinations (Step 10B.8).
|
||||
|
||||
### Step 9.7 — Pilots screen
|
||||
- List pilots: name, level, XP progress to next level, skill levels, available skill points, assignment, status. Actions: hire pilot (shows cost), assign to ship, spend skill point.
|
||||
|
|
@ -434,10 +447,12 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
### Step 9.8 — Market screen
|
||||
- List the three ship models with stats (speed, capacity, range, landing class, price). "Buy" is disabled with insufficient credits.
|
||||
- **Test:** Component test: buy is disabled when credits are below price; buying calls the endpoint and the new ship appears in Fleet.
|
||||
- **Amended by CR-002:** Buy opens a "Deliver to which HQ?" bay picker, preselected with one bay (Step 10B.9).
|
||||
|
||||
### Step 9.9 — Locations screen
|
||||
- List all locations. Discovered ones show type, landing requirement, current distance from Earth, produces/demands, and a "Lease bay" action with price (or "Bay owned"). Undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
|
||||
- **Test:** Component test: undiscovered locations hide details; leasing is available only for discovered locations without a bay.
|
||||
- **Amended by CR-002:** Every discovered location shows "View contracts"; the lease action explains "Open an HQ here: hire pilots and take delivery of new ships at this location." (Step 10B.9).
|
||||
|
||||
### Step 9.10 — Empty and error states
|
||||
- Every screen has a sensible empty state (e.g., "No ships yet — visit the Market") and a retry option on network failure.
|
||||
|
|
@ -460,6 +475,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- **Test:** Component test with simulated taps opens the correct panels.
|
||||
|
||||
---
|
||||
- **Amended by CR-002:** The location panel links every discovered location to "View contracts", with or without a bay (Step 10B.9).
|
||||
|
||||
## Phase 10A — Faster Start (CR-001)
|
||||
|
||||
|
|
@ -467,6 +483,12 @@ Steps 10A.1–10A.18 are specified in `memory-bank/change-requests/CR-001-faster
|
|||
|
||||
---
|
||||
|
||||
## Phase 10B — Open Markets, Bays as HQs (CR-002)
|
||||
|
||||
Steps 10B.1–10B.10 are specified in `memory-bank/change-requests/CR-002-open-markets.md` §4, with its §5 amendments to earlier steps. Done after Phase 10A and before Phase 11; stop for review at the end.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — PWA
|
||||
|
||||
### Step 11.1 — Installable PWA
|
||||
|
|
@ -486,6 +508,7 @@ Steps 10A.1–10A.18 are specified in `memory-bank/change-requests/CR-001-faster
|
|||
- Output: real time to finish the tutorial, real time to own 3 ships, credits over time, and pilot levels over time.
|
||||
- **Test:** Running the script with a fixed seed produces identical output twice. With default content, tutorial completion falls between 45 and 120 real minutes of active play. (Adjust content values, not the test thresholds, if it fails; record changes in `DECISIONS.md`.)
|
||||
- **Amended by CR-001:** Change the thresholds to: deal accepted under 10 real minutes; first pilot hired between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds.
|
||||
- **Amended by CR-002:** The simulated player picks up contracts at whatever location its ships are at, and only deadheads when no eligible contract exists there. Add a check: across the whole run, no ship is ever idle with no eligible contract **and** no reachable deadhead destination.
|
||||
|
||||
### Step 12.2 — Revisit the tutorial economy test
|
||||
- Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script.
|
||||
|
|
@ -504,6 +527,7 @@ Steps 10A.1–10A.18 are specified in `memory-bank/change-requests/CR-001-faster
|
|||
### Step 12.5 — End-to-end: discovery
|
||||
- Playwright test: take a first-contact contract to Mars with an eligible ship and pilot, advance the clock past arrival.
|
||||
- **Test:** Mars shows as discovered on the Locations screen and can now be leased.
|
||||
- **Amended by CR-002:** After Mars is discovered, also confirm Mars's contract board is available without a Mars bay.
|
||||
|
||||
### Step 12.6 — End-to-end: pilot progression
|
||||
- Playwright test: complete enough deliveries to level up a pilot, spend a point in Heavy Cargo, then confirm a previously ineligible Heavy contract becomes eligible in the dispatch flow.
|
||||
|
|
|
|||
|
|
@ -129,3 +129,18 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
|
|||
|
||||
- [x] HQ tutorial hint when enough runs are done but the loaner is away from Earth; dispatch sheet shows job details and lists every problem with numbers; onboarding test race fixed.
|
||||
- [x] Contract size bands (user's choice): `contractMassBands` gives every board jobs for each ship size, each band starting with a no-skill Standard job to a discovered place. Smoke test: guest → 4 runs → deal → a Courier job resolves with a loan repayment.
|
||||
|
||||
## Phase 10B — Open Markets, Bays as HQs (CR-002)
|
||||
|
||||
Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions recorded as plan §0.5.35–0.5.36.
|
||||
|
||||
- [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.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.
|
||||
- [x] **Step 10B.4 — Server: board endpoint for any discovered location.** `getContractBoard` is now `GET /api/locations/:locationId/board` (`locationBoard` in `src/game/boards.ts`): any discovered location, no bay needed; `409 location-undiscovered` for undiscovered, `404` for unknown. Step 6.9 tests moved to `location-board.test.ts`.
|
||||
- [x] **Step 10B.5 — Server: no more stranded ships.** Contract and deadhead dispatch never consulted bays after 10B.2/10B.4; this step proves it. `stranded.test.ts` covers the original bug: a Courier delivers Earth→Moon with no Moon bay, takes a Moon→Earth job from the Moon board and lands at Earth; separately it deadheads Moon→Earth. The Step 6.11 test now deadheads to the Moon without a Moon bay.
|
||||
- [x] **Step 10B.6 — Server: ship delivery to a bay.** `buyShip` takes `{ modelId, bayId }`; the ship appears idle and uncrewed at that bay's location. Missing `bayId` is `400 validation-error`; a bay the player does not own is `404 not-found` with nothing changed. The Market sends the first bay's id until the 10B.9 picker. Test helpers return `earthBayId`.
|
||||
- [x] **Step 10B.7 — Client: Contracts screen by location.** `LocationBoards` (was `BayBoards`) shows a Location button group of every discovered location with an idle-ship count badge, defaulting to the most recently arrived idle ship's location (else Earth); `?location=` selects one (map link and Fleet's new "Find work here"). API: `Ship.lastArrivalAt`. Tests: `location-boards.test.tsx`, `last-arrival.test.ts`.
|
||||
- [x] **Step 10B.8 — Client: deadhead action.** Fleet's "Reposition" is now "Deadhead (fly empty)" on every idle ship. The picker lists discovered locations except the ship's own, each with sim fuel cost and trip time; destinations the sim rejects (out of range, can't land, fuel, no pilot) are disabled with the reason. Test: `fleet.test.tsx`.
|
||||
- [x] **Step 10B.9 — Client: bays as HQs.** Market Buy opens "Deliver to which HQ?" (radio list of bays, preselected when there is one) and sends `bayId`. Locations and the map panel link every discovered location to "View contracts" and say whether the HQ is there; the lease action shows "Open an HQ here: hire pilots and take delivery of new ships at this location." Tests: `market`, `locations`, `map-interactions`.
|
||||
- [x] **Step 10B.10 — Plan amendments and regression.** CR-002 §5 amendments added to steps 3.9, 3.14, 5.3, 6.5, 6.9–6.11, 9.4, 9.6, 9.8, 9.9, 10.3, 12.1 and 12.5 (glossary and §0.5 were done with the CR). GDD §4, §10 and §12 now describe open markets and bays as HQs. Full suite and coverage green; an end-to-end smoke run against a live server (guest → 4 runs → deal → Earth→Moon → Moon→Earth backhaul with only the Earth bay) passed. **Phase 10B complete — stopped for review.**
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ export const shipSchema = z.object({
|
|||
/** The active dispatch while in flight; null when idle. */
|
||||
dispatchId: idSchema.nullable(),
|
||||
pilotId: idSchema.nullable(),
|
||||
/**
|
||||
* When the ship last arrived somewhere: its latest resolved flight, or its
|
||||
* delivery if it has not flown. Picks the default Contracts location (CR-002).
|
||||
*/
|
||||
lastArrivalAt: timeSchema,
|
||||
});
|
||||
export type Ship = z.infer<typeof shipSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ export const createDispatchRequestSchema = z.discriminatedUnion("kind", [
|
|||
export type CreateDispatchRequest = z.infer<typeof createDispatchRequestSchema>;
|
||||
export const dispatchResponseSchema = z.object({ dispatch: dispatchSchema });
|
||||
|
||||
export const buyShipRequestSchema = z.object({ modelId: idSchema });
|
||||
/** Decision 0.5.35: the ship is delivered to one of the player's bays. */
|
||||
export const buyShipRequestSchema = z.object({ modelId: idSchema, bayId: idSchema });
|
||||
export const shipResponseSchema = z.object({ ship: shipSchema });
|
||||
|
||||
export const leaseBayRequestSchema = z.object({ locationId: idSchema });
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export const routes = {
|
|||
},
|
||||
getContractBoard: {
|
||||
method: "GET",
|
||||
path: "/api/bays/:locationId/board",
|
||||
path: "/api/locations/:locationId/board",
|
||||
request: contractBoardRequestSchema,
|
||||
response: contractBoardResponseSchema,
|
||||
auth: true,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const ship = {
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: "p1",
|
||||
lastArrivalAt: 4_102_444_800_000,
|
||||
};
|
||||
const pilot = {
|
||||
id: "p1",
|
||||
|
|
@ -186,7 +187,12 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
|||
{ kind: "contract", contractId: "c1" },
|
||||
],
|
||||
["dispatch response", dispatchResponseSchema, { dispatch }, { dispatch: {} }],
|
||||
["buy ship request", buyShipRequestSchema, { modelId: "courier" }, { modelId: "" }],
|
||||
[
|
||||
"buy ship request",
|
||||
buyShipRequestSchema,
|
||||
{ modelId: "courier", bayId: "b1" },
|
||||
{ modelId: "courier" },
|
||||
],
|
||||
["ship response", shipResponseSchema, { ship }, {}],
|
||||
["lease bay request", leaseBayRequestSchema, { locationId: "moon" }, { locationId: 3 }],
|
||||
["bay response", bayResponseSchema, { bay }, { bay: null }],
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ export function standardCargo(content: SimContent): CargoType {
|
|||
}
|
||||
|
||||
export interface BoardInput {
|
||||
origin: Location;
|
||||
/** The location whose freight market this is; every contract starts here (CR-002). */
|
||||
location: Location;
|
||||
discoveredIds: ReadonlySet<string>;
|
||||
periodIndex: number;
|
||||
worldSeed: Seed;
|
||||
|
|
@ -109,12 +110,13 @@ function pickTier(rng: Rng, cargoType: CargoType, content: SimContent): number {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generates a bay's contract board for one period: `contractMassBands` decide how
|
||||
* Generates a location's contract board for one period (any discovered
|
||||
* location, bay or not, CR-002): `contractMassBands` decide how
|
||||
* many contracts fall in each ship-size mass band. Same inputs always give the
|
||||
* same board.
|
||||
*/
|
||||
export function generateContractBoard(input: BoardInput, content: SimContent): Contract[] {
|
||||
const { origin, discoveredIds, periodIndex, worldSeed } = input;
|
||||
const { location: origin, discoveredIds, periodIndex, worldSeed } = input;
|
||||
const { config } = content;
|
||||
const rng = createRng(deriveSeed(worldSeed, `board:${origin.id}:${periodIndex}`));
|
||||
|
||||
|
|
|
|||
|
|
@ -194,8 +194,8 @@ export interface RepositionProposal {
|
|||
ship: ShipState;
|
||||
pilot: PilotState;
|
||||
destinationId: string;
|
||||
/** Locations where the player has a bay (bays exist only at discovered locations). */
|
||||
bayLocationIds: ReadonlySet<string>;
|
||||
/** Locations the player has discovered; only these can be deadheaded to (CR-002). */
|
||||
discoveredIds: ReadonlySet<string>;
|
||||
credits: number;
|
||||
now: RealTime;
|
||||
}
|
||||
|
|
@ -206,7 +206,7 @@ export const REPOSITION_FAILURES = [
|
|||
"pilot-not-idle",
|
||||
"pilot-not-assigned",
|
||||
"already-there",
|
||||
"no-bay-at-destination",
|
||||
"location-undiscovered",
|
||||
"cannot-land",
|
||||
"out-of-range",
|
||||
"insufficient-credits",
|
||||
|
|
@ -216,21 +216,22 @@ export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
|
|||
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };
|
||||
|
||||
/**
|
||||
* Validates an empty flight to one of the player's bays. Only range and landing
|
||||
* apply; no skills are needed (decision 0.5.24).
|
||||
* Validates a deadhead (CR-002): an empty flight from wherever the ship is to
|
||||
* any other discovered location. Only range and landing apply; no skills are
|
||||
* needed (decision 0.5.24) and bays play no part.
|
||||
*/
|
||||
export function validateReposition(
|
||||
proposal: RepositionProposal,
|
||||
content: SimContent,
|
||||
): RepositionCheck {
|
||||
const { ship, pilot, destinationId, bayLocationIds, credits, now } = proposal;
|
||||
const { ship, pilot, destinationId, discoveredIds, credits, now } = proposal;
|
||||
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
||||
|
||||
if (ship.inFlight) return failWith("ship-not-idle");
|
||||
if (pilot.inFlight) return failWith("pilot-not-idle");
|
||||
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
|
||||
if (destinationId === ship.locationId) return failWith("already-there");
|
||||
if (!bayLocationIds.has(destinationId)) return failWith("no-bay-at-destination");
|
||||
if (!discoveredIds.has(destinationId)) return failWith("location-undiscovered");
|
||||
|
||||
const model = findShipModel(ship.modelId, content);
|
||||
const origin = findLocation(ship.locationId, content);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
|
|||
const { contract } = record;
|
||||
const base = { shipLocationId: record.destinationId };
|
||||
if (contract === null) {
|
||||
// Repositions earn nothing and only fly to bays, which are already discovered.
|
||||
// Deadheads earn nothing and only fly to discovered locations.
|
||||
return {
|
||||
status: "resolved",
|
||||
resolution: {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
const earth = content.location("earth")!;
|
||||
const discoveredIds = new Set(["earth", "moon"]);
|
||||
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
|
||||
const input: BoardInput = { origin: earth, discoveredIds, periodIndex, worldSeed: 12345 };
|
||||
const input: BoardInput = { location: earth, discoveredIds, periodIndex, worldSeed: 12345 };
|
||||
|
||||
describe("contract board generation", () => {
|
||||
it("produces an identical board from the same inputs", () => {
|
||||
|
|
@ -42,7 +42,10 @@ describe("contract board generation", () => {
|
|||
it("ties tier to skill: Standard is tier 0 with no skill, others 1–5 matching the skill level", () => {
|
||||
const moon = content.location("moon")!;
|
||||
for (let p = 0; p < 50; p++) {
|
||||
for (const c of generateContractBoard({ ...input, origin: moon, periodIndex: p }, content)) {
|
||||
for (const c of generateContractBoard(
|
||||
{ ...input, location: moon, periodIndex: p },
|
||||
content,
|
||||
)) {
|
||||
if (c.cargoTypeId === "standard") {
|
||||
expect(c.tier).toBe(0);
|
||||
expect(c.requiredSkill).toBeNull();
|
||||
|
|
@ -106,6 +109,20 @@ describe("contract board generation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("location boards (CR-002)", () => {
|
||||
it("generates a deterministic Moon board from the location alone, with no bay data", () => {
|
||||
const moon = content.location("moon")!;
|
||||
const moonInput: BoardInput = { location: moon, discoveredIds, periodIndex, worldSeed: 12345 };
|
||||
const board = generateContractBoard(moonInput, content);
|
||||
expect(board).toHaveLength(content.config.contractBoardSize);
|
||||
for (const c of board) {
|
||||
expect(c.originId).toBe("moon");
|
||||
expect(c.destinationId).not.toBe("moon");
|
||||
}
|
||||
expect(generateContractBoard(moonInput, content)).toEqual(board);
|
||||
});
|
||||
});
|
||||
|
||||
describe("required Long Haul level", () => {
|
||||
it("counts the cut-offs a distance exceeds", () => {
|
||||
expect(requiredLongHaulLevel(0.4, content.config)).toBe(0);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from "../src/index";
|
||||
|
||||
const now = Date.parse("2100-01-01T00:30:00Z");
|
||||
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
|
||||
const atEarth: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
|
||||
const pilot: PilotState = {
|
||||
id: "p1",
|
||||
isAvatar: false,
|
||||
|
|
@ -17,11 +17,12 @@ const pilot: PilotState = {
|
|||
inFlight: false,
|
||||
skills: {},
|
||||
};
|
||||
/** No bays anywhere: deadheads depend only on discovery, range and landing (CR-002). */
|
||||
const valid: RepositionProposal = {
|
||||
ship,
|
||||
ship: atEarth,
|
||||
pilot,
|
||||
destinationId: "moon",
|
||||
bayLocationIds: new Set(["earth", "moon"]),
|
||||
discoveredIds: new Set(["earth", "moon"]),
|
||||
credits: 10_000,
|
||||
now,
|
||||
};
|
||||
|
|
@ -31,25 +32,34 @@ const reason = (proposal: RepositionProposal) => {
|
|||
return result.ok ? "valid" : result.reason;
|
||||
};
|
||||
|
||||
describe("reposition", () => {
|
||||
it("accepts an empty flight to a bay in range, with no skills needed", () => {
|
||||
describe("deadhead (fly empty)", () => {
|
||||
it("lets a Courier idle at the Moon, with no Moon bay, deadhead to Earth", () => {
|
||||
const atMoon = { ...atEarth, locationId: "moon" };
|
||||
expect(reason({ ...valid, ship: atMoon, destinationId: "earth" })).toBe("valid");
|
||||
});
|
||||
|
||||
it("needs no skills", () => {
|
||||
expect(reason(valid)).toBe("valid");
|
||||
});
|
||||
|
||||
it("fails validation when the destination has no player bay", () => {
|
||||
expect(reason({ ...valid, bayLocationIds: new Set(["earth"]) })).toBe("no-bay-at-destination");
|
||||
it("refuses an undiscovered destination with location-undiscovered", () => {
|
||||
expect(reason({ ...valid, destinationId: "mars" })).toBe("location-undiscovered");
|
||||
});
|
||||
|
||||
it("still applies range and fuel checks", () => {
|
||||
expect(
|
||||
reason({ ...valid, destinationId: "mars", bayLocationIds: new Set(["earth", "mars"]) }),
|
||||
).toBe("out-of-range");
|
||||
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
||||
});
|
||||
|
||||
it("rejects flying to where the ship already is, or with a busy or unassigned crew", () => {
|
||||
it("refuses the ship's own location", () => {
|
||||
expect(reason({ ...valid, destinationId: "earth" })).toBe("already-there");
|
||||
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle");
|
||||
});
|
||||
|
||||
it("refuses an out-of-range destination with the out-of-range reason", () => {
|
||||
const discovered = new Set(["earth", "moon", "mars"]);
|
||||
expect(reason({ ...valid, destinationId: "mars", discoveredIds: discovered })).toBe(
|
||||
"out-of-range",
|
||||
);
|
||||
});
|
||||
|
||||
it("still needs fuel credits, an idle crew and an assigned pilot", () => {
|
||||
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
||||
expect(reason({ ...valid, ship: { ...atEarth, inFlight: true } })).toBe("ship-not-idle");
|
||||
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
|
||||
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(
|
||||
"pilot-not-assigned",
|
||||
|
|
@ -58,7 +68,7 @@ describe("reposition", () => {
|
|||
|
||||
it("creates a record with no contract, zero payout and a fuel charge", () => {
|
||||
const record = createRepositionRecord(
|
||||
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
|
||||
{ id: "r1", ship: atEarth, pilot, destinationId: "moon", now, worldSeed: 7 },
|
||||
content,
|
||||
);
|
||||
expect(record.contract).toBeNull();
|
||||
|
|
|
|||