Five targeted leases over two planes, the item grant, the world save, and the atlas work the spawner dropdown needed. FIVE LEASES, ONE FACTORY `uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and `uo.seasonal.status`. The four callables differ only in which key they name, so they are built rather than repeated: five copies would be five chances for one of them to forget the drift check, which is the one thing §F says a lease must not be allowed to skip. It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot express five seconds cannot express this shard's own data. The seasonal lease is a THREE-value enum over EIGHT events. §G called `GetEntry(type).Status` "a nine-value enum" and had it backwards: `EventStatus` has three values and it is `EventType` that has nine entries. Eight rather than nine because `TreasuresOfTokuno` is excluded -- `IsActive()` reads its own `DropEra` rather than `Status`, so leasing it would apply cleanly, read back, restore cleanly and do nothing at all. Two behaviours worth the review. `inForce()` reads the frame's `holds` rather than a row's `held` flag, because a catalog walk can enumerate the keys but never the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL restore: there is nothing to give back, and reporting it failed would leave a ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the lease plane's vocabulary. THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST Core has the participants in `event_run_participants`, but a module cannot read core's tables -- so the alternative was a new core surface handing them over. Not needed: the shard has held the run's ledger since it opened, keyed by the same serials core stores as `member_key`. And the grant is RETRYABLE. §G called it un-retryable because a lost acknowledgement and a grant that never applied were the same event, which is exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than grants: 500 gold to forty people and a candle to forty people are not the same imposition. THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES The parser has read `<UniqueId>` and thrown it away since the atlas shipped, on a line citing a committed artifact -- there is no committed artifact, as `spawnAtlasSource.js` says in its own header. It is the ONLY name for one particular spawner that exists off the shard, so a property lease could not have had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read. `uo.options.spawners` is the first searchable source and the first that had to be: 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list would drop two thirds of the world and say nothing about which two thirds. ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from Phase 11a onward every sidecar call on a real deployment would have been refused -- the whole event plane dead, loudly, for a reason nobody would look here for. 11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two places this repo declares it. It survived both because both live walks set the column by hand while standing the rig up, which is exactly what makes a migration nobody runs invisible. All three sites go to 7. The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER -- a real check they once failed -- but all three being equally stale passes it, and nothing in this repo can anchor it to the wire. Recorded in the model's own header so the next reader knows. CHECKS `npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals` clean; the client builds and its 42 tests pass. `check:swagger` reports the fragment stale -- it is ALREADY stale on `edge` (verified by stashing this branch's changes and re-running) and this phase adds no route, so it is left alone rather than regenerated inside an unrelated change. Two bugs the new tests caught in this branch's own code before it left: `counted()` returns `.count` and the grant read `.value`, so every grant went out with `amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s `ok` was ignored, so a bad hue passed silently instead of refusing. Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
840 lines
49 KiB
SQL
840 lines
49 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 7,
|
|
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;
|
|
|
|
-- 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');
|