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(), /** SHA-256 of the player's backup code (decision 0.5.72); null until one is made. */ backupCodeHash: text("backup_code_hash").unique(), backupCodeCreatedAt: time("backup_code_created_at"), }); 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"), /** Handed over in the deal rather than bought from the Market (CR-013 goal 3). */ fromDeal: boolean("from_deal").notNull().default(false), 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), /** The veteran who came with the deal (decision 0.5.70); otherwise a normal hired pilot. */ isVeteran: boolean("is_veteran").notNull().default(false), xp: integer("xp").notNull().default(0), skills: jsonb("skills").$type>().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().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().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(), 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] })], ); /** * Extra sign-ins for a player (decisions 0.5.72, 0.5.73): each device that * restores with a backup code gets its own guest user, linked here. */ export const playerLogins = pgTable("player_logins", { userId: text("user_id") .primaryKey() .references(() => user.id, { onDelete: "cascade" }), playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), createdAt: time("created_at").notNull(), });