Core seeded `game_account_signup` and `uo_link_protocol_3_migrated`, two keys that name a game concept. That made core's schema declare a module's settings, which is the structural half of what Phase 3 removes (MODULE_SYSTEM.md §2.7.1, slice 4). Both INSERTs move here. The keys are deliberately unchanged: they are live rows on every existing install and renaming one silently resets an operator's choice to the default. The marker is not just a tidy-up. It and the `UPDATE uo_link_config SET protocol = 3` it makes one-shot were adjacent in core's schema.sql until slice 1 moved the UPDATE here and left the INSERT behind — and the two files do not run together: core's schema is replayed in full before any module fragment. So the marker existed before the UPDATE ever read it, the NOT EXISTS guard was false on every boot of an upgraded install, and the migration could never fire. An install carrying a protocol-2 row would have stayed pinned at 2 against a v3 sidecar, 409ing every REST call — the exact failure the migration prevents. Latent rather than live: it bites only an install that first boots a post-slice-1 build while already holding a uo_link_config row, and `edge` has not cut over. `schemaFragment.test.js` asserts the order, plus the fragment rules core validates at load time (leading-verb allowlist, IF NOT EXISTS, grandfathered table prefixes) — restated here for the same reason manifest.test.js restates the manifest rules. Its statement splitter is a character walk, because a comment in this file contains quotes. Co-Authored-By: Claude <noreply@anthropic.com>
644 lines
37 KiB
SQL
644 lines
37 KiB
SQL
-- ── module-uo's schema fragment ───────────────────────────────────────────
|
|
--
|
|
-- Replayed by core on EVERY boot, after core's own schema.sql and before
|
|
-- seedDefaults (MODULE_API.md §2.6). Everything here is therefore idempotent:
|
|
-- every CREATE TABLE carries IF NOT EXISTS and every ALTER carries
|
|
-- IF NOT EXISTS, because a statement that succeeds once and fails afterwards
|
|
-- presents as a module that worked until the first restart.
|
|
--
|
|
-- Core validates this file at LOAD time, before anything mounts — statement by
|
|
-- statement, split by the same code that splits core's schema. The rules it
|
|
-- enforces and the reason each exists:
|
|
--
|
|
-- • Leading verbs are an allowlist: CREATE, ALTER, INSERT, UPDATE. Not a
|
|
-- DROP denylist — this file replays every boot, so TRUNCATE or DELETE would
|
|
-- empty a table on each restart.
|
|
-- • Every table is prefixed. `shard_*` and `uo_link_*` are grandfathered to
|
|
-- this module by name (loader.js LEGACY_TABLE_PREFIXES): they predate the
|
|
-- module system by two years, they hold live data, and renaming them would
|
|
-- be a migration this workstream deliberately does not do. Every module
|
|
-- written after this one prefixes with its own id.
|
|
-- • No table core declares may appear here, and no table another module has
|
|
-- claimed.
|
|
--
|
|
-- Two tables carry a foreign key INTO core (`users`), which is allowed and is
|
|
-- why the replay order matters: core's schema is already in place when this
|
|
-- runs, so `users` exists. The reverse — a core table referencing one of these
|
|
-- — does not occur and must not: it would make core's schema depend on a module
|
|
-- being installed.
|
|
--
|
|
-- Teardown is `purge.sql`, which is never run by a boot. See it for the drop
|
|
-- order, which is the reverse of the dependency order here.
|
|
|
|
|
|
-- ── uo-link sidecar ────────────────────────────────────────────────────────
|
|
-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the
|
|
-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the
|
|
-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never
|
|
-- plaintext, and it is only decrypted server-side (to call the sidecar). It is
|
|
-- never returned to the admin UI — responses expose only `hasToken`. base_url is
|
|
-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because
|
|
-- in production the sidecar runs on a different host from the website. `status`/
|
|
-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known
|
|
-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId
|
|
-- so a shard restart can be detected (and caches dropped).
|
|
CREATE TABLE IF NOT EXISTS uo_link_config (
|
|
id INT PRIMARY KEY DEFAULT 1,
|
|
base_url VARCHAR(255) NULL,
|
|
ws_url VARCHAR(255) NULL,
|
|
auth_token_enc TEXT NULL,
|
|
protocol INT NOT NULL DEFAULT 3,
|
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
|
status_detail VARCHAR(500) NULL,
|
|
plugin_connected TINYINT(1) NOT NULL DEFAULT 0,
|
|
last_event_at DATETIME NULL,
|
|
boot_id VARCHAR(64) NULL,
|
|
updated_by INT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Append-only log of notable shard events ingested from the uo-link WebSocket
|
|
-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS
|
|
-- client writes here, and the public/admin read endpoints + live feeds read from
|
|
-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed,
|
|
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
|
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
|
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
|
-- dedupe_key = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars
|
|
-- (fits CHAR(40)); with the UNIQUE index it makes INSERT IGNORE idempotent so
|
|
-- WS-reconnect backfill never double-inserts.
|
|
CREATE TABLE IF NOT EXISTS shard_events (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
kind VARCHAR(48) NOT NULL,
|
|
t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar)
|
|
boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId)
|
|
payload JSON NOT NULL, -- the full event object
|
|
dedupe_key CHAR(40) NOT NULL UNIQUE,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_events_kind_t (kind, t),
|
|
INDEX idx_shard_events_t (t)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Current online players. Upserted on mob.login, refreshed on char.vitals, and
|
|
-- removed on mob.logout. Cleared wholesale when the shard restarts (a new
|
|
-- server.hello.bootId). web_id is the linked website user id (present when the
|
|
-- account is linked), so the roster can be correlated to site accounts.
|
|
CREATE TABLE IF NOT EXISTS shard_online (
|
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key)
|
|
name VARCHAR(120) NULL,
|
|
acct VARCHAR(120) NULL,
|
|
web_id INT NULL,
|
|
map VARCHAR(40) NULL,
|
|
x INT NULL,
|
|
y INT NULL,
|
|
z INT NULL,
|
|
hits INT NULL,
|
|
hits_max INT NULL,
|
|
mana INT NULL,
|
|
mana_max INT NULL,
|
|
stam INT NULL,
|
|
stam_max INT NULL,
|
|
str INT NULL,
|
|
dex INT NULL,
|
|
`int` INT NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_online_acct (acct)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Total-gold-supply time series (from the periodic economy.supply event). Kept
|
|
-- append-only so the public status page can render a supply-over-time sparkline.
|
|
CREATE TABLE IF NOT EXISTS shard_economy (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
accounts INT NULL, -- number of accounts included in the total
|
|
gold BIGINT NULL, -- total gold supply across all accounts
|
|
t BIGINT NOT NULL, -- sample time, epoch ms
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_economy_t (t)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Current decay stage per house, upserted on house.decay. is_idoc is a derived
|
|
-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap
|
|
-- indexed lookup rather than a scan.
|
|
CREATE TABLE IF NOT EXISTS shard_houses (
|
|
serial VARCHAR(20) NOT NULL PRIMARY KEY,
|
|
stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ...
|
|
map VARCHAR(40) NULL,
|
|
x INT NULL,
|
|
y INT NULL,
|
|
z INT NULL,
|
|
region VARCHAR(120) NULL,
|
|
name VARCHAR(160) NULL,
|
|
owner_serial VARCHAR(20) NULL,
|
|
owner_acct VARCHAR(120) NULL,
|
|
built_on DATETIME NULL,
|
|
last_refreshed DATETIME NULL,
|
|
is_idoc TINYINT(1) NOT NULL DEFAULT 0,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_houses_idoc (is_idoc)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
-- a single user may link several game accounts.
|
|
CREATE TABLE IF NOT EXISTS shard_account_links (
|
|
account VARCHAR(120) NOT NULL PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
char_name VARCHAR(120) NULL,
|
|
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_shard_links_user (user_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Current champion-spawn board, upserted on champ.update and removed on
|
|
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
|
|
-- the public Champions page (and its live deltas) survive a shard outage, the
|
|
-- same way shard_online / shard_houses do. Three families share one table, told
|
|
-- apart by `category` (champion | mini | sea); category-specific fields (level,
|
|
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
|
|
-- not have to model every variant.
|
|
CREATE TABLE IF NOT EXISTS shard_champs (
|
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
|
|
category VARCHAR(16) NULL, -- champion | mini | sea
|
|
type VARCHAR(80) NULL,
|
|
name VARCHAR(120) NULL,
|
|
status VARCHAR(16) NULL, -- active | cooldown | dormant
|
|
active TINYINT(1) NOT NULL DEFAULT 0,
|
|
map VARCHAR(40) NULL,
|
|
x INT NULL,
|
|
y INT NULL,
|
|
z INT NULL,
|
|
boss_up TINYINT(1) NOT NULL DEFAULT 0,
|
|
payload JSON NOT NULL, -- the full champ.update object
|
|
t BIGINT NULL, -- event time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_champs_category (category)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
|
|
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
|
|
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
|
|
-- player). Staff-only data — served on the admin channel, never public.
|
|
CREATE TABLE IF NOT EXISTS shard_pages (
|
|
page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
|
|
type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
|
|
sender_name VARCHAR(120) NULL,
|
|
sender_acct VARCHAR(120) NULL,
|
|
web_id INT NULL, -- linked website user id, if any
|
|
message TEXT NULL,
|
|
map VARCHAR(40) NULL,
|
|
x INT NULL,
|
|
y INT NULL,
|
|
z INT NULL,
|
|
sent_ms BIGINT NULL, -- when the page was opened, epoch ms
|
|
handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
|
|
handler VARCHAR(120) NULL,
|
|
payload JSON NOT NULL, -- the full page.new/updated object
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_pages_handled (handled)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
|
|
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
|
|
-- actor object flattened into leader_* columns; the full event is kept in
|
|
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
|
|
-- projection into our store so the public Guilds page survives a shard outage.
|
|
CREATE TABLE IF NOT EXISTS shard_guilds (
|
|
id INT NOT NULL PRIMARY KEY, -- in-game guild id
|
|
name VARCHAR(120) NULL,
|
|
abbr VARCHAR(24) NULL,
|
|
members INT NULL,
|
|
online INT NULL,
|
|
alliance VARCHAR(120) NULL,
|
|
leader_serial VARCHAR(20) NULL,
|
|
leader_name VARCHAR(120) NULL,
|
|
leader_acct VARCHAR(120) NULL,
|
|
leader_web_id INT NULL,
|
|
payload JSON NOT NULL, -- the full guild.update object
|
|
t BIGINT NULL, -- event time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_guilds_name (name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
|
|
-- city.update (full-state, emitted only on change; there is no remove event since
|
|
-- the set of cities is fixed). governor / governorElect are actor objects
|
|
-- flattened into columns; the full event is kept in `payload`. Empty on shards
|
|
-- that do not run the City Loyalty system.
|
|
CREATE TABLE IF NOT EXISTS shard_governors (
|
|
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
|
|
governor_serial VARCHAR(20) NULL,
|
|
governor_name VARCHAR(120) NULL,
|
|
governor_acct VARCHAR(120) NULL,
|
|
governor_web_id INT NULL,
|
|
elect_serial VARCHAR(20) NULL,
|
|
elect_name VARCHAR(120) NULL,
|
|
elect_acct VARCHAR(120) NULL,
|
|
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
|
|
candidates INT NULL,
|
|
auto_pick_at DATETIME NULL,
|
|
payload JSON NOT NULL, -- the full city.update object
|
|
t BIGINT NULL, -- event time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Governor term history — the "who governed when" ledger behind the Governors
|
|
-- board. Captured from day one (history cannot be backfilled) on every observed
|
|
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
|
|
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
|
|
-- COUNT and election phase, not per-candidate tallies, so we record who governed
|
|
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
|
|
-- were all the governors of Britain?") reads this table.
|
|
CREATE TABLE IF NOT EXISTS shard_governor_terms (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
city VARCHAR(40) NOT NULL,
|
|
governor_serial VARCHAR(20) NULL,
|
|
governor_name VARCHAR(120) NULL,
|
|
governor_acct VARCHAR(120) NULL,
|
|
governor_web_id INT NULL,
|
|
started_at BIGINT NOT NULL, -- term start, epoch ms
|
|
ended_at BIGINT NULL, -- term end epoch ms (NULL = current)
|
|
votes INT NULL, -- not in the feed (reserved)
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_gov_terms_city (city, started_at),
|
|
INDEX idx_shard_gov_terms_open (city, ended_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
|
|
-- latest presence.online aggregate: total count plus per-facet and per-region
|
|
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
|
|
-- is the rolled-up headcount the public "Players Online" widget renders. The
|
|
-- time series, if ever needed, is available from GET /history?kind=presence.online.
|
|
CREATE TABLE IF NOT EXISTS shard_presence (
|
|
id INT PRIMARY KEY DEFAULT 1,
|
|
count INT NOT NULL DEFAULT 0,
|
|
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
|
|
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
|
|
t BIGINT NULL, -- snapshot time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- The shard's published ruleset (Protocol 3.0 world.ruleset). Singleton row
|
|
-- (id = 1) holding the latest frame: expansion, which optional systems are on,
|
|
-- skill/stat caps, account and house limits, champion scroll rules, the
|
|
-- save/restart schedule. The shard re-emits it on every sidecar connect, so this
|
|
-- row is simply overwritten; `rev` is the shard's own FNV-1a of the body, which
|
|
-- distinguishes "same ruleset, re-sent on reconnect" from "an operator changed a
|
|
-- .cfg". No row at all means the shard has never published one — served as null,
|
|
-- which the rules page renders differently from a published ruleset.
|
|
CREATE TABLE IF NOT EXISTS shard_ruleset (
|
|
id INT PRIMARY KEY DEFAULT 1,
|
|
rev VARCHAR(32) NULL,
|
|
expansion VARCHAR(16) NULL, -- hoisted for cheap display
|
|
payload JSON NOT NULL, -- the whole world.ruleset frame
|
|
t BIGINT NULL, -- frame time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
|
|
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
|
|
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
|
|
-- players accumulate over months.
|
|
--
|
|
-- The top-N list stays inside `payload` rather than being normalized into a
|
|
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
|
|
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
|
|
-- buy nothing until something needs a per-character reverse lookup, and a
|
|
-- character's own standings already ride inside char.profile instead.
|
|
--
|
|
-- No delete path: the shard's set of systems is fixed at startup, so there is no
|
|
-- points.remove to mirror.
|
|
CREATE TABLE IF NOT EXISTS shard_points_boards (
|
|
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
|
|
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
|
|
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
|
|
max_points BIGINT NULL,
|
|
players INT NULL, -- players actually holding points in this system
|
|
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
|
|
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
|
|
t BIGINT NULL, -- frame time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
|
|
-- vendor and one per priced listing, so the site can offer the search the in-game
|
|
-- Vendor Search gump offers — from outside the game.
|
|
--
|
|
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
|
|
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
|
|
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
|
|
-- keeping with every other shard_* table: the ingest transaction is what keeps
|
|
-- them consistent, and an FK would turn a malformed frame into a failed write
|
|
-- rather than a dropped row.
|
|
--
|
|
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
|
|
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
|
|
CREATE TABLE IF NOT EXISTS shard_vendors (
|
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
|
|
shop_name VARCHAR(160) NULL,
|
|
owner_serial VARCHAR(20) NULL,
|
|
owner_name VARCHAR(64) NULL,
|
|
map VARCHAR(40) NULL,
|
|
x INT NULL,
|
|
y INT NULL,
|
|
z INT NULL,
|
|
region VARCHAR(80) NULL,
|
|
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
|
|
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
|
|
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
|
|
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
|
|
t BIGINT NULL, -- frame time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_vendors_owner (owner_name),
|
|
INDEX idx_shard_vendors_map (map),
|
|
INDEX idx_shard_vendors_region (region),
|
|
-- The market page's staleness banner is MIN(updated_at) over this column: the
|
|
-- round-robin sweep means the oldest row is how far behind the index can be.
|
|
INDEX idx_shard_vendors_updated (updated_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
|
|
-- whole — these are the searchable rows the whole feature exists for, so they are
|
|
-- normalized rather than left inside a payload column, and there is no payload
|
|
-- column on shard_vendors at all.
|
|
--
|
|
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
|
|
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
|
|
-- at query time would make the cliloc table a join on the hot path AND make
|
|
-- search-by-name impossible. Resolving once on write buys the index. It is
|
|
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
|
|
-- re-send an unchanged shop just because the site learned what its items are
|
|
-- called.
|
|
CREATE TABLE IF NOT EXISTS shard_vendor_items (
|
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
vendor_serial VARCHAR(20) NOT NULL,
|
|
serial VARCHAR(20) NOT NULL,
|
|
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
|
|
hue INT NOT NULL DEFAULT 0,
|
|
amount INT NOT NULL DEFAULT 1,
|
|
price BIGINT NOT NULL DEFAULT 0,
|
|
name VARCHAR(160) NULL, -- the item's literal Name, null for most
|
|
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
|
|
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
|
|
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
|
|
INDEX idx_shard_vendor_items_vendor (vendor_serial),
|
|
INDEX idx_shard_vendor_items_price (price),
|
|
INDEX idx_shard_vendor_items_item (item_id),
|
|
INDEX idx_shard_vendor_items_name (display_name),
|
|
-- Search filters on name and sorts on price; the composite covers the common
|
|
-- "cheapest matching X" without a filesort over the whole table.
|
|
INDEX idx_shard_vendor_items_name_price (display_name, price)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
|
-- per feature; an absent row means "use the compiled default", and the compiled
|
|
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
|
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
|
|
-- docs/link/v3.md §3 for the contract.
|
|
--
|
|
-- audience the minimum rung on anonymous < logged_in < player < staff < admin
|
|
-- stream whether this feature's kinds fan out over SSE at all (the market
|
|
-- index ships with this off: no page needs a live firehose of
|
|
-- whole vendor inventories)
|
|
-- field_rules {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
|
|
-- `webId` are admin-only always and are rejected here — they are
|
|
-- not in-game visible and are deliberately not configurable.
|
|
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
|
|
feature VARCHAR(48) NOT NULL PRIMARY KEY,
|
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
|
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
|
|
stream TINYINT(1) NOT NULL DEFAULT 1,
|
|
field_rules JSON NULL,
|
|
updated_by INT NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
|
-- live" both answer without touching shard_spawn_points.
|
|
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
|
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
|
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
|
total INT NOT NULL DEFAULT 0,
|
|
points INT NOT NULL DEFAULT 0,
|
|
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
|
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
|
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
|
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
|
-- art when this is NULL, which is the normal case.
|
|
art VARCHAR(255) NULL,
|
|
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
|
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
|
INDEX idx_shard_spawn_creatures_name (name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
|
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
|
-- the resolved display string (region, else landmark, else 'Wilderness').
|
|
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
facet VARCHAR(40) NOT NULL,
|
|
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
|
x INT NOT NULL,
|
|
y INT NOT NULL,
|
|
width INT NOT NULL DEFAULT 0,
|
|
height INT NOT NULL DEFAULT 0,
|
|
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
|
max_count INT NOT NULL DEFAULT 0,
|
|
min_delay INT NOT NULL DEFAULT 0,
|
|
max_delay INT NOT NULL DEFAULT 0,
|
|
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
|
tod_end INT NOT NULL DEFAULT 0,
|
|
tod_mode INT NOT NULL DEFAULT 0,
|
|
region VARCHAR(120) NULL,
|
|
landmark VARCHAR(120) NULL,
|
|
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
|
INDEX idx_shard_spawn_points_facet (facet),
|
|
INDEX idx_shard_spawn_points_label (label)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- The many-to-many between the two above: one spawner commonly carries several
|
|
-- types (a single Trammel point spawns six), each with its own max. This is how
|
|
-- /atlas/creatures/:slug finds the places a creature appears.
|
|
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
|
point_id INT NOT NULL,
|
|
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
|
max_count INT NOT NULL DEFAULT 1,
|
|
PRIMARY KEY (point_id, slug),
|
|
INDEX idx_shard_spawn_point_types_slug (slug)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
|
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
|
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
|
CREATE TABLE IF NOT EXISTS shard_regions (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
facet VARCHAR(40) NOT NULL,
|
|
name VARCHAR(120) NOT NULL,
|
|
type VARCHAR(80) NULL, -- ServUO region class
|
|
priority INT NOT NULL DEFAULT 0,
|
|
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
|
rects JSON NULL,
|
|
INDEX idx_shard_regions_facet (facet),
|
|
INDEX idx_shard_regions_name (name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
|
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
|
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
|
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
facet VARCHAR(40) NOT NULL,
|
|
name VARCHAR(120) NOT NULL,
|
|
grp VARCHAR(120) NULL,
|
|
x INT NOT NULL,
|
|
y INT NOT NULL,
|
|
z INT NOT NULL DEFAULT 0,
|
|
INDEX idx_shard_landmarks_facet (facet),
|
|
INDEX idx_shard_landmarks_name (name)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
|
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
|
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
|
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
|
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
|
name VARCHAR(120) NOT NULL,
|
|
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
|
type VARCHAR(80) NULL, -- '' when randomised per activation
|
|
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
|
facet VARCHAR(40) NOT NULL,
|
|
x INT NOT NULL,
|
|
y INT NOT NULL,
|
|
z INT NOT NULL DEFAULT 0,
|
|
radius INT NOT NULL DEFAULT 0,
|
|
label VARCHAR(120) NULL, -- resolved place name
|
|
INDEX idx_shard_champion_spawns_facet (facet)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- UO's localization table: cliloc id -> display string. Items carry a
|
|
-- `LabelNumber` rather than a name, so without this the site can only render
|
|
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
|
|
-- the id (char.profile's `cliloc`, and one per marketplace listing) — the number
|
|
-- was never the missing piece, the table was.
|
|
--
|
|
-- Sourced from a file the OPERATOR converts once from their own UO client and
|
|
-- points the site at (docs/website/CLILOCS.md); nothing derived from the client
|
|
-- is committed, the same rule the spawn atlas and the creature art map follow.
|
|
-- A shard with no cliloc file configured simply renders item ids, which is what
|
|
-- it did before this table existed.
|
|
--
|
|
-- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long
|
|
-- property descriptions, and truncating them silently would be worse than
|
|
-- storing them. Item NAMES are all short — the index that matters for search is
|
|
-- on the denormalized `shard_vendor_items.display_name`, not here.
|
|
CREATE TABLE IF NOT EXISTS shard_clilocs (
|
|
number INT NOT NULL PRIMARY KEY,
|
|
flag SMALLINT NOT NULL DEFAULT 0,
|
|
text TEXT NOT NULL
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Singleton (id = 1) describing the cliloc table currently loaded: the source
|
|
-- file, its sha256, the entry count and the parser version. The boot path
|
|
-- compares the stored hash against the file on disk and skips the parse when
|
|
-- they match, which is every restart that did not follow a client patch.
|
|
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
|
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
|
payload JSON NOT NULL,
|
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
|
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
|
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
|
-- is behind the committed artifact.
|
|
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
|
payload JSON NOT NULL,
|
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
|
-- NOT applied, because it would remove a facet the site currently serves.
|
|
--
|
|
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
|
-- much as of a real map change, and boot cannot tell the two apart — so the
|
|
-- refresh is staged here for a human instead of being applied. Startup is never
|
|
-- blocked by it: the site comes up serving the atlas it already had.
|
|
--
|
|
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
|
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
|
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
|
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
|
--
|
|
-- `rejected` is remembered against those exact source hashes so a declined
|
|
-- refresh does not re-prompt on every restart; changing the tree changes the
|
|
-- hashes and asks again.
|
|
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
|
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
|
payload JSON NOT NULL, -- source hashes + facet diff
|
|
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
|
|
-- fields than the house.decay transition feed shard_houses was built for. Rather
|
|
-- than a second table for one entity, extend shard_houses: house.update writes the
|
|
-- registry columns below (owner display name, co-owner/friend counts, placement
|
|
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
|
|
-- upsert only touches its own columns, so the two feeds never clobber each other.
|
|
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
|
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
|
|
-- so the public Houses browser can list registered houses without pulling in rows
|
|
-- we only ever saw an IDOC transition for.
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
|
|
|
-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
|
|
-- points.board, vendor.listing), so the pinned version an existing install
|
|
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
|
|
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
|
|
-- created before the bump (idempotent, like the other MODIFYs here).
|
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
|
|
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
|
|
-- must be one-shot: an operator who deliberately pins an older sidecar in
|
|
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
|
|
-- it fire once — written after the UPDATE, and on a fresh install (no
|
|
-- uo_link_config row yet) it is simply written with nothing to update.
|
|
UPDATE uo_link_config SET protocol = 3
|
|
WHERE id = 1 AND protocol < 3
|
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
|
|
-- **The marker must be written HERE, not in core.** These two statements were
|
|
-- adjacent in core's schema.sql before the extraction; slice 1 moved the UPDATE
|
|
-- and left the INSERT behind, and the two files do not run at the same time —
|
|
-- core's schema is replayed in full BEFORE any module fragment (MODULE_API.md
|
|
-- §2.6). So the marker existed before the UPDATE ever read it, the NOT EXISTS
|
|
-- was true on the first boot of a fresh install and false on every boot of an
|
|
-- upgraded one, and the one-shot could never fire. An install carrying a
|
|
-- protocol-2 row would have stayed pinned at 2 against a v3 sidecar — every
|
|
-- REST call 409, which is precisely the failure this migration exists to
|
|
-- prevent. Latent rather than live: it only bites an install that first boots a
|
|
-- post-slice-1 build while already holding a uo_link_config row, and `edge` has
|
|
-- not cut over yet.
|
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
|
|
|
-- ── Settings rows this module owns ─────────────────────────────────────────
|
|
--
|
|
-- Both keys predate the module system and both name a game concept, so core
|
|
-- seeding them made core's schema declare a module's settings — the structural
|
|
-- half of what Phase 3 removes (MODULE_SYSTEM.md §2.7.1, slice 4). The KEYS are
|
|
-- deliberately unchanged: they are live rows on every existing install, and
|
|
-- renaming one would silently reset an operator's choice to the default.
|
|
--
|
|
-- INSERT IGNORE, so an install that already carries the row keeps its value and
|
|
-- only a database that has never seen the key gets the default. Nothing in core
|
|
-- reads either one; `game_account_signup` is read through ctx.settings by
|
|
-- server/utils/gameSignup.js, which owns the policy.
|
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled'); |