826233e10c
Relays the mod's throttled player_positions roster over a new /ws/players/:serverId gateway (Redis pub/sub + snapshot key so a tab connecting between mod flushes isn't empty), gated per-server by a new playerPositionsVisible admin toggle independent of the mod's own tracking config. Frontend renders the roster as map markers with a show/hide toggle and online count. Covered by unit tests (players.test.ts, ws-gateway.test.ts, admin.test.ts) and a new e2e spec that plays the real mod WS protocol from inside a browser context. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
175 lines
8.5 KiB
TypeScript
175 lines
8.5 KiB
TypeScript
import { pgTable, uuid, text, integer, smallint, boolean, timestamp, primaryKey, bigserial } from "drizzle-orm/pg-core";
|
|
|
|
// One row per registered MC server. Phase 1 has no admin registration API yet (Phase 6) —
|
|
// rows are created by `bun run seed` from MCMAPPER_SEED_SERVER_NAME/_TOKEN env vars.
|
|
export const servers = pgTable("servers", {
|
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
name: text("name").notNull(),
|
|
token: text("token").notNull().unique(),
|
|
authMode: text("auth_mode").notNull().default("offline"),
|
|
// Phase 3: web chat is open to anonymous (unlinked) visitors by default; an admin can require
|
|
// linking per-server. No admin UI to flip this yet (Phase 6) — set directly in the DB for now.
|
|
anonymousChatAllowed: boolean("anonymous_chat_allowed").notNull().default(true),
|
|
// Phase 4: which format `/mcmapper`-side chat waypoint links are built in when a marker is
|
|
// shared — 'journeymap' (default), 'xaero', or 'off' to disable sharing entirely. No admin UI
|
|
// to flip this yet (Phase 6) — set directly in the DB for now, same as anonymousChatAllowed.
|
|
waypointFormat: text("waypoint_format").notNull().default("journeymap"),
|
|
// Phase 7b: whether the api relays the mod's throttled player-position roster on to web
|
|
// viewers. Independent of the mod-local `playerTrackingEnabled` config (see MCMapperMod.java's
|
|
// doc comment) — this is the backend-side "should we show it" toggle, admin-configurable.
|
|
playerPositionsVisible: boolean("player_positions_visible").notNull().default(true),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// A linked player identity. `serverId` is null for online-mode accounts (uuid is a real Mojang
|
|
// UUID, safe to merge into one global account across every online-mode server this backend
|
|
// manages) and set for offline-mode accounts (uuid is just a hash of the username, only
|
|
// trustworthy scoped to the one server that issued it) — see the plan's "Identity & Linking"
|
|
// section. The actual uniqueness rules (one global account per online uuid; one account per
|
|
// (server, uuid) for offline) are partial unique indexes in the migration SQL, not expressible
|
|
// in drizzle's schema DSL in a way worth fighting for one column.
|
|
export const accounts = pgTable("accounts", {
|
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
mcUuid: text("mc_uuid").notNull(),
|
|
username: text("username").notNull(),
|
|
authMode: text("auth_mode").notNull(),
|
|
serverId: uuid("server_id").references(() => servers.id, { onDelete: "cascade" }),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// A persistent, revocable web session. The token is handed to the client in the redeem response
|
|
// body (not an httpOnly cookie) and sent back via the `X-MCMapper-Session` header — a deliberate
|
|
// Phase 3 MVP simplification (see api/src/link.ts's doc comment for the tradeoff) worth
|
|
// revisiting once there's an actually security-sensitive surface behind it (e.g. admin actions).
|
|
export const sessions = pgTable("sessions", {
|
|
token: text("token").primaryKey(),
|
|
accountId: uuid("account_id")
|
|
.notNull()
|
|
.references(() => accounts.id, { onDelete: "cascade" }),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// Chat history, both directions. `source` is 'game' (from the mod's ChatBridge.OutboundSink) or
|
|
// 'web' (from a browser's /ws/chat connection).
|
|
export const chatMessages = pgTable("chat_messages", {
|
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
serverId: uuid("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
username: text("username").notNull(),
|
|
message: text("message").notNull(),
|
|
source: text("source").notNull(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// A web-placed marker, owned by a linked account (anonymous visitors keep their marker list in
|
|
// browser localStorage only — see the plan's "Feature: web markers..." section — so there's
|
|
// nothing to persist here for them). x/y/z are all set directly by the player in the web UI's
|
|
// click-to-place/edit popup (x/z pre-filled from the click, y defaulting to 60), not derived
|
|
// server-side, and can be changed later via the same popup (see markers.ts's updateMarker).
|
|
export const markers = pgTable("markers", {
|
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
accountId: uuid("account_id")
|
|
.notNull()
|
|
.references(() => accounts.id, { onDelete: "cascade" }),
|
|
serverId: uuid("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
dimension: integer("dimension").notNull(),
|
|
x: integer("x").notNull(),
|
|
y: integer("y").notNull(),
|
|
z: integer("z").notNull(),
|
|
name: text("name").notNull(),
|
|
color: text("color").notNull(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// Column-granularity world state: the topmost non-air block per (dimension, x, z), plus its
|
|
// height. Kept deliberately separate from `chunkSections` below — cheap to write/read for 2D
|
|
// tile rendering, which never needs full voxel data.
|
|
export const chunkColumns = pgTable(
|
|
"chunk_columns",
|
|
{
|
|
serverId: uuid("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
dimension: integer("dimension").notNull(),
|
|
x: integer("x").notNull(),
|
|
z: integer("z").notNull(),
|
|
blockId: integer("block_id").notNull(),
|
|
blockMeta: integer("block_meta").notNull(),
|
|
height: smallint("height").notNull(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z] })],
|
|
);
|
|
|
|
// Full-voxel storage for one 16x16x16 section (sectionY = worldY / 16), for 3D meshing —
|
|
// additive to `chunkColumns`, not a replacement (see that table's comment). `blocks` is the
|
|
// same base64 the mod sends over the wire: 4096 little-endian u16 blockStateIds, indexed by
|
|
// `(ly*16 + lz)*16 + lx` within the section. Stored as base64 text rather than real bytea to
|
|
// avoid postgres-js/drizzle binary-column plumbing for what's still an MVP — worth revisiting
|
|
// if storage size ever matters (base64 is ~33% larger than raw bytes).
|
|
export const chunkSections = pgTable(
|
|
"chunk_sections",
|
|
{
|
|
serverId: uuid("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
dimension: integer("dimension").notNull(),
|
|
x: integer("x").notNull(),
|
|
z: integer("z").notNull(),
|
|
sectionY: integer("section_y").notNull(),
|
|
blocks: text("blocks").notNull(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z, table.sectionY] }),
|
|
],
|
|
);
|
|
|
|
// Metadata pointer to a rendered mesh buffer in MinIO, mirroring `tilePointers` but for 3D
|
|
// meshes — one row per rendered section (a chunk with N non-empty sections gets N mesh rows,
|
|
// each loaded as its own Babylon mesh; see worker/src/mesh/mod.rs for why section boundaries
|
|
// aren't merged in Phase 2).
|
|
export const meshPointers = pgTable(
|
|
"mesh_pointers",
|
|
{
|
|
serverId: uuid("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
dimension: integer("dimension").notNull(),
|
|
x: integer("x").notNull(),
|
|
z: integer("z").notNull(),
|
|
sectionY: integer("section_y").notNull(),
|
|
storageKey: text("storage_key").notNull(),
|
|
contentHash: text("content_hash").notNull(),
|
|
renderedAt: timestamp("rendered_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
primaryKey({ columns: [table.serverId, table.dimension, table.x, table.z, table.sectionY] }),
|
|
],
|
|
);
|
|
|
|
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
|
|
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
|
|
// multi-resolution tiles.
|
|
export const tilePointers = pgTable(
|
|
"tile_pointers",
|
|
{
|
|
serverId: uuid("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
dimension: integer("dimension").notNull(),
|
|
zoom: integer("zoom").notNull().default(0),
|
|
tileX: integer("tile_x").notNull(),
|
|
tileZ: integer("tile_z").notNull(),
|
|
storageKey: text("storage_key").notNull(),
|
|
contentHash: text("content_hash").notNull(),
|
|
renderedAt: timestamp("rendered_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
primaryKey({ columns: [table.serverId, table.dimension, table.zoom, table.tileX, table.tileZ] }),
|
|
],
|
|
);
|