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:
parent
0338d5fdbe
commit
c352936a09
|
|
@ -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).
|
||||
- **`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.
|
||||
|
||||
## 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`.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -43,6 +43,13 @@
|
|||
"when": 1791148094765,
|
||||
"tag": "0005_cr007_goods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1791155978006,
|
||||
"tag": "0006_cr009_ship_upgrades",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -65,11 +65,32 @@ export const ships = pgTable(
|
|||
status: activityStatus("status").notNull().default("idle"),
|
||||
/** Null while in flight. */
|
||||
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(),
|
||||
},
|
||||
(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(
|
||||
"pilots",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { and, count, eq, sql } from "drizzle-orm";
|
|||
import type { Queryable } from "../db/client";
|
||||
import { bays, companies, discoveredLocations, players, ships } from "../db/schema";
|
||||
import { gameError } from "../http/errors";
|
||||
import { toApiShip } from "./mappers";
|
||||
import { shipName } from "./names";
|
||||
|
||||
export async function requireCompany(tx: Queryable, playerId: string) {
|
||||
|
|
@ -115,14 +116,9 @@ export async function buyShip(
|
|||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
return {
|
||||
id: ship!.id,
|
||||
modelId: ship!.modelId,
|
||||
name: ship!.name,
|
||||
status: "idle",
|
||||
locationId: ship!.locationId,
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: ship!.createdAt,
|
||||
};
|
||||
return toApiShip(
|
||||
ship!,
|
||||
{ dispatchId: null, pilotId: null, lastArrivalAt: ship!.createdAt, upgrades: {} },
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, Pilot } from "@deadhead/schema";
|
||||
import type { Dispatch, Pilot, Ship } from "@deadhead/schema";
|
||||
import {
|
||||
dutyLimit,
|
||||
effectiveStats,
|
||||
isInShop,
|
||||
isResting,
|
||||
pilotLevel,
|
||||
skillPointsAvailable,
|
||||
type RealTime,
|
||||
} 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`. */
|
||||
export function toApiStatus<S extends "idle" | "in_flight" | "resolved">(
|
||||
|
|
@ -57,3 +59,42 @@ export function toApiPilot(
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import {
|
|||
} from "../db/schema";
|
||||
import { isDealAvailable } from "./deal";
|
||||
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. */
|
||||
export async function loadPlayerState(
|
||||
|
|
@ -56,6 +57,10 @@ export async function loadPlayerState(
|
|||
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 upgradesByShip = await loadUpgradeLevels(
|
||||
db,
|
||||
shipRows.map((s) => s.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]),
|
||||
|
|
@ -68,16 +73,18 @@ export async function loadPlayerState(
|
|||
credits: company.credits,
|
||||
loanBalance: company.loanBalance,
|
||||
},
|
||||
ships: shipRows.map((s) => ({
|
||||
id: s.id,
|
||||
modelId: s.modelId,
|
||||
name: s.name,
|
||||
status: toApiStatus(s.status),
|
||||
locationId: s.status === "in_flight" ? null : s.locationId,
|
||||
ships: shipRows.map((s) =>
|
||||
toApiShip(
|
||||
s,
|
||||
{
|
||||
dispatchId: activeByShip.get(s.id) ?? null,
|
||||
pilotId: pilotByShip.get(s.id) ?? null,
|
||||
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
|
||||
})),
|
||||
upgrades: upgradesByShip.get(s.id) ?? {},
|
||||
},
|
||||
now,
|
||||
),
|
||||
),
|
||||
pilots: pilotRows.map((p) =>
|
||||
toApiPilot(
|
||||
p,
|
||||
|
|
|
|||
|
|
@ -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) ?? {};
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ describe("database migrations", () => {
|
|||
"pilots",
|
||||
"players",
|
||||
"session",
|
||||
"ship_upgrades",
|
||||
"ships",
|
||||
"user",
|
||||
"verification",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { PlayerState, Ship } from "@deadhead/schema";
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 => ({
|
||||
id: "s",
|
||||
|
|
@ -12,6 +12,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { PlayerState } from "@deadhead/schema";
|
|||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
function returningPlayer(acknowledged: boolean): PlayerState {
|
||||
return samplePlayerState({
|
||||
|
|
@ -18,6 +18,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
|||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { ResolutionSummary, Ship } from "@deadhead/schema";
|
|||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const ship: Ship = {
|
||||
id: "s1",
|
||||
|
|
@ -12,6 +12,7 @@ const ship: Ship = {
|
|||
locationId: "mars",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: "avatar",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { formatClock } from "../src/format";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const period = boardPeriodMs(content.config);
|
||||
// A quarter of the way into a board period (derived from config, whatever its length).
|
||||
|
|
@ -21,6 +21,7 @@ const courier: Ship = {
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: "p1",
|
||||
};
|
||||
const rae: Pilot = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { realTimeOf } from "../src/components/Goods";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.now();
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ const ship = (modelId: string): Ship => ({
|
|||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras(modelId),
|
||||
});
|
||||
|
||||
function render(contracts: Contract[], ships: Ship[]) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
|
|||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
|
||||
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, shipExtras } from "./render";
|
||||
|
||||
const offered = () =>
|
||||
samplePlayerState({
|
||||
|
|
@ -33,6 +33,7 @@ const afterDeal = () =>
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
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");
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ const ship = (id: string, name: string, pilotId: string): Ship => ({
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
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 earth = content.location("earth")!;
|
||||
|
|
@ -20,6 +20,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string): Ship
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras(modelId),
|
||||
pilotId,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
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");
|
||||
|
||||
|
|
@ -47,6 +47,7 @@ function afterDeal(): PlayerState {
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useClock } from "../src/clock/clock";
|
|||
import { formatGameTime } from "../src/clock/GameClock";
|
||||
import { formatCredits, formatDuration } from "../src/format";
|
||||
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");
|
||||
|
||||
|
|
@ -27,6 +27,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string | null)
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras(modelId),
|
||||
pilotId,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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 { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
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,
|
||||
dispatchId: status === "idle" ? null : `d-${id}`,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: null,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
|
|
@ -57,6 +57,7 @@ const ship = (id: string, overrides: Partial<Ship>): Ship => ({
|
|||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: NOW - 60_000,
|
||||
...shipExtras("courier"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { loadArt, resetArtRegistration, type AssetLoader } from "../src/map/art/
|
|||
import { artManifest } from "../src/map/art/manifest";
|
||||
import { sceneVisuals } from "../src/map/art/visuals";
|
||||
import { buildScene } from "../src/map/scene";
|
||||
import { samplePlayerState } from "./render";
|
||||
import { samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
|
|
@ -35,6 +35,7 @@ const flying = (id: string, modelId: string, dispatchId: string): Ship => ({
|
|||
dispatchId,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras(modelId),
|
||||
});
|
||||
|
||||
/** Ships of every model in flight, plus the tutorial loaner. */
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useClock } from "../src/clock/clock";
|
|||
import { DEFAULT_CAMERA, toScreen } from "../src/map/camera";
|
||||
import { hitTest } from "../src/map/hitTest";
|
||||
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");
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ function mapState(): PlayerState {
|
|||
locationId: null,
|
||||
dispatchId: "d1",
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("freighter"),
|
||||
pilotId: "p-rae",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { locationOverlays } from "../src/map/overlays";
|
||||
import { buildScene } from "../src/map/scene";
|
||||
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 idleAtMoon = (id: string): Ship => ({
|
||||
|
|
@ -15,6 +15,7 @@ const idleAtMoon = (id: string): Ship => ({
|
|||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
});
|
||||
|
||||
/** After the deal: an Earth bay, two idle ships at the Moon, Mars and beyond undiscovered. */
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Dispatch } from "@deadhead/schema";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CAMERA, toScreen, zoomAt } from "../src/map/camera";
|
||||
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");
|
||||
|
||||
|
|
@ -64,6 +64,7 @@ describe("ship markers", () => {
|
|||
locationId: null,
|
||||
dispatchId: "d1",
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: "p1",
|
||||
},
|
||||
{
|
||||
|
|
@ -74,6 +75,7 @@ describe("ship markers", () => {
|
|||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: null,
|
||||
},
|
||||
{
|
||||
|
|
@ -84,6 +86,7 @@ describe("ship markers", () => {
|
|||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: null,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Ship } from "@deadhead/schema";
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
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 freighterPrice = content.shipModel("freighter")!.price;
|
||||
|
|
@ -47,6 +47,7 @@ describe("market screen", () => {
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("freighter"),
|
||||
pilotId: null,
|
||||
};
|
||||
let owned: Ship[] = [];
|
||||
|
|
@ -102,6 +103,7 @@ describe("market screen", () => {
|
|||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { screen, waitFor, within } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 => ({
|
||||
id: "s",
|
||||
|
|
@ -14,6 +14,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
|
|||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { render } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
|
@ -32,6 +34,31 @@ export const unauthorized = () => {
|
|||
};
|
||||
|
||||
/** 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 {
|
||||
return {
|
||||
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Contract, Ship } from "@deadhead/schema";
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { carryLimit } from "../src/screens/LocationBoards";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.now();
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ const ship = (id: string, overrides: Partial<Ship>): Ship => ({
|
|||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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`, `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) |
|
||||
| `Bay` | `id`, `locationId` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | 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 |
|
||||
| `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ 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 |
|
||||
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ 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` |
|
||||
| `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) |
|
||||
| `bays` | Leased bays | unique (`player_id`, `location_id`) |
|
||||
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
|
||||
|
|
|
|||
|
|
@ -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.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`.
|
||||
- [ ] 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.6 — Client: upgrade shop
|
||||
- [ ] Step 10I.7 — Client: effective stats everywhere
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ export const companySchema = z.object({
|
|||
});
|
||||
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({
|
||||
id: 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).
|
||||
*/
|
||||
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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import {
|
||||
DISPATCH_FAILURES,
|
||||
REPOSITION_FAILURES,
|
||||
UPGRADE_FAILURES,
|
||||
type DispatchFailure,
|
||||
type RepositionFailure,
|
||||
type UpgradeFailure,
|
||||
} from "@deadhead/sim";
|
||||
import { z } from "zod";
|
||||
|
||||
|
|
@ -35,11 +37,17 @@ export const SERVER_ERROR_CODES = [
|
|||
"goal-already-claimed",
|
||||
] 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. */
|
||||
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[]];
|
||||
|
||||
export const errorCodeSchema = z.enum(ERROR_CODES);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ export const dispatchResponseSchema = z.object({ dispatch: dispatchSchema });
|
|||
export const buyShipRequestSchema = z.object({ modelId: idSchema, bayId: idSchema });
|
||||
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 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 pilotResponseSchema = z.object({ pilot: pilotSchema });
|
||||
|
||||
export const acknowledgeSummariesRequestSchema = z.object({
|
||||
dispatchIds: z.array(idSchema).min(1),
|
||||
export const acknowledgeSummariesRequestSchema = z
|
||||
.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({
|
||||
acknowledged: z.number().int().nonnegative(),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
shipResponseSchema,
|
||||
spendSkillPointRequestSchema,
|
||||
restPilotRequestSchema,
|
||||
startUpgradeRequestSchema,
|
||||
unassignPilotRequestSchema,
|
||||
} from "./requests";
|
||||
|
||||
|
|
@ -87,6 +88,13 @@ export const routes = {
|
|||
response: shipResponseSchema,
|
||||
auth: true,
|
||||
},
|
||||
startUpgrade: {
|
||||
method: "POST",
|
||||
path: "/api/ships/:shipId/upgrades",
|
||||
request: startUpgradeRequestSchema,
|
||||
response: shipResponseSchema,
|
||||
auth: true,
|
||||
},
|
||||
leaseBay: {
|
||||
method: "POST",
|
||||
path: "/api/bays",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ describe("route catalog", () => {
|
|||
"assignPilot",
|
||||
"unassignPilot",
|
||||
"restPilot",
|
||||
"startUpgrade",
|
||||
"spendSkillPoint",
|
||||
"acknowledgeSummaries",
|
||||
"acceptDeal",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { z } from "zod";
|
|||
import {
|
||||
acknowledgeSummariesRequestSchema,
|
||||
acknowledgeSummariesResponseSchema,
|
||||
startUpgradeRequestSchema,
|
||||
assignPilotRequestSchema,
|
||||
baySchema,
|
||||
claimGoalRequestSchema,
|
||||
|
|
@ -56,6 +57,16 @@ const ship = {
|
|||
dispatchId: null,
|
||||
pilotId: "p1",
|
||||
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 = {
|
||||
id: "p1",
|
||||
|
|
@ -158,6 +169,24 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
|||
],
|
||||
["company", companySchema, company, { ...company, credits: 1.5 }],
|
||||
["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 }],
|
||||
[
|
||||
"resting pilot",
|
||||
|
|
@ -259,6 +288,12 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
|||
{ dispatchIds: ["d0"] },
|
||||
{ dispatchIds: [] },
|
||||
],
|
||||
[
|
||||
"acknowledge a finished upgrade",
|
||||
acknowledgeSummariesRequestSchema,
|
||||
{ dispatchIds: [], shipIds: ["s1"] },
|
||||
{ dispatchIds: [], shipIds: [] },
|
||||
],
|
||||
["acknowledge summaries response", acknowledgeSummariesResponseSchema, { acknowledged: 1 }, {}],
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue