diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index c4a18b1..ca3013d 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -999,7 +999,8 @@ Each phase ends with its findings written down, as every workstream here does. | 10 | **Notifications and engagement** (R7). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §25 (D59–D68).** The criterion walked on the Oxide rig (two authorised owners alerted once each; bystander, raider and an unlinked owner told nothing), and the same plugin loaded on Carbon; a live walk found that every generic notice never said which server (§25.6). Streams, triggers with `ceiling` and `subjectKey`, audiences and engagement seeds — **the catalogue is §10, as corrected by §25.2**. The announce leg and the post hook are **not** in it (D62 amends R7), and protocol 7 widens the raid frame, which brings the two bridge repositories in | all 3 + docs | The offline raid alert reaches the player whose base it was, and nobody else | | 11 | **Android leg C** (R10). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §26 (D69–D72).** The link check behind personal streams asked `module-uo` and locked the raid alert on every Rust site; it now asks the site's own module and holds back only push-on. A tickle is titled from the inbox row it names, and two links stopped opening the browser. **The relay hop was walked on `ntfy.sh` for the first time on any site**: a rig raid reached the emulator in 19 seconds, and after push was switched off in the app the next raid enqueued in-app only | Android-app + docs | A Rust notification arrives on a phone and can be switched off there | | 12 | **Events: option sources and the leases** (§9, **as corrected by §27**). ✅ **Built and walked 2026-09-24 on both rigs — as built and findings in §27.5–27.7.** 21 keys walked live, two `spawn.max_*` left out; two defects of its own fixed (the Oxide grant owner, `EventsEnabled` not applying); four found outside it and raised. [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised. **Plan of record in §27 (D73–D79)**: the target names the server, game convars only (vanilla Rust has no gather/craft/smelt rate), the weekend-VIP lease is a group-wide permission, an `EventsEnabled` switch off by default, seven-day holds, and no budget dimension until phase 13; protocol 8 | Module-Rust + 2 + docs | A leased value is observed changing in the running game and restored, per key; `rust.group.permission` expires without core asking | -| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate, plus **`reconcile()` and the boot-id watch calling `ctx.events.reconcile()`** (§11.1) | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it; a wipe reconciles the ledger instead of stranding it | +| 13a | **Events: the world verbs** (§9, R17, **split by D80**). **Plan of record in §28 (D80–D95).** The phase-7 owner fix (D85); `rust.zone.open` and `rust.prefab.place` (crates and NPCs, D88) at a monument or raw coordinates; the monument and prefab option sources; the plugin's ownership registry, keyed by the idempotency key; and **`reconcile()` with the boot-id and wipe watch calling `ctx.events.reconcile()`** (§11.1). Protocol 9 | all 3 + docs | A wipe reconciles the ledger instead of stranding it, and each world verb's teardown is observed in the game | +| 13b | **Events: the rewards** (§9, R3, R16). The participation tally kept by the game (D81–D83), `rust.kit.entitle` through the site mirror's per-run rows (D84) with the kit option source flagging kits with no permission gate, and `rust.announce` with the announce leg (D90). Plan of record in §29, written before its code. Protocol 10 | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it | | 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it | | 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates | | 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel | @@ -1196,6 +1197,11 @@ its reasoning as the starting point rather than inventing a parallel set. > became the group-wide `rust.group.permission` (D75). Every lease's target names its server (D73). > And *"caps are per server"* holds only for a run an admin starts by hand: a scheduled run always > has the empty scope. The tables below are the plan as first written. +> +> **Corrected again by phase 13 (§28, 2026-09-24).** `rust.zone.open` is scheduled (13a, D86), a +> location is a monument by kind and instance or raw coordinates (D87, D93), `prefab.place` covers +> crates and NPCs with NPCs on their own `rust.npcs` budget (D88, D89), and a reward's recipients +> come from a participation tally the game keeps (D81). ### Budgets — what core counts and bounds @@ -4356,6 +4362,212 @@ the pair stayed granted (`already true`), because the site had asked for it. intended behaviour (`already true`), and the cause turned out to be the core cleanup finding above, not the plugin. +**Three of the four findings are filed against core** (2026-09-24): [website#205][w205] (cleanup +restores an orphaned lease row), [website#206][w206] (`core.lease` drops `retry: false`) and +[website#207][w207] (`core.lease`'s two calls under the default budget). The fourth, the phase-7 +mirror's owner bug, belongs to this module and is fixed in phase 13a (D85). + +## 28. Phase 13 — the actions (plan of record, 2026-09-24) + +The phase where an event makes something that did not exist. The row named three verbs, one option +source and the reconcile watch. The decisions below made it two world verbs with NPCs, a monument +source, a participation tally kept by the game, the entitlement, and the announce action with its +leg. So it is **split in two**, the way Events phase 16 was (D80): + +- **13a, the world**: the owner-bug fix, `rust.zone.open`, `rust.prefab.place`, the monument source, + the plugin's ownership registry, and `reconcile()` with the boot and wipe watch. Protocol 9. +- **13b, the rewards**: the participation tally, `rust.kit.entitle` with the kit source, and + `rust.announce` with the announce leg. Protocol 10. It builds on 13a's zones. **Its plan of record + is §29, written before its code**, the same way this section is. + +Three repositories each time: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr], into +`edge`. This document goes into `main`. **Core is not expected to change and `MODULE_API` does not +move.** + +### 28.0 The decisions this phase needed + +All from the org lead, 2026-09-24. D80–D91 cover both halves. D92–D95 came from the rig probe in +§28.1 and apply to 13a. + +| # | Decision | +|---|---| +| **D80** | **Phase 13 splits into 13a (the world) and 13b (the rewards)**, each with its own plan section, PRs and walk. 13a comes first because 13b's participation is counted inside 13a's zones. | +| **D81** | **The recipients of a reward are counted by the GAME, not the website.** `rust.participation.open` / `.collect` follow `module-uo`'s shape: the plugin keeps a per-run tally and `collect` reports it to core on the envelope. Rejected (my recommendation): the module derives the tally from the connect and disconnect events it already ingests. That needs no plugin change, but it cannot see presence in a zone or kills credited in one, and it is only as complete as the sidecar's raw window. | +| **D82** | **The tally's area is a zone when the step names one, and the whole server otherwise.** The zone is normally one the same run opened with `rust.zone.open`. It is fed by `OnEnterZone`/`OnExitZone` for zones in the tally only, so no other zone costs anything (R17's "subscribe selectively"). It is persisted, so a restart does not lose it. | +| **D83** | **The score is set per event: seconds present, kills, or both.** It is chosen on the `participation.open` step. (The org lead's own answer, replacing the three options offered.) | +| **D84** | **An earned entitlement is held by the site's permission mirror, in per-run rows.** It gets a table of its own keyed to the website user (D28) and unioned with admin grants into R2's desired set. A revert deletes only this run's rows, so the same kit granted by an admin or by another run stays. A wipe cannot lose it, because the site re-pushes. The cost is stated up front: **only a LINKED participant can receive one.** Unlinked participants are counted in the step's `detail` as missed. Rejected: the plugin grants the Steam id directly from a store of its own. That reaches unlinked players, but the site stops being the author of record. `rust_perm_grants` could not be reused as it stands: it is `UNIQUE (user_id, permission, scope)`, so an event's grant and an admin's grant of one kit are one row, and reverting the event's would delete the admin's. | +| **D85** | **The phase-7 owner bug is fixed inside this phase**, as its own commit in 13a's Rust-Plugins PR. `EnsureGrant` and `EnsureGroupPermission` pass a `null` owner and read the grant back, so a grant that did not land is reported and not counted (§27.6). This section says plainly what the site was told wrongly from phase 7 until now. | +| **D86** | **Both world verbs ship in 13a: `rust.prefab.place` and `rust.zone.open`.** No phase row had scheduled `zone.open`, although R17 moved it into the base catalogue. Rejected: deferring it to phase 14 with the map. | +| **D87** | **A location is a monument (plus an optional offset) or raw world coordinates**, one or the other on each step. | +| **D88** | **`prefab.place` may spawn crates and NPCs, and never vehicles.** Vehicles were rejected because a player can take one, and a revert would kill something a person may be sitting in. The allowlist lives in the plugin, and neither the sidecar nor the site can widen it. | +| **D89** | **NPCs are counted on their own budget dimension**, `rust.npcs`, apart from `rust.prefabs`, the way `module-uo` separates creatures from decor. An operator can then allow crates freely and cap fights. 13a declares `rust.prefabs`, `rust.npcs` and `rust.zone.minutes`; 13b declares `rust.grants` and `rust.announcements`. This is D79's "each is declared beside the verb that costs it". | +| **D90** | **`rust.announce` ships with the announce leg** (D62): one chat verb in the plugin serves both. | +| **D91** | **Phase 12's three core findings are filed**: website#205, #206 and #207. | +| **D92** | **Zones are re-created from the plugin's registry.** ZoneManager keeps temporary zones in memory only, and **erases every zone a plugin owns when that plugin unloads** (§28.1). The bridge keeps its zones in its own file and re-creates them in three cases: on its own load, on `OnPluginLoaded(ZoneManager)`, and after a restart. It does this within the same wipe only; a new wipe drops them all. A zone therefore lasts until the run reverts it. Rejected: reporting the zone lost and letting reconcile orphan it. | +| **D93** | **A monument is named by kind and instance, and resolved when the step runs.** The value is `/`, and `#n` picks the n-th of a kind that repeats (`srv-a/harbor_1#2`), numbered in a stable order. A step naming a unique monument therefore survives a wipe onto a new procedural map. A kind the new map does not have is refused `retry: false`, with the reason. The source lists what this wipe's map actually has. Rejected: any instance picked at random, and a pinned position (every saved event would break at the next wipe). | +| **D94** | **`EventsEnabled` gates the world verbs too.** It is one consent: *events may change this world unattended*. As with D76, revert, reconcile and listing always work, so switching events off never strands a crate, an NPC or a zone. | +| **D95** | **Per-step bounds: 25 crates, 20 NPCs.** A 50 m scatter, a monument offset of up to 150 m, a zone radius of 5 to 150 m, and raw coordinates that must lie on this map. They are **refused, never clamped** (link v6 §8.2's rule). They are plugin config that an operator may lower, and the site mirrors them so a dry run shows them. (The org lead chose these over the 10/10 I recommended.) | + +### 28.1 Facts 13a rests on + +**From the Oxide rig**, 2026-09-24, found with a probe plugin (`RgProbe13`, rig-only, never +shipped): + +- **The map reports 85 monuments, and their names repeat.** There are two Harbors (`harbor_1`, + `harbor_2`), two Fishing Villages, two Abandoned Supermarkets, seven Train Tunnels, five Water + Wells and about thirty Substations. Each `MonumentInfo` carries a prefab path, a display phrase, a + tier, a type and bounds. A display name therefore cannot be an option value, which is why D93 + exists. The prefab short name is unique per kind, and the instance number separates repeats. +- **The allowlist candidates exist, taken from `GameManifest.Current.entities` rather than from + memory**: 51 crate prefabs, 25 `ScientistNPC_*`, `NPC_Bandit_Guard`, the tunnel and underwater + dwellers, `codelockedhackablecrate.prefab` and `supply_drop.prefab`. Each one the plan names below + was spawned at the Power Plant and reported its type: crates `LootContainer`, the hackable crate + `HackableLockedCrate`, the supply drop `SupplyDrop`, the scientists `ScientistNPC`. +- **A restart is not proof that a placed object is gone, and that is the opposite of UO's town + crier.** Everything was spawned, then `server.save`, then a restart. The crates, the hackable + crate, the supply drop and the loot barrel were all **still there with the same net id**: + `enableSaving` is true on each. All three scientists were **gone**: `enableSaving` is false on + NPCs. So `reconcileByBootId`'s trick is wrong for a crate and right for an NPC. **Reconcile asks + the plugin by net id**, and the plugin answers what the world holds. Only a **wipe** proves that + everything is gone. +- **ZoneManager keeps temporary zones in memory, and its `OnPluginUnloaded` erases every zone the + unloading plugin owns** (`ZoneManager.cs` 3.1.14, line 92). An `oxide.reload RunicGateway`, which + every framework update and every hand reload causes, would silently delete every event zone in the + middle of a run. A reload of ZoneManager itself loses them too, because the whole dictionary goes. + That is D92. +- **`EraseTemporaryZone(owner, id)` erases an unowned zone and returns `true`** (§12.4, still so in + 3.1.14). The bridge's own registry decides what it may erase, and never ZoneManager's answer. + +**From the bridge** (`Rust-Plugins`, `edge`): + +- **There is no at-most-once store.** UO's shard answers a repeated key with the original reply, + and this plugin has nothing like it. Without one, a `prefab.place` retried after a lost answer + places everything twice. **The ownership registry is therefore keyed by core's idempotency key**, + and a repeat of a key answers with the net ids the first call made. The same record answers a + `revert` that arrives with no resources and only the key: *everything placed under that key*. +- **The mirror's owner bug is live** (`RunicGateway.cs` 3144 and 3179, `this` in both). + +**From core**: an action is handed `{ runId, stepId, idempotencyKey, scope, params, actor, verify }` +and never the run's participants, which is why D81 needs a tally at all. The timeouts are the +sidecar reply at 10 s and this module's client at 12 s, so every 13a verb declares +`budgetMs: 15000`. + +### 28.2 The 13a catalogue + +**Budgets** (D89): `rust.prefabs` (crates placed, count), `rust.npcs` (NPCs placed, count) and +`rust.zone.minutes` (zone time held, minutes). + +**Option sources**: + +| Source | Values | Filled from | +|---|---|---| +| `rust.options.monuments` | `/` or `/#` | each configured server's live monument list, grouped by server. The label is the display phrase plus a grid square, so two Harbors can be told apart | +| `rust.options.prefabs` | an allowlist key (`crate.elite`, `npc.scientist.heavy`, …) | the plugin's allowlist, grouped **Crates** / **NPCs** | + +**Actions** (all `risk: 'change'`, `reversible: 'ledger'`, `budgetMs: 15000`): + +| Action | Params | Ledger | `cost()` | +|---|---|---|---| +| `rust.zone.open` | `server`, a location, `radius`, `name`, optional `minutes` | one resource: the zone | `{ 'rust.zone.minutes': minutes }` when given | +| `rust.prefab.place` | `server`, a location, `prefab`, `count`, optional `spread` | one resource **per net id** | `{ 'rust.prefabs': count }` or `{ 'rust.npcs': count }` by the prefab's kind | + +A **location** is `monument` (from the source) with an optional `offsetX`/`offsetZ` in metres, **or** +`x`/`z` (and an optional `y`, otherwise the terrain height). A step that gives both kinds, or +neither, is refused `retry: false`. + +- **A step names its server as a param, the way a lease names it in its target (D73).** A scheduled + run's scope is always `''` (§27.1), so the scope cannot carry it. +- **The allowlist as planned**, trimmed further if the walk cannot see any of it working: + - Crates: `crate.basic`, `crate.normal` (military), `crate.normal2`, `crate.elite`, `crate.tools`, + `crate.hackable`, `supply.drop`, `barrel.loot`. + - NPCs: `npc.scientist` (roam), `npc.scientist.heavy`, `npc.scientist.tethered`, + `npc.bandit.guard`. +- **One resource per net id**, as UO files one per serial. A group of which players looted half + then reconciles per crate, and not all or nothing. + +### 28.3 What 13a builds + +**Rust-Plugins**: protocol 9, and `overlay.toml`'s protocol advanced to match. + +- **The owner fix (D85), first and as its own commit.** A `null` owner, and a read-back after every + grant so the report counts only what the store holds. +- **`world.zone`, `world.place`, `world.revert`, `world.owned` and `world.monuments`.** + - **Every write is gated by `EventsEnabled`** (D94) and bounded by config (D95). The refusal names + the switch or the bound. + - **A monument resolves when the step runs** (D93), from `TerrainMeta.Path.Monuments` in a stable + order (prefab path, then position). The ground height comes from `TerrainMeta.HeightMap`. + - **Raw coordinates are refused off the map** (`World.Size`). +- **The ownership registry** is a file under the framework's data directory, beside the lease + holds. Each entry records the run id, the idempotency key, the kind (zone, crate, NPC), the net id + or zone id, the prefab key, where it was put, the `bootId` and the `wipeId`. + - **A repeated key answers with the original ids** and places nothing (§28.1). + - **`world.revert`** kills what the registry says this run owns: every net id or zone id asked + for, or, given only a key, everything under it. An id that is already gone is a success. **An + id the registry does not hold is `refused`**, never erased, whatever ZoneManager would allow + (§12.4). + - **`world.owned`** answers what the world still holds, checked live: + `BaseNetworkable.serverEntities.Find(net id)` for an entity, the registry and `CheckZoneID` for + a zone. Any entry whose thing is gone is pruned as it walks, as UO's does. + - **Load**: on the same boot, nothing changes. After a restart, NPC entries are gone, found by + looking (the game does not save them, §28.1), and crates are still there. After a new wipe, the + whole registry is dropped and everything reads gone. +- **Zones (D92)**: each zone is created with the bridge as `owner` and an id of its own naming + (`rg--`). It is re-created from the registry on the bridge's load, on + `OnPluginLoaded(ZoneManager)` and after a restart within the same wipe. ZoneManager being absent + is refused with the reason, as every base-plugin dependency is. +- **Carbon**: nothing conditional is expected. `GameManager`, `BaseNetworkable` and `TerrainMeta` + belong to the game, and ZoneManager is the same `.cs` file on both frameworks. The walk proves it. + +**Rust-Link**: protocol 9. `POST /world/zone`, `POST /world/place`, `POST /world/revert`, +`GET /world/owned?runId=` and `GET /world/monuments`, stamped and forwarded as the lease routes are. +The sidecar knows nothing about prefabs, bounds or monuments. + +**Module-Rust**: + +- `registerEventBudgets` (the three above), `registerEventActions` (the two above) and two more + `registerEventOptionSources`. +- `perform` validates everything a dry run can check without the game: the server is configured, + exactly one kind of location, the bounds (mirrored), and the prefab is in the allowlist. It + answers `verify` there, then sends the key through. +- `revert` sends **no key of its own** (MODULE_API §2.4's `module-uo` lesson: the undo must not + carry the key the do went out under), and treats `refused` as `failed`. `reconcile` asks + `world.owned` and answers `inForce`. Anything unanswerable leaves the group alone. +- **The watch**: the server refresh already writes each server's `bootId` and `wipeId`. A change in + either calls `ctx.events.reconcile()`, and the log line says which. The first sighting after the + module boots is not a change, because core's own boot reconcile covers it. +- The mirror's report (`permSync`) gains the plugin's read-back count, so the admin page shows + *"not landed"* where it used to show *applied*. +- `ci/bundle.json` gains the new file, and the frozen manifest is regenerated if it moves. No new + route, and no swagger change is expected. + +**docs**: this section as built, the phase rows, §9's catalogue corrected, and +`rust-link/PROTOCOL.md` §15 for protocol 9. + +### 28.4 13a is done when + +The row's second half, with D86 added: **a wipe reconciles the ledger instead of stranding it, and +each world verb's teardown is observed in the game.** Walked on the Oxide rig against real core, with +event runs authored in the admin UI: + +1. **Each verb, observed.** A zone opened at a monument with an offset: `GetZoneIDs` lists it, and + `IsPlayerInZone` is true for a player moved into it. Crates and NPCs placed at a monument and at + raw coordinates are found by net id where they were put. At teardown every one is gone and every + row reads `reverted`. +2. **The lost answer.** A dispatch whose reply is dropped, then retried with the same key, places + nothing new. A `revert` with no resources and only that key removes what was placed. +3. **Reload and restart.** An `oxide.reload` of the bridge mid-run, and one of ZoneManager: the zone + comes back. A server restart mid-run: the watch calls reconcile, crates stay `confirmed`, NPCs go + `orphaned`, and the zone comes back. +4. **The wipe.** A wipe mid-run (a new save on the rig): the watch calls reconcile, every row goes + `orphaned`, teardown does nothing harmful, and nothing is placed on the new map. +5. **The switch and the bounds.** With `EventsEnabled` off, a place is refused with the switch + named, and reverting an existing run still works. 26 crates is refused on the authoring form and + by the plugin. +6. **The owner fix.** A site grant of a permission another plugin registered lands in Oxide's + store, and the report counts it. Before the fix it read *applied* and held nothing. +7. **Carbon.** At least one of each verb, with reload and restart, on the Carbon rig. If that cannot + be done, this section says so. + --- [aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app @@ -4365,5 +4577,8 @@ the pair stayed granted (`already true`), because the site had asked for it. [rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link [rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins [mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust +[w205]: https://gitea.whitlocktech.com/RunicGateway/website/issues/205 +[w206]: https://gitea.whitlocktech.com/RunicGateway/website/issues/206 +[w207]: https://gitea.whitlocktech.com/RunicGateway/website/issues/207 [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit