diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index ca3013d..b3b5a01 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -999,7 +999,7 @@ 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 | -| 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 | +| 13a | **Events: the world verbs** (§9, R17, **split by D80**). ✅ **Built and walked 2026-09-24 on both rigs — plan in §28 (D80–D95), as built and findings in §28.5–28.8 (D96–D97).** A restart mid-run found that the reconcile asked a world that had not loaded, and the plugin pruned live crates on the empty answer; fixed with `worldReady` (§28.7). The owner fix was walked before and after. The placing verb became two (D97), because core infers cap boxes from examples. The phase-7 owner fix (D85); `rust.zone.open` and `rust.prefab.place` (crates and NPCs, D88) at a monument or raw coordinates (split into `rust.crate.place` and `rust.npc.place` by D97); 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 | @@ -4454,6 +4454,11 @@ sidecar reply at 10 s and this module's client at 12 s, so every 13a verb declar ### 28.2 The 13a catalogue +> **Corrected in the build (§28.5).** `rust.prefab.place` became two verbs, `rust.crate.place` and +> `rust.npc.place`, with sources `rust.options.crates` and `rust.options.npcs` (D97). A zone's `minutes` +> is **required**, and the game erases the zone when it runs out (D96). The tables below are the plan as +> first written. + **Budgets** (D89): `rust.prefabs` (crates placed, count), `rust.npcs` (NPCs placed, count) and `rust.zone.minutes` (zone time held, minutes). @@ -4568,6 +4573,126 @@ event runs authored in the admin UI: 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. +### 28.5 Two more decisions the build needed + +Both from the org lead on 2026-09-24, and both asked mid-build because the tree answered +differently from §28.3. + +| # | Decision | +|---|---| +| **D96** | **A zone's `minutes` are required, and the game erases the zone when they run out.** §28.2 had them optional, with the zone lasting until teardown. But `rust.zone.minutes` can only bound what a step declares, so a zone opened without minutes would have cost nothing against the operator's cap. Now `minutes` runs from 1 to 7 days (the lease ceiling), the plugin checks every second and emits `world.expired`, and the cost is exact. The zone never outlives its event, even with the site gone. D92's re-creation applies until the deadline. Rejected: counting zones instead of minutes, and keeping §28.2 as written. | +| **D97** | **One placing verb per kind: `rust.crate.place` and `rust.npc.place`**, in place of §28.2's single `rust.prefab.place`. Core works out which cap boxes an action accepts by **pricing its declared example params once**, and drops a dimension priced at zero. A verb whose cost moved between `rust.prefabs` and `rust.npcs` by its `prefab` param could only ever show the crates cap, so D89's separate dial for fights was unreachable. Each verb now prices exactly one dimension, and the prefab source is split to match (`rust.options.crates`, `rust.options.npcs`). The switchboard can also allow crates and leave fights off. The plugin's `world.place` is unchanged. Rejected: a core change to how dimensions are discovered, which would be a `MODULE_API` change and out of this phase's scope. | + +### 28.6 As built, 2026-09-24 + +[Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr] on `feat/phase-13a-world`, into `edge`, as +protocol 9 ([`PROTOCOL.md`](../../rust-link/PROTOCOL.md) §15). **Core is unchanged and `MODULE_API` +did not move.** + +| Repo | What | +|---|---| +| Rust-Plugins | The owner fix, with a read-back and a `notLanded` list (D85). `world.monuments` / `.zone` / `.place` / `.revert` / `.owned`, the `world.json` registry keyed by the website's key, the zone deadline and its `world.expired` event, zone re-creation, `worldReady`, and `EventsMaxCrates` / `EventsMaxNpcs` | +| Rust-Link | Five forwards and `PROTOCOL_VERSION` 9 | +| Module-Rust | `rust.zone.open`, `rust.crate.place` and `rust.npc.place` (`reversible: 'ledger'`, `budgetMs` 15 000), the three budgets, `rust.options.monuments` / `.crates` / `.npcs`, the boot-id and wipe-id watch, and the mirror keeping `notLanded` grants out of what it records as pushed | + +**Walked against real core on `main`** (the `rustp12` database, this module staged from the +branch), with event runs authored through the admin API and every effect checked in the game with a +rig-only probe plugin: + +1. **Each verb, observed.** A zone 20 m east of the Power Plant (ZoneManager listed it with the + authored name and radius), three elite crates at the monument with an offset, and two tethered + scientists at raw coordinates. Core's dry run priced all three dials separately, and the run + consumed exactly what it declared (2 of 6 NPCs, 3 of 30 crates, 20 of 600 zone-minutes). All + twelve allowlist entries were spawned through the sidecar and each reported its kind. Teardown + left nothing in the world, and every row read `reverted`. +2. **The lost answer, through core.** A proxy between the module and the sidecar let the first + placement reach the game and then swallowed the reply. Core retried a minute later under the + same key, and the plugin answered with **the same four net ids**. Four crates in the world, not + eight. In a second run the reply was dropped and the run was cancelled before any retry. Core's + placeholder was reverted by key alone, and the plugin removed all four. +3. **Reload and restart.** An `oxide.reload` of the bridge, and one of ZoneManager, each logged the + zone re-created. A server restart in the middle of a run (after the §28.7 fix) gave: about + 95 seconds of `worldReady: false`, then the watch asked core to reconcile, the three crates and + the zone stayed `confirmed`, and the two NPCs went `orphaned`. The next teardown reverted all + six. +4. **The wipe.** The save was moved aside (and put back afterwards) so the server booted into a new + one mid-run. The plugin logged *"a new wipe dropped 6 things from the old map"*. The watch saw + both the restart and the new wipe id, core orphaned all six rows, no zone was re-created on the + new map, and teardown resolved everything harmlessly. +5. **The switch and the bounds.** A 26-crate step failed the dry run with the bound named. With + `EventsEnabled` switched off from the site's own config editor, a placement was refused on its + first attempt (`retry: false` honoured) and the run paused with the switch named. A run placed + earlier was still torn down cleanly while the switch was off. +6. **The owner fix, before and after.** The phase-12 plugin from `edge` pushed a site group + carrying `zonemanager.zone`. The report said `groupPermissions: 1` and the site showed the server + in sync, while `oxide.show group` answered **"No permissions currently granted"**: phase 7's + defect, reproduced. The protocol 9 plugin, synced against the same group, granted it, read it + back, counted it, and `notLanded` was empty. +7. **Carbon.** The same plugin file, ZoneManager 3.1.14 installed on the Carbon rig (it had never + been), and a protocol 9 sidecar in its container. One run held all three verbs. A `c.reload` of + the bridge showed the same ZoneManager behaviour as Oxide (the zone erased on unload, re-created + on load). A restart mid-run gave the same answer as Oxide: **the crates kept their net ids**, + 4 in force and 2 NPCs orphaned. Teardown left the world empty. + +### 28.7 What the walk found that the plan did not say + +**Two defects, both fixed before merge, and both found by restarting the rig in the middle of a +run:** + +- **The reconcile asked about a world that had not loaded yet, and the plugin believed the empty + answer.** The link starts in `Init`, before the save loads. So the new boot id reached the site at + once, the watch asked core to reconcile, and `world.owned` looked for three live crates in an + empty world. It pruned them from the registry, and core orphaned them. They stayed in the world, + owned by nothing, and teardown's revert then **refused** them (correctly: the plugin will not erase + what it cannot prove it made), which left three rows unresolved. This is the §11.1 rule, *"I do not + know" is never "it is gone"*, broken on the game side rather than the website side. The fix has + two halves. The plugin answers `not-ready` to every world command until `OnServerInitialized`, and + `server.hello` carries `worldReady`. The module's watch waits for `worldReady: true`. Re-walked: the + gap was about 95 seconds, and nothing was orphaned that still existed. +- **`revert()` read any 200 as success.** A refusal on this bridge is a 200 carrying `world.error`, + so a `not-ready` revert would have marked every row reverted with every crate still standing. It + now requires `world.ok`. This is the same trap §19.4 found in the other direction (core's client + reading `message` where this module answered `error`), and it will come up again for every verb + in 13b. + +**Three things the contract did, found by building against it:** + +- **Core infers an action's cap boxes from its examples** (D97). This is documented in + `authorize.js` (*"a `cost()` that returns different dimension KEYS for different params + under-reports here"*) and nowhere a module author would read it. It is worth a line in + `MODULE_API.md` §2.4 and in the kit's chapter 5. **Raised, not changed here.** +- **A key-only revert cannot know its server.** Core hands `revert` no params, so a step whose + answer was lost can only be undone by asking every enabled server. One server that cannot be + reached therefore holds the row open: in walk 2 the Carbon rig was stopped, and the placeholder + stayed unresolved until Carbon came up and the manual cleanup route was used. That is the honest + outcome (MODULE_API: *"answers `{ ok: false }` and the row stays visible"*), and it is written down + here because an operator with a server switched off for a season will meet it. +- **`registerEventOptionSources` is once per module**, so the world sources join the lease sources + in one batch. This is obvious in hindsight, and a second call throws at boot. + +**Smaller things worth keeping:** + +- **ZoneManager was not installed on the Carbon rig**, although R17 made it a base plugin. It is now + installed (3.1.14, the same file as the Oxide rig). Phase 18's egg must install the base set on + both frameworks. +- The rig's `latest.log` holds only the current boot. `oxide/logs/oxide_.txt` is the log with + timestamps, and it is what showed the connect happening two minutes before the world was ready. +- Two rig-tooling traps from this walk: Git Bash's `MSYS_NO_PATHCONV=1`, which the `/oxide/...` + paths need, also stops `git -C /c/...` and `taskkill //PID` from being converted. The first + produced an empty plugin file on the rig. It was restored within a minute, and Oxide kept the + loaded version meanwhile. Also, a walk core restart can fail silently when the old process keeps + the port, so check for a new `listening on` line. + +### 28.8 What is not proven here + +- **A real player in a zone.** The rig has no second client. The zone was observed through + ZoneManager's own `GetZoneIDs` / `GetZoneLocation` / `GetZoneRadius`. `IsPlayerInZone` against a + player moved into it was §28.4's wording and was not walked. 13b's tally is where presence has to + be proven, with a person. +- **A crash, as opposed to a restart**, for the registry file. The same load path runs either way. +- **The zone deadline through core.** It was walked at the sidecar (a 60-second zone erased itself + and emitted `world.expired`), and not inside a run. + --- [aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index 49df05c..a9a74cf 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -50,7 +50,7 @@ it is listening without one. ## 2. Versioning -The wire version is a single integer — **8** as of the leases (§14) — declared in +The wire version is a single integer — **9** as of the world verbs (§15) — declared in **four** places that must agree: | Where | Repo | @@ -206,6 +206,7 @@ Every response carries `X-RustLink-Version`, including `/health` and including e | `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply | | `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect | | `GET /lease`, `POST /lease`, `POST /lease/release` | the plugin (RPC) | Protocol 8, the leases (§14) | +| `GET /world/monuments`, `GET /world/owned?runId=`, `POST /world/zone`, `POST /world/place`, `POST /world/revert` | the plugin (RPC) | Protocol 9, the world verbs (§15) | ### 4.1 The split between store-backed and live is deliberate @@ -1399,3 +1400,139 @@ That is below the sidecar's 10 s reply timeout, so the module can give up on an still going to take. It follows a timed-out apply with a release of the same value down the same link. The plugin handles the two in order: if the apply landed, the hold's own baseline goes back, and if it never did, the compare finds nothing to do. + +## 15. Protocol 9 — the world verbs + +Added in phase 13a ([`PLAN.md`](../modules/rust/PLAN.md) §28). An event **makes** something that was +not there, a zone or crates or NPCs, and gives it back at teardown. **Five commands, one event, two +plugin config keys and one `server.hello` field.** As in §14, the plugin holds everything that +decides what is allowed: the allowlist, the bounds, the monument vocabulary and the registry of what +each run owns. The website holds the ledger (core's `event_run_resources`), and the sidecar forwards +five routes. + +### 15.1 The commands + +```json +{"cmd":"world.place","reqId":"r-9","runId":"13","key":"6803fe68…","prefab":"crate.tools", + "count":4,"spread":5,"monument":"powerplant_1","offsetX":-30,"offsetZ":0} +``` + +| Command | Answers | | +|---|---|---| +| `world.monuments` | `world.monuments` | This map's monuments in one stable order (grouped by prefab short name, then by position). Each has `value` (`kind`, or `kind#n` when the kind repeats), `kind`, `instance`, `of`, `label` (the game's display phrase), `x`, `z` and `grid`. Also `worldSize`, the placeable `prefabs` (`key`, `kind`, `label`), `eventsEnabled`, `maxCrates`, `maxNpcs` and `zoneManager` | +| `world.zone` | `world.ok` or `world.error` | Opens a ZoneManager temporary zone owned by the bridge. Needs `runId`, `key`, a location, `radius` (5–150 m) and `holdMs` (1 minute to 7 days); `name` is optional | +| `world.place` | `world.ok` or `world.error` | Places `count` of one allowlisted `prefab` at a location, scattered within `spread` m (0–50, 10 by default for a group). All or nothing: if the game refuses one, the ones already made are killed | +| `world.revert` | `world.ok` | Gives back what a run owns: the named `ids`, or else everything under `key`, or else everything the run owns. The answer lists `removed`, `gone` and `refused` | +| `world.owned` | `world.owned` | What the world still holds of what events made, **looked for** by net id or zone id, narrowed by `runId`. Anything gone is pruned from the registry as the walk passes it | + +**A location is a monument or coordinates, exactly one.** A monument location is `monument` (a `value` +from `world.monuments`) plus an optional `offsetX`/`offsetZ` of up to 150 m in total. A bare kind +that repeats means its first instance. A coordinate location is `x` and `z`, which must lie on the +map, and an optional `y`. When no height is given, the ground height is used. + +`world.ok` for a zone or a placement carries `placed`, one row per thing (`id`, `kind`, `runId`, `x`, +`y`, `z`, and `prefab`, or for a zone `radius`, `name` and `remainingMs`), and **`repeat: true` when +the `key` had already been used** (§15.2). + +**`world.error` reasons**: + +| `reason` | Means | Worth retrying | +|---|---|---| +| `events-disabled` | `EventsEnabled` is off (§15.4) | no | +| `malformed` | both kinds of location or neither, a missing field, or no `runId` | no | +| `unknown-prefab` | not in the plugin's allowlist | no | +| `out-of-range` | a count, radius, spread, offset or duration outside the plugin's bounds | no | +| `no-monument` | this map has no such monument, or not that many of it | no | +| `off-map` | coordinates outside the map | no | +| `zonemanager-missing` | ZoneManager is not loaded | no | +| `not-ready` | **the world has not finished loading** (§15.5) | yes | +| `refused` | the game or ZoneManager did not create what was asked | yes | + +**The allowlist** (PLAN.md D88) is crates and NPCs only, never vehicles. The crates are +`crate.basic`, `crate.normal` (military), `crate.normal2`, `crate.elite`, `crate.tools`, +`crate.hackable`, `supply.drop` and `barrel.loot`. The NPCs are `npc.scientist`, +`npc.scientist.heavy`, `npc.scientist.tethered` and `npc.bandit.guard`. Each one was spawned on the rig +and reported its type before it was listed. + +### 15.2 The registry, and why it is keyed by the website's key + +`world.json`, under the framework's data directory beside `leases.json`, records everything events +have made on this map. Each entry holds the id, the kind, the prefab, the run, **the website's +idempotency key**, the position, a zone's radius, name and deadline, and the `bootId` and `wipeId` it +was made under. + +**This bridge has no at-most-once store**, unlike UO's shard. The registry is keyed by the website's +key instead. A `world.zone` or `world.place` whose key the registry already holds is a retry whose +first answer was lost, so it is answered with **the first call's ids and `repeat: true`**, and nothing +is placed. The same record answers a `world.revert` that carries only a key: *everything placed under +it*. + +**What may be erased is decided by the registry and never by the world.** `world.revert` of an id +the registry holds for another run is `refused`. An id the registry does not hold is `gone` when +nothing is there (a wipe or a prune already took it), and `refused` when something **is** there. The +bridge never erases a thing it cannot prove it made, and ZoneManager's `EraseTemporaryZone` returns +`true` for an operator's own hand-made zone (PLAN.md §12.4). + +### 15.3 What a restart and a wipe do + +Found on the rig, not taken from a document (PLAN.md §28.1): + +| | A crate, the hackable crate, the supply drop, a barrel | An NPC | A zone | +|---|---|---|---| +| **Plugin reload** | there | there | **erased by ZoneManager** as the bridge unloads, and re-created from the registry on load | +| **ZoneManager reload** | there | there | erased, and re-created on `OnPluginLoaded(ZoneManager)` | +| **Server restart** | **there, same net id** (the game saves it) | gone (the game does not save NPCs) | re-created at `OnServerInitialized` | +| **Wipe** | gone | gone | dropped, not re-created | + +So **a restart is not proof that a placed thing is gone**, which is the opposite of UO's town crier. +`world.owned` always looks. A new save (`OnNewSave`), or a load that finds entries recorded against +another wipe, drops those entries whole. + +**A zone has a deadline, and the game keeps it** (PLAN.md D96). The plugin checks every second and +erases a zone whose `holdMs` has run out, whether or not the website is heard from again, and emits: + +```json +{"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`. + +### 15.4 `EventsEnabled`, and the two bounds + +`EventsEnabled` (§14.3) now gates **every world write** as well as `lease.apply` (PLAN.md D94). +`world.revert`, `world.owned` and `world.monuments` are never behind it, so switching events off never +strands anything. + +Two new config keys, written into an existing config the first time protocol 9 loads: +**`EventsMaxCrates`** (25) and **`EventsMaxNpcs`** (20), per step. An operator may lower them. A value +above the ceiling is clamped down to it, because the website mirrors the ceiling. A step over the +bound is refused, never trimmed. + +### 15.5 `worldReady`, and why the world is not there yet + +**The link starts in `Init`, before the save loads**, so for the first minute or two of a real start +the plugin is connected, its `bootId` is new, and the world is empty. Asked `world.owned` then, the +phase 13a walk's plugin found no entity behind any net id and pruned three live crates from its own +registry. + +So every world command answers **`not-ready`** until `OnServerInitialized`, and `server.hello` carries +**`worldReady`**. The field is `false` from connect until the world has loaded, then `true`. A hello is +also sent at that moment, rather than at the next board tick. A website that reconciles on a changed +`bootId` or `wipeId` waits for `worldReady: true` before it asks. On the rig the gap was about +95 seconds. + +### 15.6 The sidecar + +`PROTOCOL_VERSION` becomes 9. There are five routes, each a correlated round trip that fails when the +game is down: + +| Route | Command | | +|---|---|---| +| `GET /world/monuments` | `world.monuments` | | +| `GET /world/owned?runId=` | `world.owned` | `runId` optional; forwarded as it is | +| `POST /world/zone` | `world.zone` | Opaque object; `cmd` and `reqId` written over the caller's | +| `POST /world/place` | `world.place` | The same | +| `POST /world/revert` | `world.revert` | The same | + +`world.expired` is an `event`, filed and served like every other (§8.1).