Step 10I.4: upgrade tables, API ship fields and the start upgrade route

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 19:25:42 -04:00
parent 0338d5fdbe
commit c352936a09
39 changed files with 1708 additions and 53 deletions

View File

@ -950,3 +950,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Success returns everything the server writes**: the new level, its cost, and in-shop-until (now + that level's shop time). - **Success returns everything the server writes**: the new level, its cost, and in-shop-until (now + that level's shop time).
- **`ship-in-shop` also blocks deadheads**, since a ship in the shop isn't going anywhere. It sits right after `ship-not-idle` in both validators. - **`ship-in-shop` also blocks deadheads**, since a ship in the shop isn't going anywhere. It sits right after `ship-not-idle` in both validators.
- **Costs are rounded to whole credits** like fuel; for current prices all three levels are exact. - **Costs are rounded to whole credits** like fuel; for current prices all three levels are exact.
## Step 10I.4
- **Ships remember their latest shop job** (`shop_upgrade_id`, `shop_level` with `in_shop_until`) until the player acknowledges it, so the "out of the shop" line (Step 10I.8) knows what finished while they were away without a job table. The API shows it as `shop`, and `inShopUntil` only while the ship is actually in the shop.
- **Acknowledging a finished upgrade reuses `acknowledgeSummaries`** with optional `shipIds`, since both are cleared from the same "While you were away" modal. The request needs at least one id overall (so `dispatchIds` may be empty).
- **Effective stats are sent per ship** (`stats`), computed by the server with the sim, so every screen can show them without recomputing; the client still uses the sim for predictions.
- **Upgrade codes come from the sim's `UPGRADE_FAILURES`**, joining the dispatch and deadhead reasons in the API's error list; the new ones are `not-at-hq`, `ship-in-shop`, `upgrade-maxed` and `not-upgradable`.

View File

@ -0,0 +1,11 @@
CREATE TABLE "ship_upgrades" (
"ship_id" text NOT NULL,
"upgrade_id" text NOT NULL,
"level" integer NOT NULL,
CONSTRAINT "ship_upgrades_ship_id_upgrade_id_pk" PRIMARY KEY("ship_id","upgrade_id")
);
--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "in_shop_until" bigint;--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "shop_upgrade_id" text;--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "shop_level" integer;--> statement-breakpoint
ALTER TABLE "ship_upgrades" ADD CONSTRAINT "ship_upgrades_ship_id_ships_id_fk" FOREIGN KEY ("ship_id") REFERENCES "public"."ships"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,13 @@
"when": 1791148094765, "when": 1791148094765,
"tag": "0005_cr007_goods", "tag": "0005_cr007_goods",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1791155978006,
"tag": "0006_cr009_ship_upgrades",
"breakpoints": true
} }
] ]
} }

View File

@ -65,11 +65,32 @@ export const ships = pgTable(
status: activityStatus("status").notNull().default("idle"), status: activityStatus("status").notNull().default("idle"),
/** Null while in flight. */ /** Null while in flight. */
locationId: text("location_id"), locationId: text("location_id"),
/** In the upgrade shop until this time (decision 0.5.66); null or past: not. */
inShopUntil: time("in_shop_until"),
/**
* The latest shop job (upgrade and level), kept after it finishes until the
* player has seen it ("out of the shop"); null when none or acknowledged.
*/
shopUpgradeId: text("shop_upgrade_id"),
shopLevel: integer("shop_level"),
createdAt: time("created_at").notNull(), createdAt: time("created_at").notNull(),
}, },
(t) => [index("ships_player_idx").on(t.playerId)], (t) => [index("ships_player_idx").on(t.playerId)],
); );
/** Installed upgrade levels per ship (CR-009). A missing row is level 0. */
export const shipUpgrades = pgTable(
"ship_upgrades",
{
shipId: text("ship_id")
.notNull()
.references(() => ships.id, { onDelete: "cascade" }),
upgradeId: text("upgrade_id").notNull(),
level: integer("level").notNull(),
},
(t) => [primaryKey({ columns: [t.shipId, t.upgradeId] })],
);
export const pilots = pgTable( export const pilots = pgTable(
"pilots", "pilots",
{ {

View File

@ -6,6 +6,7 @@ import { and, count, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { bays, companies, discoveredLocations, players, ships } from "../db/schema"; import { bays, companies, discoveredLocations, players, ships } from "../db/schema";
import { gameError } from "../http/errors"; import { gameError } from "../http/errors";
import { toApiShip } from "./mappers";
import { shipName } from "./names"; import { shipName } from "./names";
export async function requireCompany(tx: Queryable, playerId: string) { export async function requireCompany(tx: Queryable, playerId: string) {
@ -115,14 +116,9 @@ export async function buyShip(
createdAt: now, createdAt: now,
}) })
.returning(); .returning();
return { return toApiShip(
id: ship!.id, ship!,
modelId: ship!.modelId, { dispatchId: null, pilotId: null, lastArrivalAt: ship!.createdAt, upgrades: {} },
name: ship!.name, now,
status: "idle", );
locationId: ship!.locationId,
dispatchId: null,
pilotId: null,
lastArrivalAt: ship!.createdAt,
};
} }

View File

@ -1,13 +1,15 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Dispatch, Pilot } from "@deadhead/schema"; import type { Dispatch, Pilot, Ship } from "@deadhead/schema";
import { import {
dutyLimit, dutyLimit,
effectiveStats,
isInShop,
isResting, isResting,
pilotLevel, pilotLevel,
skillPointsAvailable, skillPointsAvailable,
type RealTime, type RealTime,
} from "@deadhead/sim"; } from "@deadhead/sim";
import type { dispatches, pilots } from "../db/schema"; import type { dispatches, pilots, ships } from "../db/schema";
/** Database status values use `in_flight`; the API uses `in-flight`. */ /** Database status values use `in_flight`; the API uses `in-flight`. */
export function toApiStatus<S extends "idle" | "in_flight" | "resolved">( export function toApiStatus<S extends "idle" | "in_flight" | "resolved">(
@ -57,3 +59,42 @@ export function toApiPilot(
restEndsAt: resting ? row.restEndsAt : null, restEndsAt: resting ? row.restEndsAt : null,
}; };
} }
/** A ship row as the API shows it, with its upgrades and effective stats (CR-009). */
export function toApiShip(
row: typeof ships.$inferSelect,
extra: {
dispatchId: string | null;
pilotId: string | null;
lastArrivalAt: number;
upgrades: Record<string, number>;
},
now: RealTime,
): Ship {
const model = content.shipModel(row.modelId);
if (!model) throw new Error(`Ship ${row.id} has unknown model ${row.modelId}`);
const stats = effectiveStats(model, extra.upgrades, content);
return {
id: row.id,
modelId: row.modelId,
name: row.name,
status: toApiStatus(row.status),
locationId: row.status === "in_flight" ? null : row.locationId,
dispatchId: extra.dispatchId,
pilotId: extra.pilotId,
lastArrivalAt: extra.lastArrivalAt,
upgrades: extra.upgrades,
stats: {
cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: stats.cargoCapacityTonnes,
fuelRangeMillionKm: stats.fuelRangeMillionKm,
overheadGameHours: stats.overheadGameHours,
handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne,
},
inShopUntil: isInShop(row, now) ? row.inShopUntil : null,
shop:
row.shopUpgradeId && row.shopLevel !== null && row.inShopUntil !== null
? { upgradeId: row.shopUpgradeId, level: row.shopLevel, until: row.inShopUntil }
: null,
};
}

View File

@ -14,7 +14,8 @@ import {
} from "../db/schema"; } from "../db/schema";
import { isDealAvailable } from "./deal"; import { isDealAvailable } from "./deal";
import { goalStates } from "./goals"; import { goalStates } from "./goals";
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; import { toApiDispatch, toApiPilot, toApiShip } from "./mappers";
import { loadUpgradeLevels } from "./upgrades";
/** Reads everything the client needs about one player. */ /** Reads everything the client needs about one player. */
export async function loadPlayerState( export async function loadPlayerState(
@ -56,6 +57,10 @@ export async function loadPlayerState(
const discoveredIds = new Set(discovered.map((d) => d.locationId)); const discoveredIds = new Set(discovered.map((d) => d.locationId));
const shipById = new Map(shipRows.map((s) => [s.id, s])); 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 activeByShip = new Map(active.filter((d) => d.shipId).map((d) => [d.shipId!, d.id]));
const upgradesByShip = await loadUpgradeLevels(
db,
shipRows.map((s) => s.id),
);
const lastArrivalByShip = new Map(arrivals.map((a) => [a.shipId, a.at])); const lastArrivalByShip = new Map(arrivals.map((a) => [a.shipId, a.at]));
const pilotByShip = new Map( const pilotByShip = new Map(
pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]), pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]),
@ -68,16 +73,18 @@ export async function loadPlayerState(
credits: company.credits, credits: company.credits,
loanBalance: company.loanBalance, loanBalance: company.loanBalance,
}, },
ships: shipRows.map((s) => ({ ships: shipRows.map((s) =>
id: s.id, toApiShip(
modelId: s.modelId, s,
name: s.name, {
status: toApiStatus(s.status),
locationId: s.status === "in_flight" ? null : s.locationId,
dispatchId: activeByShip.get(s.id) ?? null, dispatchId: activeByShip.get(s.id) ?? null,
pilotId: pilotByShip.get(s.id) ?? null, pilotId: pilotByShip.get(s.id) ?? null,
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt, lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
})), upgrades: upgradesByShip.get(s.id) ?? {},
},
now,
),
),
pilots: pilotRows.map((p) => pilots: pilotRows.map((p) =>
toApiPilot( toApiPilot(
p, p,

View File

@ -0,0 +1,25 @@
import type { UpgradeLevels } from "@deadhead/sim";
import { inArray } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { shipUpgrades } from "../db/schema";
/** Installed upgrade levels for each of the given ships (CR-009). */
export async function loadUpgradeLevels(
tx: Queryable,
shipIds: string[],
): Promise<Map<string, Record<string, number>>> {
const levels = new Map<string, Record<string, number>>();
if (shipIds.length === 0) return levels;
const rows = await tx.select().from(shipUpgrades).where(inArray(shipUpgrades.shipId, shipIds));
for (const row of rows) {
const ship = levels.get(row.shipId) ?? {};
ship[row.upgradeId] = row.level;
levels.set(row.shipId, ship);
}
return levels;
}
/** One ship's installed upgrade levels. */
export async function shipUpgradeLevels(tx: Queryable, shipId: string): Promise<UpgradeLevels> {
return (await loadUpgradeLevels(tx, [shipId])).get(shipId) ?? {};
}

View File

@ -43,6 +43,7 @@ describe("database migrations", () => {
"pilots", "pilots",
"players", "players",
"session", "session",
"ship_upgrades",
"ships", "ships",
"user", "user",
"verification", "verification",

View File

@ -2,7 +2,7 @@ import type { PlayerState, Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react"; import { screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { tabAttention } from "../src/layout/attention"; import { tabAttention } from "../src/layout/attention";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const ship = (overrides: Partial<Ship>): Ship => ({ const ship = (overrides: Partial<Ship>): Ship => ({
id: "s", id: "s",
@ -12,6 +12,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null, pilotId: null,
...overrides, ...overrides,
}); });

View File

@ -3,7 +3,7 @@ import type { PlayerState } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
function returningPlayer(acknowledged: boolean): PlayerState { function returningPlayer(acknowledged: boolean): PlayerState {
return samplePlayerState({ return samplePlayerState({
@ -18,6 +18,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar", pilotId: "avatar",
}, },
], ],

View File

@ -2,7 +2,7 @@ import type { ResolutionSummary, Ship } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const ship: Ship = { const ship: Ship = {
id: "s1", id: "s1",
@ -12,6 +12,7 @@ const ship: Ship = {
locationId: "mars", locationId: "mars",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar", pilotId: "avatar",
}; };

View File

@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { formatClock } from "../src/format"; import { formatClock } from "../src/format";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const period = boardPeriodMs(content.config); const period = boardPeriodMs(content.config);
// A quarter of the way into a board period (derived from config, whatever its length). // A quarter of the way into a board period (derived from config, whatever its length).
@ -21,6 +21,7 @@ const courier: Ship = {
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "p1", pilotId: "p1",
}; };
const rae: Pilot = { const rae: Pilot = {

View File

@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { realTimeOf } from "../src/components/Goods"; import { realTimeOf } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.now(); const NOW = Date.now();
@ -36,6 +36,7 @@ const ship = (modelId: string): Ship => ({
dispatchId: null, dispatchId: null,
pilotId: null, pilotId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras(modelId),
}); });
function render(contracts: Contract[], ships: Ship[]) { function render(contracts: Contract[], ships: Ship[]) {

View File

@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
import { screen, waitFor } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render"; import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, shipExtras } from "./render";
const offered = () => const offered = () =>
samplePlayerState({ samplePlayerState({
@ -33,6 +33,7 @@ const afterDeal = () =>
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar", pilotId: "avatar",
}, },
], ],

View File

@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { formatGameHours } from "../src/format"; import { formatGameHours } from "../src/format";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -18,6 +18,7 @@ const ship = (id: string, name: string, pilotId: string): Ship => ({
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId, pilotId,
}); });

View File

@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { formatWindow } from "../src/components/Goods"; import { formatWindow } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
const earth = content.location("earth")!; const earth = content.location("earth")!;
@ -20,6 +20,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string): Ship
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras(modelId),
pilotId, pilotId,
}); });

View File

@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { tutorialHint } from "../src/screens/HQ"; import { tutorialHint } from "../src/screens/HQ";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -47,6 +47,7 @@ function afterDeal(): PlayerState {
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar", pilotId: "avatar",
}, },
], ],

View File

@ -15,7 +15,7 @@ import { useClock } from "../src/clock/clock";
import { formatGameTime } from "../src/clock/GameClock"; import { formatGameTime } from "../src/clock/GameClock";
import { formatCredits, formatDuration } from "../src/format"; import { formatCredits, formatDuration } from "../src/format";
import { formatLocalTime } from "../src/screens/DispatchSheet"; import { formatLocalTime } from "../src/screens/DispatchSheet";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -27,6 +27,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string | null)
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras(modelId),
pilotId, pilotId,
}); });

View File

@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { formatCredits, formatDuration } from "../src/format"; import { formatCredits, formatDuration } from "../src/format";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -36,6 +36,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null, pilotId: null,
...overrides, ...overrides,
}); });

View File

@ -4,7 +4,7 @@ import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -16,6 +16,7 @@ const ship = (id: string, status: Ship["status"]): Ship => ({
locationId: status === "idle" ? "earth" : null, locationId: status === "idle" ? "earth" : null,
dispatchId: status === "idle" ? null : `d-${id}`, dispatchId: status === "idle" ? null : `d-${id}`,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null, pilotId: null,
}); });

View File

@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -57,6 +57,7 @@ const ship = (id: string, overrides: Partial<Ship>): Ship => ({
dispatchId: null, dispatchId: null,
pilotId: null, pilotId: null,
lastArrivalAt: NOW - 60_000, lastArrivalAt: NOW - 60_000,
...shipExtras("courier"),
...overrides, ...overrides,
}); });

View File

@ -4,7 +4,7 @@ import { loadArt, resetArtRegistration, type AssetLoader } from "../src/map/art/
import { artManifest } from "../src/map/art/manifest"; import { artManifest } from "../src/map/art/manifest";
import { sceneVisuals } from "../src/map/art/visuals"; import { sceneVisuals } from "../src/map/art/visuals";
import { buildScene } from "../src/map/scene"; import { buildScene } from "../src/map/scene";
import { samplePlayerState } from "./render"; import { samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -35,6 +35,7 @@ const flying = (id: string, modelId: string, dispatchId: string): Ship => ({
dispatchId, dispatchId,
pilotId: null, pilotId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras(modelId),
}); });
/** Ships of every model in flight, plus the tutorial loaner. */ /** Ships of every model in flight, plus the tutorial loaner. */

View File

@ -6,7 +6,7 @@ import { useClock } from "../src/clock/clock";
import { DEFAULT_CAMERA, toScreen } from "../src/map/camera"; import { DEFAULT_CAMERA, toScreen } from "../src/map/camera";
import { hitTest } from "../src/map/hitTest"; import { hitTest } from "../src/map/hitTest";
import { buildScene } from "../src/map/scene"; import { buildScene } from "../src/map/scene";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -42,6 +42,7 @@ function mapState(): PlayerState {
locationId: null, locationId: null,
dispatchId: "d1", dispatchId: "d1",
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("freighter"),
pilotId: "p-rae", pilotId: "p-rae",
}, },
], ],

View File

@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { locationOverlays } from "../src/map/overlays"; import { locationOverlays } from "../src/map/overlays";
import { buildScene } from "../src/map/scene"; import { buildScene } from "../src/map/scene";
import { mapStyle } from "../src/map/style"; import { mapStyle } from "../src/map/style";
import { samplePlayerState } from "./render"; import { samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
const idleAtMoon = (id: string): Ship => ({ const idleAtMoon = (id: string): Ship => ({
@ -15,6 +15,7 @@ const idleAtMoon = (id: string): Ship => ({
dispatchId: null, dispatchId: null,
pilotId: null, pilotId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
}); });
/** After the deal: an Earth bay, two idle ships at the Moon, Mars and beyond undiscovered. */ /** After the deal: an Earth bay, two idle ships at the Moon, Mars and beyond undiscovered. */

View File

@ -2,7 +2,7 @@ import type { Dispatch } from "@deadhead/schema";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_CAMERA, toScreen, zoomAt } from "../src/map/camera"; import { DEFAULT_CAMERA, toScreen, zoomAt } from "../src/map/camera";
import { buildScene, flightMarker } from "../src/map/scene"; import { buildScene, flightMarker } from "../src/map/scene";
import { samplePlayerState } from "./render"; import { samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -64,6 +64,7 @@ describe("ship markers", () => {
locationId: null, locationId: null,
dispatchId: "d1", dispatchId: "d1",
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "p1", pilotId: "p1",
}, },
{ {
@ -74,6 +75,7 @@ describe("ship markers", () => {
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null, pilotId: null,
}, },
{ {
@ -84,6 +86,7 @@ describe("ship markers", () => {
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null, pilotId: null,
}, },
], ],

View File

@ -3,7 +3,7 @@ import type { Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react"; import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const courierPrice = content.shipModel("courier")!.price; const courierPrice = content.shipModel("courier")!.price;
const freighterPrice = content.shipModel("freighter")!.price; const freighterPrice = content.shipModel("freighter")!.price;
@ -47,6 +47,7 @@ describe("market screen", () => {
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("freighter"),
pilotId: null, pilotId: null,
}; };
let owned: Ship[] = []; let owned: Ship[] = [];
@ -102,6 +103,7 @@ describe("market screen", () => {
dispatchId: null, dispatchId: null,
pilotId: null, pilotId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
}, },
}), }),
}); });

View File

@ -4,7 +4,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { levelProgress } from "../src/screens/Pilots"; import { levelProgress } from "../src/screens/Pilots";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const ship = (overrides: Partial<Ship>): Ship => ({ const ship = (overrides: Partial<Ship>): Ship => ({
id: "s", id: "s",
@ -14,6 +14,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null, pilotId: null,
...overrides, ...overrides,
}); });

View File

@ -1,4 +1,6 @@
import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadhead/schema"; import { content } from "@deadhead/content";
import type { PlayerState, RouteName, RouteRequest, RouteResponse, Ship } from "@deadhead/schema";
import { effectiveStats } from "@deadhead/sim";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
@ -32,6 +34,31 @@ export const unauthorized = () => {
}; };
/** A brand-new tutorial player, as guest sign-up returns it. */ /** A brand-new tutorial player, as guest sign-up returns it. */
/**
* The CR-009 fields of a ship with no upgrades and not in the shop: its model's
* stats as effective stats. Spread into Ship fixtures, then override as needed.
*/
export function shipExtras(
modelId: string,
upgrades: Record<string, number> = {},
): Pick<Ship, "upgrades" | "stats" | "inShopUntil" | "shop"> {
const model = content.shipModel(modelId);
if (!model) throw new Error(`Unknown ship model ${modelId}`);
const s = effectiveStats(model, upgrades, content);
return {
upgrades,
stats: {
cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: s.cargoCapacityTonnes,
fuelRangeMillionKm: s.fuelRangeMillionKm,
overheadGameHours: s.overheadGameHours,
handlingGameHoursPerTonne: s.handlingGameHoursPerTonne,
},
inShopUntil: null,
shop: null,
};
}
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState { export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
return { return {
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 }, company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },

View File

@ -2,7 +2,7 @@ import type { Contract, Ship } from "@deadhead/schema";
import { screen } from "@testing-library/react"; import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { carryLimit } from "../src/screens/LocationBoards"; import { carryLimit } from "../src/screens/LocationBoards";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.now(); const NOW = Date.now();
@ -34,6 +34,7 @@ const ship = (id: string, overrides: Partial<Ship>): Ship => ({
dispatchId: null, dispatchId: null,
pilotId: null, pilotId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"),
...overrides, ...overrides,
}); });

View File

@ -25,7 +25,7 @@ Living reference for how the code is organised. Update it after any database sch
|---|---| |---|---|
| `ServerTime` | `realTime`, `gameTime` | | `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits`, `loanBalance` | | `Company` | `id`, `name`, `credits`, `loanBalance` |
| `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) | | `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), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, range, overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) |
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) | | `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) |
| `Bay` | `id`, `locationId` | | `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` | | `LocationState` | `id`, `discovered` |
@ -52,6 +52,7 @@ The `routes` catalog is the single source for both server and client. Each entry
| `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes | | `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes |
| `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ 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 | | `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes |
| `startUpgrade` | `POST /api/ships/:shipId/upgrades` | `shipId`, `upgradeId` (next level; ship idle at an HQ) | `{ ship }` | yes |
| `buyShip` | `POST /api/ships` | `modelId`, `bayId` (one of the player's bays; the ship is delivered there) | `{ 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 | | `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes |
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes | | `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
@ -59,7 +60,7 @@ The `routes` catalog is the single source for both server and client. Each entry
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes | | `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
| `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ pilot }` | yes | | `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ pilot }` | yes |
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes | | `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes | | `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes | | `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes | | `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes | | `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |
@ -72,7 +73,8 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
|---|---|---| |---|---|---|
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` | | `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` | | `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight) | — | | `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009) | — |
| `ship_upgrades` | Installed upgrade level per ship and upgrade (CR-009) | primary key (ship, upgrade) |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004) | unique `assigned_ship_id` (one pilot per ship) | | `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004) | unique `assigned_ship_id` (one pilot per ship) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) | | `bays` | Leased bays | unique (`player_id`, `location_id`) |
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) | | `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |

View File

@ -229,7 +229,7 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006
- [x] **Step 10I.1 — Content: upgrades.** `upgradeSchema` (id, name, `stat` from `SHIP_STATS`, `percentPerLevel`, `maxLevel`, optional `modelOverrides`) and `data/upgrades.ts`: engines +10 %, cargo-bay +15 %, fuel-tanks +20 % (Courier +600 %, Freighter +15 %), landing-thrusters −15 %, handling-gear −15 %, all 3 levels; `upgradePercentPerLevel` / `upgradeMaxLevel`; `content.upgrades` / `content.upgrade(id)`. Config `upgradeLevels`: 10/20/35 % of price, 10/20/30 real minutes. Tests: `content/test/upgrades.test.ts` (validity, stats exist, decision 0.5.67), `sim/test/courier-mars.test.ts` (decision 0.5.68 over monthly samples for two game years). - [x] **Step 10I.1 — Content: upgrades.** `upgradeSchema` (id, name, `stat` from `SHIP_STATS`, `percentPerLevel`, `maxLevel`, optional `modelOverrides`) and `data/upgrades.ts`: engines +10 %, cargo-bay +15 %, fuel-tanks +20 % (Courier +600 %, Freighter +15 %), landing-thrusters −15 %, handling-gear −15 %, all 3 levels; `upgradePercentPerLevel` / `upgradeMaxLevel`; `content.upgrades` / `content.upgrade(id)`. Config `upgradeLevels`: 10/20/35 % of price, 10/20/30 real minutes. Tests: `content/test/upgrades.test.ts` (validity, stats exist, decision 0.5.67), `sim/test/courier-mars.test.ts` (decision 0.5.68 over monthly samples for two game years).
- [x] **Step 10I.2 — Sim: effective stats.** `sim/src/upgrades.ts`: `effectiveStats(model, levels, content)` → `EffectiveStats` (the model with upgraded stats plus its own `handlingGameHoursPerTonne`). `ShipState.upgrades` (optional) and `shipStats(ship, content)` feed every check in `validateDispatch`, `validateReposition` and dispatch records (capacity, range, travel time, Priority window). `travelTimeGameHours` uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (`FleetMember.upgrades`). `SimContent` gains `upgrades`. Test: `effective-stats.test.ts`; all earlier sim tests unchanged. - [x] **Step 10I.2 — Sim: effective stats.** `sim/src/upgrades.ts`: `effectiveStats(model, levels, content)` → `EffectiveStats` (the model with upgraded stats plus its own `handlingGameHoursPerTonne`). `ShipState.upgrades` (optional) and `shipStats(ship, content)` feed every check in `validateDispatch`, `validateReposition` and dispatch records (capacity, range, travel time, Priority window). `travelTimeGameHours` uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (`FleetMember.upgrades`). `SimContent` gains `upgrades`. Test: `effective-stats.test.ts`; all earlier sim tests unchanged.
- [x] **Step 10I.3 — Sim: upgrade validation and cost.** `upgradeCost(model, level)` (whole credits, % of price), `upgradeShopMs(level)`, `nextUpgradeLevel`, `isInShop(ship, now)` and `validateUpgrade` with `UPGRADE_FAILURES` in check order: `tutorial-active`, `not-upgradable`, `ship-not-idle`, `ship-in-shop`, `not-at-hq`, `upgrade-maxed`, `insufficient-funds`; success returns the level, cost and in-shop-until. `ShipState.inShopUntil`; dispatch and deadhead validation fail `ship-in-shop` right after `ship-not-idle`. Test: `upgrade-validation.test.ts`. - [x] **Step 10I.3 — Sim: upgrade validation and cost.** `upgradeCost(model, level)` (whole credits, % of price), `upgradeShopMs(level)`, `nextUpgradeLevel`, `isInShop(ship, now)` and `validateUpgrade` with `UPGRADE_FAILURES` in check order: `tutorial-active`, `not-upgradable`, `ship-not-idle`, `ship-in-shop`, `not-at-hq`, `upgrade-maxed`, `insufficient-funds`; success returns the level, cost and in-shop-until. `ShipState.inShopUntil`; dispatch and deadhead validation fail `ship-in-shop` right after `ship-not-idle`. Test: `upgrade-validation.test.ts`.
- [ ] Step 10I.4 — Schema and database - [x] **Step 10I.4 — Schema and database.** Migration `0006_cr009_ship_upgrades`: `ship_upgrades` (ship, upgrade, level; PK ship+upgrade), and on `ships` `in_shop_until`, `shop_upgrade_id`, `shop_level`. API `Ship` gains `upgrades`, `stats` (effective), `inShopUntil`, `shop`; one server mapper `toApiShip` (player state and purchases). Route `startUpgrade` (`POST /api/ships/:shipId/upgrades`). Upgrade failure reasons join the API error codes. `acknowledgeSummaries` takes optional `shipIds`. Web test helper `shipExtras(modelId)`. Dev database migrated.
- [ ] Step 10I.5 — Server: start upgrade - [ ] Step 10I.5 — Server: start upgrade
- [ ] Step 10I.6 — Client: upgrade shop - [ ] Step 10I.6 — Client: upgrade shop
- [ ] Step 10I.7 — Client: effective stats everywhere - [ ] Step 10I.7 — Client: effective stats everywhere

View File

@ -27,6 +27,16 @@ export const companySchema = z.object({
}); });
export type Company = z.infer<typeof companySchema>; export type Company = z.infer<typeof companySchema>;
/** A ship's stats after upgrades (CR-009). */
export const effectiveStatsSchema = z.object({
cruiseSpeedKmPerGameHour: z.number().positive(),
cargoCapacityTonnes: z.number().positive(),
fuelRangeMillionKm: z.number().positive(),
overheadGameHours: z.number().nonnegative(),
handlingGameHoursPerTonne: z.number().nonnegative(),
});
export type EffectiveShipStats = z.infer<typeof effectiveStatsSchema>;
export const shipSchema = z.object({ export const shipSchema = z.object({
id: idSchema, id: idSchema,
modelId: idSchema, modelId: idSchema,
@ -42,6 +52,19 @@ export const shipSchema = z.object({
* delivery if it has not flown. Picks the default Contracts location (CR-002). * delivery if it has not flown. Picks the default Contracts location (CR-002).
*/ */
lastArrivalAt: timeSchema, lastArrivalAt: timeSchema,
/** Installed upgrade levels by upgrade id; a missing upgrade is level 0 (CR-009). */
upgrades: z.record(z.string(), z.number().int().positive()),
/** The model's stats after upgrades (decision 0.5.63). */
stats: effectiveStatsSchema,
/** In the upgrade shop until then; null when not in the shop now. */
inShopUntil: timeSchema.nullable(),
/**
* The latest shop job, until the player acknowledges it: in progress while
* `until` is in the future, finished ("out of the shop") once it has passed.
*/
shop: z
.object({ upgradeId: idSchema, level: z.number().int().positive(), until: timeSchema })
.nullable(),
}); });
export type Ship = z.infer<typeof shipSchema>; export type Ship = z.infer<typeof shipSchema>;

View File

@ -1,8 +1,10 @@
import { import {
DISPATCH_FAILURES, DISPATCH_FAILURES,
REPOSITION_FAILURES, REPOSITION_FAILURES,
UPGRADE_FAILURES,
type DispatchFailure, type DispatchFailure,
type RepositionFailure, type RepositionFailure,
type UpgradeFailure,
} from "@deadhead/sim"; } from "@deadhead/sim";
import { z } from "zod"; import { z } from "zod";
@ -35,11 +37,17 @@ export const SERVER_ERROR_CODES = [
"goal-already-claimed", "goal-already-claimed",
] as const; ] as const;
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number]; export type ErrorCode =
DispatchFailure | RepositionFailure | UpgradeFailure | (typeof SERVER_ERROR_CODES)[number];
/** Every machine-readable error code. Sim validation reasons are used unchanged. */ /** Every machine-readable error code. Sim validation reasons are used unchanged. */
export const ERROR_CODES = [ export const ERROR_CODES = [
...new Set<ErrorCode>([...DISPATCH_FAILURES, ...REPOSITION_FAILURES, ...SERVER_ERROR_CODES]), ...new Set<ErrorCode>([
...DISPATCH_FAILURES,
...REPOSITION_FAILURES,
...UPGRADE_FAILURES,
...SERVER_ERROR_CODES,
]),
] as [ErrorCode, ...ErrorCode[]]; ] as [ErrorCode, ...ErrorCode[]];
export const errorCodeSchema = z.enum(ERROR_CODES); export const errorCodeSchema = z.enum(ERROR_CODES);

View File

@ -49,6 +49,9 @@ export const dispatchResponseSchema = z.object({ dispatch: dispatchSchema });
export const buyShipRequestSchema = z.object({ modelId: idSchema, bayId: idSchema }); export const buyShipRequestSchema = z.object({ modelId: idSchema, bayId: idSchema });
export const shipResponseSchema = z.object({ ship: shipSchema }); export const shipResponseSchema = z.object({ ship: shipSchema });
/** CR-009: start the next level of an upgrade on a ship at an HQ. */
export const startUpgradeRequestSchema = z.object({ shipId: idSchema, upgradeId: idSchema });
export const leaseBayRequestSchema = z.object({ locationId: idSchema }); export const leaseBayRequestSchema = z.object({ locationId: idSchema });
export const bayResponseSchema = z.object({ bay: baySchema }); export const bayResponseSchema = z.object({ bay: baySchema });
@ -61,8 +64,14 @@ export const restPilotRequestSchema = z.object({ pilotId: idSchema });
export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema }); export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema });
export const pilotResponseSchema = z.object({ pilot: pilotSchema }); export const pilotResponseSchema = z.object({ pilot: pilotSchema });
export const acknowledgeSummariesRequestSchema = z.object({ export const acknowledgeSummariesRequestSchema = z
dispatchIds: z.array(idSchema).min(1), .object({
dispatchIds: z.array(idSchema),
/** Ships whose finished upgrade the player has now seen (CR-009). */
shipIds: z.array(idSchema).optional(),
})
.refine((r) => r.dispatchIds.length + (r.shipIds?.length ?? 0) > 0, {
message: "Acknowledge at least one summary or ship",
}); });
export const acknowledgeSummariesResponseSchema = z.object({ export const acknowledgeSummariesResponseSchema = z.object({
acknowledged: z.number().int().nonnegative(), acknowledged: z.number().int().nonnegative(),

View File

@ -21,6 +21,7 @@ import {
shipResponseSchema, shipResponseSchema,
spendSkillPointRequestSchema, spendSkillPointRequestSchema,
restPilotRequestSchema, restPilotRequestSchema,
startUpgradeRequestSchema,
unassignPilotRequestSchema, unassignPilotRequestSchema,
} from "./requests"; } from "./requests";
@ -87,6 +88,13 @@ export const routes = {
response: shipResponseSchema, response: shipResponseSchema,
auth: true, auth: true,
}, },
startUpgrade: {
method: "POST",
path: "/api/ships/:shipId/upgrades",
request: startUpgradeRequestSchema,
response: shipResponseSchema,
auth: true,
},
leaseBay: { leaseBay: {
method: "POST", method: "POST",
path: "/api/bays", path: "/api/bays",

View File

@ -20,6 +20,7 @@ describe("route catalog", () => {
"assignPilot", "assignPilot",
"unassignPilot", "unassignPilot",
"restPilot", "restPilot",
"startUpgrade",
"spendSkillPoint", "spendSkillPoint",
"acknowledgeSummaries", "acknowledgeSummaries",
"acceptDeal", "acceptDeal",

View File

@ -9,6 +9,7 @@ import type { z } from "zod";
import { import {
acknowledgeSummariesRequestSchema, acknowledgeSummariesRequestSchema,
acknowledgeSummariesResponseSchema, acknowledgeSummariesResponseSchema,
startUpgradeRequestSchema,
assignPilotRequestSchema, assignPilotRequestSchema,
baySchema, baySchema,
claimGoalRequestSchema, claimGoalRequestSchema,
@ -56,6 +57,16 @@ const ship = {
dispatchId: null, dispatchId: null,
pilotId: "p1", pilotId: "p1",
lastArrivalAt: 4_102_444_800_000, lastArrivalAt: 4_102_444_800_000,
upgrades: { "cargo-bay": 1 },
stats: {
cruiseSpeedKmPerGameHour: 3_000_000,
cargoCapacityTonnes: 23,
fuelRangeMillionKm: 5,
overheadGameHours: 2,
handlingGameHoursPerTonne: 0.02,
},
inShopUntil: null,
shop: { upgradeId: "cargo-bay", level: 1, until: 4_102_444_000_000 },
}; };
const pilot = { const pilot = {
id: "p1", id: "p1",
@ -158,6 +169,24 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
], ],
["company", companySchema, company, { ...company, credits: 1.5 }], ["company", companySchema, company, { ...company, credits: 1.5 }],
["ship", shipSchema, ship, { ...ship, status: "lost" }], ["ship", shipSchema, ship, { ...ship, status: "lost" }],
[
"ship in the shop",
shipSchema,
{ ...ship, inShopUntil: 4_102_445_000_000 },
{ ...ship, upgrades: { engines: 0 } },
],
[
"ship stats",
shipSchema,
{ ...ship, shop: null },
{ ...ship, stats: { ...ship.stats, cargoCapacityTonnes: 0 } },
],
[
"start upgrade request",
startUpgradeRequestSchema,
{ shipId: "s1", upgradeId: "engines" },
{ shipId: "s1" },
],
["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }], ["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }],
[ [
"resting pilot", "resting pilot",
@ -259,6 +288,12 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
{ dispatchIds: ["d0"] }, { dispatchIds: ["d0"] },
{ dispatchIds: [] }, { dispatchIds: [] },
], ],
[
"acknowledge a finished upgrade",
acknowledgeSummariesRequestSchema,
{ dispatchIds: [], shipIds: ["s1"] },
{ dispatchIds: [], shipIds: [] },
],
["acknowledge summaries response", acknowledgeSummariesResponseSchema, { acknowledged: 1 }, {}], ["acknowledge summaries response", acknowledgeSummariesResponseSchema, { acknowledged: 1 }, {}],
]; ];