Until now the only way a creature got a picture on this site was for an
operator to open UOFiddler on a desktop, export sprites by hand, copy them to
the web host and write a spawnAtlas.art.json naming each one. Almost nobody
did, so shard_spawn_creatures.art was NULL on every install.
The shard has had those files the whole time. Admin -> Shard -> Import now
walks its asset manifest, fetches only the sprites whose hash changed, writes
them under uploads/atlas/, asks the shard for a body id per atlas creature
(§8: it CONSTRUCTS the creature and reads Body.BodyID, which is the only thing
that is right for a shard's own custom creatures) and points each creature at
its picture. On a stock client that is 787 portraits, about a megabyte.
**The one thing v8.md §12 got wrong, and it is not cosmetic.** It says
`shard_spawn_creatures.art` "starts being filled by the import". That table is
emptied and refilled by replaceAtlas on EVERY atlas refresh, and a refresh runs
on every boot -- so a filename stored there would be destroyed by an ordinary
re-parse of the ServUO tree, with the next Update finding the client files
unchanged, reporting "nothing to do", and never restoring it. Nothing would
report a fault; the pictures would just be gone.
So the assets and the body map live in their own tables outside that blast
radius, and applyAtlas re-derives `art` on the way past as
`{ ...derived, ...operatorMap }` -- which is also the one place "the operator's
own artwork wins" is enforced, on every rebuild rather than only at import.
Smaller decisions worth not rediscovering:
- The derivation joins on the catalogue KEY, not on the body id. The simpler
join is correct today and stops being correct the moment phase 6 adds
body/400/a2/f0, at which point one slug matches dozens of rows.
- Filenames are content-addressed. A stable name overwritten in place leaves
every browser and CDN serving the previous client's sprite, with the database
row perfectly correct.
- An unchanged key whose FILE is missing is fetched again. The row and the disk
can disagree (a wiped uploads volume, a restore from a dump), and a broken
image on a creature page is worse than one re-fetched sprite.
- A key the shard cannot render is not a failure. Two thirds of the playable
ghost and gargoyle bodies have no art on a stock client, and an import that
reported eight failures every time would teach an operator to ignore the panel.
- A key that VANISHED from the manifest needs review before anything changes:
an unmounted client volume and a deliberate downgrade look identical here.
23 new tests; 674 server and 42 client tests pass. The SQL was also run against
a real MariaDB, which is what proved the CONCAT join and the singleton CHECK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
927 lines
54 KiB
SQL
927 lines
54 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 8,
|
|
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;
|
|
|
|
-- Guild membership (Protocol 4). One row per member per guild, replaced on
|
|
-- guild.roster and thinned by guild.leave. Protocol 2 could only say HOW MANY
|
|
-- members a guild had, so this table has no pre-4 equivalent and the Guilds page
|
|
-- could show a count but never a roster.
|
|
--
|
|
-- `acct` / `web_id` are the site-identity fields and are stored because the
|
|
-- sidecar forwards them; they are NOT public. shardVisibility locks any key that
|
|
-- is or ends in acct/webId to `admin` and recurses into arrays, so a projected
|
|
-- roster loses them below that rung — storing them here is what lets a linked
|
|
-- member be matched to a site user at all.
|
|
--
|
|
-- A roster over the shard's per-frame cap arrives in several frames, so rows are
|
|
-- keyed on (guild_id, serial) and the frame carrying seq 0 clears the guild first;
|
|
-- see upsertGuildRoster.
|
|
CREATE TABLE IF NOT EXISTS shard_guild_members (
|
|
guild_id INT NOT NULL,
|
|
serial VARCHAR(20) NOT NULL, -- in-game mobile serial, "0x1F5"
|
|
name VARCHAR(120) NULL,
|
|
acct VARCHAR(120) NULL, -- absent for a mobile with no account
|
|
web_id INT NULL, -- set only when the account is linked
|
|
is_player TINYINT(1) NOT NULL DEFAULT 1,
|
|
-- Guild rank, 0-4, with 4 being Leader (ServUO RankDefinition.Ranks). NULL means
|
|
-- "not known", which is a real state and not a demotion: the shard omits the rank
|
|
-- for a staff account, because PlayerMobile.GuildRank reports Leader for anyone at
|
|
-- GameMaster or above whatever their actual rank, and publishing that would put a
|
|
-- staff member on a public roster as a guild leader.
|
|
-- Backticked, like `int` on shard_online: RANK is a reserved word in MySQL 8 and
|
|
-- a non-reserved keyword in MariaDB, so it parses here bare but must not be
|
|
-- written that way anywhere it might not.
|
|
`rank` TINYINT NULL,
|
|
-- The rank's NAME, as the game states it: a cliloc id for the five standard ranks
|
|
-- (1062959-1062963, which ship with no text), or a literal string when a shard has
|
|
-- replaced the rank table with custom definitions. Resolving one to a label is this
|
|
-- module's job -- it owns the cliloc table and the game vocabulary.
|
|
rank_cliloc INT NULL,
|
|
rank_name VARCHAR(64) NULL,
|
|
t BIGINT NULL, -- roster event time, epoch ms
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (guild_id, serial),
|
|
INDEX idx_shard_guild_members_acct (acct),
|
|
INDEX idx_shard_guild_members_web (web_id),
|
|
-- Leadership is "rank >= 4", asked per guild, which is the query the Team provider
|
|
-- runs on every reconcile.
|
|
INDEX idx_shard_guild_members_rank (guild_id, rank)
|
|
) 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
|
|
-- `XmlSpawner.UniqueId` (Phase 12b): the only name for one particular spawner
|
|
-- that exists OFF the shard. A property lease is targeted by it, because a
|
|
-- serial is assigned when the world is built and nothing here could know one --
|
|
-- so without this column the lease's target field could have no dropdown at
|
|
-- all. NULLable: a shard's own spawners, added in-world rather than from the
|
|
-- spawn files, carry none, and they are addressed by serial instead.
|
|
unique_id VARCHAR(64) NULL,
|
|
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),
|
|
-- The spawner target's dropdown searches by name, and 6,707 rows is more than
|
|
-- a dropdown holds, so the search is the read rather than a filter over one.
|
|
INDEX idx_shard_spawn_points_name (name)
|
|
) 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;
|
|
|
|
-- Item types this shard uses as decoration, from Data/Decoration/**/*.cfg.
|
|
--
|
|
-- Import-owned like every other shard_* atlas table. It exists so the events
|
|
-- decoration verb can offer an author a dropdown of what THIS shard already
|
|
-- calls scenery, rather than a list of item types curated by us: a shard with
|
|
-- custom decoration gets its own, and the list resolves with the shard offline
|
|
-- because it came out of the tree at import time.
|
|
--
|
|
-- `item_id` is a preview, not an identity. A type appears under as many item
|
|
-- ids as it has facings or variants (a BarredMetalDoor under eight), and the
|
|
-- first one seen is kept; the plugin constructs from the TYPE NAME and picks
|
|
-- its own graphic. `uses` is how many times the shard's own decoration reaches
|
|
-- for the type, which is the only ordering signal available that means anything.
|
|
CREATE TABLE IF NOT EXISTS shard_decor_types (
|
|
type VARCHAR(120) NOT NULL PRIMARY KEY,
|
|
item_id INT NOT NULL DEFAULT 0,
|
|
uses INT NOT NULL DEFAULT 0,
|
|
INDEX idx_shard_decor_types_uses (uses)
|
|
) 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;
|
|
|
|
-- ── The Asset Bridge (docs/link/v8.md, protocol 8 phase 3) ─────────────────
|
|
--
|
|
-- One row per imported asset: the manifest side of §6, and what makes an Update
|
|
-- a diff rather than a re-download. `sha256` is of the PNG the shard produced, so
|
|
-- a re-import fetches only the keys whose hash moved.
|
|
--
|
|
-- **`file` is a filename under the uploads directory, never a path.** Images are
|
|
-- written through `ctx.uploads`, the same door the operator's own atlas art comes
|
|
-- in by, and storing a path here would let a row decide where the server reads
|
|
-- from.
|
|
--
|
|
-- `bytes`/`width`/`height` are carried from the manifest rather than re-derived,
|
|
-- because the manifest reports them before the pixels are fetched and a screen
|
|
-- that lists what WOULD be imported needs them then.
|
|
CREATE TABLE IF NOT EXISTS shard_assets (
|
|
asset_key VARCHAR(191) NOT NULL PRIMARY KEY, -- §5's key: `body/34/a0`
|
|
family VARCHAR(24) NOT NULL DEFAULT 'body',
|
|
sha256 CHAR(64) NOT NULL,
|
|
bytes INT NOT NULL DEFAULT 0,
|
|
width INT NOT NULL DEFAULT 0,
|
|
height INT NOT NULL DEFAULT 0,
|
|
body INT NULL, -- the body id, for the atlas join
|
|
direction TINYINT NULL,
|
|
file VARCHAR(191) NULL, -- filename under uploads/, NULL until fetched
|
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
-- The atlas art derivation joins creature → body → asset on every atlas refresh,
|
|
-- so the body lookup is the read that has to be fast, not the key.
|
|
INDEX idx_shard_assets_body (body)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Slug → body id, as the shard itself answered it (§8).
|
|
--
|
|
-- **Deliberately NOT a column on `shard_spawn_creatures`.** That table is
|
|
-- IMPORT-OWNED: `replaceAtlas` empties and refills it inside one transaction on
|
|
-- every atlas refresh. A body id living there would be destroyed by a routine
|
|
-- re-parse of the ServUO tree — and the next asset Update would find the source
|
|
-- hashes unchanged, report "nothing to do", and never put it back. The portrait
|
|
-- would simply vanish from every creature page until somebody thought to force a
|
|
-- re-import.
|
|
--
|
|
-- So the resolution lives here, outside the atlas's blast radius, and
|
|
-- `replaceAtlas` READS it to derive `shard_spawn_creatures.art` on the way past.
|
|
--
|
|
-- `status` is the shard's own verdict and each value is a different thing an
|
|
-- operator can act on: `ok`, `unknown` (the spawn file names a type this shard's
|
|
-- scripts do not define — real drift), `notCreature` (a spawn file legitimately
|
|
-- naming an item or decoration, a permanent answer), `failed` (its constructor
|
|
-- threw). A row is kept for every one of them, because "asked and answered no" is
|
|
-- what stops the next pass asking again.
|
|
CREATE TABLE IF NOT EXISTS shard_creature_bodies (
|
|
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- → shard_spawn_creatures.slug (no FK)
|
|
type_name VARCHAR(120) NOT NULL, -- the ServUO class name that was asked
|
|
body INT NULL, -- NULL unless status = 'ok'
|
|
status VARCHAR(16) NOT NULL DEFAULT 'ok',
|
|
resolved_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_shard_creature_bodies_body (body)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
|
|
-- Singleton (id = 1) describing the asset import currently applied: the shard's
|
|
-- catalogue id, its extractor version, the counts and when it ran. Same shape and
|
|
-- same job as `shard_cliloc_meta` — it is what an Update compares against to
|
|
-- decide there is nothing to do.
|
|
CREATE TABLE IF NOT EXISTS shard_asset_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_asset_meta_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');
|
|
|
|
-- Protocol 4 cutover: the same migration one step later, and the one this module
|
|
-- OWED and did not pay.
|
|
--
|
|
-- The protocol-4 work shipped across three repos — `link`'s PROTOCOL_VERSION, the
|
|
-- overlay's `overlay.toml`, and this module's `guild.roster` / `guild.leave` ingest —
|
|
-- but the pinned version stayed at 3 on both of its declaration sites here. A fresh
|
|
-- install therefore came up speaking 3 to a sidecar speaking 4, and a sidecar answers
|
|
-- a stale client with `409 protocol version mismatch` rather than mis-parsing it. The
|
|
-- symptom is total: every REST read fails and the WS closes on ws.hello, so a new
|
|
-- deployment shows an empty marketplace, an empty guild board and no shard status,
|
|
-- with the cause visible only in the server log. Found while standing up a demo
|
|
-- deployment for the marketing site's screenshots.
|
|
--
|
|
-- Same shape as the block above, for the same reasons: MODIFY fixes the column
|
|
-- default for databases created before the bump, and the UPDATE is one-shot against
|
|
-- its own marker so that an operator who deliberately pins an older sidecar in
|
|
-- Admin → Shard stays pinned. `protocol < 4` and not `= 3`, so an install that
|
|
-- somehow never took the protocol-3 migration is carried the whole way rather than
|
|
-- one step.
|
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 4;
|
|
UPDATE uo_link_config SET protocol = 4
|
|
WHERE id = 1 AND protocol < 4
|
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_4_migrated');
|
|
-- The marker is written HERE, in this module's fragment, for the reason spelled out
|
|
-- above: core's schema is replayed in full BEFORE any module fragment, so a marker
|
|
-- left in core would already exist when this UPDATE read it and the one-shot could
|
|
-- never fire.
|
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_4_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');
|
|
-- Protocol 4 guild rank, added to databases that already have shard_guild_members.
|
|
--
|
|
-- The table itself is new in Protocol 4 and unreleased, so no production install has
|
|
-- it — but `edge` deployments do, from the roster work that landed before the rank
|
|
-- amendment, and CREATE TABLE IF NOT EXISTS adds a table and never a column. This is
|
|
-- the same gap the sidecar's own store hit when `guilds.members` was added.
|
|
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
|
|
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
|
|
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
|
|
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);
|
|
|
|
-- ── Protocol 5 ───────────────────────────────────────────────────────────────
|
|
--
|
|
-- Three wire enrichments, bumped together (link/sidecar/src/main.rs, overlay.toml).
|
|
-- Two of them land as columns here; the third is a new event kind and needs none.
|
|
--
|
|
-- 1. house.decay's decay SCHEDULE. `shard_houses` could say what stage a house was
|
|
-- at and when it was last refreshed, but nothing about WHEN the next thing
|
|
-- happens — which is the only part a player can act on. `estimated_collapse` is
|
|
-- nullable and stays null far more often than not, deliberately: under dynamic
|
|
-- decay (Core.ML) ServUO draws each stage's duration at random when the stage is
|
|
-- entered, so collapse is exactly knowable only once the house is already at
|
|
-- IDOC. A null here means "not knowable", never "not yet read".
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS next_stage DATETIME NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS estimated_collapse DATETIME NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay_period_sec INT NULL;
|
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS dynamic_decay TINYINT(1) NULL;
|
|
|
|
-- 2. vendor.listing's owner account and fee state.
|
|
--
|
|
-- `owner_acct` is the one that matters structurally: the table has carried
|
|
-- `owner_name` since Protocol 3, but a character name is not an identity — only
|
|
-- the game ACCOUNT joins to shard_account_links, so until now a vendor row named
|
|
-- an owner the site could not resolve to a user.
|
|
--
|
|
-- The fee columns describe PlayerVendor.PayTimer's dismissal rule: at each tick
|
|
-- the charge is compared with the funds and the vendor is destroyed when the
|
|
-- charge wins. `dismissal_at` is that comparison resolved into an instant, which
|
|
-- is what any surface actually wants; the parts are kept alongside it so a
|
|
-- display can explain the number rather than only state it.
|
|
--
|
|
-- `fees_exempt` marks a commission vendor: it has no pay timer at all and is
|
|
-- never dismissed for fees, which is a different thing from having a long time
|
|
-- left and must not render as one.
|
|
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS owner_acct VARCHAR(120) NULL;
|
|
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS fees_exempt TINYINT(1) NOT NULL DEFAULT 0;
|
|
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS charge_per_period INT NULL;
|
|
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS funds INT NULL;
|
|
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS pay_interval_sec INT NULL;
|
|
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS next_pay_at DATETIME NULL;
|
|
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS periods_remaining INT NULL;
|
|
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS dismissal_at DATETIME NULL;
|
|
-- Both of these exist for the same reader: the Phase 11 trigger that has to find
|
|
-- "vendors about to be dismissed" without scanning every shop, and the owner join
|
|
-- that turns one into a person.
|
|
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_dismissal (dismissal_at);
|
|
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_owner_acct (owner_acct);
|
|
|
|
-- 3. The protocol pin, one step on from the Protocol 4 block above and for exactly
|
|
-- the reasons it spells out. `protocol < 5` rather than `= 4`, so an install that
|
|
-- missed an earlier migration is carried the whole way; the one-shot marker is
|
|
-- written here in the module's own fragment, because core's schema is replayed in
|
|
-- full BEFORE any module fragment and a marker left in core would already exist
|
|
-- when this UPDATE read it.
|
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 5;
|
|
UPDATE uo_link_config SET protocol = 5
|
|
WHERE id = 1 AND protocol < 5
|
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
|
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
|
|
|
|
-- 4. The protocol pin again, at 7 -- and this block is a FIX to already-merged
|
|
-- code rather than ordinary Phase 12b work.
|
|
--
|
|
-- Phase 11a took the wire to 6 and Phase 12a took it to 7, and neither moved
|
|
-- this. `uoLinkClient` sends `X-UOLink-Version: <this column>` on every call and
|
|
-- the sidecar answers an exact mismatch with a 409, so a deployment that installed
|
|
-- this module at any point since Phase 10 would have had EVERY sidecar call
|
|
-- refused against a protocol-7 sidecar -- the whole event plane dead, loudly but
|
|
-- for a reason nobody would look here for.
|
|
--
|
|
-- It survived two phases because both live walks set the column by hand while
|
|
-- standing the rig up, which is exactly the shape of a migration nobody runs.
|
|
-- One block carries an install the whole way rather than one per missed version:
|
|
-- `protocol < 7` is deliberate, and it is why the 4 and 5 blocks above wrote
|
|
-- `< n` rather than `= n-1`.
|
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 7;
|
|
UPDATE uo_link_config SET protocol = 7
|
|
WHERE id = 1 AND protocol < 7
|
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_7_migrated');
|
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_7_migrated', '1');
|
|
|
|
-- The protocol pin at 8 -- the Asset Bridge (docs/link/v8.md), and the first bump this
|
|
-- module takes IN the phase that consumes it rather than a phase or two later.
|
|
--
|
|
-- Phase 1 of that work moved `link`'s PROTOCOL_VERSION and the overlay's `overlay.toml`
|
|
-- together, because the installer refuses to pair a sidecar and an overlay that disagree.
|
|
-- Nothing enforces the third declaration -- this one -- and the block above is the record
|
|
-- of what that costs: two phases of every REST call answered `409 protocol version
|
|
-- mismatch`, invisible because both live walks had set the column by hand.
|
|
--
|
|
-- Phase 2 is where this module first calls a protocol-8 route (`GET /cliloc`), so it is
|
|
-- where the pin moves. Same one-shot shape and the same `protocol < 8`, so an install
|
|
-- that missed an earlier bump is carried the whole way rather than one step.
|
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 8;
|
|
UPDATE uo_link_config SET protocol = 8
|
|
WHERE id = 1 AND protocol < 8
|
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_8_migrated');
|
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_8_migrated', '1');
|