diff --git a/modules/rust/PLAN_FIXES.md b/modules/rust/PLAN_FIXES.md index 7119d63..b6cdbcc 100644 --- a/modules/rust/PLAN_FIXES.md +++ b/modules/rust/PLAN_FIXES.md @@ -395,6 +395,13 @@ in Rust-Plugins, Rust-Link and Module-Rust, and the three cut over to `main` tog (D176, D177). 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. + *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 (§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). diff --git a/rust-link/INSTALL.md b/rust-link/INSTALL.md index dbb6ce5..0628861 100644 --- a/rust-link/INSTALL.md +++ b/rust-link/INSTALL.md @@ -11,7 +11,7 @@ speaks one protocol, never "the latest of each": | 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 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`. 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 - `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 `{ "ServerId": "", "Port": 7799 }`. Without it, the plugin calls the server `main`. 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 ]` | 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 ]` | 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 | | `uninstall --game rust [--server-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 | diff --git a/rust-link/PLAYER_WALK.md b/rust-link/PLAYER_WALK.md index d82c5a7..e7814bb 100644 --- a/rust-link/PLAYER_WALK.md +++ b/rust-link/PLAYER_WALK.md @@ -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` | | 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 | -| 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 | **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 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. diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index 6ecf906..04b971c 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -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"} ``` -The website maps it to nothing. Core learns about it through `reconcile` and `revert`, as with -`lease.expired`. +Until protocol 13 the website mapped it to nothing (D96), and core learned about it only through +`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 @@ -1950,8 +1953,9 @@ untouched. 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, -Rust-Link and Module-Rust and released together (D177). **This section grows as the pieces land**; -the first is the configuration save. +Rust-Link and Module-Rust and released together (D177). **This section grows as the pieces land**: +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 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 `GET /api/v1/admin/rust/config/{serverId}/writes/{writeId}` every two seconds until the write settles (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 `:` (§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. diff --git a/website/EVENTS.md b/website/EVENTS.md index ec75136..fc3014b 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -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_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_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_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. | @@ -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. 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 @@ -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` | | 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` | -| 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 retention sweep from the start — `engagementRetentionPrune` is the pattern, and the rule it learned is diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 5cd33e5..3f1514e 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -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`: ```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 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. +**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(...)`, `api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and `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. 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([{ id: 'uo.options.creatures', label: 'Creatures', 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 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 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([...])`, @@ -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.activity.push` | `(items) => Promise`, 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.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.events.emit(triggerId, envelope)`** fires an event the module DECLARED with