MODULE_API 1.6.0 expands the contract this book teaches against, so the book
owes two shapes and one correction. Chapter 2 gains both and the template grows
a working version of each, because a reader following a snippet has no way to
find out whether it runs.
ONE SENTENCE WAS WRONG. Chapter 2 said, of extension slots, "Only core may
declare a slot; a module may only fill one". 1.6.0 inverted exactly that: a
module declares a place on its OWN page and core fills it. That is not a stale
detail - a new game's module cannot implement Teams at all without the inverted
direction, so it is the shape the reader needs and did not have.
THE TWO SHAPES
- The inverted slot. A new "Slots go the other way too" section: why the
direction has to invert (core owns the Team, not the word for one), the
namespace rule, one slot per PLACE, the optional { core } naming which of
core's three contributions goes there, and why asking for one core does not
offer throws when almost everything else in that registry fails open.
- registerTeamProvider, in "Becoming the source of Teams". The first
registration where core calls YOU and waits, which is where every rule in it
comes from: the envelope, the ten-second budget, refusing as a normal answer,
and the one mistake worth naming - answering with an empty list because the
game is unreachable, which core reads as authoritative and acts on.
projectRoster gets its own treatment because it is the exception that fails
CLOSED. pageUrlTemplate is a footnote beside it, as intended.
WHAT THE TEMPLATE GREW
model/clans/ - the provider over two tables, with the guards that matter: an
unreachable game refuses rather than reporting no clans, an empty roster is
refused unless the game says the clan is empty (which is why the schema keeps a
member count the rows cannot supply), and the audience rule lives in one file
that both projectRoster and the module's own page consult, because a second copy
drifts in the direction that publishes what core is withholding.
Its own /clans routes, deliberately not /teams - core mounts that itself, and
the loader would refuse the collision. A clan list page and a clan page that
declares three slots for core.
12 provider tests and three registration tests, 47 server and 20 client in
total. The purge test finally proves something: two of the three tables are now
a parent and its child.
WHAT IT DOES NOT DO. Enumerate the contract. The kit teaches one path end to end
and links out; it has never mentioned three pre-Teams registrations and that is
the design, not a gap.
FOUND WHILE WRITING IT: core filled three literal uo.guild.* slot names, so the
inverted direction reached exactly one module and every other game's page came
up empty with nothing logged. Fixed in website#160 / Module-uo#15 / docs#165
before this chapter could teach it - which is what this phase is for.
The ci/core-ref.json pin moves in a later commit on this branch: checkCoreApi is
an equality against a core on main, and 1.6.0 does not reach main until the
cutover.
Co-Authored-By: Claude <noreply@anthropic.com>
125 lines
7.1 KiB
SQL
125 lines
7.1 KiB
SQL
-- ── The schema fragment ───────────────────────────────────────────────────
|
|
--
|
|
-- Core replays this file on EVERY boot, statement by statement, immediately
|
|
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
|
|
--
|
|
-- **There is no migration runner anywhere in this project, and that is a
|
|
-- decision rather than an omission.** Core's own schema is one idempotent file
|
|
-- replayed the same way. So a module's schema is not a sequence of changes to
|
|
-- apply once — it is a statement of what the tables should look like, written so
|
|
-- that running it against a database that already matches does nothing.
|
|
--
|
|
-- Which means: every CREATE TABLE carries IF NOT EXISTS and every ALTER carries
|
|
-- IF NOT EXISTS. A statement that succeeds once and fails afterwards presents as
|
|
-- a module that worked until the first restart.
|
|
--
|
|
-- **And it means CHANGING a table is an ALTER, never an edit to its CREATE.**
|
|
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
|
|
-- there, so an edited column definition takes effect on a fresh install and on no
|
|
-- existing one — the worst possible split, because your development database is
|
|
-- usually the fresh one. Add the column with
|
|
-- `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, below the CREATE, and leave the
|
|
-- CREATE describing what a new install gets.
|
|
--
|
|
-- ── What core checks, and when ────────────────────────────────────────────
|
|
--
|
|
-- Core validates this file at LOAD time, before your module mounts anything —
|
|
-- so a rule broken here costs you the mount entirely rather than leaving you
|
|
-- with half-created tables and routes that 503. What is left for replay time is
|
|
-- the class only the database can answer: an unknown column type, a bad foreign
|
|
-- key. Those are post-mount and do answer 503.
|
|
--
|
|
-- • **Leading verbs are an allowlist: CREATE, ALTER, INSERT, UPDATE.** Not a
|
|
-- DROP denylist. This file replays every boot, so a TRUNCATE or a DELETE
|
|
-- would empty a table on every restart.
|
|
-- • **Every table you create must be prefixed with your module id** —
|
|
-- `examplegame_` here. Nothing else in the database is yours to create.
|
|
-- • **No table core declares, and none another module has claimed.**
|
|
--
|
|
-- A foreign key INTO a core table is allowed, and works because core's schema is
|
|
-- already in place when this runs. The reverse is not, and could not be: it
|
|
-- would make core's schema depend on your module being installed.
|
|
--
|
|
-- Teardown is `purge.sql`, which no boot ever runs. See it.
|
|
|
|
|
|
-- ── World status ──────────────────────────────────────────────────────────
|
|
-- One row, id 1, holding the last thing the game server said about itself.
|
|
--
|
|
-- A singleton row rather than a settings key because it is *observed state* and
|
|
-- not configuration: it is written by whatever ingests from your sidecar, and an
|
|
-- operator never edits it. In a real module the writer is the sidecar ingest;
|
|
-- here `boot.js` writes it once so the page has something to render.
|
|
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
|
|
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
|
|
-- the same numbers back — which is what a quiet game looks like — leaves the
|
|
-- timestamp frozen at the first write, and the row then goes stale while nothing
|
|
-- is wrong. The writer sets the column explicitly instead; see
|
|
-- `model/worldStatus/worldStatus.db.js`.
|
|
CREATE TABLE IF NOT EXISTS examplegame_world_status (
|
|
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
|
online TINYINT(1) NOT NULL DEFAULT 0,
|
|
players INT UNSIGNED NOT NULL DEFAULT 0,
|
|
world_name VARCHAR(120) NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Seed the singleton. `INSERT IGNORE` rather than a plain INSERT: this runs
|
|
-- again on every boot, and the second run must be a no-op rather than a
|
|
-- duplicate-key error that fails the whole replay.
|
|
INSERT IGNORE INTO examplegame_world_status (id, online, players) VALUES (1, 0, 0);
|
|
|
|
|
|
-- ── Clans, and who is in them ─────────────────────────────────────────────
|
|
-- The module's half of Teams (MODULE_API.md — `api.registerTeamProvider`, and
|
|
-- TEAMS.md §2.3). A **Team** is core's word and a core table; a **clan** is this
|
|
-- game's word for the same thing, and these two tables are what the module knows
|
|
-- about them. Core never reads either — it asks the provider in
|
|
-- `model/clans/clanProvider.model.js`, which reads these.
|
|
--
|
|
-- **That separation is the point of the whole primitive, and it is worth being
|
|
-- concrete about.** Core owns `teams`, `team_members`, the reconciler that syncs
|
|
-- them, the access rules, the forum and the activity feed. This module owns what
|
|
-- a clan IS, which members exist, and who may look. Nothing here is prefixed
|
|
-- `team_` because nothing here is core's; §2.6's prefix rule would refuse it
|
|
-- anyway, and the rule is doing real work in this direction — a module that
|
|
-- wrote into `team_members` would be a module racing core's reconciler.
|
|
--
|
|
-- In a real module both tables are filled by your sidecar ingest. Here `boot.js`
|
|
-- seeds two clans so the pages render and the seam is visible.
|
|
CREATE TABLE IF NOT EXISTS examplegame_clans (
|
|
external_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
|
name VARCHAR(120) NOT NULL,
|
|
abbr VARCHAR(16) NULL,
|
|
-- What the game says the clan's roster size is, which is NOT the number of
|
|
-- rows next door. The two arrive separately in every real ingest, and the
|
|
-- provider needs both to tell "this clan is empty" from "its roster has not
|
|
-- landed yet" — the distinction that decides whether it answers or refuses.
|
|
member_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- One row per character in a clan.
|
|
--
|
|
-- `member_key` is the game's own stable id for a character — a serial, a UUID,
|
|
-- whatever your game keeps — and it is what core stores as the member's identity.
|
|
-- It must survive a rename, because core reads a changed name as a rename and a
|
|
-- changed key as a different person.
|
|
--
|
|
-- `user_id` is the site account behind that character, resolved **by this
|
|
-- module**: the game↔site link table is yours, and a core that resolved it would
|
|
-- be core reading a module's table by name. NULL is the ordinary case — most
|
|
-- characters are not linked to an account.
|
|
CREATE TABLE IF NOT EXISTS examplegame_clan_members (
|
|
clan_id VARCHAR(64) NOT NULL,
|
|
member_key VARCHAR(64) NOT NULL,
|
|
display_name VARCHAR(120) NULL,
|
|
rank_label VARCHAR(60) NULL,
|
|
is_leader TINYINT(1) NOT NULL DEFAULT 0,
|
|
is_online TINYINT(1) NOT NULL DEFAULT 0,
|
|
user_id INT UNSIGNED NULL,
|
|
PRIMARY KEY (clan_id, member_key),
|
|
CONSTRAINT fk_examplegame_clan_members_clan
|
|
FOREIGN KEY (clan_id) REFERENCES examplegame_clans (external_id) ON DELETE CASCADE
|
|
);
|