-- ── 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 {"": ""} 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');