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:
@@ -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).
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user