docs(rust): protocol 13 step 2 — expiry, plugin loads, the zone helper, the tally (§19.4-19.8, MODULE_API 1.11.0)

The spec for PLAN_FIXES §6 step 2, as built (D181-D185):

- PROTOCOL.md §19.4 world.expired's `what` and the website recording an
  expiry (amends §15's "maps it to nothing"); §19.5 plugin.loaded /
  plugin.unloaded with the permission diff; §19.6 the ZoneManager helper and
  `zoneHelper` at hello; §19.7 what the tally counts (F1, F3, F4); §19.8 the
  website (F7 hold, F5/F6 link fleet, F2 names).
- MODULE_API.md 1.11.0 and ctx.events.expired; EVENTS.md §L the `expired`
  status, terminal and green, and `resource.expired`.
- rust-link/INSTALL.md: the helper in the tarball, by hand, and in doctor.
- PLAYER_WALK.md: events step 6 can pass now; a step-2 section whose rows 1-2
  were walked on both rigs without a player and 3-9 need one.
- PLAN_FIXES §6: step 2 as built, with its PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 21:47:42 -05:00
parent e7551cbf8f
commit 1a7f52743f
6 changed files with 185 additions and 11 deletions

View File

@@ -395,6 +395,13 @@ in Rust-Plugins, Rust-Link and Module-Rust, and the three cut over to `main` tog
(D176, D177). (D176, D177).
2. **Protocol 13 with the remaining fixes:** F13, F14, F12, F1, F3, F8, F7, F2, F4 in the bridge and the module, 2. **Protocol 13 with the remaining fixes:** F13, F14, F12, F1, F3, F8, F7, F2, F4 in the bridge and the module,
and F5/F6 in the module. and F5/F6 in the module.
*As built, 2026-09-27* (decisions D181–D185): Rust-Plugins#18, Module-Rust#22, Rust-Link#19 (the egg),
installer#35 and website#209 (core's `expired` status, MODULE_API 1.11.0). The spec is
[`PROTOCOL.md`](../../rust-link/PROTOCOL.md) §19.4–19.8. Walked on both rigs without a player: the
bridge and the ZoneManager helper compile and load, the helper patches ZoneManager 3.1.14, the hello
carries its state, and `plugin.loaded` / `plugin.unloaded` carry the right permissions. The rows that
need somebody in the game are [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md)'s step-2 section,
rows 3–9.
3. **The redesigns**, each planned in detail before code and walked on both frameworks: the permission manager 3. **The redesigns**, each planned in detail before code and walked on both frameworks: the permission manager
(§4.1), the event step editor with the kit-weekend template (§4.2, §4.3), zones and domes (§4.4), the map's (§4.1), the event step editor with the kit-weekend template (§4.2, §4.3), zones and domes (§4.4), the map's
marker types (§4.5), the chat title conditions (§4.6), and the NPC research (§4.7). marker types (§4.5), the chat title conditions (§4.6), and the NPC research (§4.7).

View File

@@ -11,7 +11,7 @@ speaks one protocol, never "the latest of each":
| Component | What it is | Released from | | Component | What it is | Released from |
|---|---|---| |---|---|---|
| **The plugin** | `RunicGateway.cs`, one file that runs unchanged on Oxide and Carbon | [Rust-Plugins](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/releases) | | **The plugin** | `RunicGateway.cs`, one file that runs unchanged on Oxide and Carbon — and beside it, from protocol 13, the optional **ZoneManager helper** `RunicGatewayZones.cs` (PLAN_FIXES D181, D182), installed by default | [Rust-Plugins](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/releases) |
| **The sidecar** | `rust-link-sidecar`, which the plugin dials on loopback and the website reaches over HTTP | [Rust-Link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases) | | **The sidecar** | `rust-link-sidecar`, which the plugin dials on loopback and the website reaches over HTTP | [Rust-Link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases) |
The game server opens no port for the bridge: the plugin is the client and the sidecar the The game server opens no port for the bridge: the plugin is the client and the sidecar the
@@ -156,7 +156,10 @@ history, and the token is unchanged.
plugin tarball, each with a `sha256`. plugin tarball, each with a `sha256`.
2. **Download and verify** both against those checksums (`sha256sum -c`, or `Get-FileHash`). 2. **Download and verify** both against those checksums (`sha256sum -c`, or `Get-FileHash`).
3. **The plugin:** copy `runicgateway-rust-plugin/RunicGateway.cs` from the tarball into 3. **The plugin:** copy `runicgateway-rust-plugin/RunicGateway.cs` from the tarball into
`oxide/plugins/` or `carbon/plugins/`. To name the server, create its config first — `oxide/plugins/` or `carbon/plugins/`, and every other `.cs` the tarball's `manifest.json` lists
in `files` beside it — from protocol 13 that is `RunicGatewayZones.cs`, which lets ZoneManager count a
player already standing in a zone when it opens or comes back after a restart. It is optional: leave
it out and events still score everybody, but ZoneManager's own flags miss that player. To name the server, create its config first —
`oxide/config/RunicGateway.json` or `carbon/configs/RunicGateway.json` — holding `oxide/config/RunicGateway.json` or `carbon/configs/RunicGateway.json` — holding
`{ "ServerId": "<id>", "Port": 7799 }`. Without it, the plugin calls the server `main`. `{ "ServerId": "<id>", "Port": 7799 }`. Without it, the plugin calls the server `main`.
4. **The sidecar:** put the binary somewhere stable and give it a config: 4. **The sidecar:** put the binary somewhere stable and give it a config:
@@ -215,7 +218,7 @@ With the installer:
| | | | | |
|---|---| |---|---|
| `doctor --game rust [--server-id <id>]` | Per server: the framework; whether the plugin file is still the one deployed; that the plugin's config names this server; the required uMod plugins; the service; and `/health` through to **plugin connected**. A stopped server is a warning; a running one whose plugin never connected is a failure, printed with the framework versions the plugin is known good on | | `doctor --game rust [--server-id <id>]` | Per server: the framework; whether the plugin file is still the one deployed; each helper deployed beside it, as a **warning** when missing or edited (the bridge runs without one, and the row says what that costs); that the plugin's config names this server; the required uMod plugins; the service; and `/health` through to **plugin connected**. A stopped server is a warning; a running one whose plugin never connected is a failure, printed with the framework versions the plugin is known good on |
| `update --game rust` | Moves the sidecar and every server's plugin to the current bundle, and restarts the sidecars. Always all servers together — they share one binary | | `update --game rust` | Moves the sidecar and every server's plugin to the current bundle, and restarts the sidecars. Always all servers together — they share one binary |
| `uninstall --game rust [--server-id <id>] [--purge]` | Removes the service and the plugin file. **Keeps the plugin's config** — it is the website's, and it names the server. `--purge` also removes the sidecar config (the token) and the database. Removing the last server removes the shared binary too | | `uninstall --game rust [--server-id <id>] [--purge]` | Removes the service and the plugin file. **Keeps the plugin's config** — it is the website's, and it names the server. `--purge` also removes the sidecar config (the token) and the database. Removing the last server removes the shared binary too |

View File

@@ -289,7 +289,7 @@ start the run.
| 3 | **Leave step 2's run going and restart the server.** Rejoin, walk back into the zone and run `rgp.inzone` | `zone_list` shows the zone **back** after the restart, at the same place and with the same name, re-created from the bridge's own record because ZoneManager forgets temporary zones. `rgp.inzone` names you again. On the site the run's zone row still reads `confirmed` | | 3 | **Leave step 2's run going and restart the server.** Rejoin, walk back into the zone and run `rgp.inzone` | `zone_list` shows the zone **back** after the restart, at the same place and with the same name, re-created from the bridge's own record because ZoneManager forgets temporary zones. `rgp.inzone` names you again. On the site the run's zone row still reads `confirmed` |
| 4 | **`rust.crate.place`**: `crate.hackable` ×1 and `crate.elite` ×3 at the same monument, spread 8. Loot one elite crate empty; start the hack on the locked crate. Then cancel the run | The crates are there and lootable, the hack timer runs. At teardown the untouched crates vanish, and the looted one is simply **gone** already — its row reads `reverted` all the same (§15.2: reverting something a player took is a success) | | 4 | **`rust.crate.place`**: `crate.hackable` ×1 and `crate.elite` ×3 at the same monument, spread 8. Loot one elite crate empty; start the hack on the locked crate. Then cancel the run | The crates are there and lootable, the hack timer runs. At teardown the untouched crates vanish, and the looted one is simply **gone** already — its row reads `reverted` all the same (§15.2: reverting something a player took is a success) |
| 5 | **`rust.npc.place`**: `npc.scientist` ×3 at the monument. Kill one; let the others see you | They are hostile and fight. The one you killed is just gone; at teardown the survivors vanish mid-fight. Every row reads `reverted`. If an NPC spawned **inside a wall or fell through the ground**, note the monument — some offsets land off the navmesh, and that is a finding about where to put things, not a bug to hide | | 5 | **`rust.npc.place`**: `npc.scientist` ×3 at the monument. Kill one; let the others see you | They are hostile and fight. The one you killed is just gone; at teardown the survivors vanish mid-fight. Every row reads `reverted`. If an NPC spawned **inside a wall or fell through the ground**, note the monument — some offsets land off the navmesh, and that is a finding about where to put things, not a bug to hide |
| 6 | **A zone for 3 minutes.** Stand inside it (`rgp.inzone` names you) and wait out the three minutes | The zone disappears around you **without anybody on the website doing anything**, because the game keeps the deadline (D96). `zone_list` no longer lists it, and the sidecar's `/events?kind=world.expired` shows the frame. Best done on the non-admin account, which is the view a real player has | | 6 | **A zone for 3 minutes.** Stand inside it (`rgp.inzone` names you) and wait out the three minutes | The zone disappears around you **without anybody on the website doing anything**, because the game keeps the deadline (D96). `zone_list` no longer lists it, and the sidecar's `/events?kind=world.expired` shows the frame, with `what: "zone"` (protocol 13, F13 — before it, the frame was filed as `zone` and this check could not pass). On the run console the zone reads **`expired` — ended by the game on time**, not `confirmed` (F14). Best done on the non-admin account, which is the view a real player has |
| 7 | **If players can be arranged, a `core.lease` on `spawn.max_rate`** — these two scalars are left out of the catalogue today because they only matter with people online (§27.5) | With several players on, the population system's spawn rate moves (`spawn.report`) while the lease holds and returns after. If it does, the two `spawn.max_*` keys can be added to the allowlist; if nothing moves even with players, they stay out | | 7 | **If players can be arranged, a `core.lease` on `spawn.max_rate`** — these two scalars are left out of the catalogue today because they only matter with people online (§27.5) | With several players on, the population system's spawn rate moves (`spawn.report`) while the lease holds and returns after. If it does, the two `spawn.max_*` keys can be added to the allowlist; if nothing moves even with players, they stay out |
**Run steps 1–5 on both frameworks.** One thing is specific to Carbon: ZoneManager was only installed **Run steps 1–5 on both frameworks.** One thing is specific to Carbon: ZoneManager was only installed
@@ -407,3 +407,25 @@ above, and a staff member at the website.
**What counts as a pass:** a real chat line carries a site title and a site style, the voice speaks **What counts as a pass:** a real chat line carries a site title and a site style, the voice speaks
without a sender, and a popup appears on a real screen. without a sender, and a popup appears on a real screen.
## The step-2 fixes (protocol 13, PLAN_FIXES §6 step 2)
Added 2026-09-27. What [`PLAN_FIXES.md`](../modules/rust/PLAN_FIXES.md) §8 asks the re-walk to show for
step 2's fixes, in the order they are quickest to walk. Player A from the walks above; a staff member
at the website. **Run it on Oxide and on Carbon.** Rows marked *no player* were walked on both rigs on
2026-09-27 without one; the rest need somebody in the game.
| # | Do this | You should see |
|---|---|---|
| 1 | *No player.* `rgz.status` in the server console | `Runic Gateway Zones 0.1.0: patched`, and the servers page says nothing about the helper. Delete `RunicGatewayZones.cs` and wait a minute: the servers page warns that ZoneManager's flags miss a player already inside a new zone (D182). Put it back |
| 2 | *No player.* `oxide.unload Kits` (Carbon: `c.unload Kits`), grant a site group `kits.admin`, then `oxide.load Kits` | The sidecar's `/events?kind=plugin.loaded` shows Kits with `["kits.admin","kits.rgreward"]`, and the grant lands **within a minute** — not on the fifteen-minute audit (F8) |
| 3 | A fells a tree and picks up a hemp plant | The next `player.tally` counts the wood the inventory shows — the final hit's bonus included — and the cloth from the hemp (F1, D159) |
| 4 | A breaks a wall of their own base | `entity.destroyed` arrives; the next tally's `structures` does **not** move (F3) |
| 5 | Staff open a zone with a timed event while A stands where it will be | ZoneManager counts A at once: the zone's enter message shows, and the run's tally gives A seconds without A walking out and back in (F12) |
| 6 | With A standing in a live event zone, restart the server | After the restart A is in the zone again (ZoneManager says so) and keeps scoring without moving (F12). The site's restart permission sync goes **after** "Server startup complete", not before, and says what it applied (F7) |
| 7 | Staff delete the helper, then repeat step 5 | A still scores (the bridge measures the zone's shape, D182), but the zone's enter message does not show for A |
| 8 | Stop another enabled server, then have A type a made-up code on the website | "That code is unknown or has expired" — not "one of the servers could not be reached … your code is still good" (F5) — in a second or two, not many (F6). A real code from A's server still links |
| 9 | A is killed by a wolf, then by a scientist | The killfeed says **Wolf** and **Scientist**, not `wolf2` or `scientistnpc full any` (F2) |
**What counts as a pass:** a zone counts somebody who was already standing in it, on both frameworks,
and an expired zone reads `expired` on the run console.

View File

@@ -1511,8 +1511,11 @@ erases a zone whose `holdMs` has run out, whether or not the website is heard fr
{"kind":"world.expired","type":"event","id":"rg-13-35875416-1","runId":"13"} {"kind":"world.expired","type":"event","id":"rg-13-35875416-1","runId":"13"}
``` ```
The website maps it to nothing. Core learns about it through `reconcile` and `revert`, as with Until protocol 13 the website mapped it to nothing (D96), and core learned about it only through
`lease.expired`. `reconcile` and `revert`, as with `lease.expired`. **Protocol 13 changes both halves** (§19.4): the
frame carries the entity's kind as `what` — protocols 9 to 12 wrote it over the frame's own `kind`, so no
`world.expired` ever arrived as one — and the website records the run's resource as `expired`
(PLAN_FIXES D170, D183).
### 15.4 `EventsEnabled`, and the two bounds ### 15.4 `EventsEnabled`, and the two bounds
@@ -1950,8 +1953,9 @@ untouched.
Planned in [`PLAN_FIXES.md`](../modules/rust/PLAN_FIXES.md): one bump for every wire change the org Planned in [`PLAN_FIXES.md`](../modules/rust/PLAN_FIXES.md): one bump for every wire change the org
lead's first walk with a player in the game found (§5 there). It is built on `edge` in Rust-Plugins, lead's first walk with a player in the game found (§5 there). It is built on `edge` in Rust-Plugins,
Rust-Link and Module-Rust and released together (D177). **This section grows as the pieces land**; Rust-Link and Module-Rust and released together (D177). **This section grows as the pieces land**:
the first is the configuration save. the configuration save first (§19.1–19.3, step 1), then step 2's fixes (§19.4–19.8, decisions
D181–D185).
One fix rides in the same release without a wire change: **the plugin decodes what the sidecar sends One fix rides in the same release without a wire change: **the plugin decodes what the sidecar sends
as UTF-8** (F15). Protocols 1 to 12 decoded each byte as a character — Latin-1 — so every non-ASCII as UTF-8** (F15). Protocols 1 to 12 decoded each byte as a character — Latin-1 — so every non-ASCII
@@ -2073,3 +2077,108 @@ from `config.outcome` by `(server_id, write_id)`, and only while it is still `re
frame moves nothing and a late one still lands. A row past `settle_by` reads as **`lost`**. The page polls frame moves nothing and a late one still lands. A row past `settle_by` reads as **`lost`**. The page polls
`GET /api/v1/admin/rust/config/{serverId}/writes/{writeId}` every two seconds until the write settles `GET /api/v1/admin/rust/config/{serverId}/writes/{writeId}` every two seconds until the write settles
(D179). (D179).
### 19.4 `world.expired` is recognisable, and an expiry is recorded (F13, F14)
The frame gains **`what`**, the entity's kind — the name the website's resource rows already use:
```json
{"kind":"world.expired","type":"event","id":"rg-13-35875416-1","what":"zone","runId":"13"}
```
Protocols 9 to 12 wrote the entity's kind into `kind`, over the frame's own, so every expiry reached the
sidecar as `kind: "zone"` and nothing listening for `world.expired` ever heard one (F13). A website that
reads protocol 12 frames will find them filed under `zone`; nothing is re-filed.
**The website records it** (D170, D183). Module-Rust hands core the resource the zone step ledgered —
kind `world`, ref `<serverId>:<id>` (§15, the ref names the server) — through
`ctx.events.expired({ kind, ref })` (MODULE_API 1.11.0). Core marks that row **`expired`**: terminal,
never taken back at teardown, and distinct from `orphaned`, which `reconcile` uses for a thing that
vanished with nobody asking. An expiry for a zone no run ledgered is not an error.
`world.expired` and `lease.expired` are **staff** kinds (§8.5). Neither was classified before protocol
13 — default deny kept both off public pages — and both are an event's machinery; the public hears what
an event did from core's announcements.
### 19.5 `plugin.loaded` and `plugin.unloaded` (F8)
A grant the site made for a plugin that was not loaded stays unresolved until the plugin is back. The
first walk watched one land thirteen minutes after the plugin loaded, on the fifteen-minute audit. So
the bridge now announces a load or an unload of **another** plugin, with the permissions it registered
or dropped (D184):
```json
{"kind":"plugin.loaded","type":"event","name":"Kits","version":"4.4.9","permissions":["kits.admin","kits.rgreward"]}
```
- **`permissions`** is a diff of the framework's registered-permission list, taken **one tick after the
hook**, because the order of "drop an unloaded plugin's permissions" and "call `OnPluginUnloaded`"
is the framework's own. It is sorted, and it may be empty.
- **Only once the world is ready** (§15.5). A boot loads every plugin, and a boot is the restart sync's
job; the bridge takes its baseline list at `OnServerInitialized`.
- **A reload in one tick shows an empty list on both frames.** Carbon's `c.reload` unloads and loads in
the same tick, so by the next the list is what it was. That is the right answer: what a grant
resolves against did not change. Two plugins loaded in one tick share one diff, under the first name.
- Both are **staff** kinds. The website marks the server's permission sync dirty when `permissions` is
not empty, and the next tick (30 s) pushes. §4.1's permission manager will take plugin ownership from
its own inventory read, not from this.
Walked on both rigs, 2026-09-27: `oxide.unload Kits` / `oxide.load Kits` and Carbon's `c.unload` and
the queued load each carried `["kits.admin","kits.rgreward"]`; the ZoneManager helper's load carried `[]`.
### 19.6 The ZoneManager helper, and `zoneHelper` at hello (F12)
ZoneManager counts a player as inside a zone only when the zone's trigger fires on **entry**, so a zone
created, updated or re-created around somebody standing there holds nobody — its flags, messages and
`OnEnterZone` never happen for them, and the bridge's tally, which asks ZoneManager's membership, scored
nobody already in an arena after a restart. ZoneManager has no public way to look again.
**`RunicGatewayZones.cs`** (D181, D168) ships beside the bridge and is installed by default (D182). It
Harmony-postfixes ZoneManager's `Zone.InitializeZone` — the one method a zone passes through when it is
created, updated (`Zone.Reset`) or loaded from ZoneManager's data file — and one tick later enters every
connected player ZoneManager's own `IsPositionInZone` places inside, through ZoneManager's own
`OnPlayerEnterZone`. A player ZoneManager already counts is skipped, and ZoneManager refuses a second
entry anyway. It fixes **every** zone on the server, not only the bridge's. It sweeps all zones once when
it patches, and re-patches when ZoneManager reloads. `rgz.status` prints its state.
The bridge never depends on it. `server.hello` carries, whenever ZoneManager is loaded:
```json
"zoneHelper": { "state": "patched", "version": "0.1.0" }
```
`state` is `patched`, `unsupported` (loaded, but this ZoneManager lacks what it patches — `reason` says
what), `no-zonemanager`, or `missing` (the file is not there). **Anything but `patched` and the bridge
scores the zones it made by ZoneManager's public `IsPositionInZone`**, for the participation tally and
the kill credit alike — so a missing helper costs ZoneManager's flags for somebody already inside, never
their score. The website shows the cost on the servers page.
Walked 2026-09-27: it patched ZoneManager 3.1.14 on Oxide and on Carbon, and the site read
`{"state":"patched","version":"0.1.0"}` from both hellos. **Not yet walked with a player standing in a
zone** — that needs somebody in the game.
### 19.7 What the tally counts (F1, F3, F4)
- **`gathered` is everything a player harvests** (D159). `OnDispenserGather` is the swings;
`OnDispenserBonus` (the final hit's bonus), `OnCollectiblePickup` (hemp, mushrooms, stones off the
ground) and `OnGrowableGathered` (a farmed plant) now add to the same per-item counter. All three are
`void` (§8.7) and in `rg.hooks`. A pick-up another plugin vetoes is still counted — the hook fires
before the items move — which is rarer than a second hook is worth.
- **`structures` no longer counts your own base.** A building block the attacker placed, or one on a
cupboard they are authorised on, is not counted. The `entity.destroyed` frame still goes; the site
already skips the raid alert for an authorised attacker (D59).
- **Every Steam id comes from `userID`.** The thirteen frames that read `UserIDString` sent `null` for a
player another plugin spawned; they now use the same `SteamIdOf` as the map.
### 19.8 The website
- **The loading hold** (F7). Permission and title pushes wait while the last hello says
`worldReady: false` — the first walk's restart sync went 35 s before the save loaded, timed out, and
the retry reported "0 applied". A human's "sync now" is not held. A failed or refused sync logs a
warning.
- **Link codes** (F5, F6). A code is asked of the servers that minted one in the last six minutes (every
`/link` stores an `account.link.requested`) first, then of the rest, each group **in parallel**. The
answer is "a server could not be reached — your code is still good" only when one of the minting
servers is unreachable; a dead server that minted nothing no longer makes a wrong code look good.
- **NPC attackers** (F2, D185) are named by the killfeed: a family (`scientistnpc_*` → Scientist,
`bradleyapc` → Bradley APC) or the prefab without its variant digits (`wolf2` → Wolf). No wire change.

View File

@@ -426,7 +426,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
| `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. | | `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. |
| `event_action_settings` | `action_id` (the primary key), `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per action an admin has an opinion about; **a missing row is not "disabled", it is the default for the action's risk class** — see [§K](#k--security-model). Not a grant table — nobody is named, because the role check already answered who. Rows outlive their actions, so uninstalling a module and re-installing it restores the caps the operator chose. | | `event_action_settings` | `action_id` (the primary key), `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per action an admin has an opinion about; **a missing row is not "disabled", it is the default for the action's risk class** — see [§K](#k--security-model). Not a grant table — nobody is named, because the role check already answered who. Rows outlive their actions, so uninstalling a module and re-installing it restores the caps the operator chose. |
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap` **nullable**, `effective_from`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND (cap IS NULL OR consumed + ? <= cap)` — so the cap holds under concurrent steps without a transaction. **A NULL cap is uncapped and still a row**, so the console's meter counts what nothing bounds, and a *missing* row keeps its one meaning: a step spending a dimension its own run's version never priced, which is refused. `effective_from` names the action whose cap won, so a number on the meter traces back to a switch. | | `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap` **nullable**, `effective_from`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND (cap IS NULL OR consumed + ? <= cap)` — so the cap holds under concurrent steps without a transaction. **A NULL cap is uncapped and still a row**, so the console's meter counts what nothing bounds, and a *missing* row keeps its one meaning: a step spending a dimension its own run's version never priced, which is refused. `effective_from` names the action whose cap won, so a number on the meter traces back to a switch. |
| `event_run_resources` | `run_id`, `step_id` **`SET NULL`**, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among the rows core still believes are ITS — see the amendment below | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. `@step` is a reserved `kind` core owns (rule 1, below); a module reporting one is refused. | | `event_run_resources` | `run_id`, `step_id` **`SET NULL`**, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired')` (`expired` from MODULE_API 1.11.0), `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among the rows core still believes are ITS — see the amendment below | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. `@step` is a reserved `kind` core owns (rule 1, below); a module reporting one is refused. |
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque **and NOT NULL**, `score` `DECIMAL(18,4)`, `rank_at`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. The unique key is what makes a retried collect step an upsert rather than a doubled leaderboard, and `rank_at` carries the suffix because `rank` is a reserved word from MariaDB 10.2 — one forgotten pair of backticks away from a syntax error in a query nothing runs until a run completes at four in the morning. **Written only from an action's success envelope** (Phase 10): core stores what a module tells it and sources nothing, because a `member_key` → account mapping is one game's. | | `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque **and NOT NULL**, `score` `DECIMAL(18,4)`, `rank_at`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. The unique key is what makes a retried collect step an upsert rather than a doubled leaderboard, and `rank_at` carries the suffix because `rank` is a reserved word from MariaDB 10.2 — one forgotten pair of backticks away from a syntax error in a query nothing runs until a run completes at four in the morning. **Written only from an action's success envelope** (Phase 10): core stores what a module tells it and sources nothing, because a `member_key` → account mapping is one game's. |
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. | | `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
| `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. | | `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. |
@@ -1795,6 +1795,18 @@ through a revert — a revert that finds nothing there is a SUCCESS (§L, and wh
whereas a resource the module reports missing is a thing that vanished while nobody was looking. whereas a resource the module reports missing is a thing that vanished while nobody was looking.
Those are two different sentences to the operator reading the console the morning after. Those are two different sentences to the operator reading the console the morning after.
**`expired` is the third sentence** (MODULE_API 1.11.0, amended 2026-09-27 for Rust PLAN_FIXES F14,
D170, D183). Some game objects carry their own deadline down the wire and the game ends them when it
passes, without being asked again — a Rust zone the plugin erases when its time is up. Reached only
through reconcile, that looked like `orphaned`: amber, "gone", and still claimable for a revert. So a
module that hears the game end one says so with `ctx.events.expired({ kind, ref })`, and core marks the
module's `pending`, `confirmed` or `orphaned` row for that target `expired` and logs `resource.expired`.
It is **terminal and green, like `reverted`**: it releases the target (it is not one of the three that
hold it), it is not "unreverted", and the sweep never tries to give it back. A row a revert has already
claimed (`reverting`) is left to that revert, which finds nothing and succeeds. A finished run whose last
unresolved row this was goes to `cleanup_status = 'complete'`, including from `incomplete`, which the
sweep no longer scans.
--- ---
## Versioning, and editing a live event ## Versioning, and editing a live event
@@ -1998,7 +2010,7 @@ Phase 3 — "The Boss" has not started.
| What a phase is waiting for, its tally and its last related firing | `event_run_phase_gates`, served already-rendered as a run's `gates` | | What a phase is waiting for, its tally and its last related firing | `event_run_phase_gates`, served already-rendered as a run's `gates` |
| A phase opening a gate, and a gate opening — on a firing, a deadline or a human | `event_run_log`, kinds `phase.gate` and `phase.advanced` | | A phase opening a gate, and a gate opening — on a firing, a deadline or a human | `event_run_log`, kinds `phase.gate` and `phase.advanced` |
| Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` | | Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` |
| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources`, plus six `event_run_log` kinds: `resource.recorded`, `resource.orphaned`, `cleanup.reverted`, `cleanup.failed`, `cleanup.swept`, `cleanup.retry`. `resource.recorded` is written at the ANSWER rather than at the placeholder, because a placeholder is a promise and the operator's question is about the world | | Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources`, plus seven `event_run_log` kinds: `resource.recorded`, `resource.orphaned`, `resource.expired` (1.11.0), `cleanup.reverted`, `cleanup.failed`, `cleanup.swept`, `cleanup.retry`. `resource.recorded` is written at the ANSWER rather than at the placeholder, because a placeholder is a promise and the operator's question is about the world |
One caution carried over from the engagement retention work: the run log is high-cardinality and grows per event, so it needs a One caution carried over from the engagement retention work: the run log is high-cardinality and grows per event, so it needs a
retention sweep from the start — `engagementRetentionPrune` is the pattern, and the rule it learned is retention sweep from the start — `engagementRetentionPrune` is the pattern, and the rule it learned is

View File

@@ -26,13 +26,23 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`: Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js ```js
const MODULE_API_VERSION = '1.10.0' const MODULE_API_VERSION = '1.11.0'
``` ```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer. module chunk evaluates, which is earlier than any network round trip could answer.
**1.11.0 — `ctx.events.expired({ kind, ref })`** (`website/EVENTS.md` §L; Rust
`modules/rust/PLAN_FIXES.md` F14, D183). One addition and no removal, so minor. A game that ends
something at its own deadline — a Rust zone the plugin erases when its time is up — tells core, and core
marks the calling module's live row for that target **`expired`**: terminal like `reverted`, never taken
back at teardown, and not `orphaned`, which `reconcile` uses for a thing that vanished with nobody
asking. The owner is bound, as `reconcile`'s is; it is fire-and-forget and returns `undefined`; a
`{ kind, ref }` no run ledgered is not an error. **Module-uo is unaffected**: it declares `^1.10.0`,
calls none of this and reads no ledger status, and its frozen-manifest job, server and client suites
passed against the 1.11.0 core before the bump merged (website#209).
**1.10.0 — the event contract opens to modules: `api.registerEventActions(...)`, **1.10.0 — the event contract opens to modules: `api.registerEventActions(...)`,
`api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and `api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and
`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phases 7 and 8). `api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phases 7 and 8).
@@ -100,6 +110,11 @@ api.registerEventActions([{
// claim about a world that no longer exists. // claim about a world that no longer exists.
ctx.events.reconcile() ctx.events.reconcile()
// 1.11.0. The game ended one of this module's resources at its OWN deadline —
// the same `{ kind, ref }` the action reported when it made it. Core files it
// `expired`: terminal, and not the `orphaned` a reconcile would have said.
ctx.events.expired({ kind: 'world', ref: 'srv-a:rg-13-35875416-1' })
api.registerEventOptionSources([{ api.registerEventOptionSources([{
id: 'uo.options.creatures', label: 'Creatures', id: 'uo.options.creatures', label: 'Creatures',
async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] }, async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] },
@@ -352,6 +367,11 @@ branches lacks — so it is the only place `permits` is true between two values
between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise
unchanged, and so is every rule about composition narrowing rather than widening. unchanged, and so is every rule about composition narrowing rather than widening.
**1.11.0 — `ctx.events.expired`** (`website/EVENTS.md` §L, Rust PLAN_FIXES D183). One addition, so
minor; see §1.1. The resource ledger gains an `expired` status, and nothing that existed changes
meaning — `expired` joins neither the statuses that hold a target nor the ones that leave a run's
cleanup unfinished. Module-uo's `^1.10.0` still resolves and was proved against it.
**1.10.0 — the event contract** (`website/EVENTS.md` §F). Four additions, no removals and no changed **1.10.0 — the event contract** (`website/EVENTS.md` §F). Four additions, no removals and no changed
signature, so minor; `module-uo`'s `coreApi: "^1.9.0"` still resolves and it registers no actions signature, so minor; `module-uo`'s `coreApi: "^1.9.0"` still resolves and it registers no actions
until `EVENTS_PLAN.md` Phase 9. `api.registerEventActions([...])`, `api.registerEventBudgets([...])`, until `EVENTS_PLAN.md` Phase 9. `api.registerEventActions([...])`, `api.registerEventBudgets([...])`,
@@ -636,6 +656,7 @@ module-uo does not need is on the list.
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) | | `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) | | `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (1.7.0) | | `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (1.7.0) |
| `ctx.events.expired` | `({ kind, ref }) => void`, fire-and-forget, owner bound | `events/cleanup` `expireResource` | `module-rust`'s ingest of `world.expired` (1.11.0) |
| `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) | | `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) |
**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with **`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with