diff --git a/server/db/schema.sql b/server/db/schema.sql index a3e89fd..ad00b7c 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1,6 +1,13 @@ -- Runic Gateway database schema (MariaDB) -- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a -- fresh volume, and idempotently by ensureSchema() on every server boot. +-- +-- CORE ONLY. The 27 shard_* / uo_link_* tables left with module-uo in Phase 3 +-- and live in its schema fragment, which core replays immediately after this +-- file (MODULE_API.md 2.6). Two of them carry a foreign key INTO users, which +-- is why that order matters and why the reverse -- a core table referencing a +-- module table -- must never appear here: it would make core unable to boot +-- without a module installed. CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -343,395 +350,11 @@ CREATE TABLE IF NOT EXISTS email_config ( CONSTRAINT chk_email_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; --- ── uo-link sidecar ──────────────────────────────────────────────────────── --- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the --- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the --- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never --- plaintext, and it is only decrypted server-side (to call the sidecar). It is --- never returned to the admin UI — responses expose only `hasToken`. base_url is --- the REST endpoint, ws_url the live-feed endpoint; both are configurable because --- in production the sidecar runs on a different host from the website. `status`/ --- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known --- state for the admin panel between polls; `boot_id` tracks server.hello.bootId --- so a shard restart can be detected (and caches dropped). -CREATE TABLE IF NOT EXISTS uo_link_config ( - id INT PRIMARY KEY DEFAULT 1, - base_url VARCHAR(255) NULL, - ws_url VARCHAR(255) NULL, - auth_token_enc TEXT NULL, - protocol INT NOT NULL DEFAULT 3, - enabled TINYINT(1) NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'disconnected', - status_detail VARCHAR(500) NULL, - plugin_connected TINYINT(1) NOT NULL DEFAULT 0, - last_event_at DATETIME NULL, - boot_id VARCHAR(64) NULL, - updated_by INT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, - CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Append-only log of notable shard events ingested from the uo-link WebSocket --- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS --- client writes here, and the public/admin read endpoints + live feeds read from --- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed, --- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*, --- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged --- here — they update shard_online / shard_economy instead, keeping the log lean. --- dedupe_key = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars --- (fits CHAR(40)); with the UNIQUE index it makes INSERT IGNORE idempotent so --- WS-reconnect backfill never double-inserts. -CREATE TABLE IF NOT EXISTS shard_events ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - kind VARCHAR(48) NOT NULL, - t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar) - boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId) - payload JSON NOT NULL, -- the full event object - dedupe_key CHAR(40) NOT NULL UNIQUE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_shard_events_kind_t (kind, t), - INDEX idx_shard_events_t (t) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Current online players. Upserted on mob.login, refreshed on char.vitals, and --- removed on mob.logout. Cleared wholesale when the shard restarts (a new --- server.hello.bootId). web_id is the linked website user id (present when the --- account is linked), so the roster can be correlated to site accounts. -CREATE TABLE IF NOT EXISTS shard_online ( - serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key) - name VARCHAR(120) NULL, - acct VARCHAR(120) NULL, - web_id INT NULL, - map VARCHAR(40) NULL, - x INT NULL, - y INT NULL, - z INT NULL, - hits INT NULL, - hits_max INT NULL, - mana INT NULL, - mana_max INT NULL, - stam INT NULL, - stam_max INT NULL, - str INT NULL, - dex INT NULL, - `int` INT NULL, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_shard_online_acct (acct) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Total-gold-supply time series (from the periodic economy.supply event). Kept --- append-only so the public status page can render a supply-over-time sparkline. -CREATE TABLE IF NOT EXISTS shard_economy ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - accounts INT NULL, -- number of accounts included in the total - gold BIGINT NULL, -- total gold supply across all accounts - t BIGINT NOT NULL, -- sample time, epoch ms - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_shard_economy_t (t) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Current decay stage per house, upserted on house.decay. is_idoc is a derived --- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap --- indexed lookup rather than a scan. -CREATE TABLE IF NOT EXISTS shard_houses ( - serial VARCHAR(20) NOT NULL PRIMARY KEY, - stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ... - map VARCHAR(40) NULL, - x INT NULL, - y INT NULL, - z INT NULL, - region VARCHAR(120) NULL, - name VARCHAR(160) NULL, - owner_serial VARCHAR(20) NULL, - owner_acct VARCHAR(120) NULL, - built_on DATETIME NULL, - last_refreshed DATETIME NULL, - is_idoc TINYINT(1) NOT NULL DEFAULT 0, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_shard_houses_idoc (is_idoc) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -- Site-side mirror of in-game-account → website-user links. The sidecar is the -- source of truth (it tags the game account with the websiteUserId on -- /link/confirm); this table mirrors it so the player portal can list a user's -- linked accounts and enforce ownership on roster/vendor reads without a shard -- round-trip. account is unique (one game account maps to at most one site user); --- a single user may link several game accounts. -CREATE TABLE IF NOT EXISTS shard_account_links ( - account VARCHAR(120) NOT NULL PRIMARY KEY, - user_id INT NOT NULL, - char_name VARCHAR(120) NULL, - linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - INDEX idx_shard_links_user (user_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Current champion-spawn board, upserted on champ.update and removed on --- champ.remove. Mirrors the sidecar's /champs projection into our own store so --- the public Champions page (and its live deltas) survive a shard outage, the --- same way shard_online / shard_houses do. Three families share one table, told --- apart by `category` (champion | mini | sea); category-specific fields (level, --- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does --- not have to model every variant. -CREATE TABLE IF NOT EXISTS shard_champs ( - serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex) - category VARCHAR(16) NULL, -- champion | mini | sea - type VARCHAR(80) NULL, - name VARCHAR(120) NULL, - status VARCHAR(16) NULL, -- active | cooldown | dormant - active TINYINT(1) NOT NULL DEFAULT 0, - map VARCHAR(40) NULL, - x INT NULL, - y INT NULL, - z INT NULL, - boss_up TINYINT(1) NOT NULL DEFAULT 0, - payload JSON NOT NULL, -- the full champ.update object - t BIGINT NULL, -- event time, epoch ms - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_shard_champs_category (category) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Current open help-page (support ticket) queue, upserted on page.new/page.updated --- and removed on page.closed. Snapshotted authoritatively from the sidecar's --- GET /pages on every (re)connect. page_id is the sender's serial (one page per --- player). Staff-only data — served on the admin channel, never public. -CREATE TABLE IF NOT EXISTS shard_pages ( - page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player) - type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ... - sender_name VARCHAR(120) NULL, - sender_acct VARCHAR(120) NULL, - web_id INT NULL, -- linked website user id, if any - message TEXT NULL, - map VARCHAR(40) NULL, - x INT NULL, - y INT NULL, - z INT NULL, - sent_ms BIGINT NULL, -- when the page was opened, epoch ms - handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game - handler VARCHAR(120) NULL, - payload JSON NOT NULL, -- the full page.new/updated object - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_shard_pages_handled (handled) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state --- snapshot emitted only on change) and removed on guild.remove. The leader is an --- actor object flattened into leader_* columns; the full event is kept in --- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds --- projection into our store so the public Guilds page survives a shard outage. -CREATE TABLE IF NOT EXISTS shard_guilds ( - id INT NOT NULL PRIMARY KEY, -- in-game guild id - name VARCHAR(120) NULL, - abbr VARCHAR(24) NULL, - members INT NULL, - online INT NULL, - alliance VARCHAR(120) NULL, - leader_serial VARCHAR(20) NULL, - leader_name VARCHAR(120) NULL, - leader_acct VARCHAR(120) NULL, - leader_web_id INT NULL, - payload JSON NOT NULL, -- the full guild.update object - t BIGINT NULL, -- event time, epoch ms - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_shard_guilds_name (name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on --- city.update (full-state, emitted only on change; there is no remove event since --- the set of cities is fixed). governor / governorElect are actor objects --- flattened into columns; the full event is kept in `payload`. Empty on shards --- that do not run the City Loyalty system. -CREATE TABLE IF NOT EXISTS shard_governors ( - city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ... - governor_serial VARCHAR(20) NULL, - governor_name VARCHAR(120) NULL, - governor_acct VARCHAR(120) NULL, - governor_web_id INT NULL, - elect_serial VARCHAR(20) NULL, - elect_name VARCHAR(120) NULL, - elect_acct VARCHAR(120) NULL, - election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending - candidates INT NULL, - auto_pick_at DATETIME NULL, - payload JSON NOT NULL, -- the full city.update object - t BIGINT NULL, -- event time, epoch ms - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Governor term history — the "who governed when" ledger behind the Governors --- board. Captured from day one (history cannot be backfilled) on every observed --- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one --- opened. `votes` stays NULL — the city.update feed exposes only the candidate --- COUNT and election phase, not per-candidate tallies, so we record who governed --- and when (reliable) and never fabricate vote numbers. The look-back UI ("who --- were all the governors of Britain?") reads this table. -CREATE TABLE IF NOT EXISTS shard_governor_terms ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - city VARCHAR(40) NOT NULL, - governor_serial VARCHAR(20) NULL, - governor_name VARCHAR(120) NULL, - governor_acct VARCHAR(120) NULL, - governor_web_id INT NULL, - started_at BIGINT NOT NULL, -- term start, epoch ms - ended_at BIGINT NULL, -- term end epoch ms (NULL = current) - votes INT NULL, -- not in the feed (reserved) - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_shard_gov_terms_city (city, started_at), - INDEX idx_shard_gov_terms_open (city, ended_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the --- latest presence.online aggregate: total count plus per-facet and per-region --- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this --- is the rolled-up headcount the public "Players Online" widget renders. The --- time series, if ever needed, is available from GET /history?kind=presence.online. -CREATE TABLE IF NOT EXISTS shard_presence ( - id INT PRIMARY KEY DEFAULT 1, - count INT NOT NULL DEFAULT 0, - by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 } - by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 } - t BIGINT NULL, -- snapshot time, epoch ms - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT chk_shard_presence_singleton CHECK (id = 1) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- The shard's published ruleset (Protocol 3.0 world.ruleset). Singleton row --- (id = 1) holding the latest frame: expansion, which optional systems are on, --- skill/stat caps, account and house limits, champion scroll rules, the --- save/restart schedule. The shard re-emits it on every sidecar connect, so this --- row is simply overwritten; `rev` is the shard's own FNV-1a of the body, which --- distinguishes "same ruleset, re-sent on reconnect" from "an operator changed a --- .cfg". No row at all means the shard has never published one — served as null, --- which the rules page renders differently from a published ruleset. -CREATE TABLE IF NOT EXISTS shard_ruleset ( - id INT PRIMARY KEY DEFAULT 1, - rev VARCHAR(32) NULL, - expansion VARCHAR(16) NULL, -- hoisted for cheap display - payload JSON NOT NULL, -- the whole world.ruleset frame - t BIGINT NULL, -- frame time, epoch ms - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point --- system, keyed by the shard's own PointsType name. The shard publishes ~25 of --- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing --- players accumulate over months. --- --- The top-N list stays inside `payload` rather than being normalized into a --- shard_points_entries table. It is a fixed-size list (10 by default) that is only --- ever read whole, exactly like shard_governors.candidates — normalizing it would --- buy nothing until something needs a per-character reverse lookup, and a --- character's own standings already ride inside char.profile instead. --- --- No delete path: the shard's set of systems is fixed at startup, so there is no --- points.remove to mirror. -CREATE TABLE IF NOT EXISTS shard_points_boards ( - system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty - name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal - name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number - max_points BIGINT NULL, - players INT NULL, -- players actually holding points in this system - show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag - payload JSON NOT NULL, -- the whole points.board frame, incl. `top` - t BIGINT NULL, -- frame time, epoch ms - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player --- vendor and one per priced listing, so the site can offer the search the in-game --- Vendor Search gump offers — from outside the game. --- --- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per --- vendor, so ingest is delete-then-insert of that vendor's items inside one --- transaction (see shardMarket.db.js). No foreign key from items to vendors, in --- keeping with every other shard_* table: the ingest transaction is what keeps --- them consistent, and an FK would turn a malformed frame into a failed write --- rather than a dropped row. --- --- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent, --- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs. -CREATE TABLE IF NOT EXISTS shard_vendors ( - serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234" - shop_name VARCHAR(160) NULL, - owner_serial VARCHAR(20) NULL, - owner_name VARCHAR(64) NULL, - map VARCHAR(40) NULL, - x INT NULL, - y INT NULL, - z INT NULL, - region VARCHAR(80) NULL, - house VARCHAR(160) NULL, -- the house SIGN's name, not the house type - item_count INT NOT NULL DEFAULT 0, -- listings published in the frame - item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds - truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count - t BIGINT NULL, -- frame time, epoch ms - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_shard_vendors_owner (owner_name), - INDEX idx_shard_vendors_map (map), - INDEX idx_shard_vendors_region (region), - -- The market page's staleness banner is MIN(updated_at) over this column: the - -- round-robin sweep means the oldest row is how far behind the index can be. - INDEX idx_shard_vendors_updated (updated_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- One priced listing. Unlike the points board's top-N — a fixed-size list read --- whole — these are the searchable rows the whole feature exists for, so they are --- normalized rather than left inside a payload column, and there is no payload --- column on shard_vendors at all. --- --- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's --- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page --- at query time would make the cliloc table a join on the hot path AND make --- search-by-name impossible. Resolving once on write buys the index. It is --- re-resolved in bulk after a cliloc import, because the diff sweep will not --- re-send an unchanged shop just because the site learned what its items are --- called. -CREATE TABLE IF NOT EXISTS shard_vendor_items ( - id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, - vendor_serial VARCHAR(20) NOT NULL, - serial VARCHAR(20) NOT NULL, - item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id) - hue INT NOT NULL DEFAULT 0, - amount INT NOT NULL DEFAULT 1, - price BIGINT NOT NULL DEFAULT 0, - name VARCHAR(160) NULL, -- the item's literal Name, null for most - cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs - display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches - child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself - INDEX idx_shard_vendor_items_vendor (vendor_serial), - INDEX idx_shard_vendor_items_price (price), - INDEX idx_shard_vendor_items_item (item_id), - INDEX idx_shard_vendor_items_name (display_name), - -- Search filters on name and sorts on price; the composite covers the common - -- "cheapest matching X" without a filesort over the whole table. - INDEX idx_shard_vendor_items_name_price (display_name, price) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row --- per feature; an absent row means "use the compiled default", and the compiled --- defaults reproduce the behavior that shipped before v3 — so an empty table is --- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and --- docs/link/v3.md §3 for the contract. --- --- audience the minimum rung on anonymous < logged_in < player < staff < admin --- stream whether this feature's kinds fan out over SSE at all (the market --- index ships with this off: no page needs a live firehose of --- whole vendor inventories) --- field_rules {"": ""} for SENSITIVE fields only. `acct` and --- `webId` are admin-only always and are rejected here — they are --- not in-game visible and are deliberately not configurable. -CREATE TABLE IF NOT EXISTS shard_feature_visibility ( - feature VARCHAR(48) NOT NULL PRIMARY KEY, - enabled TINYINT(1) NOT NULL DEFAULT 1, - audience VARCHAR(20) NOT NULL DEFAULT 'anonymous', - stream TINYINT(1) NOT NULL DEFAULT 1, - field_rules JSON NULL, - updated_by INT NULL, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone -- by email at a pre-chosen access level; the invitee accepts via a tokened link, @@ -1192,175 +815,6 @@ ALTER TABLE announce_jobs -- One row per spawnable type, aggregated across the world. `total` is the sum of -- each type's own MX across every point that spawns it (how many exist at once); --- `facets` is a per-facet point count, so the facet filter and "where does this --- live" both answer without touching shard_spawn_points. -CREATE TABLE IF NOT EXISTS shard_spawn_creatures ( - slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key - name VARCHAR(120) NOT NULL, -- display spelling chosen by the build - total INT NOT NULL DEFAULT 0, - points INT NOT NULL DEFAULT 0, - facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... } - -- Operator-supplied artwork, always NULL on a fresh import. The repo ships no - -- creature art: sprites live in the operator's own client .mul/.uop files and - -- are theirs to extract and place under uploads/atlas/. The UI renders without - -- art when this is NULL, which is the normal case. - art VARCHAR(255) NULL, - -- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free, - -- and FULLTEXT's min-token-length would break searches for names like "orc". - INDEX idx_shard_spawn_creatures_name (name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- One row per spawner. `region`/`landmark` are the resolved place name — the --- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is --- the resolved display string (region, else landmark, else 'Wilderness'). -CREATE TABLE IF NOT EXISTS shard_spawn_points ( - id INT AUTO_INCREMENT PRIMARY KEY, - facet VARCHAR(40) NOT NULL, - name VARCHAR(120) NULL, -- the ServUO spawner's own name - x INT NOT NULL, - y INT NOT NULL, - width INT NOT NULL DEFAULT 0, - height INT NOT NULL DEFAULT 0, - spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB - max_count INT NOT NULL DEFAULT 0, - min_delay INT NOT NULL DEFAULT 0, - max_delay INT NOT NULL DEFAULT 0, - tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0 - tod_end INT NOT NULL DEFAULT 0, - tod_mode INT NOT NULL DEFAULT 0, - region VARCHAR(120) NULL, - landmark VARCHAR(120) NULL, - label VARCHAR(120) NOT NULL DEFAULT 'Wilderness', - INDEX idx_shard_spawn_points_facet (facet), - INDEX idx_shard_spawn_points_label (label) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- The many-to-many between the two above: one spawner commonly carries several --- types (a single Trammel point spawns six), each with its own max. This is how --- /atlas/creatures/:slug finds the places a creature appears. -CREATE TABLE IF NOT EXISTS shard_spawn_point_types ( - point_id INT NOT NULL, - slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK) - max_count INT NOT NULL DEFAULT 1, - PRIMARY KEY (point_id, slug), - INDEX idx_shard_spawn_point_types_slug (slug) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Named regions from Data/Regions.xml, flattened out of their nesting. `rects` --- holds the region's rectangles; `priority` and rect area are what resolved each --- spawn point at build time, kept here so the admin drift check can re-derive. -CREATE TABLE IF NOT EXISTS shard_regions ( - id INT AUTO_INCREMENT PRIMARY KEY, - facet VARCHAR(40) NOT NULL, - name VARCHAR(120) NOT NULL, - type VARCHAR(80) NULL, -- ServUO region class - priority INT NOT NULL DEFAULT 0, - parent VARCHAR(120) NULL, -- enclosing named region, if any - rects JSON NULL, - INDEX idx_shard_regions_facet (facet), - INDEX idx_shard_regions_name (name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing --- parent ("Covetous"), which is the label worth showing — "Covetous" reads --- better than the individual marker "Level 1". (`group` is reserved in SQL.) -CREATE TABLE IF NOT EXISTS shard_landmarks ( - id INT AUTO_INCREMENT PRIMARY KEY, - facet VARCHAR(40) NOT NULL, - name VARCHAR(120) NOT NULL, - grp VARCHAR(120) NULL, - x INT NOT NULL, - y INT NOT NULL, - z INT NOT NULL DEFAULT 0, - INDEX idx_shard_landmarks_facet (facet), - INDEX idx_shard_landmarks_name (name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Configured champion altars from Config/ChampionSpawns.xml. This is static --- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from --- the live champ.update feed in shard_champs ("it is on level 3 right now"). -CREATE TABLE IF NOT EXISTS shard_champion_spawns ( - slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit" - name VARCHAR(120) NOT NULL, - grp VARCHAR(80) NULL, -- spawn group; one active per group - type VARCHAR(80) NULL, -- '' when randomised per activation - random_type TINYINT(1) NOT NULL DEFAULT 0, - facet VARCHAR(40) NOT NULL, - x INT NOT NULL, - y INT NOT NULL, - z INT NOT NULL DEFAULT 0, - radius INT NOT NULL DEFAULT 0, - label VARCHAR(120) NULL, -- resolved place name - INDEX idx_shard_champion_spawns_facet (facet) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- UO's localization table: cliloc id -> display string. Items carry a --- `LabelNumber` rather than a name, so without this the site can only render --- `id 1023721` where the game shows "quarter staff". The shard has always sent --- the id (char.profile's `cliloc`, and one per marketplace listing) — the number --- was never the missing piece, the table was. --- --- Sourced from a file the OPERATOR converts once from their own UO client and --- points the site at (docs/website/CLILOCS.md); nothing derived from the client --- is committed, the same rule the spawn atlas and the creature art map follow. --- A shard with no cliloc file configured simply renders item ids, which is what --- it did before this table existed. --- --- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long --- property descriptions, and truncating them silently would be worse than --- storing them. Item NAMES are all short — the index that matters for search is --- on the denormalized `shard_vendor_items.display_name`, not here. -CREATE TABLE IF NOT EXISTS shard_clilocs ( - number INT NOT NULL PRIMARY KEY, - flag SMALLINT NOT NULL DEFAULT 0, - text TEXT NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Singleton (id = 1) describing the cliloc table currently loaded: the source --- file, its sha256, the entry count and the parser version. The boot path --- compares the stored hash against the file on disk and skips the parse when --- they match, which is every restart that did not follow a client patch. -CREATE TABLE IF NOT EXISTS shard_cliloc_meta ( - id TINYINT NOT NULL PRIMARY KEY DEFAULT 1, - payload JSON NOT NULL, - imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Singleton (id = 1) describing the artifact currently loaded: when it was --- built, its counts, and a sha256 per ServUO source file. The admin drift check --- compares this against db/data/spawnAtlas.meta.json to report when the database --- is behind the committed artifact. -CREATE TABLE IF NOT EXISTS shard_atlas_meta ( - id TINYINT NOT NULL PRIMARY KEY DEFAULT 1, - payload JSON NOT NULL, - imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - --- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately --- NOT applied, because it would remove a facet the site currently serves. --- --- Losing a facet is the signature of a half-copied or mid-update ServUO tree as --- much as of a real map change, and boot cannot tell the two apart — so the --- refresh is staged here for a human instead of being applied. Startup is never --- blocked by it: the site comes up serving the atlas it already had. --- --- Only the DECISION is stored, not the parsed world: `payload` holds the source --- hashes and the facet diff (a few KB), and approving re-parses the tree. That --- keeps a multi-megabyte blob out of the database and guarantees the applied --- atlas matches the tree as it is at approval time, not as it was at boot. --- --- `rejected` is remembered against those exact source hashes so a declined --- refresh does not re-prompt on every restart; changing the tree changes the --- hashes and asks again. -CREATE TABLE IF NOT EXISTS shard_atlas_pending ( - id TINYINT NOT NULL PRIMARY KEY DEFAULT 1, - status ENUM('pending','rejected') NOT NULL DEFAULT 'pending', - payload JSON NOT NULL, -- source hashes + facet diff - detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row -- per module the operator has installed onto the modules volume, keyed by the @@ -1456,23 +910,6 @@ ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL; ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL; --- 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; - -- Mobile device sessions (M9): a friendly label the app may send at login, and -- the last time this session token was issued/used, for the "Active Devices" -- self-service list. Both nullable and additive; existing rows get them here. @@ -1484,19 +921,4 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME -- trust token. A boolean only — the token is returned over that app→server call -- and never persisted here (only its sha256 lands in trusted_devices). ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device 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'); -INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1'); +INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1'); \ No newline at end of file diff --git a/server/package.json b/server/package.json index 417a9cf..b2ae7c7 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,6 @@ "seed": "node db/seed.js", "swagger": "node swagger/swagger.js", "routes:manifest": "node scripts/routeManifest.js", - "atlas:import": "node scripts/importSpawnAtlas.js", "test": "node --test --require ./test/_setup.js" }, "keywords": [ diff --git a/server/routes.guards.json b/server/routes.guards.json index e04bc9c..c9f4520 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -635,272 +635,6 @@ "multerMiddleware" ] }, - { - "method": "POST", - "path": "/api/v1/admin/shard/account", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/accounts", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/atlas", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/atlas/approve", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/atlas/import", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "PUT", - "path": "/api/v1/admin/shard/atlas/path", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/atlas/reject", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/audit", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/ban", - "handlers": 7, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/broadcast", - "handlers": 5, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/char/:serial", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/clilocs", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/clilocs/import", - "handlers": 5, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "PUT", - "path": "/api/v1/admin/shard/clilocs/path", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/houses", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/kick", - "handlers": 5, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/link", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/pages", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/pages/:id/close", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/pages/:id/respond", - "handlers": 6, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/roster/:account", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/sales", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/unban", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/vendors/:account", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/visibility", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "PUT", - "path": "/api/v1/admin/shard/visibility", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, { "method": "PUT", "path": "/api/v1/admin/site-mode", @@ -912,57 +646,6 @@ "validate" ] }, - { - "method": "GET", - "path": "/api/v1/admin/uo-link/config", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "PUT", - "path": "/api/v1/admin/uo-link/config", - "handlers": 8, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/uo-link/stream", - "handlers": 2, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/uo-link/towncrier", - "handlers": 7, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "DELETE", - "path": "/api/v1/admin/uo-link/towncrier/:id", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, { "method": "POST", "path": "/api/v1/admin/uploads", @@ -1037,72 +720,6 @@ "validate" ] }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/accounts", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/houses", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "DELETE", - "path": "/api/v1/admin/users/:id/shard/link/:account", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/online", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/sales", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/standing", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, { "method": "DELETE", "path": "/api/v1/admin/users/:id/trusted-devices", @@ -1798,146 +1415,6 @@ "requireAuth" ] }, - { - "method": "POST", - "path": "/api/v1/player/shard/account", - "handlers": 5, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/player/shard/accounts", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/player/shard/char/:serial", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/player/shard/houses", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "POST", - "path": "/api/v1/player/shard/link", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/player/shard/roster/:account", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/player/shard/sales", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/player/shard/vendors/:account", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/champions", - "handlers": 5, - "gates": [ - "middleware", - "validate", - "siteMode" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/creatures", - "handlers": 8, - "gates": [ - "middleware", - "validate", - "siteMode" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/creatures/:slug", - "handlers": 7, - "gates": [ - "middleware", - "validate", - "siteMode" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/landmarks", - "handlers": 6, - "gates": [ - "middleware", - "validate", - "siteMode" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/meta", - "handlers": 3, - "gates": [ - "siteMode" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/regions", - "handlers": 6, - "gates": [ - "middleware", - "validate", - "siteMode" - ] - }, { "method": "POST", "path": "/api/v1/public/contact", @@ -1989,135 +1466,6 @@ "handlers": 1, "gates": [] }, - { - "method": "GET", - "path": "/api/v1/public/shard/champs", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/economy", - "handlers": 4, - "gates": [ - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/features", - "handlers": 1, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/feed", - "handlers": 5, - "gates": [ - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/governors", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/governors/:city/history", - "handlers": 5, - "gates": [ - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/guilds", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/houses", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/idoc", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/market", - "handlers": 13, - "gates": [ - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/market/meta", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/market/vendors/:serial", - "handlers": 7, - "gates": [ - "middleware", - "validate" - ] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/online", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/points", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/points/:system", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/presence", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/ruleset", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/status", - "handlers": 2, - "gates": [] - }, - { - "method": "GET", - "path": "/api/v1/public/shard/stream", - "handlers": 1, - "gates": [] - }, { "method": "GET", "path": "/api/v1/public/status", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 5ce03ea..c3ee498 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -257,134 +257,10 @@ "method": "POST", "path": "/api/v1/admin/settings/brand-asset/:slot" }, - { - "method": "POST", - "path": "/api/v1/admin/shard/account" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/accounts" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/atlas" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/atlas/approve" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/atlas/import" - }, - { - "method": "PUT", - "path": "/api/v1/admin/shard/atlas/path" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/atlas/reject" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/audit" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/ban" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/broadcast" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/char/:serial" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/clilocs" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/clilocs/import" - }, - { - "method": "PUT", - "path": "/api/v1/admin/shard/clilocs/path" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/houses" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/kick" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/link" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/pages" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/pages/:id/close" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/pages/:id/respond" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/roster/:account" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/sales" - }, - { - "method": "POST", - "path": "/api/v1/admin/shard/unban" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/vendors/:account" - }, - { - "method": "GET", - "path": "/api/v1/admin/shard/visibility" - }, - { - "method": "PUT", - "path": "/api/v1/admin/shard/visibility" - }, { "method": "PUT", "path": "/api/v1/admin/site-mode" }, - { - "method": "GET", - "path": "/api/v1/admin/uo-link/config" - }, - { - "method": "PUT", - "path": "/api/v1/admin/uo-link/config" - }, - { - "method": "GET", - "path": "/api/v1/admin/uo-link/stream" - }, - { - "method": "POST", - "path": "/api/v1/admin/uo-link/towncrier" - }, - { - "method": "DELETE", - "path": "/api/v1/admin/uo-link/towncrier/:id" - }, { "method": "POST", "path": "/api/v1/admin/uploads" @@ -413,30 +289,6 @@ "method": "POST", "path": "/api/v1/admin/users/:id/mfa/reset" }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/accounts" - }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/houses" - }, - { - "method": "DELETE", - "path": "/api/v1/admin/users/:id/shard/link/:account" - }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/online" - }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/sales" - }, - { - "method": "GET", - "path": "/api/v1/admin/users/:id/shard/standing" - }, { "method": "DELETE", "path": "/api/v1/admin/users/:id/trusted-devices" @@ -721,62 +573,6 @@ "method": "GET", "path": "/api/v1/player/appeals/eligible" }, - { - "method": "POST", - "path": "/api/v1/player/shard/account" - }, - { - "method": "GET", - "path": "/api/v1/player/shard/accounts" - }, - { - "method": "GET", - "path": "/api/v1/player/shard/char/:serial" - }, - { - "method": "GET", - "path": "/api/v1/player/shard/houses" - }, - { - "method": "POST", - "path": "/api/v1/player/shard/link" - }, - { - "method": "GET", - "path": "/api/v1/player/shard/roster/:account" - }, - { - "method": "GET", - "path": "/api/v1/player/shard/sales" - }, - { - "method": "GET", - "path": "/api/v1/player/shard/vendors/:account" - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/champions" - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/creatures" - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/creatures/:slug" - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/landmarks" - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/meta" - }, - { - "method": "GET", - "path": "/api/v1/public/atlas/regions" - }, { "method": "POST", "path": "/api/v1/public/contact" @@ -805,82 +601,6 @@ "method": "GET", "path": "/api/v1/public/settings" }, - { - "method": "GET", - "path": "/api/v1/public/shard/champs" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/economy" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/features" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/feed" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/governors" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/governors/:city/history" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/guilds" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/houses" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/idoc" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/market" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/market/meta" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/market/vendors/:serial" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/online" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/points" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/points/:system" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/presence" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/ruleset" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/status" - }, - { - "method": "GET", - "path": "/api/v1/public/shard/stream" - }, { "method": "GET", "path": "/api/v1/public/status" diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index dd4c3cc..5452660 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -27,8 +27,6 @@ const postsRouter = require('./posts.router') const uploadsRouter = require('./uploads.router') const wikiRouter = require('./wiki.router') const pagesRouter = require('./pages.router') -const shardRouter = require('./shard.router') -const uoLinkRouter = require('./uoLink.router') const emailRouter = require('./email.router') const discordBotRouter = require('./discordBot.router') const settingsRouter = require('./settings.router') @@ -64,12 +62,14 @@ adminRouter.use('/posts', postsRouter) adminRouter.use('/uploads', uploadsRouter) adminRouter.use('/wiki', wikiRouter) adminRouter.use('/pages', pagesRouter) -// Ops and configuration. /shard mixes tiers on one prefix — self-service game -// account linking (no extra gate) alongside modAccess in-game staff ops — so -// one router owns the prefix and gates per route. The rest are admin-only. -// /admin/shard/pages is the in-game help-page queue, unrelated to /admin/pages. -adminRouter.use('/shard', shardRouter) -adminRouter.use('/uo-link', uoLinkRouter) +// Ops and configuration. +// +// `/shard` and `/uo-link` are absent here and are still served: they are +// module-uo's, mounted onto this same router by the loader after every core +// mount above (MODULE_API.md §2.4). The URLs did not move — the code did. That +// ordering is also what makes the prefixes unclaimable by anyone else: the +// loader asks this live router what core owns, so a second module claiming +// `/shard` is rejected against the mounts actually present, not against a list. adminRouter.use('/email', emailRouter) adminRouter.use('/discord-bot', discordBotRouter) adminRouter.use('/settings', settingsRouter) diff --git a/server/src/router/v1/player/index.js b/server/src/router/v1/player/index.js index 179ee92..c02a1ca 100644 --- a/server/src/router/v1/player/index.js +++ b/server/src/router/v1/player/index.js @@ -13,7 +13,9 @@ // Adding a requireRole('player') here would 403 an admin off their own characters // (it happened once — see docs/website/BACKEND_DESIGN.md). Staff also reach the // identical self-scoped handlers under /admin/shard and /auth/me/account; those -// are alternative URLs onto the same controllers, not duplicated logic. +// are alternative URLs onto the same controllers, not duplicated logic — and +// both of those live in module-uo now, which changes where they are defined and +// nothing about which URLs answer. // // See docs/website/API_V2_PLAN.md § Phase 2 for the split. @@ -23,7 +25,6 @@ const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const accountRouter = require('./account.router') -const shardRouter = require('./shard.router') const appealsRouter = require('./appeals.router') const playerRouter = express.Router() @@ -37,7 +38,6 @@ const playerRouter = express.Router() playerRouter.use(noindex, requireAuth) playerRouter.use('/account', accountRouter) -playerRouter.use('/shard', shardRouter) playerRouter.use('/appeals', appealsRouter) module.exports = playerRouter diff --git a/server/src/router/v1/public/index.js b/server/src/router/v1/public/index.js index 0737905..0a1139b 100644 --- a/server/src/router/v1/public/index.js +++ b/server/src/router/v1/public/index.js @@ -20,8 +20,6 @@ const express = require('express') const postsRouter = require('./posts.router') const wikiRouter = require('./wiki.router') const pagesRouter = require('./pages.router') -const shardRouter = require('./shard.router') -const atlasRouter = require('./atlas.router') const modulesRouter = require('./modules.router') const siteRouter = require('./site.router') @@ -32,13 +30,6 @@ const publicRouter = express.Router() publicRouter.use('/posts', postsRouter) publicRouter.use('/wiki', wikiRouter) publicRouter.use('/pages', pagesRouter) -// Live shard data, never site-mode gated. -publicRouter.use('/shard', shardRouter) -// The spawn atlas: static shard CONTENT, parsed from the shard's ServUO tree -// rather than fetched from the sidecar. Deliberately not under /shard — nothing -// here depends on the bridge — and site-mode gated per route like the content -// routers above, which is the other half of that distinction. -publicRouter.use('/atlas', atlasRouter) // What this backend serves beyond core. A real prefix layer rather than a fifth // singleton in site.router.js, because the loader's prefix-collision probe reads // the live tier stack and skips root-mounted layers — this mount is what makes diff --git a/server/src/server.js b/server/src/server.js index 8055468..1fc3951 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -4,19 +4,12 @@ const http = require('http') const app = require('./app') const internalApp = require('./internalApp') const botScore = require('./middleware/botScore') -const uoLinkSocket = require('./utils/uoLinkSocket') -const uoLinkClient = require('./utils/uoLinkClient') -const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model') -const shardBroadcast = require('./utils/shardBroadcast') const announceWorker = require('./utils/announceWorker') const { ensureSchema, close } = require('./utils/db') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const settings = require('./model/settings/settings.model') const revokedSessions = require('./model/revokedSessions/revokedSessions.model') const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model') -const shardAtlas = require('./model/shardAtlas/shardAtlas.model') -const shardClilocs = require('./model/shardClilocs/shardClilocs.model') -const shardMarket = require('./model/shardMarket/shardMarket.model') const moduleLifecycle = require('./modules/lifecycle') const createLogger = require('./utils/logger') const { evaluateBotInternalKey } = require('./utils/botInternalKey') @@ -81,37 +74,18 @@ async function start() { log.warn('mobile-auth-bridge prune failed', { error: err.message }) } - // Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps - // change over its lifetime — facets get added, replaced or renamed — so the - // atlas is rebuilt on every boot rather than shipped as a snapshot that would - // silently go stale. Hash-gated, so an unchanged tree costs one read pass and - // no database write. - // - // Best-effort by contract: no configured path, an unreadable mount or a - // malformed file must never stop the site coming up. A refresh that would - // REMOVE a facet is staged for admin approval instead of being applied. - await shardAtlas.refreshOnBoot() - - // Refresh the cliloc table (UO's id → display-string map) from the file the - // operator converted out of their own client. Same contract as the atlas: - // hash-gated so an unchanged file costs one read, and best-effort so a missing - // or wrong-format file never stops the site coming up — it just means item - // names render as ids, which is what they did before the table existed. - const clilocResult = await shardClilocs.refreshOnBoot() - - // A cliloc import changes what item names RESOLVE to, and the marketplace - // stores those names denormalized (shard_vendor_items.display_name) so it can - // index and search them. The shard's market sweep will not re-send an unchanged - // shop just because the site learned what its items are called, so the backfill - // has to be pulled rather than waited for. Only after an actual import — the - // common boot is hash-gated to a no-op and must stay one. - if (clilocResult && clilocResult.status === 'imported') await shardMarket.refreshDisplayNames() - const mode = await settings.get('site_mode') log.info(`site mode: ${String(mode || 'live').toUpperCase()}`) // Reconcile installed_modules with what the loader found on the volume at - // require time, then run each module's onBoot (MODULE_API.md §2.5). Placed + // require time, then run each module's onBoot (MODULE_API.md §2.5). + // + // This is where the shard now warms up. Core used to do it inline just above — + // rebuild the spawn atlas from the ServUO tree, refresh the cliloc table, open + // the uo-link WebSocket — and all of it is module-uo's `onBoot` since Phase 3. + // The ordering guarantee is unchanged and is why it belongs here rather than + // after the listener: the tables exist by now, and nothing is served until the + // warm-up finishes. Placed // after core's own boot work and BEFORE the listener binds, for both reasons // the contract gives: a module's warm-up may depend on core being up, and a // module that must not serve traffic until it has warmed a cache gets that @@ -132,17 +106,6 @@ async function start() { log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`) }) - // Start the uo-link WebSocket ingest client. Self-guards: it only actually - // connects when the admin has enabled the integration and saved a token, so - // this is a no-op on shards that haven't configured the sidecar. Never let a - // sidecar problem block server startup. - try { - await uoLinkSocket.start() - await checkUoLink() - } catch (err) { - log.warn('uo-link socket failed to start (continuing)', { error: err.message }) - } - // Start the news-announcement dispatcher: a light in-process poller that pushes // published news posts to the in-game town crier + Discord with independent // retry per leg. No-op until a news post is actually published. @@ -151,33 +114,6 @@ async function start() { setupShutdown(server, internalServer) } -// Best-effort startup probe of the uo-link sidecar: if the integration is -// enabled, log whether it is reachable and warn loudly on a protocol mismatch -// (fail-fast visibility rather than silently mis-parsing a newer wire format). -async function checkUoLink() { - const config = await uoLinkConfig.getSafe() - if (!config.enabled) return - const health = await uoLinkClient.health() - if (!health.ok) { - log.warn('uo-link is enabled but the sidecar is unreachable at startup', { - baseUrl: config.baseUrl, - error: health.error || `status ${health.status}`, - }) - return - } - if (health.data && health.data.protocol && health.data.protocol !== config.protocol) { - log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', { - pinned: config.protocol, - sidecar: health.data.protocol, - }) - } else { - log.info('uo-link sidecar reachable', { - pluginConnected: health.data && health.data.plugin_connected, - protocol: health.data && health.data.protocol, - }) - } -} - function setupShutdown(server, internalServer) { let closing = false const shutdown = async (signal) => { @@ -192,8 +128,6 @@ function setupShutdown(server, internalServer) { await moduleLifecycle.shutdown() botScore.stopSweeper() // stop the bot-store cleanup interval announceWorker.stop() // stop the news-announcement dispatcher poller - uoLinkSocket.stop() // close the uo-link WS ingest client - shardBroadcast.closeAll() // end any open shard live-feed SSE streams server.close(() => log.info('http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed')) try { diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index dfca9ab..d2feea3 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3728,1314 +3728,6 @@ ] } }, - "/api/v1/admin/shard/account": { - "post": { - "tags": [ - "Admin · Account" - ], - "summary": "Create a game account and link it to the caller (staff self-service)", - "description": "Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.", - "responses": { - "201": { - "description": "Account created and linked", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Game-account signup unavailable (site or shard)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Account name already taken", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": {} - } - }, - "/api/v1/admin/shard/accounts": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "List the caller’s linked game accounts (self)", - "description": "", - "responses": { - "200": { - "description": "Linked accounts", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardLink" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/atlas": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Spawn atlas status: path, drift, counts, pending review (admin only)", - "description": "Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.", - "responses": { - "200": { - "description": "Atlas status", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasStatus" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/atlas/approve": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Approve a staged atlas refresh that removes a facet (admin only)", - "description": "Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.", - "responses": { - "200": { - "description": "What happened", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasRefreshResult" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/atlas/import": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Re-import the spawn atlas from the ServUO tree (admin only)", - "description": "Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status \"unavailable\" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.", - "responses": { - "200": { - "description": "What happened", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasRefreshResult" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "force": { - "type": "boolean", - "description": "Reimport even if the tree is unchanged." - } - } - } - } - } - } - } - }, - "/api/v1/admin/shard/atlas/path": { - "put": { - "tags": [ - "Admin · Shard" - ], - "summary": "Set the ServUO tree the atlas reads from (admin only)", - "description": "Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.", - "responses": { - "200": { - "description": "Atlas status after the change", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasStatus" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "path" - ], - "properties": { - "path": { - "type": "string", - "description": "Absolute path to the ServUO server root. Blank disables the atlas." - } - } - } - } - } - } - } - }, - "/api/v1/admin/shard/atlas/reject": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Reject a staged atlas refresh (admin only)", - "description": "Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.", - "responses": { - "200": { - "description": "Rejected", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasRefreshResult" - } - } - } - }, - "404": { - "description": "Nothing is awaiting review", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/audit": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Recent in-game moderation audit events (admin/moderator)", - "description": "", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "admin.audit events, newest first", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardEvent" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/ban": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Ban an account, timed or indefinite (admin/moderator)", - "description": "", - "responses": { - "200": { - "description": "Banned", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Protected target or write plane disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - }, - "serial": { - "type": "string" - }, - "durationSec": { - "type": "integer" - }, - "reason": { - "type": "string" - } - } - } - } - } - } - } - }, - "/api/v1/admin/shard/broadcast": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Broadcast a system message to everyone online (admin/moderator)", - "description": "", - "responses": { - "200": { - "description": "Broadcast", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "hue": { - "type": "integer" - } - }, - "required": [ - "text" - ] - } - } - } - } - } - }, - "/api/v1/admin/shard/char/{serial}": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Character sheet (self-linked characters; admins: any character)", - "description": "", - "parameters": [ - { - "name": "serial", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Mobile serial, e.g. 0x24C." - } - ], - "responses": { - "200": { - "description": "Character profile", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Character not on an account linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Service Unavailable" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/clilocs": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Cliloc table status: sources, drift, entry count (admin only)", - "description": "Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.", - "responses": { - "200": { - "description": "Cliloc status", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClilocStatus" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/clilocs/import": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Re-import the cliloc table from its source files (admin only)", - "description": "Applies a client patch, or a change to the shard\\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\\'s own COMPRESSED Cliloc.enu — answers 200 with status \"unavailable\" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.", - "responses": { - "200": { - "description": "What happened", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClilocRefreshResult" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "force": { - "type": "boolean", - "description": "Reimport even if the sources are unchanged." - }, - "approve": { - "type": "boolean", - "description": "Accept a refresh in which a previously-loaded source has vanished." - } - } - } - } - } - } - } - }, - "/api/v1/admin/shard/clilocs/path": { - "put": { - "tags": [ - "Admin · Shard" - ], - "summary": "Set the cliloc source the site reads from (admin only)", - "description": "Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.", - "responses": { - "200": { - "description": "Cliloc status after the change", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClilocStatus" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "path" - ], - "properties": { - "path": { - "type": "string", - "description": "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." - } - } - } - } - } - } - } - }, - "/api/v1/admin/shard/houses": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Full house registry — owner, price, decay (admin/moderator)", - "description": "The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.", - "responses": { - "200": { - "description": "Houses, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardHouse" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/kick": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Kick every live session of an account (admin/moderator)", - "description": "", - "responses": { - "200": { - "description": "Kicked", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Protected target or write plane disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - }, - "serial": { - "type": "string" - } - } - } - } - } - } - } - }, - "/api/v1/admin/shard/link": { - "post": { - "tags": [ - "Admin · Account" - ], - "summary": "Link an in-game account with a one-time code (self)", - "description": "", - "responses": { - "200": { - "description": "Linked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardLinkResult" - } - } - } - }, - "400": { - "description": "Unknown or expired code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Service Unavailable" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardLinkRequest" - } - } - } - } - } - }, - "/api/v1/admin/shard/pages": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Open help-page (support) queue (admin/moderator)", - "description": "", - "responses": { - "200": { - "description": "Open pages", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/pages/{id}/close": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Resolve a help page without a reply (admin/moderator)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Page id (sender serial)." - } - ], - "responses": { - "200": { - "description": "Closed", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/pages/{id}/respond": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Reply to a help page, optionally closing it (admin/moderator)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Page id (sender serial)." - } - ], - "responses": { - "200": { - "description": "Responded", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Unknown page", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "close": { - "type": "boolean" - } - }, - "required": [ - "message" - ] - } - } - } - } - } - }, - "/api/v1/admin/shard/roster/{account}": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Character roster for an account (self; admins: any account)", - "description": "", - "parameters": [ - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "A game account linked to the caller." - } - ], - "responses": { - "200": { - "description": "Account roster", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Account not linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/sales": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Recent player-vendor sales for the caller’s linked accounts (self)", - "description": "", - "responses": { - "200": { - "description": "Vendor sales", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardVendorSale" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/unban": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Clear an account ban (admin/moderator)", - "description": "", - "responses": { - "200": { - "description": "Unbanned", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - } - }, - "required": [ - "account" - ] - } - } - } - } - } - }, - "/api/v1/admin/shard/vendors/{account}": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Player vendors for an account (self; admins: any account)", - "description": "", - "parameters": [ - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "A game account linked to the caller." - } - ], - "responses": { - "200": { - "description": "Vendor snapshot", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Account not linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/visibility": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Get per-feature shard visibility config (admin only)", - "description": "The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.", - "responses": { - "200": { - "description": "Visibility config", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardVisibilityConfig" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": [ - "Admin · Shard" - ], - "summary": "Update per-feature shard visibility config (admin only)", - "description": "Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.", - "responses": { - "200": { - "description": "Updated config", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardVisibilityConfig" - } - } - } - }, - "400": { - "description": "Unknown feature, rung, or a locked field", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardVisibilityUpdate" - } - } - } - } - } - }, "/api/v1/admin/site-mode": { "put": { "tags": [ @@ -5108,270 +3800,6 @@ } } }, - "/api/v1/admin/uo-link/config": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Get uo-link config + live status + ingestion stats (admin only)", - "description": "", - "responses": { - "200": { - "description": "Masked config, health and ingestion stats", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": [ - "Admin · Shard" - ], - "summary": "Save uo-link connection config (admin only)", - "description": "token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.", - "responses": { - "200": { - "description": "Updated config + live status", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Validation error, or missing token while enabling", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "baseUrl": { - "type": "string" - }, - "wsUrl": { - "type": "string" - }, - "token": { - "type": "string" - }, - "protocol": { - "type": "integer" - }, - "enabled": { - "type": "boolean" - } - } - } - } - } - } - } - }, - "/api/v1/admin/uo-link/stream": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Full live shard event stream incl. audit/cheat (SSE, admin only)", - "description": "", - "responses": { - "200": { - "description": "An SSE stream (Content-Type: text/event-stream)." - } - } - } - }, - "/api/v1/admin/uo-link/towncrier": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Publish / replace a town-crier message (admin only)", - "description": "", - "responses": { - "200": { - "description": "Posted", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Rejected (over caps)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Shard unavailable", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TownCrierRequest" - } - } - } - } - } - }, - "/api/v1/admin/uo-link/towncrier/{id}": { - "delete": { - "tags": [ - "Admin · Shard" - ], - "summary": "Remove a town-crier message (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Town-crier message id." - } - ], - "responses": { - "200": { - "description": "Removed", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Unknown id", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Service Unavailable" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, "/api/v1/admin/uploads": { "post": { "tags": [ @@ -5885,390 +4313,6 @@ ] } }, - "/api/v1/admin/users/{id}/shard/accounts": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "A user’s linked game accounts (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Linked accounts", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardLink" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/houses": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "Houses owned by a user’s accounts (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Houses (IDOC first)", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/link/{account}": { - "delete": { - "tags": [ - "Admin · Users" - ], - "summary": "Unlink a game account from this user (admin only)", - "description": "Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - }, - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Game account to unlink." - } - ], - "responses": { - "200": { - "description": "Unlinked", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - }, - "unlinked": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Protected staff account (refused by shard)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not linked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Service Unavailable" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/online": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "A user’s characters currently online (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Online characters", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/sales": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "Recent vendor sales on a user’s accounts (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Vendor sales", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardVendorSale" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/standing": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "A user’s shard standing — governorships held and guilds led (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Standing { governorOf, guildsLed }", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, "/api/v1/admin/users/{id}/trusted-devices": { "get": { "tags": [ @@ -10694,871 +8738,6 @@ ] } }, - "/api/v1/player/shard/account": { - "post": { - "tags": [ - "Player · Shard" - ], - "summary": "Create a game account (hybrid signup) and link it to the caller", - "description": "Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.", - "responses": { - "201": { - "description": "Account created and linked", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - }, - "linked": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "description": "Validation error or rejected name/password", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Game-account signup unavailable (site or shard)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Account name already taken", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Per-IP account cap reached", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": {} - } - }, - "/api/v1/player/shard/accounts": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "List the caller’s linked game accounts", - "description": "", - "responses": { - "200": { - "description": "Linked accounts", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardLink" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/char/{serial}": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "Character sheet — only for a character on the caller’s linked account", - "description": "", - "parameters": [ - { - "name": "serial", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Mobile serial, e.g. 0x24C." - } - ], - "responses": { - "200": { - "description": "Character profile", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Character not on an account linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/houses": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "The caller’s own houses (home status)", - "description": "Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.", - "responses": { - "200": { - "description": "The caller’s houses", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardHouse" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/link": { - "post": { - "tags": [ - "Player · Shard" - ], - "summary": "Link an in-game account with a one-time code", - "description": "The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.", - "responses": { - "200": { - "description": "Linked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardLinkResult" - } - } - } - }, - "400": { - "description": "Unknown or expired code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardLinkRequest" - } - } - } - } - } - }, - "/api/v1/player/shard/roster/{account}": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "Character roster for a linked account", - "description": "", - "parameters": [ - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "A game account linked to the caller." - } - ], - "responses": { - "200": { - "description": "Account roster", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Account not linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/sales": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "Recent player-vendor sales for the caller’s linked accounts", - "description": "", - "responses": { - "200": { - "description": "Vendor sales", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardVendorSale" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/vendors/{account}": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "Player vendors for a linked account", - "description": "", - "parameters": [ - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "A game account linked to the caller." - } - ], - "responses": { - "200": { - "description": "Vendor snapshot", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Account not linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/public/atlas/champions": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "Configured champion altars (the roster, not the live board)", - "description": "Where the altars are and what each one summons — \"there is an Unholy Terror altar in Deceit\". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board (\"it is on level 3 right now\").", - "parameters": [ - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Limit to one facet." - } - ], - "responses": { - "200": { - "description": "Altars, by facet then name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AtlasChampion" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/creatures": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "Search the bestiary (paginated)", - "description": "Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\\'s share on it. Static content parsed from the shard\\'s ServUO tree — unaffected by the shard being offline.", - "parameters": [ - { - "name": "q", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Substring match on the creature name (max 60 chars)." - }, - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Limit to creatures spawning on this facet. Facet names come from the shard's own files; an unknown one returns an empty page." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Page size, 1..100 (default 50)." - }, - { - "name": "offset", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Rows to skip (default 0)." - } - ], - "responses": { - "200": { - "description": "A page of creatures plus the unpaginated total", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasCreaturePage" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "The atlas feature is gated above this caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "The atlas feature is disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/creatures/{slug}": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "One creature: where it spawns, and what spawns with it", - "description": "The answer the atlas exists to give. `places` is the aggregate — \"lizardman → Shrines, Isamu-Jima, Yew\" — resolved by point-in-rect against the shard\\'s own region rectangles, falling back to the nearest landmark, else \"Wilderness\". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Creature slug, e.g. lizardman." - }, - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Restrict places and spawners to one facet." - }, - { - "name": "points", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max spawners to return, 1..1000 (default 200)." - } - ], - "responses": { - "200": { - "description": "The creature", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasCreature" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "No such creature in this atlas (or the feature is disabled)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/landmarks": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "Points of interest (dungeon levels, town markers)", - "description": "From the shard\\'s Data/Locations files. `group` is the innermost enclosing parent (\"Covetous\"), which is the label worth showing over the individual marker (\"Level 1\").", - "parameters": [ - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Limit to one facet." - }, - { - "name": "q", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Substring match on the landmark name or its group." - } - ], - "responses": { - "200": { - "description": "Landmarks, by facet then group", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AtlasLandmark" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/meta": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "What atlas is loaded: facets, counts, when it was imported", - "description": "Drives the facet filter and the \"parsed from the shard\\'s own files on \" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.", - "responses": { - "200": { - "description": "Atlas metadata", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AtlasMeta" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/atlas/regions": { - "get": { - "tags": [ - "Public · Atlas" - ], - "summary": "Named regions and their rectangles", - "description": "Flattened out of the shard\\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.", - "parameters": [ - { - "name": "facet", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Limit to one facet." - }, - { - "name": "q", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Substring match on the region name." - } - ], - "responses": { - "200": { - "description": "Regions, by facet then name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AtlasRegion" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, "/api/v1/public/contact": { "post": { "tags": [ @@ -11880,795 +9059,6 @@ } } }, - "/api/v1/public/shard/champs": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Current champion-spawn board (all categories)", - "description": "The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.", - "responses": { - "200": { - "description": "Champion spawns, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/economy": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Gold-supply time series (oldest → newest)", - "description": "", - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max samples (default 100, max 1000)." - } - ], - "responses": { - "200": { - "description": "Economy samples", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardEconomyPoint" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/features": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Shard features visible to the caller (drives client nav)", - "description": "The caller\\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.", - "responses": { - "200": { - "description": "Visible features", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardFeatures" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/feed": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Recent notable shard events (from the ingested log)", - "description": "The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\\'s audience rung under the live visibility config, and each event\\'s payload is field-projected against its own kind\\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.", - "parameters": [ - { - "name": "kind", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max rows (default 100, max 1000)." - } - ], - "responses": { - "200": { - "description": "Events, newest first", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardEvent" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/governors": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Current town-governor board (City Loyalty)", - "description": "One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.", - "responses": { - "200": { - "description": "Cities, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/governors/{city}/history": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Governor term history for a city", - "description": "", - "parameters": [ - { - "name": "city", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "City name, e.g. Britain." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max terms (default 100, max 500)." - } - ], - "responses": { - "200": { - "description": "Terms, newest first", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/guilds": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Current guild board (rosters, alliances, leaders)", - "description": "The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.", - "responses": { - "200": { - "description": "Guilds, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/houses": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "House registry (owner, co-owners, price, decay)", - "description": "Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.", - "responses": { - "200": { - "description": "Houses, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardHouse" - } - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/idoc": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Houses currently in danger (IDOC)", - "description": "Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\\'s field rules (default `staff`), and the owner\\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.", - "responses": { - "200": { - "description": "IDOC houses", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardHouse" - } - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/market": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Search the player-vendor marketplace", - "description": "Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site\\'s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.", - "parameters": [ - { - "name": "itemId", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "map", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "region", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "sort", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "q", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Substring match on the resolved item name or the item's own literal name (max 60 chars)." - }, - { - "name": "minPrice", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Lowest price to include." - }, - { - "name": "maxPrice", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Highest price to include." - } - ], - "responses": { - "200": { - "description": "A page of listings plus the unpaginated total and the staleness stamp", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardMarketPage" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "The market feature is gated above this caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "The market feature is disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Rate limited", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/market/meta": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Marketplace size, staleness and filter options", - "description": "How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.", - "responses": { - "200": { - "description": "Marketplace metadata", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardMarketMeta" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/market/vendors/{serial}": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "One player vendor and everything it is selling", - "description": "A single shop by its vendor serial, with its listings. `truncated` (and `total` exceeding `count`) means the shop holds more than the shard publishes per frame — a commodity reseller with thousands of stacks is a real thing, and the page says so rather than presenting a partial shop as complete. Returns 404 for a serial the index has never seen, which also covers a vendor since dismissed or hidden.", - "parameters": [ - { - "name": "serial", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Vendor serial, e.g. 0x40001234" - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Listings to return, 1..500 (default 250)." - }, - { - "name": "offset", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Listings to skip (default 0)." - } - ], - "responses": { - "200": { - "description": "The vendor", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardMarketVendor" - } - } - } - }, - "400": { - "description": "Malformed vendor serial" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "No such vendor in the index" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/online": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Staff online now (linked staff accounts; location is admin/moderator-only)", - "description": "", - "responses": { - "200": { - "description": "Online players", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardOnlinePlayer" - } - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/points": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Points / loyalty leaderboards, one board per point system", - "description": "Every points/loyalty leaderboard the shard publishes (Queen\\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.", - "responses": { - "200": { - "description": "Boards, ordered by display name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardPointsBoard" - } - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/points/{system}": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "One points system\\'s leaderboard", - "description": "A single board by the shard\\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.", - "parameters": [ - { - "name": "system", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "PointsType name, e.g. QueensLoyalty" - } - ], - "responses": { - "200": { - "description": "The board", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardPointsBoard" - } - } - } - }, - "400": { - "description": "Malformed system name" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "The shard has never published that system" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/presence": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Online population aggregate (count + per-facet + per-region)", - "description": "The latest presence.online snapshot powering the \"Players Online\" widget. Live via presence.online on /shard/stream.", - "responses": { - "200": { - "description": "Population snapshot", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/ruleset": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "The shard\\'s published ruleset (expansion, systems, caps, limits)", - "description": "How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.", - "responses": { - "200": { - "description": "The ruleset, or null if never published", - "content": { - "application/json": { - "schema": { - "type": "object", - "nullable": true, - "additionalProperties": true - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/status": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Shard connection state, online count and latest economy", - "description": "", - "responses": { - "200": { - "description": "Shard status", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardStatus" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/stream": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Live shard event stream (Server-Sent Events, filtered by audience)", - "description": "text/event-stream of live events. The caller\\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.", - "responses": { - "200": { - "description": "An SSE stream (Content-Type: text/event-stream)." - } - } - } - }, "/api/v1/public/status": { "get": { "tags": [