deadhead-space-freight-tycoon/apps/server/src/db/schema.ts

234 lines
8.1 KiB
TypeScript

import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
import {
bigint,
boolean,
doublePrecision,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { user } from "./auth-schema";
// All times are Unix milliseconds (bigint), matching sim and the API.
const time = (name: string) => bigint(name, { mode: "number" });
export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]);
export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]);
export const boardKind = pgEnum("board_kind", ["location", "tutorial"]);
export const players = pgTable("players", {
id: text("id").primaryKey(),
/** The Better Auth user (a guest account in the base game). */
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id, { onDelete: "cascade" }),
/** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */
worldSeed: bigint("world_seed", { mode: "number" }).notNull(),
tutorialActive: boolean("tutorial_active").notNull().default(true),
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
loanerLocationId: text("loaner_location_id"),
/** Tutorial runs resolved so far (CR-001). */
tutorialRunsCompleted: integer("tutorial_runs_completed").notNull().default(0),
/** The one-time deal has been accepted (decision 0.5.30). */
dealAccepted: boolean("deal_accepted").notNull().default(false),
createdAt: time("created_at").notNull(),
});
export const companies = pgTable("companies", {
id: text("id").primaryKey(),
playerId: text("player_id")
.notNull()
.unique()
.references(() => players.id, { onDelete: "cascade" }),
name: text("name").notNull(),
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
credits: bigint("credits", { mode: "number" }).notNull(),
/** Owed to the lender (decision 0.5.32). */
loanBalance: bigint("loan_balance", { mode: "number" }).notNull().default(0),
});
export const ships = pgTable(
"ships",
{
id: text("id").primaryKey(),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
modelId: text("model_id").notNull(),
name: text("name").notNull(),
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",
{
id: text("id").primaryKey(),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
name: text("name").notNull(),
isAvatar: boolean("is_avatar").notNull().default(false),
xp: integer("xp").notNull().default(0),
skills: jsonb("skills").$type<Record<string, number>>().notNull().default({}),
skillPointsSpent: integer("skill_points_spent").notNull().default(0),
/** One pilot per ship (decision 0.5.2). */
assignedShipId: text("assigned_ship_id")
.unique()
.references(() => ships.id, { onDelete: "set null" }),
/** Only unassigned pilots store a location (decision 0.5.27). */
locationId: text("location_id"),
status: activityStatus("status").notNull().default("idle"),
/** Game hours flown since the last rest (decision 0.5.42). */
dutyHours: doublePrecision("duty_hours").notNull().default(0),
/** Resting until this time; null (or past) when not resting (decision 0.5.45). */
restEndsAt: time("rest_ends_at"),
createdAt: time("created_at").notNull(),
},
(t) => [index("pilots_player_idx").on(t.playerId)],
);
export const bays = pgTable(
"bays",
{
id: text("id").primaryKey(),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
locationId: text("location_id").notNull(),
createdAt: time("created_at").notNull(),
},
(t) => [uniqueIndex("bays_player_location_idx").on(t.playerId, t.locationId)],
);
export const discoveredLocations = pgTable(
"discovered_locations",
{
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
locationId: text("location_id").notNull(),
discoveredAt: time("discovered_at").notNull(),
},
(t) => [primaryKey({ columns: [t.playerId, t.locationId] })],
);
/**
* One stored board per bay per period, and one tutorial board per period per
* loaner location (decisions 0.5.5 and 0.5.21). `locationId` is the bay's or
* the loaner's location.
*/
export const contractBoards = pgTable(
"contract_boards",
{
id: text("id").primaryKey(),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
kind: boardKind("kind").notNull(),
locationId: text("location_id").notNull(),
periodIndex: integer("period_index").notNull(),
/** Tutorial boards only: runs completed when generated (0 for bay boards). */
tutorialRun: integer("tutorial_run").notNull().default(0),
contracts: jsonb("contracts").$type<Contract[]>().notNull(),
createdAt: time("created_at").notNull(),
},
(t) => [
uniqueIndex("contract_boards_unique_idx").on(
t.playerId,
t.kind,
t.locationId,
t.periodIndex,
t.tutorialRun,
),
],
);
export const dispatches = pgTable(
"dispatches",
{
id: text("id").primaryKey(),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
/** Null for a tutorial run (the loaner is not a ship row). */
shipId: text("ship_id").references(() => ships.id, { onDelete: "set null" }),
pilotId: text("pilot_id")
.notNull()
.references(() => pilots.id, { onDelete: "cascade" }),
tutorial: boolean("tutorial").notNull(),
/** The full sim record, including a snapshot of the contract. */
record: jsonb("record").$type<DispatchRecord>().notNull(),
arrivalAt: time("arrival_at").notNull(),
status: dispatchStatus("status").notNull().default("in_flight"),
/** Applied effects, kept for the "while you were away" summary. */
resolution: jsonb("resolution").$type<Resolution>(),
resolvedAt: time("resolved_at"),
acknowledgedAt: time("acknowledged_at"),
},
(t) => [
index("dispatches_due_idx").on(t.status, t.arrivalAt),
index("dispatches_player_status_idx").on(t.playerId, t.status),
],
);
export const acceptedContracts = pgTable(
"accepted_contracts",
{
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
contractId: text("contract_id").notNull(),
periodIndex: integer("period_index").notNull(),
dispatchId: text("dispatch_id")
.notNull()
.references(() => dispatches.id, { onDelete: "cascade" }),
acceptedAt: time("accepted_at").notNull(),
},
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
);
/** Onboarding goals whose reward has been claimed (decision 0.5.34). */
export const claimedGoals = pgTable(
"claimed_goals",
{
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
goalId: text("goal_id").notNull(),
claimedAt: time("claimed_at").notNull(),
},
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
);