# `module-rust` — the redesigns, planned **Status:** plan, written 2026-09-27. **Its eleven questions were answered the same day: D188–D198 (§10).** On 2026-09-28 the org lead added the first-class optional plugins (§11, D199–D202), and after the RaidableBases spikes, D203–D208 (§11.4). It is step 3 of [`PLAN_FIXES.md`](PLAN_FIXES.md) §6: the six changes the org lead decided during the first player walk, each "planned in detail before code". The org lead asked for all six in one plan (2026-09-27), so they can be read together. **Built so far:** §1, walked 2026-09-28 (§1.9); §5, built 2026-09-28 with its rig walk still to come (§5.8, D209); §3, built and walked on the rigs and the site 2026-09-29, its in-game rows still to come (§3.4); §4, built 2026-09-29 (§4.1). **§6 was spiked on 2026-09-30, and the org lead chose neither route: Runic Gateway writes its own NPC plugin, RunicNPC, planned in [`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) (D214–D314), and the rest of this plan waits for it.** RunicNPC v1.0.0 shipped on 2026-10-09, and the plan resumed with §2 the same day (D331–D334); §2 was built on 2026-10-12, and §11's Economics spike the same day raised D335–D336 (§11.2.1). Economics was built on 2026-10-12 too (§11.2.4). The Backpacks spike followed and raised D337–D339 (§11.3.1), and Backpacks was built the same day (D340, D341, §11.3.4). The org lead then added permission rewards (§12, D342–D346), planned and built the same day. On 2026-10-14 RaidableBases was deferred until the current paid copy arrives (D347), with both editions to be supported (D348, §11.4.2). The same day the org lead added Rust's own world events to the base module (§13, D349) and six more first-class optional plugins (§14, D350–D352), to be tested together in one phase before their integration plan. Their questions were answered the same day (D353–D359, the end walk now waiting for RaidableBases), and Steam sign-in stays on the website's plan, linked as §15 (D360). **The §14.2 test phase ran the same day on both rigs (§14.2.1)**: the org lead ruled D361–D362 during it and answered its Q19–Q25 the same day (D363–D369). **Every item left is built, tested and PR'd first; every walk — theirs and the ones still owed by §3 and §5 — happens together at the end (D331, §9).** This is a companion to [`PLAN_FIXES.md`](PLAN_FIXES.md) and [`PLAN.md`](PLAN.md). Where they disagree, this document is later and wins. Its decisions continue PLAN_FIXES' numbering at **D188**. | § | Redesign | PLAN_FIXES | Where the work lands | |---|---|---|---| | 1 | The permission manager | §4.1, D160–D163 | Rust-Plugins, Module-Rust | | 2 | The event step editor and the kit weekend | §4.2, §4.3, D164, U-3–U-6, F11 | **core** (website `main`), Module-Rust, Rust-Plugins | | 3 | Zones: ZoneManager's options, and a dome | §4.4, D166, D167 | Rust-Plugins (bridge and helper), Module-Rust | | 4 | The live map's marker types | §4.5, D165 | Module-Rust | | 5 | Chat titles: twenty-three conditions | §4.6, D172–D175 | Rust-Plugins, Module-Rust | | 6 | NPCs — now RunicNPC, its own plan ([`runicnpc/PLAN.md`](../../runicnpc/PLAN.md)) | §4.7 | **runicnpc-rust**, Rust-Plugins, Rust-Link, Module-Rust, installer | | 11 | First-class optional plugins: Economics, Backpacks, RaidableBases (Kits stays required) | — (added 2026-09-28) | Rust-Plugins, Module-Rust, installer, Android-app | | 12 | Permissions as event rewards: until the event ends, or for good | — (added 2026-10-12) | **core** (website `main`, MODULE_API 1.14.0), Module-Rust | | 13 | Rust's own world events (helicopter, Bradley, cargo, Chinook, airdrops, locked crates) in the base module | — (added 2026-10-14) | Rust-Plugins, Rust-Link, Module-Rust | | 14 | Six more optional plugins: TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels, Server Rewards | — (added 2026-10-14) | Rust-Plugins, Rust-Link, Module-Rust, Android-app | | 15 | Steam sign-in: a link to the website's own plan, [`website/STEAM_SIGNIN.md`](../../website/STEAM_SIGNIN.md) | §4.8, D171 | **core** (website), Android-app, Module-Rust | **Protocol.** §1, §3, §4 and §5 joined **protocol 13**, which shipped on 2026-10-09 with RunicNPC v1.0.0 (bundle 2026.10.09). The wire changes still to come, §2's and §11's, **open protocol 14** on `edge` and are released together once, after the end-of-plan walk (D332, §7). **Core.** §2 is mostly core's editor, which is game-agnostic and serves Module-uo too. Its contract additions are **MODULE_API 1.12.0 → 1.13.0**, additions only (§8; 1.12.0 went to Events' `progress()`, website#210). The same proof website#209 ran for 1.11.0 applies: Module-uo's frozen-manifest job and its suites, run against the new core. --- ## 0. What was checked before writing Each of these was a question PLAN_FIXES left open or a claim this plan depends on. All were checked on 2026-09-27 against the two rigs (`rust-oxide`, `rust-carbon`) or the code. The rig checks used a read-only probe plugin that wrote one data file. It was unloaded and deleted afterwards on both rigs. | # | Question | Answer | Where it lands | |---|---|---|---| | 0.1 | Can the bridge learn which plugin registered a permission through public API? | **Yes, on both frameworks.** `permission.PermissionExists(name, owner)` takes the owning plugin (Oxide: `Plugin`; Carbon: `BaseHookable`). Asking it of every loaded plugin named an owner for all 85 permissions on Oxide (`RustCore` owns `oxide.*`) and all but 30 on Carbon, in 3.3 ms and 1.8 ms. **ZoneManager owns all 70 `zonemanager.*` names, `zonemanager.ignoreflag.nokits` included**, which uMod PermissionsManager files under Kits. The 30 Carbon names without a plugin owner are `adminmodule.*`, which belong to Carbon's built-in AdminModule (a Carbon *module*, not a plugin). | §1.2 | | 0.2 | Can the bridge list every user in the store? | **No.** Neither framework has a public "all users" call (`GetUsers` and `GetAllUsers` do not exist on either). The complete holder list comes from `GetPermissionUsers` for each permission plus `GetUsersInGroup` for each group. That took 1.7 ms and 2.2 ms on the rigs' tiny stores, but each call walks every user, so on a real store the cost is permissions × users. | §1.2 (sliced) | | 0.3 | Do groups carry anything the site does not model? | **Parents.** `GetGroupParent` answers on both. The site's `rust_perm_groups` has no parent column. | §1.4 | | 0.4 | Is there a framework hook for a finished Rust mission (D173)? | **Yes: option 1 of D173's spike.** Oxide's patched `Assembly-CSharp.dll` raises `OnMissionSucceeded`, `OnMissionFailed`, `OnMissionStarted` and `OnMissionAssigned`, and `Carbon.Hooks.Oxide.dll` patches the same four names onto `BaseMission`. Rust's own methods are `BaseMission.MissionSuccess(MissionInstance, BasePlayer assignee)` and `MissionFailed(MissionInstance, BasePlayer, MissionFailReason, bool)`. The catalogues [`HOOKS.md`](HOOKS.md) and [`CARBON.md`](CARBON.md) predate them. The exact hook arguments are confirmed when a person finishes a mission (§5.7). | §5.7 | | 0.5 | Is ZoneDomes' API safe with a null player (PLAN_FIXES §4.4, §7)? | **No.** `AddNewDome` and `RemoveExistingDome` end with `player.ChatMessage(...)` and no null check. With a null player the dome is made and saved, and then the call throws. On boot, ZoneDomes also drops every dome whose zone does not exist yet, and the bridge's temporary zones are re-created after that. | §3.2 | | 0.6 | Can ZoneManager's flag list be read at run time? | **Only from inside.** The list is `ZoneManager.ZoneFlags.NameToIndex`, a public static on a nested type. No hook or `Call` method returns it. The zone helper (`RunicGatewayZones.cs`) already references ZoneManager's types, and the bridge may not (R2, D168). **Wrong, found at the build (D211):** ZoneManager 3.1.14's API region has `ZoneFieldListRaw()`, which returns the zone field names followed by every `NameToIndex` key, and the bridge calls it by name. | §3.1, §3.4 | | 0.7 | What does the map actually show? | **85 markers on the rigs' map (world 3000, seed 1234): 31 substations in four prefab kinds, 7 caves in six, 7 train-tunnel entrances in four, and 5 water wells in three.** The label (`Substation`) groups the variants. Rust also classifies monuments itself (`MonumentInfo`'s `MonumentType`: Cave, WaterWell, Lake, Mountain, Radtown, Building, Town, Airport, Lighthouse). | §4 | | 0.8 | Can the site see how much of a kit players have used? | **One player at a time.** `kits.list` already returns each kit's `max` (MaximumUses) and `cooldown`, and the module ignores `cooldown`. Per-player use is read only during a credit settle (`GetPlayerKitUses`). | §2.6 | | 0.9 | Where do the Ultima Online examples a Rust admin sees come from (U-3)? | **Core's own actions.** `core.lease` gives `uo.rate.skillgain` as its example, and the announcement actions talk about Britain and Cove. | §2.1 | | 0.10 | What free NPC plugins exist (§4.7)? | **HumanNPC** (Remod/RFC1920) spawns NPCs with a name and a Kits kit (`SpawnHumanNPC(position, rotation, name, clone)`, `SetHumanNPCInfo`), but it **requires two more plugins, Pathfinding and Waypoints**, and its NPCs run its own AI, not Rust's. **NPC Loadouts** (VisEntities) dresses Rust's own NPCs from configuration, for every NPC of a type rather than one spawn. The bridge's `rust.npc.place` already spawns Rust's own scientists, and a scientist is a `BasePlayer`, so it has an inventory and a `displayName`. | §6 | --- ## 1. The permission manager (§4.1; D160–D163) ### 1.1 In one paragraph Today the site pushes the set it authored, and the plugin reports "foreign" holders only for the names the site claims (D31). After this change, **the plugin reads the whole store and the site keeps all of it**. The first read of a server imports everything (D160). After that, every difference between the game and the site is either something the site changed, which it pushes as it does now, or something changed in the game. An in-game change is settled by the server's policy: auto-adopt, adopt, or revoke (D161). The screen is rebuilt on PermissionsManager's flow (D162), and every subject is shown with its linked account and in-game name (D163). ### 1.2 The inventory (plugin, protocol 13) A new request/reply verb, **`perm.inventory`**, returns the whole store in the same shape on both frameworks: - **`permissions`**: every registered name with its **`owner`**, the plugin that registered it, found with `PermissionExists(name, plugin)` over `plugins.GetAll()` (§0.1). A name no plugin owns is sent with no owner. On Carbon those are the built-in modules' names, and the site groups them by prefix under "Carbon modules". - **`groups`**: name, title, rank, **parent**, and the permissions the group carries directly (`GetGroupPermissions(name, false)`; inherited permissions are the parent's, not the group's). - **`users`**: for each Steam id holding anything, the permissions held directly, the groups (**except `default`**, which every connected player is in by framework rule and which the site treats as "everyone"), and the last name the store recorded. The name comes from the store's own `id(name)` entry. - **`leased`**: the group permissions an event lease is holding. The site excludes them from the in-game-change classification the way the foreign scan excludes them today (PLAN.md §27.3). **Cost.** §0.2 means the read is permissions × users. It is **built in slices on the main thread**, one group of permissions per tick, the same drain the sync already uses (`PermOpsPerStep`). It is then **sent in pages** below 768 KiB, the way `map.fetch` stays under the sidecar's 1 MiB line cap. The site asks for page 0 and follows `more`. A snapshot is taken once and paged from memory, so a change between pages cannot tear it. The build walk measures a large store: the plan's ceiling is 20,000 users × 500 permissions under 2 s of drain, with no single tick over 20 ms. **What leaves the wire.** The sync's `managed` list and the plugin's foreign scan (`ScanPermission`, `ScanGroup`) are replaced by the inventory. Protocol 13 is unreleased, so they are removed rather than kept beside it. `perm.sync` groups gain **`parent`**, applied with `SetGroupParent` on both frameworks. ### 1.3 Who can hold a permission on the site Grants and memberships are keyed by **website user** today (D28), and adopt refuses a Steam id with no account ("there is nobody to author this against"). D160 makes the site the owner of **every** holder, including the many who never link. So the site gains **Steam-keyed holders**: - `rust_perm_steam_grants (steam_id, permission, scope, source, …)` and `rust_perm_steam_members (group_id, steam_id, …)`, beside the existing user-keyed tables. `buildDesired` unions both, and they can overlap; the existing `seenGrant` de-duplication already handles that. - **The import writes Steam-keyed rows, even for a linked player (D188).** They are an exact copy of what the game holds. A user-keyed row would reach every Steam account the person has linked (D28), which would widen what the game held. - **A toggle on the screen for a linked player writes a user-keyed row** (it reaches all their accounts, as today). A toggle for an unlinked player writes a Steam-keyed row. - Linking or unlinking an account moves nothing. A Steam-keyed row stays about that game account, and the screen shows it under the person's name once they link. ### 1.4 Groups across servers A group is one name fleet-wide today (`rust_perm_groups.name` is the primary key), with one scope, and **everything it carries or contains is the same on every server in that scope**. Real servers disagree: each has its own `default` and `admin`, and a `vip` on one server can carry different permissions from a `vip` on another. **D189: a group belongs to one server, unless an admin shares it.** The same group on several servers, or on the whole fleet, is allowed, but it is never assumed: - **A group is a row with its own id**, `rust_perm_groups (id, name, title, rank, parent, shared)`. What it carries (`rust_perm_group_permissions`) and who is in it (the member tables) belong to that row, so they are the same on every server the group is on. - **Which servers it is on** is `rust_perm_group_servers (group_id, server_id)`, or `shared = '*'` for every server, including servers added later. A server never has two groups of one name; the model refuses it. - **The import makes one group per server**, even when two servers have a group of the same name with the same contents. Nothing is merged by guessing. - **Sharing is an admin's act.** "Use this group on other servers" picks servers, or all of them. When a chosen server already has its own group of that name, the screen shows the difference and the admin picks which version wins; the other is replaced. "Stop sharing" gives each server its own copy. - `parent` names a group on the same server. A shared group's parent must be on every server the group is on. - **Built-in groups** (`default` and `admin` on both frameworks, plus `moderator` on Carbon) are imported and editable, and **never retired**: the site does not send `RetireGroup` for them. `default`'s membership is never modelled (§1.2). - **Existing installs:** a group scoped `*` today becomes a group shared with every server, and one scoped to a server becomes that server's group. What each server receives does not change. ### 1.5 The sync, rebuilt on three sets `rust_perm_pushed`'s own comment names the three sets. Until now the plugin could only compute the third one for the names the site claimed. The inventory gives the site all three: | In the game | Pushed (the site put it there) | Desired (the site authors it) | Means | |---|---|---|---| | yes | no | no | **added in the game** | | no | yes | yes | **removed in the game** | | no | no | yes | to apply (today's path) | | — | yes | no | to retire (today's path) | | yes | no | yes | landed by someone else; recorded as pushed | One sync becomes: **inventory, then classify, then apply the policy (§1.6), then push** the desired set and the retirements with the existing `perm.sync`. D32's shape is unchanged: whole set, one verb, the plugin reconciles. Triggers are unchanged too. The drift hooks still mark the server dirty, so an in-game change is settled within one tick (30 s) plus the read. **The first import (D160).** A server with no completed inventory yet (new column `rust_perm_sync.imported_at`) takes everything present as site-owned, whatever its policy, and records it as pushed. Policies apply only to changes after that. **This includes existing installs.** The first inventory after the upgrade imports every current hand edit, including the drift rows now waiting for an answer, and clears those rows (D198, confirming D160 as written). ### 1.6 The policy (D161) `rust_servers.perm_policy`, one of `auto-adopt` (the default), `adopt`, or `revoke`, set on the permission screen's server header. | Policy | Added in the game | Removed in the game | |---|---|---| | **auto-adopt** | written as the site's own, for that server only: a Steam-keyed grant scoped to it, or a change to that server's group, with `source: 'adopted'`, and recorded as pushed | the site stops giving it **on that server only** (below) | | **adopt** | a row on the "needs a person" list, with Adopt and Revoke (today's drift answers) | a row on the list, with "Accept the removal" and "Put it back" | | **revoke** | queued as a revocation and removed at this sync | pushed back at this sync (nothing new: the desired set re-applies it) | **D190: an in-game change touches that server and nothing else, even when the site's row reaches more servers.** Auto-adopt acts on it without asking: - **A grant that reaches several servers** (scoped `*`, or a user grant) and is removed on one gains an **exception** for that server: `rust_perm_exceptions (kind, subject, object, server_id)`. It keeps reaching every other server, including servers added later. `buildDesired` skips an excepted row on that server. The screen shows it as "all servers except `rust-carbon`", and removing the exception gives it back. - **A group shared across servers** that changes on one server (a permission or a member added or removed in the game) is **split**: that server gets its own copy of the group with the change applied, and the shared group stops covering that server. The other servers see no change. The "needs a person" list records the split, so an admin who wanted the change everywhere can apply it to the shared group and share it back. - A change to a row that is **that server's alone** is adopted directly: a removal deletes the row, and an addition writes one. ### 1.7 The screen (D162, D163, U-1) Following PermissionsManager 2.1.2, the reference screenshots in the workspace's `perms-screenshots/`: - **A server picker first.** PermissionsManager works on one server, and the site holds several. Everything below is for the chosen server. A grant that reaches more servers shows where it reaches ("all servers", "all except `rust-carbon`"), and a shared group says which servers share it. Changing either asks whether to change it everywhere or on this server alone. "This server alone" makes an exception or splits the group, as D190 does for an in-game change. - **Players ⇄ Groups**, as two tabs. The players list shows the server's holders from the inventory first, then a search over every player seen on that server and every linked account. Each row shows the **in-game name**, the **linked website account** if there is one, and the Steam id when the server has no cached name (D163). - **A subject, then its plugins.** The plugin buttons come from the inventory's `owner` (§0.1), never from the name's prefix. A button shows a count of what the subject holds from that plugin. - **A plugin's permissions, with Granted / Revoked**, plus Grant all and Revoke all. **Each toggle carries its own state on this server** (U-1): *granted*; *through group `vip`* (read-only, with a link to the group); *waiting for their first connection* (a member the store cannot place yet); *not registered here* (the plugin is not loaded, formerly "unresolved"); or *did not land* (D85). - **A player's groups** (add, remove, Remove all). **A group's players** (Remove all), its title, rank and parent, and the servers it is shared with (§1.4). - **The server header:** sync state, the policy, and the "needs a person" list (§1.6). - **`zonemanager.ignoreflag.*`** (the question PLAN_FIXES §4.1 left open): **D191, listed like any other permission.** They are ZoneManager's 69 exemptions, under ZoneManager's button with their own names, the same as its seventieth, `zonemanager.zone`. The existing account-first view (what one website account holds across the fleet) stays, on the account's own page, because that is where the events' grants (D84) and the chat styles (phase 17) are read. ### 1.8 The walk On both rigs: install on a server with hand-made state (a `vip` group with a parent, two direct grants, one to an unlinked Steam id), and check that the import shows it exactly, as two separate `vip` groups when both rigs have one. Then an `oxide.grant` / `c.grant` under each of the three policies. Then, under auto-adopt, an in-game revoke of a server-only grant (deleted), of a grant reaching all servers (an exception, still on the other rig), and a change to a shared group (split off, the other rig unchanged). Then share a group, stop sharing it, and Grant all / Revoke all. The plugin buttons must be by owner: `nokits` is under ZoneManager. The large-store measurement is in §1.2. ### 1.9 As built, and as walked (2026-09-28) Rust-Plugins `feat/perm-inventory`, Rust-Link `feat/perm-inventory`, Module-Rust `feat/perm-manager`. The wire is [`PROTOCOL.md`](../../rust-link/PROTOCOL.md) §19.9, and the admin API is the module's swagger fragment (`/admin/rust/permissions…`). **What the build settled that the plan left open:** - **The data model.** Groups are rows with ids: `rust_permgroups`, plus `_servers` (with `included = 0` to take one server out of an all-servers group), `_permissions`, `_members`, `_steam_members` and `_chat`. The chat style belongs to a group row now, because one server's `vip` may be styled differently. Steam-account grants are `rust_perm_steam_grants`, and D190's exceptions are `rust_perm_exceptions`. `rust_servers.perm_policy`, `rust_perm_sync.imported_at`, `rust_perm_catalogue.owner` and `rust_perm_drift.direction` are new columns. The old group tables are copied once at boot (a `rust_settings` marker makes it once) and are then left unread. - **Every sync is read → reconcile → push.** The reconcile step runs under one fleet-wide lock, so two servers never split one shared group at once. Each desired row remembers the authored rows that produced it (its *sources*). That is how a removal knows whether to delete a row, add an exception, or split a group. - **Two things are never judged.** A permission the server does not register right now (an unloaded plugin is not a revocation), and a pair an event lease holds. - **A removal at the first import is pushed back**, not deleted. D198 imports what is present; a snapshot taken the moment the site first looked is not taken as a revocation. - **An event's grant removed in the game** is pushed back under auto-adopt, with a notice. The event owns it, and its revert withdraws it. - **Under `adopt`**, a removal or a changed group is *held*: it is not pushed back, retired or recorded until a person answers. "Put back" works by forgetting the ledger row, and the next sync pushes it. - **A group the site already authored keeps the site's title** at import. `walkvoice`, made by the site before the rebuild, overwrote the game's empty title with "Walk voice" on Oxide. - **The announcement voice names a group by id.** A setting saved as a name before the rebuild still resolves, to the first styled group of that name. **Walked** on `rust-oxide` and `rust-carbon`, against the walk core on `rustp16` (backed up first): | Case | Result | |---|---| | Migration of the old groups | 2 copied (`site` → Oxide only; `walkvoice` → every server) | | First import (D198) on an existing install | Oxide: `default`, `admin` made its own, 2 hand grants → Steam grants, the site's own grants recorded as landed; Carbon: `default`, `admin`, `moderator` with titles kept byte for byte | | Auto-adopt: `oxide.grant` + `oxide.revoke` | adopted within one 30 s tick; the revoked Steam grant deleted | | Fleet grant (`*`) revoked in the game on Oxide (D190) | an exception for `rust-oxide`; still pushed and landed on Carbon | | `oxide.unload Kits` | nothing deleted; the report lists both `kits.*` as unresolved | | `c.grant group walkvoice kits.admin` on Carbon (D190) | split: Carbon got its own `walkvoice` with the change and the style; the shared one excludes Carbon; a notice waits | | Policy `adopt` | one addition and one removal waited; the removal was held, not pushed back; adopt and accept answered them | | Policy `revoke` | an in-game grant undone at the next sync (`revokes: 1`) | | Server view | plugins by owner (`nokits` under ZoneManager), groups with where they are, players named, the exception listed | **Not walked yet:** a group with a parent; sharing and unsharing from the screen, and its conflict answer; Grant all / Revoke all; the screen itself in a browser (every endpoint behind it was called); and the large-store measurement. --- ## 2. The event step editor and the kit weekend (§4.2, §4.3; U-3–U-6, F11; D164) Most of this is **core's editor** (`EventEditor.jsx`), so it lands on website `main`, stays game-agnostic, and must leave Module-uo exactly as it is. ### 2.1 A step becomes the thing it does (U-3, U-4, F11) - **No type tags.** `ParamField` stops printing ` · {type}` and the bare `*`. A required field says so in words only when it is empty at save time. - **Each type gets its control:** for `boolean`, a toggle when the param is required and three buttons side by side, **[Not set] [Yes] [No]**, when it is optional, because "not set" is a real third state there — the action's own default — and a two-state toggle would save No for every field nobody touched (D334); a number field with the declared range beside it for `int` and `float`, a date picker for `datetime`, and a text box only for `string` without a source. - **`core.lease` renders the chosen lease's own form.** The lease is picked by its **label**. The step then shows the value as the lease's type (a toggle for a boolean lease, a number with the lease's `min`–`max` beside it, or a dropdown when the lease declares `values`), **the target as a picker over the lease's own `target.source`** (F11, never built: today the editor reads only `param.source`), and the minutes with the lease's `maxDurationMs` shown beside them. - **Examples.** Core's own examples become game-neutral (§0.9). A lease may declare an optional `example` for its value (**MODULE_API 1.13.0**, §8), and `target.example` already exists, so the examples on a lease step are that lease's. ### 2.2 A phase that holds a timed step (U-6) A phase with no advance rule ends when its steps finish, and teardown gives a lease back at once. So the "10 minutes" of a lease lasted one second. The fix is to make the duration visible: - A param may declare **`duration: 'minutes'`** (**MODULE_API 1.13.0**). `core.lease`'s `minutes` is one, and so are `rust.zone.open`'s `minutes` and the other modules' equivalents. - When a timed step is added to a phase that has **no advance rule**, the editor gives the phase **`advance: { after: 'm' }`**, visibly, and the author may change it. - A phase whose advance rule ends before its longest timed step says so under the phase: "This phase ends after 5 minutes; the lease is given back then, not after 10." ### 2.3 Start with an unpublished edit (U-5) When the definition has a saved draft newer than its published version, **Start** asks: "Publish and start", or "Start version *N* (the published one)". Core client only. `runs.create` already pins the published version. ### 2.4 Popups (§4.3) `rust.announce`'s `delivery` already has two values from an option source. With two or three choices the editor draws them as a segmented control rather than a dropdown. **D193: a zone's enter and leave messages and a player's "reward earned" message get the same Chat / Popup choice.** - **Zones.** `rust.zone.open` gains `enterMessage`, `leaveMessage` and `delivery`. The bridge no longer passes the messages to ZoneManager for its own zones. It subscribes to ZoneManager's `OnEnterZone` and `OnExitZone` and sends them itself, as chat or as a popup, only for the zones in its registry. This also gives F12's "row 5 cannot show an enter message" (PLAN_FIXES, the step-2 walk) a message to show. **Built with §3** (§3.4), so §2 leaves it alone. - **Rewards (D333).** There was no in-game reward message to give a `delivery` to: a reward's only notice was core's engagement notification (`rust.kit.entitled`). So the message is new. `rust.kit.entitle` gains an optional **`message`** and **`delivery`**. Alice comes second and is rewarded kit `vip`: if she is online she is told at once, in chat or a popup — "You earned kit vip — type /kit vip" — and if she is offline she is told when she next wakes up on that server, since the grant already waits for her. - A blank `message` sends nothing, so a step saved before §2 behaves exactly as it did. `{kit}` in the text is the kit's name. Only linked recipients have a grant, so only they are told. - The notice rides `perm.sync` like the credit it belongs to (PROTOCOL §16.3): a new `notices` field, the site's whole set of untold notices for that server, each `{key, steamId, message, delivery}`, keyed by run and step. The plugin tells each player once, keeps the keys it has told, and reports them back (`noticesTold`), so the site marks the grant row told and stops sending it. - A grant withdrawn before its player came back takes its notice with it: nobody is told about a reward that is no longer theirs. - Protocol 14 (D332). An older plugin ignores `notices`; an older site sends none. - **Without PopupNotifications** a popup falls back to chat. The step editor says so beside the choice when the server's hello reports PopupNotifications absent, as `rust.options.delivery` already does. ### 2.5 The kit weekend as a template (D164) There is no template concept anywhere today. **D192: core gains `api.registerEventTemplates`** (**MODULE_API 1.13.0**), which any module can use: a module declares a named skeleton (phases, steps, params filled in, and a short list of the inputs the author supplies). The editor's "New event" screen offers the skeletons, asks for their inputs, and opens an ordinary draft. Nothing about a template survives into the definition, so it is purely an authoring shortcut and the runner never sees it. Module-Rust registers **"Kit weekend"**. Its inputs are a server, a kit and a duration, and it makes one phase with `advance: { after }` equal to the duration and two steps: 1. `core.lease` → `rust.group.permission`, target `/default/`, value `true`, for the duration. The kit becomes visible and redeemable for everyone. 2. `rust.kit.entitle` → that kit, **every linked player on the server**, one use each (D103: withdrawn on revert). This recipient mode does not exist yet. Today's modes are all about a run's scoring ("everyone who scored", top N, and so on), so Module-Rust adds **`linked`**. ### 2.6 The kit warning (D164) "N linked players have already used this kit" needs a bulk read the plugin does not have (§0.8). So there is a new verb, **`kits.uses`**: `{kit, steamIds[]}` in, and `{max, cooldown, exhausted, onCooldown}` out (counts, not a list). It is main thread, capped at the site's linked-player count for that server (the same bound as the sync's). It is **protocol 14** (D332): against a protocol-13 server the note is simply left off. The warning reaches the core editor through an **optional `note` on an option row** (**MODULE_API 1.13.0**). When the chosen option carries one, the editor shows it under the field. `rust.options.grouppermissions` attaches the note to a `kits.*` target: "Kit `vip`: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up. The weekend's extra use (step 2) is what lets them take it again." ### 2.7 The walk **Deferred to the end-of-plan walk (D331, §9).** The rows, for that walk, plus D333's reward message (online and on next wake, chat and popup, and a withdrawn grant that tells nobody): Author a kit weekend from the template on each rig; a lease step with the phase's advance removed (the warning shows); a draft edited after publishing (Start asks); a boolean lease (a toggle); and a Module-uo event opened in the same editor, which must be unchanged apart from the controls. ### 2.8 As built: the core half (2026-10-12) website `feat/event-step-editor`, MODULE_API **1.13.0** (§8; `website/MODULE_API.md` §1.1, `website/EVENTS.md` §I). Everything in §2.1–§2.3 and §2.5's core half, with three things settled while building: - **The phase guard counts the RUN, not the phase.** Teardown gives a held thing back when the run ends, so a 10-minute lease in phase 1 is safe if phases 2 and 3 take ten minutes between them. The floor is the sum of every later phase's `after`; a phase with no rule counts as no time, and one that waits on the game makes the floor unknown, so it is not warned about. Only a phase with **no** rule is given one, and only when a change can create or lengthen a hold. - **"Is the draft ahead?" is answered by the server** (`draftAhead`): the working spec against the published one, as data with keys sorted. A timestamp cannot answer it, because publishing writes the row too. - **A template's inputs are params**, checked by the same function, so they get the same controls, sources and duration unit as a step's fields. The draft comes back unsaved, from `POST /admin/events/catalog/templates/:templateId`. **The Module-uo proof**, against this branch: the frozen-manifest job (76 routes, all documented; `uo` registers its 12 actions), the server suite 723/723 and the client suite 45/45 — the same numbers as 1.11.0's. The Module-Rust half (the kit weekend, `linked`, `kits.uses`, the reward message) follows on `edge`. ### 2.9 As built: the Rust half, protocol 14 (2026-10-12) Branches `feat/protocol-14-step-editor` in Rust-Plugins, Rust-Link and Module-Rust, into `edge`; the wire is `rust-link/PROTOCOL.md` §20. - **The kit weekend** is `rust.template.kit_weekend`: inputs kit, minutes (a duration), message and delivery. It reads the kit from its server when it is filled in, so the lease names the kit's real permission. A kit with no permission is open to everyone already and gets step 2 alone; a kit with no use limit has no "one more use" and gets step 1 alone. The phase is the weekend: `advance: { after }` equals the minutes. Run through the real 1.13.0 core (website#212), it registers, and the plan it builds validates as a spec. - **`linked`** is "every linked account that has played on this server" — a stats row there — because a link is fleet-wide and the server is what narrows it. It reads no tally, and past the server's recipient bound it is refused with the bound's name (`EventsMaxRecipients`), never trimmed. - **The reward message** is checked with the kit's name in place of `{kit}`, against the chat bound, so a long kit name cannot push past what the game will say. It is stored on each grant row (`rust_perm_run_grants.notice`, `notice_delivery`, `notice_told_at`). - **The kit warning** is the `note` on `rust.options.grouppermissions` rows. That source is searchable, so the editor may not hold the chosen row; core asks for that one value when it needs its note (website#212). - **Timed params:** `rust.zone.open` and `rust.participation.open` declare `minutes` a duration, and the group-permission lease gives `true` as its value example. - **Compiled on both rigs, 2026-10-12.** Carbon 2.0.262 on Rust 25823813 (the latest of both): the bridge compiled and loaded in 3.8 s, protocol 14, its told-notices store read at load. Oxide 2.0.7820 on the same Rust build (the rig was behind on both, and was updated first: its first restart's steamcmd update did not finish, the second did): compiled in 5.3 s and loaded. Both rigs were put back on their protocol-13 bridge afterwards, to match their sidecars until the end-of-plan walk sets up protocol 14 (D331). --- ## 3. Zones: ZoneManager's options, and a dome (§4.4; D166, D167) ### 3.1 The options (D166) - **Read from the installed ZoneManager, by the helper.** §0.6 means only code that references ZoneManager's types can read the flag list, and D168 puts that in the helper, not the bridge. `RunicGatewayZones.cs` gains a hook, `RgZoneOptions()`, returning ZoneManager's version and `ZoneFlags.NameToIndex`'s names. The bridge calls it and adds **`zoneManager: { version, flags[] }`** to hello. **Without the helper** the site offers only a name, radius and duration, and says why, the same way D182 degrades. - **Settings offered:** radiation, comfort, temperature, safe zone and permission. The enter and leave messages are the bridge's own (D193, §2.4). The bridge keeps control of the id, location, radius and size, and the rest (rotation, parent, eject spawns) stays out, because each one changes what the bridge believes the zone is. - **Wire:** `world.zone` gains `flags` (names) and `settings` (key → value). The plugin checks every flag against the live list and every setting against the allowlist, and refuses an unknown one with a new reason, `bad-option`. It passes them to `CreateOrUpdateTemporaryZone` as ZoneManager's own key/value arguments, stores them on the zone's registry entry, and re-applies them in `RecreateZones` after a restart or a ZoneManager reload. - **The form groups the flags** from one module file (Vehicles, Combat, Building, Loot, NPCs, Communication, Survival). A flag the file does not know (a newer ZoneManager) goes under **Other**, so it is never hidden. - **Presets are the admins' own (D195).** None ship with the module, because what an "arena" is differs by server. An admin saves a set of flags and settings under a name, for one server, several servers, or the whole fleet: `rust_zone_presets (id, name, flags, settings, shared)` plus `rust_zone_preset_servers`, the same shape as a shared group (§1.4). The zone step offers the presets that cover its server, and **Save as preset** turns the flags on a step into one. A preset fills the form once, when it is picked; the step keeps its own copy, so editing a preset later does not change a published event. - **Exemptions** are `zonemanager.ignoreflag.` permissions, managed on the permission screen (§1.7). The zone form links there. - **Flying** has no ZoneManager flag (PLAN_FIXES §4.4) and is not in this step. ### 3.2 The dome (D167, D194) **D194: ZoneDomes stays the optional dependency D167 chose, because it already has the logic. What it lacks is filled in by a helper (D168).** §0.5 found two gaps: - **Its API throws without a player.** A second helper, **`RunicGatewayDomes.cs`**, fixes that for ZoneDomes and nothing else (a helper does one job). It patches ZoneDomes' message method with Harmony so a call with no player sends no message, and so the call returns normally instead of throwing after the dome is made. It ships in the overlay beside the zone helper and is released, installed and checked the way D182's helper is. Hello reports it, like the zone helper's `patched` / `missing` / `unsupported`. - **Its boot drops the domes of temporary zones.** The bridge owns the fix, because it owns the order: after `RecreateZones` puts a zone back (at boot or after a ZoneManager reload), it calls `AddNewDome` again for each zone that had one, and it does the same when ZoneDomes itself reloads. At expiry and at teardown it calls `RemoveExistingDome`, so a dome never outlives its zone in ZoneDomes' data file. The zone step gains **"Show a dome"**, with ZoneDomes' own type and stack. It is offered only when the server reports ZoneDomes loaded **and** the domes helper patched. Otherwise the step says which one is missing. **The default type and stack** (PLAN_FIXES §9 question 3) are chosen by looking at each on the rig, in daylight and at night, during the build walk. ### 3.3 The walk On each rig, a zone made from an admin's preset: the flags hold for a player without the exemption and not for one with it; the enter and leave messages arrive as chat and then as popups; a restart, a ZoneManager reload and a ZoneDomes reload all keep the flags and the dome; expiry and teardown remove the dome from the world and from ZoneDomes' data file; and with the domes helper removed, the step stops offering a dome. ### 3.4 As built (2026-09-29; not yet walked on a rig) Built on `edge` as Rust-Plugins `feat/zones-domes`, Module-Rust `feat/zones-domes` and installer `feat/rust-domes-helper`; the wire is PROTOCOL §19.11. The build changed §3.1 and §3.2 in four places. - **Where the flags are ticked (D210).** Core's step editor holds one value per field: a text box, a number, a yes/no, a date, or one dropdown. It has no checkbox list and no slot for a module's own button, and MODULE_API 1.13.0 (§8) adds neither. So the checkboxes are on a new page, **Admin → Rust zone presets**, and the step's **Options** field is one line of text (`NoBuild, NoPlayerLoot, radiation=10`). The field's dropdown lists the presets, and **each row's value is the preset's line**, so picking one writes that line into the field. Core already writes a dropdown's pick into the free-text field beside it, so this needed no core change. It keeps §3.1's promise: the step holds its own copy, and editing a preset changes no published event. There is no **Save as preset** button in the step; a preset is made on its page. Presets are `rust_zone_presets` (`name`, `flags`, `settings`, `all_servers`) and `rust_zone_preset_servers`. As with a group (D189), the same name may be used on two servers but not twice on one. - **Who reads the flag list (D211).** The bridge does, with `ZoneFieldListRaw()` (§0.6 corrected). The zone helper gained nothing, and a server without it still offers every flag, so there is no "name, radius and duration only" mode. The list is in hello, and the bridge sends hello again when ZoneManager, ZoneDomes or either helper loads or unloads. - **The dome must wait for ZoneDomes' own start.** ZoneDomes' `InitializeDomes` also re-draws every dome in its data file whose zone exists. A dome the bridge added before that is drawn twice, and the first set of spheres is left in the world with nothing able to remove it. The domes helper therefore postfixes `InitializeDomes` and raises `OnRgDomesReady`, and the bridge puts domes back only after it. The helper's patch goes in at load, before ZoneDomes' `OnServerInitialized`. - **A popup falls back to chat at the moment of speaking**, as §2.4 says, and the zone is not refused for it: a zone's messages are said long after the step ran. (`rust.announce` still refuses a popup without PopupNotifications, because its line is said at once.) **Chosen in the build, within the plan:** - **Settings bounds:** radiation 0–500, comfort 0–1, temperature −100–100 °C. - **Permission:** up to 32 of `a–z0–9_`. - **Dome stack:** 1–10. ZoneDomes itself has no limit, and each sphere is an entity. - **The 5 s quiet:** after re-creating a zone, the bridge stays silent to anyone already inside it. - **The flag groups:** in `server/model/zones/zoneOptions.js`. `NoTp` and ZoneManager's `Custom1`–`5` are under Other. **The default dome (D212).** ZoneDomes 2.0.2 has five types, and only **Standard** (type 0, Rust's shaded `sphere.prefab`) is a whole dome: a dark, see-through sphere that each stacked copy makes darker. Red, Blue, Green and Purple are the Twitch battle-royale spheres, which ZoneDomes itself says show only where they meet terrain or an object, so they read as a ring at the zone's edge. **The org lead chose the full dome as the default:** the step lists Standard first as "Full dome (shaded)", labels each colour for what it shows, and a `dome` sent without a `type` is Standard. **The default stack is 3** (D326, `runicnpc/PLAN.md`): the org lead chose it by eye at noon in RunicNPC's 9d player session, and the module sends it when the step's stack is blank. Steps saved with a blank stack draw 3 from their next run. The wire is unchanged: a `dome` sent without a `stack` still gets 1 from the bridge, and the module always sends one. **Compiled on both rigs, and the rig rows walked, 2026-09-29** (no player; the "file API outage" that morning was a Git Bash path-rewriting bug in the walk's own helper, not the rigs): - **Both rigs:** the bridge and both helpers compiled and loaded (Oxide; Carbon with 0 failed plugins). Both helpers reported `patched`, and the domes helper `ready`. - **Hello (Oxide, read from the sidecar's board):** - `zoneManager` carried all **69** flags of ZoneManager 3.1.14 (64 named and `Custom1`–`5`), with no zone helper involved (D211). - `integrations.zoneDomes` was `{ loaded, patched, ready }`. - It was re-sent within a second each time ZoneDomes or a helper loaded or unloaded. - **Refusals:** an unknown flag (`NoFlying`), a setting outside the allowlist (`rotation`) and radiation 900 were each refused `bad-option`, with the reason. - **A full zone:** `nobuild, PvpGod, NoPlayerLoot`, radiation 5, safezone off, a popup enter/leave message and a stacked dome. - ZoneManager held exactly those flags and settings (read through a throwaway probe calling `ZoneFieldList`). - ZoneManager held no enter message of its own: the bridge says it (D193). - The registry stored the flags as ZoneManager spells them. - ZoneDomes' data file held the dome. - **ZoneManager reload:** the zone came back ("1 re-created") with all its flags. The dome's data was untouched. - **ZoneDomes reload:** the helper re-patched and was ready again. The dome was redrawn from ZoneDomes' own data. - **Server restart:** - The zone was re-created with its flags. - On this boot the bridge's zones existed before ZoneDomes started, so ZoneDomes drew the dome itself, and the bridge's restore added nothing. - **Exactly 2 sphere entities for a stack of 2**, so the dome was not drawn twice. - **The other order, forced:** - The bridge was unloaded (ZoneManager erased the zone), then ZoneDomes reloaded and dropped the dome's data. - The bridge was loaded again: "domes after load: 1 put back", and again exactly 2 spheres. §0.5's boot fault is handled whichever plugin starts first. - **Teardown (`world.revert`):** the zone was erased, 0 spheres were left, and ZoneDomes' data was empty. - **Expiry:** a 1-minute domed zone was erased at its deadline, and its dome went with it (0 spheres, data empty). - **Without the domes helper:** a dome was refused `dome-unavailable`, "RunicGatewayDomes.cs is not installed beside the bridge…". Hello said `missing`, then `patched`/`ready` once the helper was loaded again. **The site half, walked 2026-09-29** on a local core (website `main` `f0e7d2a`) with this branch's module, against `rust-carbon`. `rust-oxide`'s fresh egg installed the last released sidecar, which speaks protocol 12, so the site refuses it until protocol 13 ships: - **The page, in the browser as an admin:** - Each server's line: Carbon's ZoneManager 3.1.14 with 69 flags, and "domes available". - The edit form: the flags in their groups with the preset's ticked, the servers, and the settings with their bounds. - A save with comfort 3 was refused under the form ("comfort is 0 to 1, not "3""). Corrected, it saved, and the preset's line updated. - The sidebar row. No console errors. - **The API:** a flag Carbon does not have was refused, naming the server. So were an out-of-range setting, a preset for no server, and a second "arena" on the same server (409). - **The step:** - The Options dropdown's row value is the preset's line (D210). - After the preset was edited, the published step still held its own old line. Picking the preset in the editor wrote the new one. - The dome dropdown carries D212's labels. - A draft with `NoFlying` failed its dry run with that reason. - **A run through the whole path:** - The site opened the zone on Carbon: the preset's flags and settings, the popup messages, and ZoneDomes' Standard dome at stack 2. - The run's cleanup removed it, leaving ZoneDomes' data empty. **Still to walk:** - **The in-game rows, deferred by the org lead (2026-09-29) to the later in-game walk, with §5.7's:** - The flags hold for a player without the exemption and not for one with it. - The enter and leave messages arrive as chat and as a popup. - ~~The default stack is chosen by eye, in daylight and at night.~~ Chosen at noon in RunicNPC's 9d player session: 3 (D326). --- ## 4. The live map's marker types (§4.5; D165) - **The site filters (D196).** The map meta already carries each monument's `kind` and `label`. The toggle is by **label**, because that is what staff read and it groups the variants (31 substations, four prefabs, one "Substation"). No wire change is needed. - **The default** is a module list of the minor labels, **off** unless turned on: Substation, Underground Cave, Train Tunnel, Water Well, Wild Swamp, Jungle Swamp, Ice Lake, Jungle Ruin, Fishing Village (not the large one), Large Barn and Abandoned Supermarket, as the rig's map labels them, plus the gas station, mining outpost and ranch under whatever labels the game gives them (read at build time; they are not on the rig's map). **Every label not on the list is on**, so a monument Facepunch adds next month appears rather than disappears. - **Where:** `Visibility.jsx`'s map section, under the *Monuments & world events* layer: a list of the labels on that server's current map with a switch each, a fleet default, and a per-server override. That is the same machinery the four layers already use (D114). The filter is applied where the map meta is served, so a hidden marker is not sent at all. - **Not chosen:** the game's own `MonumentType` (§0.7), sent by the plugin. It is authoritative, but it is a wire change, and nothing yet shows that substations are a type of their own. ### 4.1 As built (2026-09-29; Module-Rust only, no wire change) - **Storage.** One row per label, `on` or `off`: the fleet's in `rust_settings` as `map.marker.`, a server's in `rust_map_overrides` under the same setting name. No schema change. The key is the label trimmed, single-spaced and lower case, because the game is not consistent: the rig's map says `jungle swamp` for `ue_jungle_swamp_a`. Both columns are `VARCHAR(64)`, so a label longer than 53 characters cannot carry a switch and the write says so. A stored word that is neither `on` nor `off` follows the default above it. Unlike a layer audience, this does not narrow, because a label switch is clutter control and not a security boundary. The world layer's audience still decides whether a viewer gets monuments at all. - **Precedence.** A server's switch wins over the fleet's, and the fleet's over the built-in list, label by label. Setting the fleet back to what the built-in list says deletes the row instead of storing a copy, so a later change to the list reaches that label. - **The built-in list** (`MINOR_LABELS`, off by default): Substation, Underground Cave, Train Tunnel, Water Well, Wild Swamp, Jungle Swamp, Ice Lake, Jungle Ruin, Fishing Village, Large Barn, Abandoned Supermarket, Oxum's Gas Station, Mining Outpost, Ranch, and (**D213**) Canyon B, Canyon C, Lake A, Oasis A, Oasis C, Mountain, Train Tunnel Link, Abandoned Cabins. **All twenty-two were read from a rig**: the Carbon rig regenerated at world 6000, seed 981448696 (2026-09-29), whose map has 144 markers under 51 labels, including every one on the list. Mining Outpost is the `warehouse` prefab and Ranch is `stables_a`. The game spells one label two ways, "Jungle ruin" on that map and "Jungle Ruin" on the 3000 map, which the lower-case key absorbs. *Large* Fishing Village is a separate label and stays on. A 6000 world needs more than 8 GB: the rig was killed at 8 GB after "Initialized … occlusion sub-chunks", ran at 8.5 GB once up, and now has a 12 GB limit. - **Where it is applied.** `GET /public/rust/servers/:id/map` leaves out every monument whose label is hidden on that server. The website and the Android app read the same answer, so both lose the markers with no client change. `map.access()` carries the merged switches. Event steps that name a monument (`world.monuments`) are untouched: an event can still be placed at a substation that the map hides. - **Admin → Rust visibility, the live map card.** Under the fleet layer switches, **Monuments on the map** is a collapsed list with a checkbox for each label found on any server's current map plus the built-in list (a minor label no map has yet is still listed, so it can be decided before a wipe brings it). Each server row has its own collapsed **Monuments** list of the labels on its current map, each set to *Default (drawn/hidden)*, *Drawn* or *Hidden*. The page's one `PUT` carries `map.fleet.markers` and `map.servers..markers`, each `{ key: true | false | null }`. `null` clears the switch. - **Tests:** 7 server (`map.test.js`, one of them the rig map's 51 real labels) and 8 client (`mapMarkers.test.js`, the page's pure half) — 462 and 66 in all. - **Walked 2026-09-29** on the local walk core against the Carbon rig's 6000 map. Signed in as a test admin: the fleet list read 51 labels, 22 hidden. Carbon read 51/22 and the Oxide rig's 3000 map 33/11. Ticking Substation on the fleet dropped both server counts by one before saving. Carbon was then set to Hidden and the page saved. The rows were `map.marker.substation` = `on` (fleet) and `off` (rust-carbon), with one activity row naming both. The public answer then gave Oxide 55 markers (its 31 substations back) and Carbon 31 (none). Undoing both through the page deleted both rows, logged as `default` and `inherit`. The public map tab on Carbon drew its 31 monuments over the Rust+ picture with no console errors (144 before the filter). The walk found one cosmetic flaw, now fixed: the fleet list printed the game's lower-case "jungle swamp". It now prefers a capitalised spelling. The page remounts after a save, which collapses the open lists, as it does for the rest of the page. --- ## 5. Chat titles: twenty-three conditions (§4.6; D172–D175) ### 5.1 What is counted, and where Everything rides on `player.tally` (rule 2: aggregates, never a frame per event). The **gathering** conditions need **no plugin change**: `rust_gather_totals` already keeps every resource by shortname from all three sources (D159), so the site sums named lists. | Condition (title) | Counted from | Tally field (new unless noted) | Site | |---|---|---|---| | Animal kills (Beastbane) | `OnEntityDeath`, victim `BaseNpc` or `Rust.Ai.Gen2.BaseNPC2` (not `ScientistNPC2`; RunicNPC D320) | `animalKills` | column | | NPC kills (Exterminator) | `OnEntityDeath`, victim a human NPC | `npcKills` (exists, and keeps meaning *all* NPC kills) | `npc_kills − animal_kills` | | Players killed (Reaper) | `player.death` (exists) | — | `kills` | | Bow / Melee / Blade / Revolver kills (Fletcher, Brawler, Duelist, Outlaw) | every credited kill's weapon shortname, same source as the killfeed | `weaponKills: { shortname: n }` | new table, classified by lists (§5.3) | | Headshots (Crownbreaker) | the killing blow's `HitInfo.isHeadshot`, any victim | `headshots` | column | | PvP / PvE kill distance (Longshot, Marksman) | the killfeed's distance | `bestPvpM`, `bestPveM` (this interval's maximum) | *best* columns, `GREATEST` at ingest | | APC / helicopter kills (Tankbreaker, Rotorbane) | `OnEntityDeath`, victim `BradleyAPC`, `PatrolHelicopter`, `CH47Helicopter` | `apcKills`, `heliKills` | columns (§5.4) | | Wood / ore / plants (Timberwright, Prospector, Greenhand) | exists (D159) | — | sums of named resource lists | | Clothes / weapons crafted (Seamster, Armorer) | `OnItemCraftFinished`, `item.info.category` | `crafted: { category: n }` | columns for attire and weapon | | Structures built (Mason) | `OnEntityBuilt`, a `BuildingBlock` | `built` | column (not `structures`, which is raids) | | Structures repaired (Tinkerer) | `OnStructureRepair` | `repaired` | column | | Players healed (Mender) | `OnHealingItemUse` on another player, `OnPlayerRevive` | `healed` | column | | Rockets fired (Artillerist) | `OnRocketLaunched` | `rockets` | column | | Explosives thrown (Demolitionist) | `OnExplosiveThrown`, `OnExplosiveDropped` | `explosives` | column | | Quests completed (Wayfarer) | `OnMissionSucceeded` (§0.4) | `missions` | column | **Three of these hooks can veto** (`OnStructureRepair`, `OnHealingItemUse`, `OnPlayerRevive`), so they are declared `void` (PROTOCOL.md §8.7). All nine new hooks join `ExpectedHooks`, so a framework that stops raising one is visible on the servers page. ### 5.2 NPC kills, without changing a public number Splitting `npcKills` in two would change what the public leaderboard's *NPC kills* column means, and it would change it mid-wipe. So `npcKills` keeps counting every NPC and animal. `animalKills` counts the animals again separately, and the human-NPC count is the difference. Nothing already on a page moves. ### 5.3 The weapon lists live on the site D174 wants the weapon lists "in one place, so a Rust update adds a weapon by editing a list". The plugin sends every credited kill by **weapon shortname**, and the site keeps `rust_weapon_kills (server, wipe, steam, weapon, n)`, shaped like `rust_gather_totals`. The bow, melee, blade and revolver lists are one module file, **applied when the standings are read**. Editing a list corrects the whole wipe, and adding a Rust weapon needs no plugin release. Melee is a list too, because the site cannot ask `BaseMelee`. ### 5.4 Who killed the helicopter D174: the killing blow. A patrol helicopter usually dies by crashing, and its death `HitInfo` may carry no player. The build walk shoots one down on the rig. If the killing blow has no player, the credit goes to **the last player who damaged it within 60 s**, tracked only for those three vehicle types. That is a stated default, and the walk decides whether it is needed. ### 5.5 Titles an admin can rename (D175) - `rust_title_categories (stat PK, text, updated_by, updated_at)` holds the admin's title for a category. The shipped defaults (§4.6's table) stay in the module. - `rust_title_rules.text` may be **empty, meaning "use the category's title"**. That is what makes a rename reach existing rules. D175 also says "new rules open with the category's current title filled in". If it were filled in as the rule's own text, the rename would never reach that rule. So the form shows the current category title **as a placeholder** and saves nothing unless the admin types. - `stat` takes the 23 category ids (they fit `VARCHAR(16)`). The standings query for a *best* stat is a `MAX`. - The titles screen gains a "Category titles" section: 23 rows, each with the default, the admin's text, and Reset. - **The new stats feed titles only.** The public leaderboard keeps its columns in this step. Adding any of them there is a later, separate choice. ### 5.6 Missions (D173) `OnMissionSucceeded` increments `missions` for the assignee. A failed or abandoned mission is never counted. The hook's exact arguments are confirmed the first time a person finishes a mission on the rig, which is also D173's pass condition: a mission taken from an NPC and finished moves Wayfarer by exactly one, and an abandoned one does not move it. Should the hook disappear on a framework, D173's option 2 is known to work: the player's `acceptedMissions` carries each instance's `status` (`Completed`, `Failed`, …, §0.4). ### 5.7 The walk Every condition earned once on each rig (PLAN_FIXES §8): kill an animal and a scientist with a bow, a machete and a revolver, one of them by headshot; shoot a player from range (this needs the second player); craft a shirt and a weapon; build and repair a wall; bandage and revive another player; fire a rocket; throw a grenade; destroy an APC; shoot down a helicopter; finish a mission and abandon one. Each count is checked in the tally frame and on the titles screen. ### 5.8 As built (2026-09-28/29; compiled on both rigs, not yet walked) Rust-Plugins `feat/title-conditions`, Module-Rust `feat/title-conditions`. The wire is [`PROTOCOL.md`](../../rust-link/PROTOCOL.md) §19.10, and the admin API is the module's swagger fragment (`/admin/rust/title-categories…`). **One question the plan left open, answered (D209):** a rule's `npckills` now ranks **human NPCs only**, every NPC kill less the animals. Titles had never shipped, so no live rule changes meaning; the rigs' walk database held one such rule. **What the build settled:** - **The categories are ids a rule stores**, in `model/titles/titles.js`: `kills`, `npckills`, `animalkills`, `bowkills`, `meleekills`, `bladekills`, `revolverkills`, `headshots`, `pvpdistance`, `pvedistance`, `apckills`, `helikills`, `wood`, `ore`, `plants`, `attire`, `weapons`, `built`, `repaired`, `healed`, `rockets`, `explosives`, `missions`, plus `playtime`, which predates them. **Playtime has no default title**, so it cannot be renamed and a playtime rule always carries its own text. - **Each category names where it is read from:** a summed column, a column's `MAX` (the two distances), a difference (`npc_kills − animal_kills`, in signed arithmetic, since a row counted before `animal_kills` existed would otherwise wrap), or a named list over `rust_weapon_kills` or `rust_gather_totals`. Every standing is ordered by value, then by who was seen last, the leaderboard's own tie-break. - **The lists are one file**, `model/titles/lists.js`: bow, melee, blade, revolver, wood, ore and plants. Every blade is also melee. Plants include cloth, which is what hemp picks up as, and leave out seeds. - **A rule's text may be empty**, meaning its category's title. **A text typed as only markup is still refused** (§33.4 reading 3): brackets are a mistake, not a request for the default. The form shows the category's current title as a placeholder and never saves it into the rule. - **The category titles** are `GET /admin/rust/title-categories` and `PUT /admin/rust/title-categories/:stat` (an empty `text` is the Reset). A change forgets every server's cached answer, so the next push carries the new name. The servers list also returns them, so each server's summary line names what its rules show. - **The plugin's choices, each to be checked by the walk:** a repair counts only when the entity's health rose by the next tick (the hook fires before the game decides there is anything to repair); a syringe revive raises both the revive and the heal hook and counts once; an explosive is a `TimedExplosive` that deals damage, which leaves out supply signals and smoke; the helicopter's last-hitter credit (§5.4) is built rather than waiting for the walk, because proving it unnecessary needs a helicopter shot down first. **Read off the rig, 2026-09-29.** A read-only probe walked the live server's `ItemManager.itemList` and named each held entity's prefab and class. The lists in `lists.js` are that output, not memory: the revolvers are `pistol_revolver`, `python` and `hc_revolver` (there is no `revolver.entity`); the bows include the legacy bow, the mini crossbow and the bowless crossbow, and leave out the speargun, a crossbow underneath; melee is every `BaseMelee` except the pies (`Food` is a `BaseMelee` too). C4, the satchel and the beancan are all `TimedExplosive` subclasses, so §5.1's explosive test holds. The same probe corrected three members the plan had assumed, each of which would not have compiled or would never have been called: - `ItemCraftTask` has no owner; the crafter is `ItemCrafter.owner`. - A mission's assignee is the hook's third argument (`MissionSuccess(instance, assignee)`); the instance has no accessor for it. - `MedicalTool.GiveEffectsTo` now takes an `IMedicalToolTarget`, so `OnHealingItemUse` takes its target as `object` and counts only a player. **Corrected by the RunicNPC 9d walk (2026-10-07, D320–D322).** Two of the plugin's choices above were wrong on a live server, and a third gap was ZoneManager's: Rust's animals are `Rust.Ai.Gen2.BaseNPC2`, not `BaseNpc`, so no animal kill had counted (D320); `OnHealingItemUse` is `(tool, fromPlayer, toTarget)`, so the two-parameter handler read the healer as the target and no heal counted (D321); and an event's zone did not enforce its flags until RunicGatewayZones 0.2.0 has ZoneManager refresh its hooks (D322, §3). See [`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) stage 9d. **Compiled on both rigs, 2026-09-29.** `rust-oxide` (a fresh install on the published egg) and `rust-carbon`: all 45 hooks bound, and `OnEntityTakeDamage` fires on world damage with nobody on (53 and 109 in the first half-minute), which is the cost the helicopter credit was expected to have. The other nine new hooks need a player: that is §5.7's walk. --- ## 6. NPCs (§4.7) > **Superseded 2026-09-30.** The spike below was run on the Oxide rig. Route A kept its kit and fought with it > but cannot be named; HumanNPC counted its NPCs as players in the killfeed and tallies. The org lead then chose > **neither route**: Runic Gateway writes its own NPC plugin, **RunicNPC**, in its own repository (D214–D220). The > spike's results, two corrections to the text below, and the staged plan are in > [`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md). The text below is kept as what was planned. **D197: the spike tries both routes on both rigs, then the org lead picks one.** The goal is the one the org lead named: NPCs with different kits and names. NPC Loadouts is out, because it cannot dress one spawn differently from the next (§0.10). The spike reports on each route before any site work. **Route A: extend `rust.npc.place`.** The bridge already spawns Rust's own scientists, and they are players underneath: - **`kit`**: a Kits kit. After spawning, the bridge empties the NPC's inventory and gives it the kit through Kits' API (`GiveKit`). Kits is already a required plugin (PLAN.md R6). - **`name`**: the NPC's `displayName`, which already flows into the killfeed (`attackerName`) and on to F2's labels. - The spike answers: does a scientist given a kit fight with the kit's weapon, or fall back to its prefab loadout? Does the name reach the killfeed? Does Rust re-dress it on a later tick? **Route B: HumanNPC.** Its API (`SpawnHumanNPC`, `SetHumanNPCInfo`, `RemoveHumanNPC`) gives a name and a kit directly. The gotchas the spike checks, each of which could rule it out: - **Two more dependencies.** It requires Pathfinding and Waypoints. All three become optional dependencies that operators have to install, and the egg and the installer do not ship them. - **Carbon.** Nothing says it runs on Carbon. The spike loads all three on `rust-carbon`. - **Its own persistence.** HumanNPC saves its NPCs to its data file and respawns them on boot, which is the ZoneDomes problem again (§0.5). An event's NPC must be removed at teardown and must not come back after a restart that happened mid-event. The bridge would track them in its registry by HumanNPC's id, not by an entity net id as `world.place` does today. - **Its own AI.** Its NPCs do not use Rust's scientist brain. Hostility, the weapon they use and whether they chase are its settings. The spike checks that a hostile one actually fights. - **Being counted as a player.** A HumanNPC is a `BasePlayer` with a generated `userID`. The bridge's death and kill paths (`OnPlayerDeath`, the tally, the killfeed, a player-kill title) must count it as an NPC, not as a player. The spike kills one and reads the frames. - **Upkeep.** Its last release date and open issues are recorded, because an abandoned dependency is a cost. If route A works on both frameworks it needs nothing new installed, and that is weighed against what B offers beyond it. --- ## 7. Protocols 13 and 14, what this adds The rows for §1, §3 and §5 were additions to protocol 13, which shipped on 2026-10-09; PROTOCOL.md §19 has a section for each. **§2's two rows open protocol 14 (D332)**, which §11 joins, and PROTOCOL.md §20 gains them. §6's row was never built: RunicNPC replaced route A. | Change | Direction | § | |---|---|---| | `perm.inventory` / its paged reply (owners, groups with parents, users, leased) | site → game | 1.2 | | `perm.sync`: groups carry `parent`; `managed` and the foreign report removed | site → game | 1.2 | | `kits.uses` / its reply — **protocol 14** | site → game | 2.6 | | hello: `zoneManager: { version, flags[] }` (from the zone helper); `zoneDomes: { loaded, version }` and the domes helper's state | game → site | 3.1, 3.2 | | `world.zone`: `flags`, `settings`, `enterMessage`, `leaveMessage`, `delivery`, `dome: { type, stack }`; reason `bad-option` | site → game | 2.4, 3.1, 3.2 | | `perm.sync`: `notices` (the reward message, D333), and the report's `noticesTold` — **protocol 14** | site ⇄ game | 2.4 | | ~~`world.place` for an NPC~~ — not built; RunicNPC (D214) | — | 6 | | `player.tally`: `animalKills`, `weaponKills`, `headshots`, `bestPvpM`, `bestPveM`, `apcKills`, `heliKills`, `crafted`, `built`, `repaired`, `healed`, `rockets`, `explosives`, `missions` | game → site | 5.1 | | hello: `integrations.economics` `{ loaded, version, startingBalance, wipesWithMap }` — **protocol 14** | game → site | 11.2.2 | | `economics.pay`, `economics.balances`, `economics.adjust` and their replies — **protocol 14** | site → game | 11.2.2 | | `player.tally`: `balance` (a value); `economics.wiped` — **protocol 14** | game → site | 11.2.2 | ## 8. MODULE_API 1.13.0 (core, website `main`) Planned as 1.12.0; that number went to Events' `progress()` (website#210) while this plan waited for RunicNPC, so these are 1.13.0. Additions only, so Module-uo, declared `^1.10.0`, is unaffected, and that is proven the way 1.11.0 was. - `registerEventTemplates` (§2.5). - A param's optional `duration: 'minutes'` (§2.2). - A lease's optional value `example` (§2.1). - An option row's optional `note` (§2.6). ## 9. Order of building Each item is built on `edge` and PR'd with its spec, like steps 1 and 2. Items 1–4 were also walked as they were built; **from item 6 on, nothing is walked until every item is coded (D331)**. Code that runs in the game is still compiled on both rigs before its PR, and a rig spike that a design depends on (§11's source pulls) is research, not a walk, so it still comes first: 1. **The permission manager** (§1). It is the largest, and §3's exemptions point at it. 2. **Chat titles** (§5), including the mission hook. 3. **Zones and the dome** (§3), with the zone half of D193's popups and the domes helper. 4. **The map's marker types** (§4). 5. **NPCs** (§6): spiked 2026-09-30; now **RunicNPC**, built and released through its own stages 0–9 ([`runicnpc/PLAN.md`](../../runicnpc/PLAN.md) §9) **before items 6 and 7** (D220). 6. **The step editor and the kit weekend** (§2). It is core's, so it can be built alongside any of the above. It goes out as its own website PR with the Module-uo proof. 7. **The first-class optional plugins** (§11): Economics, then Backpacks, then RaidableBases, each starting with its source pull and rig spike. Economics and Backpacks were built on 2026-10-12. **RaidableBases waits for the current paid copy (D347)** and is built for both editions (D348). 8. **Permissions as event rewards** (§12), built on 2026-10-12. 9. **Rust's own world events and six more optional plugins** (§13, §14). First comes one test phase covering all of them on both rigs (§14.2, D352); **it ran on 2026-10-14 (§14.2.1)**. Then one integration plan, answered before code: **written on 2026-10-14 as §14.3**, from D361–D369, and answered as D370–D376. Then the build, in §14.3.11's nine stages (two of them core), §13 first. **Then one walk, on Oxide and Carbon, of everything still owed (D331):** §2.7; §11's rows; §12.9; §13's and §14's rows; the in-game rows of §5.7 and §3.4; PLAN_FIXES §8's re-walk, including the Carbon pass of the first player walk. What it finds is fixed, protocol 14 is released, and the cutover follows. **The walk waits for RaidableBases (D359)**, so it runs only once §11.4 is built from the current paid copy (D347). ## 10. Decisions of record The plan asked eleven questions. The org lead answered them on 2026-09-27, the day it was written. | # | Decision | Rejected | § | |---|---|---|---| | **D188** | **The import records a holding by Steam account, exactly as the game has it, even when that account is linked.** A toggle made on the site for a linked player is a grant to their website account and reaches every Steam account they link (D28). | Recording imported holdings against the website account, which would widen them to the person's other Steam accounts. | 1.3 | | **D189** | **A group belongs to one server unless an admin shares it** with chosen servers or the whole fleet. The import makes one group per server, even for identical names. The same group across servers is allowed but never assumed. | One group name fleet-wide with per-server contents (the plan's first proposal). | 1.4 | | **D190** | **An in-game change affects that server only, and auto-adopt applies it without asking.** A grant that reaches more servers gains an exception for that server; a shared group is split, and that server gets its own copy. | Holding a change to a multi-server row for a person under every policy. | 1.6 | | **D191** | **ZoneManager's `ignoreflag` permissions are listed like any other permission.** | One collapsed "exempt from zone flags" section. | 1.7 | | **D192** | **Core gains event templates** (`registerEventTemplates`, MODULE_API 1.13.0 — planned as 1.12.0, §8), and the kit weekend is the first. | A Rust-only kit-weekend button. | 2.5 | | **D193** | **A zone's enter and leave messages and a player's reward message get the Chat / Popup choice**, as announcements have. The bridge sends zone messages itself for its own zones. | Popups for announcements only in this step. | 2.4 | | **D194** | **ZoneDomes stays the optional dependency (D167 stands).** A domes helper makes its API safe without a player; the bridge re-adds domes after re-creating zones and removes them at expiry and teardown. | The bridge drawing the dome itself (the plan's first proposal). | 3.2 | | **D195** | **Zone presets are the admins' own**, saved for one server, several, or the fleet. None ship. | Shipped presets (Arena, Safe zone, No building). | 3.1 | | **D196** | **The site filters map markers by label**, with a default-off list of minor labels; every other label shows. | The plugin sending Rust's `MonumentType`. | 4 | | **D197** | **The NPC spike tries both routes**, extending `rust.npc.place` and HumanNPC, and reports HumanNPC's gotchas before the org lead picks. | Deciding the route before the spike. | 6 | | **D198** | **The first inventory on an existing install adopts everything present**, including hand edits waiting for an answer today (D160 as written). | Leaving existing drift rows on the "needs a person" list. | 1.5 | The org lead added §11 on 2026-09-28 and answered its questions the same day: | # | Decision | Rejected | § | |---|---|---|---| | **D199** | **RaidableBases, Economics and Backpacks become first-class optional plugins, and Kits stays required.** Optional means what it means for PopupNotifications and Clans: the bridge detects each at hello, the steps that need one refuse with a reason when it is absent, and the installer does not require it. | Making Kits optional as well. | 11 | | **D200** | **Economics: all four uses.** Coins as an event reward (withdrawn on revert); balances shown to the player (site and app) and to staff; staff deposit, withdraw and set a balance, with an activity row; and a *richest* leaderboard column and chat-title condition. | — | 11.2 | | **D201** | **Backpacks: staff view any player's backpack, a player views their own (site and app), and an event can grant extra capacity for a time**, taken back at the end. | Showing backpack sizes as named tiers on the permission screen. | 11.3 | | **D202** | **RaidableBases: all four uses, and the paid edition too.** Active bases on the live map and in the feed; an event step that spawns a base at a difficulty and despawns it at the end; bases raided per player on the leaderboard and as a title condition; and the paid edition's extras (five difficulties, lockouts) where it is installed, falling back to what both editions share. | Supporting only what the free edition has. | 11.4 | | **D203** | **RaidableBases picks where an event's base goes**; the site learns the spot from `OnRaidableBaseStarted` and shows it. The paid edition's placement, if it has one, is offered where installed. | A helper that places a base at a chosen monument. | 11.4.3 | | **D204** | **At an event's end, a helper (`RunicGatewayRaids.cs`, D168) despawns only that event's base.** Without the helper the base despawns on its own timer, and the run records it as expiring on its own. | Leaving every base to its timer; RaidableBases' "despawn all". | 11.4.3 | | **D205** | **An event picks a RaidableBases profile and a difficulty, may override a bounded set of settings for that spawn (through the helper), and the site edits RaidableBases' profiles** — a named exception to the editor never walking a data directory. | Profile and difficulty only; overrides without profile editing. | 11.4.3 | | **D206** | **Lock-to-first-attacker is set per event.** RaidableBases keeps it only in its main config, so the raids helper patches the base's `BypassUseOwners()` to answer from the run's own setting for the event's base, and leaves every other base to the config. | One server-wide switch the config page edits; no lock control at all. | 11.4.5 | | **D207** | **A step places its base either where RaidableBases picks (the default, D203) or at an admin-given spot:** x and z, plus either a height adjustment or an exact height. The spot needs the raids helper; without it the step offers only the plugin's pick. | Only an admin-given spot; keeping D203 alone. | 11.4.5 | | **D208** | **A blocked spot is refused, with the reason.** The step does not spawn when the plugin's area check fails or the helper's water and terrain test fails, and the run log says why ("player building", "raid base", "in water"). The editor runs the same check when the step is saved. | Falling back to the plugin's pick; spawning anyway. | 11.4.5 | The §5 build raised one more, answered on 2026-09-28: | # | Decision | Rejected | § | |---|---|---|---| | **D209** | **A title rule's "NPC kills" ranks human NPCs only** (scientists and bandits): every NPC kill less the animals, which Beastbane ranks. The public leaderboard's NPC column still counts both (§5.2). | Keeping the old all-NPC count as a 25th category beside Exterminator. | 5.8 | The §3 build raised two more, answered on 2026-09-29: | # | Decision | Rejected | § | |---|---|---|---| | **D210** | **A zone's flags and settings are ticked on an Admin → Rust zone presets page**, and the zone step's Options field copies a preset's line when it is picked. Core is unchanged. | A list param type in core (MODULE_API) drawn as grouped checkboxes; a module-supplied step editor. | 3.4 | | **D211** | **The bridge reads ZoneManager's flag list** through its API call `ZoneFieldListRaw()` and sends it in hello. A server without the zone helper still offers every flag. | Keeping the read in the zone helper, with a name/radius/minutes-only mode without it. | 3.4 | | **D212** | **A zone's dome defaults to ZoneDomes' Standard type, the full shaded dome.** The coloured types show only where they meet the ground or a building, and the step says so. | A coloured default. | 3.4 | The §4 build raised one more, answered on 2026-09-29: | # | Decision | Rejected | § | |---|---|---|---| | **D213** | **Eight labels the 6000 map showed also start hidden:** Canyon B, Canyon C, Lake A, Oasis A, Oasis C, Mountain (terrain, not places to loot), Train Tunnel Link (like Train Tunnel), and Abandoned Cabins (the third swamp, like Wild Swamp). | Leaving them drawn under "every other label is on"; hiding the terrain and tunnel but drawing the cabins for their loot. | 4.1 | | **D214** | **Runic Gateway writes its own NPC plugin, RunicNPC**, API-style like NpcSpawn, in its own repository `runicnpc-rust`; behaviours may be borrowed, code may not. | Route A alone; HumanNPC; depending on NpcSpawn. | 6, [RunicNPC §0](../../runicnpc/PLAN.md) | | **D215** | **RunicNPC is not only for events:** admins place NPCs in game for normal play, through chat commands. | Event steps only. | 6, RunicNPC §5 | | **D216** | **No visual editor:** profiles are authored on the website; in game, commands only. | NpcSpawn's in-game GUI. | 6, RunicNPC §0 | | **D217** | **Kits is how an NPC is equipped, and Kits is required.** | Item lists in the profile. | 6, RunicNPC §2 | | **D218** | **Loot tables are RunicNPC's own for now;** links to other loot plugins later. | A loot plugin as a dependency. | 6, RunicNPC §7 | | **D219** | **Rust's navmesh is used;** a custom navigation mesh is a later stage, only if needed. | NpcSpawn-style point meshes from the start. | 6, RunicNPC §8 | | **D220** | **RunicNPC is built and tested in stages before this plan resumes, then `module-rust` requires it.** | Building it alongside items 6 and 7. | 9, RunicNPC §9 | The plan resumed on 2026-10-09, after RunicNPC v1.0.0, with four more: | # | Decision | Rejected | § | |---|---|---|---| | **D331** | **Every item left (§2, §11) is coded before anything is walked; then one walk covers it all**, with the walks §3 and §5 still owe and PLAN_FIXES §8's re-walk, and what it finds is fixed before the release. Rig spikes a design depends on, and compiling on both rigs, still happen as each item is built. | Walking each item as it is built, as items 1–4 were. | 9 | | **D332** | **§2 and §11 open protocol 14 on `edge`, released together once after the end walk.** Protocol 13 shipped on 2026-10-09. | Protocol 14 for §2 alone and 15 for §11; dropping `kits.uses` to keep §2 off the wire. | 7, 11.5 | | **D333** | **A kit reward can tell its player in game.** `rust.kit.entitle` gains an optional `message` and `delivery`; the notice rides `perm.sync` and is told once, at once or on the player's next wake; a blank message tells nobody. | The engagement notification as the only notice. | 2.4 | | **D334** | **An optional boolean param is three buttons, [Not set] [Yes] [No]; a required one is a toggle.** | A two-state toggle everywhere, which saves No for a field nobody touched. | 2.1 | The Economics spike raised two more, answered on 2026-10-12: | # | Decision | Rejected | § | |---|---|---|---| | **D335** | **Coins paid by an event are kept.** `rust.coins.pay` is `reversible: 'none'`: a run that finishes, is cancelled or is aborted after paying leaves the coins where they are, and staff take coins back by hand from the player's admin page. | Kept on a finish but taken back on a cancel or abort (core would have to tell `revert()` how the run ended, MODULE_API 1.14.0); a per-step choice; always taken back at the end, like a kit weekend's credits. | 11.2.2 | | **D336** | **The richest column and *Magnate* rank only balances above the server's starting balance**, read when a player connects and whenever it changes. A fresh wipe's untouched players are not ranked; a balance carried over from the last wipe is, at once. | Ranking a player only once their balance moves this wipe; ranking everyone, ties at the starting balance included. | 11.2.2 | The Backpacks spike raised three more, answered on 2026-10-12: | # | Decision | Rejected | § | |---|---|---|---| | **D337** | **Extra backpack space is a size permission leased for the run.** `rust.backpack.size` grants one of the server's `backpacks.size.N` to each recipient through `rust_perm_run_grants`, as a kit reward is granted, and the run's end revokes it. It reaches offline players, needs no experimental setting, and needs no plugin code. The highest size a player holds wins. | Backpacks' `API_AddBackpackCapacity` (+N slots), which throws unless the owner turns on *Dynamic Size (EXPERIMENTAL)*, is clamped to a size profile, and needs the player online; offering both; dropping the reward. | 11.3.2 | | **D338** | **Taking the space back is Backpacks' own behaviour:** nothing moves at the revoke, and on the owner's next open the stacks past their size go to their inventory, or drop at their feet when it is full. Tested on both rigs at the org lead's request; the step's default message warns about it. | Holding the space until the extra slots are empty; never taking it back. | 11.3.1, 11.3.2 | | **D339** | **The Carbon permission leak found by the spike stays out of §11.3**: filed as Rust-Plugins#32, for later. | Fixing it in §11.3; only noting it. | 11.3.1 | The build raised two more, answered on 2026-10-12 before any code: §11.3.2's grid named an "item catalogue" the module does not have, and R9 keeps item icons out of the asset bridge. | # | Decision | Rejected | § | |---|---|---|---| | **D340** | **A backpack slot shows the item's name, not an icon.** The plugin sends each stack's English name from the game (`display`), so "Wood ×1000" and "Assault Rifle + Weapon Lasersight" need no item catalogue and nothing loads from outside the site. R9 stands. | Icons from a third-party host keyed by shortname; shortnames alone. | 11.3.4 | | **D341** | **A blueprint names what it makes.** Backpacks stores a blueprint as `blueprintbase` with a target; the stack carries `blueprint` (the target's shortname), its `display` is the target's name, and the slot reads "Assault Rifle blueprint". | Every blueprint shown as the same anonymous "Blueprint". | 11.3.4 | Permission rewards, added on 2026-10-12, were decided as D342–D346; they are tabled with their section, in §12.8. Reading the paid edition (§11.4.2) raised two more, answered on 2026-10-14: | # | Decision | Rejected | § | |---|---|---|---| | **D347** | **RaidableBases waits for the current paid copy.** No §11.4 code is written until the org lead supplies it and its spike (§11.4.2) has run. The old 3.0.5 copy was read for drift only. | Building §11.4 on the free edition now and fitting the paid edition in later; spiking the old 3.0.5 copy on the rigs. | 11.4.2 | | **D348** | **Runic Gateway and all its systems support either edition, free or paid, as far as each allows.** Every part detects what the installed copy offers and uses the best route it has, and says so where there is none (§11.4.2). This widens D202: both editions must get each part, not only the paid edition's extras. | Building on the free edition's API and treating the paid edition as extras on top. | 11.4.2 | The plugin survey the same day led to four more, decided by the org lead on 2026-10-14: | # | Decision | Rejected | § | |---|---|---|---| | **D349** | **Rust's own world events are part of the base module:** the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops and locked crates reach the feed and the event system on every server, with no plugin. | Leaving them on the map only; supporting them through a plugin such as Inbound or Automated Events. | 13 | | **D350** | **TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels Remastered and Server Rewards become first-class optional plugins**, as D199 defines them. **Duelist is left out entirely.** | Duelist; SmoothRestarter and PlayerRanks (not wanted); Event Manager, Quests and Bounty (no result hooks). | 14 | | **D351** | **As few helper plugins as possible, and nothing that runs afoul of a licence.** Runic Gateway never copies, modifies, bundles or redistributes a third-party plugin; it uses only their public hooks, methods and console commands, and operators install them from uMod. | A helper per plugin by default; shipping plugins with the installer or egg. | 14 | | **D352** | **One test phase covers §13 and all six plugins together, on both rigs; then one integration plan, answered before code.** | A spike and a plan per plugin, as §11 did. | 14.2, 14.3 | The org lead answered §13's and §14's questions the same day, and added Steam sign-in: | # | Decision | Rejected | § | |---|---|---|---| | **D353** | **The public feed shows a world event with its grid and the player's name; the staff feed adds the Steam id** (Q12). | Staff only. | 13.4 | | **D354** | **An event can call a vanilla event** (patrol helicopter, cargo ship, Chinook, airdrop at a grid or position) through a new step whose result is kept (`reversible: 'none'`): nothing is removed when the run ends. A new verb, not an exception to D88 (Q13). | No such step; removing the called event at teardown. | 13.4 | | **D355** | **Every world event in §13.2 is an event-system trigger** (Q14). | Feed only. | 13.4 | | **D356** | **Two new counts with titles: locked crates hacked (*Safecracker*) and airdrops looted first (*Scavenger*)** (Q15). Barrels broken, K/D ratio and time played are not added. | All of PlayerRanks' extra counts. | 13.4 | | **D357** | **§13 and §14 join protocol 14** (D332), released with the rest after the end walk (Q16). | A protocol of their own. | 13.4 | | **D358** | **BotReSpawn stays out** (Q17). | Feed and kill-count support for its bots. | 14.4 | | **D359** | **The end walk waits for RaidableBases** (Q18). D331's one walk runs once every item is coded, §11.4 included, and §11.4 waits for the current paid copy (D347). | Walking the rest without it and giving RaidableBases its own walk later. | 9, 14.4 | | **D360** | **Steam sign-in is supported in the same manner as the other providers:** a built-in provider beside Google and Discord, link-only, through the one session service, configured on Admin → Auth, on the website and in the Android app. **It stays on the website's plan (D171)**, now written as [`website/STEAM_SIGNIN.md`](../../website/STEAM_SIGNIN.md) and on the roadmap as the `website` milestone *Steam sign-in*. §15 links it. | Planning it inside this document. | 15 | During the §14.2 test phase (2026-10-14) the org lead ruled on two points it raised: | # | Decision | Rejected | § | |---|---|---|---| | **D361** | **Guarded Crate may depend on NpcSpawn.** NpcSpawn (Codefling) is downloaded and used as is, on the rigs and by operators; Runic Gateway never ships or modifies it (D351). | Treating NpcSpawn as a licence problem that rules Guarded Crate out. | 14.2.1 | | **D362** | **Our teardown removes our TruePVE mappings.** TruePVE never prunes a mapping whose zone id is not numeric, and ours are `rg-…`, so the run that maps a zone unmaps it. | Relying on TruePVE to clean up. | 14.2.1 | The org lead answered the phase's Q19–Q25 on 2026-10-14: | # | Decision | Rejected | § | |---|---|---|---| | **D363** | **Carbon's console route (Q19): watch Carbon's updates and keep an issue open in Rust-Plugins so it is not forgotten** ([Rust-Plugins#34](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/issues/34)). No workaround is built for now; each new Carbon production build is re-tested. | Running commands through Carbon's `CommandManager` by reflection now. | 14.2.1 | | **D364** | **A missing NpcSpawn is reported and refused (Q20):** the hello reports NpcSpawn beside Guarded Crate, the site refuses a guarded-crate step without it and says why, and the operator docs say what NpcSpawn does on load. | Documenting it only. | 14.2.1 | | **D365** | **Dangerous Treasures' warm-up (Q21):** a step that fails because DT is not ready says so (*"Dangerous Treasures is not ready yet"*), retries once on its own after ten minutes, **and offers staff a manual retry** on the run. | Failing with DT's own text; the bridge pre-warming DT at boot. | 14.2.1 | | **D366** | **One Bradley event, two places (Q22):** `bradley.destroyed` carries `where: launch_site | road`, and both count toward *Tankbreaker*. | Two separate events; Launch Site only. | 14.2.1, 13 | | **D367** | **The newer events join §13 (Q23):** the satellite crash, the Deep Sea opening and closing, and the ghost ship's crate (a fourth crate location) are feed events and triggers. **The F-15 flyover is not an event; it can be added to an event as decoration**, a step that calls the flyover (D354). | The F-15 as a feed event or trigger. | 14.2.1, 13 | | **D368** | **TruePVE in the zone step (Q24):** a *"no PvP protection in this zone"* checkbox (`exclude`), plus an optional ruleset name typed by staff, which the bridge validates by `AddOrUpdateMapping`'s own `false`. | Checkbox only; reading TruePVE's config file. | 14.2.1, 3 | | **D369** | **Wipes mirror the plugin (Q25):** the site shows what ZLevels and Server Rewards hold and does nothing at a wipe; ZLevels columns are *current level*, not *this wipe*. | Per-wipe snapshots of our own; a site-side wipe reset. | 14.2.1 | The org lead answered §14.3's Q26–Q32 on 2026-10-14: | # | Decision | Rejected | § | |---|---|---|---| | **D370** | **A trigger can start a run (Q26).** Core gains a definition's `starts` (a trigger, conditions and a cooldown), one run at a time per definition, `event.*` triggers excluded; core work on the website's own plan, its own PR with the Module-uo proof. | Only phase gates; an event that waits for the trigger in its first phase. | 14.3.12 | | **D371** | **A failed step may name its retry delay (Q27):** `retryAfterMs`, 1–30 min, counted as one attempt, a MODULE_API minor bump. DT's warm-up retries ten minutes later through it. | The bridge holding the request and starting DT itself later. | 14.3.4, 14.3.13 | | **D372** | **The operator chooses who is credited with a helicopter or a Bradley (Q28):** killing blow (the default; for the helicopter, the crippling hit, else the last hit), last hit, most damage, or everyone who hurt it. The plugin sends every candidate; the site applies the server's rule to the feed line and to *Rotorbane* / *Tankbreaker* alike. **This amends D174.** | The crippling hit as the one fixed rule; D174's 60 s last-hitter. | 14.3.1 | | **D373** | **A guarded crate outlives its run (Q29):** nothing is removed at teardown; the step says so and the run log notes a crate left standing. | Ending every guarded crate on the server with `gcrate stop all`. | 14.3.5 | | **D374** | **The leaderboard is not changed for ZLevels (Q30):** no column and no title, because most of its skills already line up with counts of ours. ZLevels is reported at hello, and its XP boosts are lent as permissions (§12). | One total column; six columns; one column with a skill picker. | 14.3.6 | | **D375** | **Three new titles (Q31):** *Treasure Hunter* (DT treasures won), *Warden* (guarded crates cleared), *Hoarder* (the most RP, above 0), renamable, each with a column on servers that report the plugin. | No titles for the plugins' counts. | 14.3.4, 14.3.5, 14.3.7 | | **D376** | **A public line names a player only for the presence audience (Q32):** the event and its grid are public (*"Patrol helicopter downed at G8"*), and the name shows only to the audience the operator chose for presence. | The name always public (D353 as written); the whole line hidden below the presence audience. | 14.3.1 | | **D377** | **The leaderboard changes only for something new and substantial.** A new title may add its column (*Safecracker*, *Scavenger*, *Treasure Hunter*, *Warden*, *Hoarder* do), but a small plugin's own numbers, such as ZLevels' levels, never become columns (org lead, 2026-10-14, confirming D374 beside D375). | Adding a column for every number a plugin reports. | 14.3.1, 14.3.6 | ## 11. First-class optional plugins (D199–D202) The org lead named four plugins the module supports as first-class integrations. Kits already is one. The other three are new, and none of them is mentioned anywhere in PLAN.md or PLAN_FIXES.md. They are **optional** (D199): the pattern PopupNotifications set (D141) and Clans set (D47) before them. - **Detected at hello.** `integrations` gains `economics`, `backpacks` and `raidableBases`, each as `{ loaded, version }`, and RaidableBases also reports which **edition** it is. - **A step or screen that needs one refuses with a reason** when it is absent ("Economics is not installed on this server"). It never disappears and never fails silently, as R3 settled for Kits. - **Not required by the installer or the egg.** `overlay.toml`'s `requires_plugins` keeps Kits and ZoneManager only. - **Public API only (R2).** Where a plugin lacks what we need, a D168 helper does the rest. ### 11.1 What was found, and what must be pulled before any build From the research on 2026-09-28. Every exact signature below is confirmed again from the plugin's current `umod.org/plugins/.cs` at the start of its build, the way Kits' was (PLAN.md, "pulled 2026-09-24"). Personal GitHub mirrors are not a source: k1lly0u's mirror of Kits is two major versions behind. | Plugin | Author · version · price | API we would use | Hooks it raises | Risk | |---|---|---|---|---| | **Economics** | Wulf · 3.9.2 · free | `Balance(id)` → double; `Deposit`, `Withdraw`, `SetBalance(id, amount)` → bool; `Transfer` | `OnEconomicsDeposit`, `OnEconomicsWithdrawl` (sic), `OnEconomicsTransfer`, `OnEconomicsBalanceUpdated`, `OnEconomicsDataWiped` | Last released about two years ago; fine as optional, never load-bearing | | **Backpacks** | WhiteThunder · 3.17.7 (3.17.8 at the spike) · free | `API_ReadBackpackContentsAsJson(ownerId)`; the capacity family (`API_GetBackpackCapacity`, `API_AddBackpackCapacity`, `API_SetBackpackCapacity`) | `OnBackpackOpened`, `OnBackpackClosed`, `OnBackpackDropped`, `CanOpenBackpack` | Newer builds add `EncryptedValue` overloads; one community report of backpack data lost across a **Carbon** restart — a rig test before shipping (**not reproduced, §11.3.1**) | | **RaidableBases** | nivex · free 3.2.0 on uMod, paid on Codefling | `EventTerritory(Vector3)`, `SpawnRandomBase(type)`; listing and despawn not yet confirmed | `OnRaidableBaseStarted` (two overloads), `OnRaidableBaseEnded`, `OnRaidableBasePrivilegeDestroyed`, `OnRaidableBaseDespawn` — signatures not yet confirmed from source | Two products with different features and release lines; data path differs by framework; needs a PvE-damage plugin (TruePVE) for players to raid | ### 11.2 Economics (D200) This is the plan as the org lead approved it on 2026-09-28. The spike (§11.2.1) changed two points of it, and **§11.2.2 is the design to build**: - **Coins as a reward.** A step `rust.coins.pay` gives each recipient N coins (the same recipient modes as `rust.kit.entitle`, plus §2.5's `linked`). ~~It is ledgered per recipient like D103's kit credits, and revert withdraws what the step paid.~~ **Superseded by D335: the coins are kept.** Core reverts a ledgered step at the end of every run, a normal finish included, so a ledgered payment would take back a prize. - **Balances.** A new read verb, `economics.balances {steamIds[]}` (bounded like `kits.uses`), and the `OnEconomicsBalanceUpdated` hook folded into the tally (rule 2: aggregates only), so the site keeps each player's last balance per server. The player's account page and the app show it; staff see anyone's. - **Staff adjust.** Deposit, withdraw and set from the player's admin page, through the plugin's API. Each is an activity row with the amount before and after. - **Richest.** A leaderboard column (the last known balance) and a title condition, *Magnate* by default, admin-renamable like every other category (D175). It is a *best* column in §5.5's sense, the current value rather than a sum. **It ranks only balances above Economics' starting balance (D336).** #### 11.2.1 The spike (2026-10-12) The source is Economics 3.9.2, Wulf, pulled from `umod.org/plugins/Economics.cs` (845 lines) on 2026-10-12. A throwaway plugin called its API and logged every hook on both rigs, on Rust 25877464 with Oxide 2.0.7826 and Carbon 2.0.262. **Both frameworks answered identically, line for line.** The spike and Economics were then removed from both rigs, with their data and config. **The API** is five private methods, each with a `string` and an `object` overload (the `object` one takes a `ulong`). Both overloads bind through `Call` on both frameworks. | Call | Returns | Found | |---|---|---| | `Balance(id)` | `double` | **An account Economics has never seen reads as the starting balance** (1000 by default). Nothing is stored for it, and at load Economics deletes every account that sits exactly at the starting balance ("Remove unused accounts", on by default). So "1000" cannot tell a new player from one who earned and spent back to it | | `Deposit(id, amount)` | `bool` | Refuses an amount of 0 or less. **Returns `true` while adding less than asked:** the balance is rounded to two places and silently clamped to the balance limit, so `Deposit(id, 0.004)` answers `true` and changes nothing. What was paid is the balance after less the balance before, never the amount asked | | `Withdraw(id, amount)` | `bool` | **All or nothing.** It refuses an amount above the balance (unless the config allows negative balances); there is no partial withdrawal | | `SetBalance(id, amount)` | `bool` | Refuses a negative amount unless the config allows one; rounds and clamps like `Deposit` | | `Transfer(from, to, amount)` | `bool` | A withdrawal and a deposit. Not needed | - **The amount must be a `double`.** `Call("Deposit", id, 10)`, with an `int`, matches no overload on either framework: `Call` returns `null` and nothing happens. The bridge boxes every amount as `double`. - **A string id is not checked.** `Deposit("notanid", 1.0)` stored an account named `notanid`. The bridge sends only ids it has parsed as Steam ids. - **Cost:** 500 `Balance` calls took 2.6 ms (Carbon) and 2.8 ms (Oxide) on the main thread. - **Balances survive a reload** on both frameworks (`data/Economics.json`, written at unload and at every server save). - **What the API does not give:** the starting balance, the balance limit, and whether balances wipe with the map. They are in `Economics.json` in the framework's config directory, which the bridge already reads for Kits ([PROTOCOL.md](../../rust-link/PROTOCOL.md) §16.3). Economics' own chat and console commands find a player through covalence, so they cannot reach a Steam id the server has never seen; the API can. **The hooks**, in the order one deposit raises them: | Hook | Arguments | When | |---|---|---| | `OnEconomicsBalanceUpdated` | `(string playerId, double balance)` | **Every** change, including the ones below, first. The new balance, not the change | | `OnEconomicsDeposit` | `(string playerId, double amount)` | After a deposit; the amount asked, even when less landed | | `OnEconomicsWithdrawl` (sic) | `(string playerId, double amount)` | After a withdrawal | | `OnEconomicsTransfer` | `(string from, string to, double amount)` | After a transfer's withdrawal and deposit | | `OnEconomicsDataWiped` | none, **or** `(IPlayer player)` | None at a new save when the config wipes balances; the `IPlayer` after `ecowipe`. One method taking an `IPlayer` binds both shapes on both frameworks, with `null` for the first | **Two traps on the rigs**, neither Economics' fault: a Rust update needed **two** panel restarts on each rig, because the first boot ran the old build with the download unfinished (`StateFlags` 1158 and 1062). And **Carbon kept Economics loaded after its file was deleted**, until `c.unload`; Oxide unloaded it. #### 11.2.2 The design to build (D335, D336) **Hello.** `integrations.economics` is `{ loaded, version, startingBalance, wipesWithMap }`. The last two come from `Economics.json`. When the file cannot be read, they are absent, the site assumes Economics' default of 1000, and the servers page says so. **Paying coins: `economics.pay`, site → game.** A one-shot command, because the coins are kept (D335): ```json {"cmd":"economics.pay","reqId":"r-31","key":"7:4","amount":500,"steamIds":["76561190000000001"], "message":"You earned {amount} coins","delivery":"popup"} {"kind":"economics.paid","type":"reply","reqId":"r-31","key":"7:4","repeat":false, "paid":[{"steamId":"76561190000000001","before":1000,"after":1500}],"failed":[]} ``` - **Main thread, one call for the whole list.** For each id it reads the balance, deposits, and reads it again. `paid` holds the before and after values; a deposit that moved nothing, at the balance limit, is listed in `failed` with `at-limit`. - **The key is remembered with its reply**, on disk (`data/RunicGateway/payments.json`) for seven days, as tallies are. A retried step is answered from the first reply with `repeat: true`, and never pays twice. - **The message** is told once to each player paid: at once if they are awake, otherwise when they next wake on this server, as reward notices are (PROTOCOL.md §20.2). The untold ids are kept in `payments.json` with the key. `{amount}` is the amount the player received. A blank message tells nobody. - **Bounds:** at most `EventsMaxRecipients` ids, and an amount from 0.01 to 1,000,000. Refusals are `economics.error`: `economics-missing`, `events-disabled`, `bad-amount`, `too-large`. **Reading balances: `economics.balances {steamIds[]}`**, answered with `{balances: [{steamId, balance}], startingBalance}`, and bounded by the sync's own bound (`PermMaxOps`), like `kits.uses`. A balance at the starting value may belong to an account Economics has never seen (§11.2.1). **Staff: `economics.adjust {key, steamId, op, amount}`**, where `op` is `deposit`, `withdraw` or `set`. It answers `{before, after}`, and a remembered key is answered with `repeat: true` like a payment. A withdrawal above the balance is refused with `insufficient` and the balance, because Economics will not take part of it. Staff then see "has only 340". **The tally.** `player.tally` gains `balance`: **the current balance, a value and not a change**, like the `best*` fields. It is sent when it moved since the last flush, and it is read: - when a player connects, so a balance carried over from last wipe is known without waiting for it to move (D336); and - from `OnEconomicsBalanceUpdated`, for an id that parses as a Steam id. **The player need not be online:** a payment or a staff adjustment reaches an offline player, and that frame carries no `name`. **`economics.wiped`**, an event from `OnEconomicsDataWiped` in either shape. The site clears the server's current-wipe balances, since everyone is back at the start. **The sidecar** adds `POST /economics/pay`, `POST /economics/balances` and `POST /economics/adjust`, thin forwards like `/kits/uses`. It keeps nothing. **The website (Module-Rust).** - **`rust.coins.pay`**, *"Pay coins"*: `risk: 'irreversible'`, `reversible: 'none'` (D335), as Module-uo's `uo.item.grant` is. Its params are the server, the amount, the recipients and count (the modes of `rust.kit.entitle`, including `linked`), and the message and delivery. One payment goes to each website user: of two linked accounts that both took part, the higher-scoring one is paid, as for kits (D103). The run log lists what each person received, anyone missed for having no linked account, and anyone at the limit. **A run cancelled or aborted after the payment keeps it;** staff take coins back by hand. - **Balances** are stored in `rust_player_wipe_stats.balance`, written as a value, never summed. They are shown on the player's own Rust page, per linked account and server, with a new `GET /player/rust/economics` that reads live and falls back to the last known balance, and on a user's admin page beside their links. - **Staff adjust** through `POST /admin/users/:userId/rust/economics`, `{ serverId, steamId, op, amount }`. Each adjustment is a core activity row: "Deposited 500 coins to *name* on *server*: 1,000 → 1,500". - **Richest.** A leaderboard column on servers that report Economics, and a title category `richest`, *Magnate*, whose standing is the current balance, **counted only above that server's starting balance (D336)**. A fresh wipe's 40 untouched players at 1,000 are not on it; a player carrying 5,000 over is. - **Every surface that needs Economics refuses with a reason where it is absent** (D199): the step's verify, the adjust form, and the account page ("Economics is not installed on this server"). **The app (Android-app).** The Rust account screen shows the player's balances from the same route, capability-gated like the rest of that screen (R10). **Not touched:** the installer and the egg. `requires_plugins` stays Kits and ZoneManager (D199). #### 11.2.3 The walk (deferred to the end, D331) On both rigs, with Economics installed: - pay three players from a tally, one of them offline, and see the popup on wake; - retry the step, and see `repeat` and no second payment; - pay at the balance limit, and see `at-limit`; - deposit, withdraw (including more than the balance) and set from the admin page, and read the activity rows; - watch the richest column and *Magnate* on a fresh wipe, then with a carried-over balance; - run `ecowipe`, and see the column clear; - read a balance on the account page and in the app. #### 11.2.4 Built (2026-10-12) Built to §11.2.2 on protocol 14, and specified in [PROTOCOL.md](../../rust-link/PROTOCOL.md) §20.3. The bridge compiled on both rigs (Rust 25877464; Oxide 2.0.7826, Carbon 2.0.262), which were then given back their protocol 13 bridge. Six points the design left open were settled in the code, and are named here so they can be overruled: - **`economics.wiped` carries `newSave`.** After `ecowipe` the site clears the wipe's balances; at a new save it clears nothing, because the new map's balances arrive as players connect, and the last wipe's stay as the record of how it ended. - **A balance frame with no `name` is not play.** The plugin names only a connected player, and the site writes such a frame's balance without moving anybody's last-seen. - **The Coins column is per wipe.** A balance is a value, so there is no all-time one: the column and `sort=balance` exist only when one wipe of a server that reports Economics is shown. - **A payment's `failed` reasons** are `at-limit`, `refused`, `unmeasured` (deposited, but not measured — remembered with the key so a retry pays nothing more) and `bad-id`. - **Payments and adjustments keep their keys apart**, so a step's key can never be answered with a staff adjustment's reply. The admin form sends a fresh key with each submission. - **The step spends a new `rust.coins` budget**, priced like the kit reward: the amount times the count, or times the recipient bound for a mode that is not a count. ### 11.3 Backpacks (D201) This is the plan as the org lead approved it on 2026-09-28. The spike (§11.3.1) changed how the extra space is given, and **§11.3.2 is the design to build**: - **Read, never write (R2).** A new verb, `backpacks.read {steamId}`, returns `API_ReadBackpackContentsAsJson` reduced to shortname, amount, skin and slot. It never uses the write counterpart, so the site cannot change a backpack. - **Staff** open any player's backpack from the player's admin page. **A player** sees their own on the account page and in the app, only for accounts they have linked (the player tier's usual scoping). - **Extra space as a reward.** ~~A step `rust.backpack.capacity` adds N slots for the event's duration through `API_AddBackpackCapacity`, and takes them back at teardown, ledgered like a lease. If the player is offline at either end, it waits for their next connection: the capacity calls take a `BasePlayer`.~~ **Superseded by D337: the step leases one of Backpacks' size permissions for the run instead.** The capacity calls work only on a server whose owner has turned on an experimental setting (§11.3.1). - **The spike first:** the Carbon restart report. Fill a backpack on `rust-carbon`, restart, and check it. **Done; the loss did not reproduce (§11.3.1).** #### 11.3.1 The spike (2026-10-12) The source is Backpacks **3.17.8** (one patch past the 3.17.7 that §11.1 names), WhiteThunder, pulled from `umod.org/plugins/Backpacks.cs` (9,782 lines) on 2026-10-12. A throwaway plugin made stand-in players with Steam-shaped ids (the RunicNPC 9d pattern), called the API and logged every hook, on both rigs, on Rust 25877464 with Oxide 2.0.7826 and Carbon 2.0.262. **Both frameworks answered identically everywhere the plugin was concerned.** The spike and Backpacks were then removed from both rigs, with their data, config and lang files, and the permissions the stand-ins held. **The restart report did not reproduce.** A backpack changed after the last save (the save interval raised to an hour first, so no timed save could run) was intact after a panel restart on both frameworks, for an online owner and an offline one alike. A plugin reload kept it too. Backpacks saves at every server save and again at unload. **The one way the spike lost a backpack** was its own: the first run killed its stand-ins at unload, Backpacks took each kill for a death, and *Drop on death*, on by default, dropped the contents on the ground. On Carbon, Backpacks was still loaded when the spike unloaded during shutdown; on Oxide it was not, so only Carbon showed it. That is Backpacks working as configured: **any plugin that kills a player entity drops that player's backpack.** The bridge kills no players. **Reading.** | Call | Found | |---|---| | `API_ReadBackpackContentsAsJson(ulong)` | A JSON string, or **`null` for a player who has never had a backpack**. It loads an offline player's backpack from disk. 0.2–7 ms once loaded; **55–117 ms the first time after a boot** (Oxide logs its own "took 116ms" warning), on the main thread | | `API_CountBackpackItems(ulong, null)` | The number of stacks | | `API_GetBackpackCapacityById(ulong, string)` | The capacity an id would have. **Caches it** (see the trap below) | The JSON is a list of Backpacks' own `ItemData`, not of Rust items: ```json [{"ID":-151838493,"Position":0,"Amount":1000}, {"ID":1545779598,"Position":2,"Amount":1,"Contents":[{"ID":567235583,"Position":0,"Amount":1}]}, {"ID":1751045826,"Skin":10129,"Position":3,"Amount":1}, {"ID":1414245162,"Name":"spike-a","Position":4,"Amount":1}] ``` - **`ID` is the item id, not the shortname**; the bridge maps it with `ItemManager.FindItemDefinition`. - **`Position` runs across pages**: page two of a 96-slot backpack starts at 48 ("Max size per page"). - **`Contents`** holds a weapon's attachments or a container's items, nested. `Name` is a renamed item's name. Condition, ammo, fuel and the rest appear when they differ from the default. **Capacity.** A player's size comes from permissions: `backpacks.use` gives the default (6), and the highest `backpacks.size.N` they hold sets it, for each N in the config's *Permission sizes* (6, 12, 18, 24, 30, 36, 42, 48, 96 and 144 by default). Backpacks registers each one, so the bridge can list them. - **`API_AddBackpackCapacity` and `API_SetBackpackCapacity` throw** (`InvalidOperationException`, logged with a stack trace on both frameworks; `Call` returns `null`) unless the config's **"Dynamic Size (EXPERIMENTAL)"** is on, and it is off by default. With it on, each player is clamped to the initial and maximum size of a *size profile* they hold (`backpacks.size.profile.6-48` …); a player with no profile gains nothing. They take a `BasePlayer`, so an offline player cannot be given space. This is why D337 leases a permission instead. - **A size permission takes effect at once, both ways, for a player whose backpack is loaded** (an online player who has opened it, or whose backpack anything has read): granted `backpacks.size.48`, 6 became 48; revoked, 48 became 6. An offline player's grant is simply there when they connect. - **The trap: a capacity cached without its backpack.** Backpacks clears its capacity cache on a permission change only for a backpack it has loaded. If anything reads an online player's capacity while their backpack is not loaded (`API_GetBackpackCapacity*`), the value is pinned: a size granted afterwards did not show, even after the owner opened the backpack, **until they disconnected**. Same on both frameworks. Backpacks itself reads capacity only through a loaded backpack, so only another plugin's call can cause it. **The bridge never reads a capacity except through a backpack it has just loaded with a read.** **Taking space back (the org lead's test, 2026-10-12).** A stand-in held `backpacks.size.96`, six stacks on page one and five notes on page two (slots 48–52); the permission was revoked. | State | What happened, on both frameworks | |---|---| | After the revoke, before the owner opens it | Nothing moves. Capacity reads 6, and **the read still returns all eleven stacks**: the five past slot 6 wait in the backpack | | The owner opens it, inventory has room | The five notes **moved into the owner's inventory** (main 5/24), and the backpack read six stacks. Backpacks tells the owner in chat: "Your backpack was over capacity. Overflowing items were added to your inventory or dropped." | | The owner opens it, inventory full (30/30) | The five notes **dropped on the ground at the owner's feet** as loose items, and the backpack read six stacks | That is Backpacks' default, the behaviour a VIP page that lapses already has, and **it stands (D338).** A staff member looking at someone else's backpack sees the overflow too; only the owner's own open moves it. **Hooks.** `OnBackpackOpened(BasePlayer looter, ulong ownerId, ItemContainer)` and `OnBackpackClosed` fired for the owner's open and close; `OnBackpackDropped(ulong ownerId, List)` for each death drop. None is needed by the design. **Two quirks the bridge never meets,** because it never writes: `API_TryDepositBackpackItem` took only the first item into a backpack nobody had opened yet, and `API_GetBackpackContainer` returns `null` for a backpack that does not exist yet. A page that has never been opened is sized to what it holds until its owner opens it. **Found on the way, outside Backpacks:** on Carbon, a permission granted to a Steam id the store has no user record for leaks onto every such id. Filed as [Rust-Plugins#32](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/issues/32) and kept out of §11.3 (D339). The size lease grants through `perm.sync` like a kit reward, so it inherits whatever that issue decides. #### 11.3.2 The design to build (D337, D338) **Hello.** `integrations.backpacks` is `{ loaded, version, sizes }`. `sizes` lists the N of every registered `backpacks.size.N` permission, ascending (`[6, 12, 18, 24, 30, 36, 42, 48, 96, 144]` by default), read from the permission store, so an operator's own *Permission sizes* are what the site offers. **Reading a backpack: `backpacks.read {steamId}`, site → game.** ```json {"cmd":"backpacks.read","reqId":"r-40","steamId":"76561190000000001"} {"kind":"backpacks.contents","type":"reply","reqId":"r-40","steamId":"76561190000000001","exists":true, "capacity":12,"pageSize":48, "items":[{"slot":0,"shortname":"wood","amount":1000}, {"slot":2,"shortname":"rifle.ak","amount":1, "contents":[{"slot":0,"shortname":"weapon.mod.small.scope","amount":1}]}, {"slot":3,"shortname":"hoodie","amount":1,"skin":10129}, {"slot":4,"shortname":"note","amount":1,"name":"spike-a"}, {"slot":14,"shortname":"note","amount":1,"overflow":true}]} ``` - **One id per call, on the main thread.** It calls `API_ReadBackpackContentsAsJson`, and only when that answered a backpack does it read `API_GetBackpackCapacityById`: that backpack is now loaded, so the capacity follows later grants (§11.3.1's trap). A player who has never had one is `exists: false`, with no capacity. - **Each stack is reduced** to slot, shortname, amount, and skin and name when set; nested `contents` keep the same shape, one level deep. An id no item definition knows is sent as `shortname: null` with its `itemId`. - **`overflow: true`** marks a stack at or past the capacity: it is waiting for its owner's next open to be moved (§11.3.1). - **Refusals** are `backpacks.error`: `backpacks-missing`, `bad-id`. **Extra space: a size lease (D337).** A new step, **`rust.backpack.size`**, *"Bigger backpack"*, grants one of `integrations.backpacks.sizes` for the run, to the recipients a mode picks. - **It is a kit reward with a different permission.** It writes one `rust_perm_run_grants` row per recipient, with `permission` = `backpacks.size.N` and no kit. The permission mirror pushes it in the next `perm.sync`, so a recipient who is offline has it when they connect, and a wipe cannot take it away. Its revert deletes the step's rows, and the next sync revokes them. **So the space lasts until the run ends, a normal finish included** (core reverts every ledgered step at the end of a run, §11.2). - **No plugin code.** The grant, the revoke and their read-backs are `perm.sync`'s. - **Params:** the server; the size (a select of the server's `sizes` above the smallest, refused when it is not one of them); the recipients and count (`rust.kit.entitle`'s modes, `linked` included); and a message and delivery, told once like a kit reward's notice (D333). The default message warns about the end: *"Your backpack holds {size} until the event ends. Anything past your normal size then moves to your inventory, or drops at your feet if it is full."* - **The highest size wins.** A recipient who already holds a size at least as big gains nothing, and nothing is taken from them at the end. The step's description says so. - **The take-back is Backpacks' own (D338):** nothing moves at the revoke, and the overflow goes to the owner's inventory, or the ground, the next time they open the backpack. - `risk` and `reversible` are `rust.kit.entitle`'s (`change`, `ledger`). It spends a new `rust.backpacks` budget, *Backpack sizes*: one per recipient, or the recipient bound for a mode that is not a count, as kit rewards are priced. - **It refuses where Backpacks is absent** (D199): "Backpacks is not installed on this server". **The sidecar** adds `POST /backpacks/read`, a thin forward like `/kits/uses`. It keeps nothing. **The website (Module-Rust).** - **Staff** see a player's backpack on the user's admin page, per linked account and server, through `GET /admin/users/:userId/rust/backpacks?serverId=&steamId=`. **A player** sees their own on their Rust page through `GET /player/rust/backpacks?serverId=&steamId=`, for their own linked accounts only. - **Live, never stored.** Each view is one `backpacks.read`; nothing is written to the database. A server that is down or lacks Backpacks says so on the panel. Opening the panel reads once; there is no polling. - **The grid** draws the slots as pages of `pageSize`, with each stack's icon from the item catalogue, its amount, and an *overflow* mark. **The app (Android-app).** The Rust account screen shows the player's own backpack from the player route, capability-gated like the rest of that screen (R10). **Not touched:** the installer and the egg. `requires_plugins` stays Kits and ZoneManager (D199). The bridge uses none of Backpacks' hooks. #### 11.3.3 The walk (deferred to the end, D331) On both rigs, with Backpacks installed and a real player: - open a staff view of a full backpack, a two-page one, and one that has never been used; - read your own on the account page and in the app, and see another player's refused; - run a *Bigger backpack* step, online and offline at the grant, and see the size on the next open; - end the run with items on the extra page, open the backpack, and see them move to the inventory; then again with a full inventory, and see them on the ground; - remove Backpacks, and see the step and the panels refuse with a reason. The walk's players must have real Steam ids: the bridge reads only a 17-digit id at or above 76561197960265728, so the spike's `7656119000000000N` stand-ins are refused `bad-id` (§11.3.4). #### 11.3.4 Built (2026-10-12) Built to §11.3.2 on protocol 14, and specified in [PROTOCOL.md](../../rust-link/PROTOCOL.md) §20.4. The bridge compiled on both rigs (Rust changeset 167584; Oxide 2.0.7826, Carbon 2.0.262), which were then given back the bridge they ran before. Before any code the grid's "item catalogue" turned out not to exist; D340 and D341 answered it. These points the design left open were settled in the code, and are named here so they can be overruled: - **Each stack carries `display`**, the game's English name (D340); for a blueprint, the name of what it makes (D341). The grids on the site and in the app show it, then the shortname, then `item ` for an item the game no longer knows. - **A third refusal, `unreadable`**, for contents the bridge cannot parse. Backpacks writes the JSON itself, so it should never happen; it is a sentence rather than an exception if it does. - **The step's message is never silent.** Left empty, it is the default take-back warning, because a player nobody warned is the one surprised by D338. An author can reword it, with `{size}`. - **The size picker** offers each server's sizes above its smallest, grouped by server, from the last hello. The step checks the pair it is given, and names the sizes the server does offer when it refuses one. - **The panels offer every enabled server** and learn that one lacks Backpacks when the read answers `409`, so nothing about which mods a server runs reaches the public server list. - **The player route is rate-limited** to 20 reads a minute, because a read runs on the game's main thread. - **A skin id is text** on the site's API: workshop ids are labels, and some pass 2^53. - **The app gates the section on the Coins read's 404**: Backpacks and Economics ship in the same protocol, so an older module has neither. ### 11.4 RaidableBases (D202) - **Live map and feed.** `OnRaidableBaseStarted` / `Ended` / `Despawn` / `PrivilegeDestroyed` become events (`raid.started`, `raid.ended`, …) carrying the base's position, difficulty and owner. The map draws active bases as a layer with its own audience switch (D114), and the feed says who raided what. - **An event step, `rust.raidbase.spawn`**, spawns a base at a difficulty near a monument or position, owned by the run. The run's teardown despawns it, and D170's expiry pattern marks it `expired` if the plugin ends it first. - **Stats and titles.** Bases completed per player per wipe (the player credited with the tool cupboard, as the plugin credits it), a leaderboard column, and a title condition, *Warlord* by default. - **The paid edition (D202).** Hello reports the edition. Where it is the paid one, the spawn step offers its five difficulties and the feed carries lockouts. On the free edition the step offers the one difficulty it has. **Testing the paid edition needs a licence for the rigs**, which is the org lead's to buy or supply. D348 (2026-10-14) widens this: every part works on either edition, by the best route each has (§11.4.2). - **The spike first:** pull the free source, confirm every hook and API signature above, and find the listing and despawn calls. Then load it on both rigs, with TruePVE, and raid one base. #### 11.4.1 The free edition, as the spike found it (2026-09-28) The source is RaidableBases 3.2.0 (free, `umod.org/plugins/RaidableBases.cs`), and it was loaded on both rigs with CopyPaste 4.3.0. A throwaway spike plugin made one base file from the walk hut and logged every lifecycle hook. It was deleted afterwards, and so were the three plugins on both rigs. **What it needs.** **CopyPaste is a hard dependency.** A base is a CopyPaste building file in `data/copypaste/`, and **none ship with the free edition**: the default profile `RaidBases` names `RaidBase1`–`RaidBase5`, and none of them exist. A base with no storage box is thrown away as soon as it is pasted ("No usable boxes found"). A PvE plugin (TruePVE, SimplePVE, NextGenPVE, Imperium or AegisPVE) is optional. **What it costs at boot.** `OnServerInitialized` took 6.7 s (Oxide) and 7.9 s (Carbon) on the main thread. The spawn grid then builds for about a minute: 52 s for 1,172 points on the rigs' 3000 map. **During that minute a spawn is refused** ("grid is loading"). **Its public API** (`[HookMethod]`): | Call | Returns | Use to us | |---|---|---| | `IsPremium()` | `false` on the free edition | hello's edition field | | `GetAllDifficulties()` | `[("Normal", 512)]` on the free edition | the step's difficulty list, read from the installed plugin rather than hard-coded | | `GetAllEventsCount()`, `GetActiveEventCount()` | ints | the servers page | | `GetAllEvents()` / `GetAllEventsNonAlloc(list)` | an 18-field tuple per base: position, mode, level, PvP, owner, raiders, intruders, entities, base name, spawn and despawn time, radius, loot left | the live map layer. **The plugin warns that `GetAllEvents` changes shape in the next update.** | | `EventTerritory(pos)`, `…Any`, `…All`, `GetPlayersFrom`, `GetOwnerFrom`, `HasPVPDelay` | | not needed | **Nothing in the API spawns, despawns, or lists profiles.** **The hooks it raises.** `OnRaidableBaseStarted`, `Ended`, `Completed`, `Despawn`, `Despawned` and `PrivilegeDestroyed` all carry the same 17 arguments: ``` (Vector3 location, int level, bool allowPVP, string id, float, float, float loadTime, ulong ownerId, BasePlayer owner, List raiders, List intruders, List entities, string baseName, DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) ``` A plugin method with exactly that signature binds on both frameworks. `Started` also has a one-argument overload carrying the plugin's own class, which the bridge cannot name and does not need. Other hooks: `OnPlayerEnteredRaidableBase` / `Exited` (14 arguments, the player first), `OnRaidableBaseUnlocked`, `OnRaidableAwardGiven` / `Owner`, the PvP-delay hooks and loot hooks. **How another plugin spawns a base: only through the server console.** - `rbevent [baseName]` queues a base, and **RaidableBases chooses where**. On the rigs, three spawns landed at Q6, C13 and C4, and none of them was named by the command. There is no position, no monument and no difficulty argument, and **nothing comes back** to the caller. - `rbe` pastes where a player is looking, so it needs a player in the game. - **Every base's `id` is `"0"`** on the free edition. The only way to know which base the event made is the next `Started` for that base name after the command. That is safe while events make one base at a time. **How another plugin despawns a base: all or nothing.** `rbevent despawnall` removes every base on the server, including ones the event did not make, and `rbevent despawn_inactive` removes every inactive one. Despawning **one** base needs a player standing at it. Left alone, a base despawns on its profile's own timer: 45 minutes in the default profile, as `despawn` in `Started` said. **Its options.** Each profile has well over a hundred settings. Among them: NPCs, turrets, loot rules, despawn timers, the plugins to block, where bases may spawn (beaches, roads, monuments), what players may do, and backpacks. The global config has its own schedulers (Maintained, Scheduled and Manual events), map markers, ZoneManager zones, a ranked ladder and messages. **Profiles live in the plugin's data directory**, which the site's configuration editor deliberately never walks. The global config is in the config directory, where the editor already reaches it. **What that means for D202's event step.** "Spawn a base at a difficulty near a monument, and despawn it at the end" is not possible on the free edition through anything RaidableBases offers another plugin. What it does offer is: spawn a named base, somewhere, now; learn where and when it will despawn from the hook; and remove every base at once. §11.4.3 asks which way to go. #### 11.4.2 The paid edition: an old copy read (2026-10-14), the current copy still to come **RaidableBases is deferred until the org lead supplies the current paid copy (D347). Runic Gateway supports both editions as far as each allows (D348).** The only paid copy at hand on 2026-10-14 was **3.0.5, from 2025-05-03** (`IsPremium()` returns `true`). By then uMod's free edition was **3.2.2**; the spikes above ran on 3.2.0. The org lead asked for the free source to be compared with that old paid copy before anything else, and the comparison was a reading only. Nothing was loaded on a rig. Because 3.0.5 is older than 3.2.2, most of what differs is probably old against new rather than paid against free. The current paid copy settles which. **The same in both, so §11.4.3–§11.4.5 stand on them:** - `OnRaidableBaseStarted`, `Ended`, `Completed`, `Despawn`, `Despawned` and `PrivilegeDestroyed` carry the same 17 arguments in the same order (§11.4.1). Only `level` differs: the free edition always passes `512`, and the paid one passes the base's tier, 0 (Easy) to 4 (Nightmare). `OnPlayerEnteredRaidableBase` / `Exited` keep their 14 arguments, with the same split on `level`. - `rbevent [baseName]`, `rbevent despawnall` / `despawn_inactive` and `rb.reloadprofiles` all work as before. - Every base's `id` is `"0"` in both. - Everything §11.4.4's helper reached has the same name and shape: - the plugin's list of live bases, `OpenEvent(RandomBase)` (which sets `raid.Options = rb.options`), and each base's own `Despawn()`; - the shallow `BuildingOptions.Clone()` trap, `PasteBuilding(RandomBase)`, and `RandomBase` itself (the paid one adds a `payments` field); - the profile settings the overrides change: `AllowPVP`, `SpawnAmountMurderers` / `SpawnAmountScientists`, `DespawnMinutes` / `DespawnMinutesInactive`, `OverrideConfig`, and `Setup.EnableForcedHeight` / `ForcedHeight` / `PasteHeightAdjustment`. Their JSON names match too. Only the NPC counts' defaults differ: `-9` free, `1` paid. - the "Initialized Grid" log line that ends the boot-time spawn refusal. **Where paid 3.0.5 differs from free 3.2.2:** | Area | Free 3.2.2 | Paid 3.0.5 | What it touches | |---|---|---|---| | Difficulties | `GetAllDifficulties()` → `[("Normal", 512)]` | No `GetAllDifficulties`. `GetRaidableModes()` returns names: Easy, Medium, Hard, Expert, Nightmare | the step's difficulty list (§11.4.3, D205) | | Live bases | `GetAllEvents()` and `GetAllEventsNonAlloc`: an 18-field tuple, with the level as an int. Plus `GetAllEventsCount` and `GetActiveEventCount` | `GetAllEvents(Vector3, float)`: a 17-field tuple with the mode as a string and no level int; the position is ignored. No count or non-alloc calls | the map layer and the servers page (§11.4.1's API table) | | Spawning | `rbevent [base]` only | `rbevent` also takes a difficulty name and a count. A private `SpawnRandomBase(string baseName, Vector3 position, int mode, int type, bool free)` that another plugin can `Call` returns `"API_INVALID_PROFILE"` or a result, and queues the base at that position when one is given | D203 and D207: a position and a difficulty without the helper's reflection | | Lock to first attacker | Each base has `BypassUseOwners()`, and the config has a **Manual** pair and a **Management** pair | **No `BypassUseOwners()` method and no Manual pair.** The Management pair is checked inline in three places | **D206's patch target is missing** | | Water | No seabed spawns | Profiles can place bases on the seabed (`Water.Seabed`, with minimum and maximum depths) | D208's "in water" refusal would block a profile built for the seabed | | The area check | `SpawnsController.IsAreaSafe(…, RaidableType type)` | One more optional parameter at the end (`BuildingOptionsDifficultySpawns`) | the helper has to bind it by name, not by an exact parameter list | | Paid-only hooks | — | `OnRaidableBasePurchased` (10 arguments: buyer id, position, grid, level, PvP, …, base name, spawn and despawn times), `OnPurchaseBase`, `OnPurchaseTakePayments`, `OnRaidableResetPayLock`, `OnRaidableBaseHint` | the feed. **No hook announces a lockout**, so §11.4's "the feed carries lockouts" has nothing to listen to in 3.0.5 | | Free-only hooks | `OnRaidableBaseUnlocked`, and a 17-argument `OnRaidableAwardOwner` | Neither. A 5-argument `OnRaidableBaseCompleted` overload instead | the feed. The bridge binds only the 17-argument hooks, so it does not hear the overload | | Ranked ladder | A player's `Raids` and `TotalRaids` | Adds `Points`, `TotalPoints` and a count per difficulty | none: *Warlord* counts from the bridge's own `Completed` hook | | `loadTime` | `Time.realtimeSinceStartup` | `Time.time` | nothing we read | **When the current paid copy arrives**, the same spike runs against it on both rigs, and this section records: - its version, and a fresh read of the free edition's at the same time, since each may have moved; - `IsPremium()`, and whether it has `GetAllDifficulties()`, `GetRaidableModes()` or both; - whether 3.0.5's callable `SpawnRandomBase` is still there and takes the same parameters, and whether it now adds a despawn of one base or real base ids; - whether the 17-argument hooks and `GetAllEvents` have the same shape as free 3.2.2's (the free edition warns that the latter is changing), and what `level` carries per difficulty; - whether `BypassUseOwners()` and the Manual lock pair exist, which decides how D206 is built; - lockouts, buyable events and the ranked ladder: the hooks they raise and what the feed could say; - the seabed option, and how D208's water test should treat it; - everything §11.4.4's helper touches, rerun on both rigs (Oxide and Carbon), as the helper spike was on the free edition. **What D348 asks of the build.** Every part of §11.4 asks the installed copy what it has, rather than assuming an edition: - **Detection.** Hello reports the edition (`IsPremium()`) and the version. The bridge and the helper check for each call and member they use, by name, and report what they found. "Paid" or "free" is never taken to mean that a call exists. - **Every edition gets each part, by the best route it has.** Difficulties come from whichever list call exists. The live map is built from the lifecycle hooks, which both editions raise alike, and needs neither `GetAllEvents` shape. A spawn uses the plugin's own position-and-difficulty call where there is one, and the helper's route (§11.4.4) where there isn't. - **Where a part has no route on the installed copy, the site says so** on the step, the page or the feed, as D199 does for a missing plugin. It never offers something that fails at run time. The open cases on 3.0.5 are the lock (D206) and lockouts in the feed. - **The checks D348 adds are part of the build's tests:** each mechanism is exercised against both editions' shapes, and the end walk (D331) covers a rig with each edition. The design in §11.4.3–§11.4.5 stays, and D348 governs how it is built. Which route each part takes on the current paid edition is recorded here after its spike, before any §11.4 code. #### 11.4.3 Decided (2026-09-28) The org lead answered the spike's three questions the same day: - **D203: where a base goes — RaidableBases picks.** The step spawns the chosen base with `rbevent`, and RaidableBases places it wherever its grid allows. The bridge learns the spot from the next `OnRaidableBaseStarted` for that base name. The site then shows it on the live map and in the run log, and records it on the run's ledger. If the paid edition offers placement, the step offers it there too (§11.4.2). - **D204: at the end — a helper removes only the event's base.** Teardown, a revert, and an operator's stop each despawn exactly the base the run made, never another. The free edition cannot do that from outside (§11.4.1), so a D168 helper, **`RunicGatewayRaids.cs`**, finds the run's base by the position and spawn time `Started` reported, and calls RaidableBases' own despawn on that one base. Like the zone helper (D181, D182), it is optional and default-installed, and it reports at hello. **Without it, the step still spawns, and the base despawns on its own timer.** The site says so on the step, and the run records the base as expiring on its own, like D170's zones. A base the plugin despawns first is marked `expired`. - **D205: what an event can choose — a profile, a difficulty, and per-event overrides; and the site edits profiles.** - **Profile and difficulty.** The step picks a RaidableBases profile, and a difficulty from the installed plugin's `GetAllDifficulties()`. The free edition offers "Normal"; the paid edition offers its five. - **Per-event overrides.** The step may override a bounded set of a profile's settings for this spawn only. The first set is PvP on/off, despawn minutes, NPCs on/off and count, and the lock-to-first-attacker rule; the build's spike settles which of them the plugin lets a helper change after `Started`. The free API has no way to do this, so the helper applies them. **Without the helper there are no overrides**, and the step says so. - **The site edits profiles.** The configuration page gains RaidableBases' profile files, `data/RaidableBases/Profiles/*.json`. This is a **named exception** to the rule that the editor never walks a plugin's data directory, which exists because that directory holds live state and the permission stores. The exception is that one folder, for that one plugin. It gets the editor's usual guarantees: the write is recorded, a reload is watched, and a failed load rolls back (D178, D179). The reload is `rb.reloadprofiles`, which the spike ran on both rigs, not a plugin reload. **Still to settle in the build's own spike:** the same helper questions on Carbon and on the paid edition. Also the one-at-a-time rule: while a first base of the same name is still waiting to start on a server, the step refuses to spawn a second one, because the free edition's bases have no ids to tell them apart. §11.4.4 may retire that rule. #### 11.4.4 The helper spike (2026-09-28) The org lead asked for two tests of a D168 helper before the build: can it despawn one base, and which settings can it override? A third question came with them: can an admin give the spot, meaning x, z and a height adjustment? The spike ran on the rust-oxide rig against the free edition, 3.2.0. Carbon and the paid edition are still to run (§11.4.2). Every finding below reaches RaidableBases' internals by reflection and a Harmony patch. None of it is public API, so the helper has to check the plugin's version at hello, as the zone helper does. - **Despawning one base works.** The plugin keeps its live bases in a list on the plugin, and each base has its own `Despawn()`. Calling it on one base removed that base and no other. It fired the usual `OnRaidableBaseDespawn` and `OnRaidableBaseDespawned` hooks, so the bridge's feed sees nothing new. - **The helper can know exactly which base is the event's.** A Harmony postfix on the plugin's `OpenEvent` receives the new base object itself. The helper can tag it for the run at birth, instead of matching the position and spawn time `Started` reports afterwards (D204). The one-at-a-time rule above may then go, if the build confirms it on Carbon and the paid edition. - **Overrides go in at `OpenEvent`, not after `Started`.** At `OpenEvent` the base holds a reference to its profile's settings object, which every base of that profile shares. The helper swaps in a deep copy (a JSON round trip), changed for this spawn. The shared profile was checked afterwards and was untouched. Proven on the rig: - PvP on/off; - despawn minutes (inactive and total, via the profile's own "override config" switch); - NPCs on/off, and the scientist and murderer counts. - **Lock-to-first-attacker is not a profile setting.** It lives in RaidableBases' main config, in two pairs of switches (PvP and PvE): one pair for "Manual" bases, which is what `rbevent` and the helper spawn, and one pair for scheduled bases. A per-event lock would need a second Harmony patch, on the base's `BypassUseOwners()`. Not built; a question for the org lead. - **An admin-given spot works.** The helper builds the same spawn request the plugin's own "spawn where I look" command builds, from numbers instead of a player's view. It then calls the plugin's paste. Bases landed at the given x and z every time. - **Height has to go through a copy of the profile.** The plugin recomputes y at paste time from the ground (or water), plus the building's own height, plus the profile's paste height adjustment. It ignores any y it is handed, unless the profile forces a height. So the helper copies the profile: - an adjustment is added to the copy's paste height adjustment; - an absolute y turns on the copy's forced height. Results, measured as the height of the base's building blocks: | Request | Blocks at | Ground there | | --- | --- | --- | | no adjustment | 1–4 | −0.2 | | +5 | 6–9 | −1.1 | | absolute y = 30 | 31–34 | 0.5 | | a plain `rbevent` spawned right after | 1–4 | −0.2 | **Trap:** the plugin's own `BuildingOptions.Clone()` is a shallow `MemberwiseClone`. The first run changed the height through that clone, and the change leaked into the shared profile: the next plain `rbevent` base spawned 35 m up. The height settings sit in a nested object, so the helper must copy that too, or use the same deep copy as the overrides. Nothing reached the file on disk, and `rb.reloadprofiles` reset the leak. - **The plugin's area check catches buildings, but not water.** Its safety check, run at an admin's spot, refused a player's building and refused an existing raid base. At 25 m away from the building it passed. A spot on the seabed 50 m under water also passed: the plugin tests water in its own location picker, not in this check. So an admin-given spot needs the helper's own water and terrain test as well as the plugin's check. The spike reported the check but did not enforce it; whether to enforce is a question for the org lead. - **A RaidableBases reload drops the helper's Harmony patches**, because the patched methods belong to the old assembly. The helper must re-patch in `OnPluginLoaded`, as the zone helper does for ZoneManager. - **After a server boot the plugin refuses every spawn for about 50 s**, until its log says "Grid initialization completed". This includes the helper's spawns. The step has to wait for that, not fail on it. #### 11.4.5 Decided after the helper spike (2026-09-28) The org lead answered §11.4.4's three questions the same day: - **D206: lock-to-first-attacker is per event.** The helper adds a second Harmony patch, on each base's `BypassUseOwners()`. For a base the helper tagged for a run, it answers from that run's setting. Every other base, including one an admin spawns by hand, still follows RaidableBases' config. Without the helper the lock follows the config, and the step says so. - **D207: a step places its base one of two ways.** The default is D203, where RaidableBases picks. The other is an admin-given spot: x and z, plus either a height adjustment (added to the profile's own) or an exact height (the profile's forced height). Both go through the helper's deep copy of the profile (§11.4.4). The spot needs the helper; without it the step offers only the plugin's pick. - **D208: a blocked spot is refused, and the run log says why.** At spawn time the helper runs RaidableBases' own area check and its own water and terrain test. If either fails, the step does not spawn, and the run records the reason ("player building", "raid base", "in water"). The editor runs the same check through the bridge when the step is saved. That is advice only, since the world can change before the event runs. ### 11.5 Protocol These are new verbs, events and hello fields. **They join protocol 14 (D332)**, which §2 opens: protocol 13 shipped on 2026-10-09, before §9 reached item 7. --- ## 12. Permissions as event rewards (D342–D346) The org lead asked for this on 2026-10-12, during the Backpacks build: an event reward that grants **any permission the server's plugins registered**, so a perk nobody planned for (night vision, a chat colour, a `/remove` command) is a reward without code written for it. The picker sorts by mod, then by permission inside the mod. It is its own item, after Backpacks, and planned first. Five decisions, D342–D346, were answered the same day (§12.8). Two steps come out of it. **Permissions until the event ends** lends them for the run, as a kit reward does. **Permissions for good** gives them for keeps, as a staff member's grant in the permission manager does. Both take several permissions in one step, which needs one small addition to core's step editor (§12.3). ### 12.1 What it builds on Nothing below is new machinery; both steps are rows in tables the permission manager already pushes. - **The lease is a kit reward's** (PLAN.md §29, D84). `rust.kit.entitle` and `rust.backpack.size` (D337) write one `rust_perm_run_grants` row per recipient, the permission mirror pushes it in the next `perm.sync`, and the step's revert deletes the rows so the next sync revokes them. The push reads the **union** of these rows and the standing grants, so a player who already held the permission keeps it after the revert. - **A standing grant is the permission manager's own** (§1): a `rust_perm_grants` row for a website user, scoped to a server, reaching every account the user has linked (D28). It is `UNIQUE` on (user, permission, scope), so granting one twice leaves the first. - **The catalogue** is `rust_perm_catalogue`: per server, every registered permission and the plugin that registered it (`owner`, the plugin's `Name`). A permission no plugin owns, as Carbon's built-in modules register theirs, has no owner, and the permission manager groups it by its prefix (§1). - **The message is D333's notice**, which rides `perm.sync` and is told once, at once or on the player's next wake. - **So there is no plugin code, no sidecar code and no protocol change.** Protocol 14 is untouched. ### 12.2 The picker: `rust.options.permissions` (D342) - **Every permission in the catalogue of every enabled server, once each.** Its `group` is the plugin that registered it. A permission with no owner is grouped by its prefix, labelled *"<prefix> (not registered by a plugin)"*, the same rule the permission manager draws. - **Sorted by group, then by permission inside the group**, both case-insensitively. The editor keeps a source's order. - **A row says where it is missing.** A permission that is not in every enabled server's catalogue carries a row `note` (MODULE_API 1.13.0): *"Only on Main, PvP"*. - **Not `searchable`.** A server registers a few hundred permissions; the multi-select's own filter box (§12.3) narrows them in the browser. If a deployment ever passes the 2,000-row bound, the source says so, as every bounded source does. - **No blocklist and no allowlist** (D343). The bridge's own `runicgateway.*` permissions are in the list like any other. - **The step checks the server it is given.** Every picked permission must be in that server's catalogue; one that is not is refused by name: *"PvP has no permission nightvision.allowed — its plugins did not register it"*. The check runs in the dry run too. ### 12.3 The core addition: a param that holds several values (D344, MODULE_API 1.14.0) The step editor has six param types, all single values. A step that takes several permissions needs a list, so core gains one declaration flag. **It is an addition only**: a module that does not use it is unchanged, and Module-uo's `coreApi: "^1.10.0"` still resolves. - **`multiple: true` on an action param** of type `string` or `int`, with an optional `max` (1 to 100, 20 if left out). It is refused on any other type, on a `duration` param, and on a template's inputs, which this version does not extend. - **The value is a JSON array**: each element checked as the type's literal, duplicates dropped, order kept, at most `max`. `required` means at least one. A bare value is read as a list of one, so a spec written by hand or over the API does not trip on it. The module's `perform()` and `cost()` receive the array. - **The catalog serves `multiple` and `max`** beside the rest of the declaration (`GET /admin/events/catalog`). - **The editor draws a multi-select.** - With a `source`: the chosen values as chips above a grouped checklist, with a filter box over it. Ticking past `max` is refused with the number. - With a `searchable` source: the typeahead adds a chip per pick. - With no source: a tag box, a value per Enter. - **The definition view and the run console** show a list as its values joined by commas. - **Docs:** `website/MODULE_API.md` gains 1.14.0's entry and the flag in the declaration reference; `website/EVENTS.md` §F names the control. ### 12.4 Permissions until the event ends: `rust.perm.lend` (D345, D346) *"Lends the permissions you pick to the people a mode picks from this run's tally — or every linked player of the server — until the run ends. Waits for them if they are offline. Someone who already held one keeps it when the event ends."* - **Params:** - the server; - the permissions (`multiple`, `max` 10, from §12.2); - the recipients and count (`rust.kit.entitle`'s modes, `linked` included); - a message and its delivery; - **`confirm`**, a required boolean: *"I am sure I want to give these permissions as a reward"* (D343). - **One row per recipient per permission** in `rust_perm_run_grants`, with no kit and no credit; the notice goes on one row per recipient, so each is told once. - **Unticked, it refuses** in the dry run and at run time, naming what it would have given: *"Tick "I am sure" to make nightvision.allowed, betterchat.color a reward"*. Being a required toggle (D334), it cannot be left unset. - **`risk: 'change'`, `reversible: 'ledger'`**, as a kit reward is. Its revert and reconcile are `rust.kit.entitle`'s, and core reverts it at the end of every run, a normal finish included. - **The budget is new: `rust.permissions`, *Permissions lent*.** One per recipient per permission; a mode that is not a count is priced at the recipient bound times the permissions, as kit rewards are. ### 12.5 Permissions for good: `rust.perm.give` (D345, D346) *"Gives the permissions you pick to the people a mode picks, for good. Ending or cancelling the event does not take them back; staff remove them in the permission manager."* - **The same params as §12.4**, the confirm included. - **A standing grant per website user per permission**, scoped to the step's server, with source `event` and the note *"event run <run>, step <step>"*. A grant already there is left alone and counted as *already held*. - **`risk: 'irreversible'`, `reversible: 'none'`**, as `rust.coins.pay` is (D335). Like every step that changes the world it arrives off until an admin turns it on under Actions; unlike §12.4's, its failure aborts the run by default instead of pausing it, and Actions lists it as irreversible, so an admin enables the two separately. - **The budget is new too: `rust.permissions.kept`, *Permissions given for good***, priced as §12.4's. - **Taking one back is the permission manager's.** The grant shows there with its source and note, and staff delete it like any other. - **The message rides a notice-only row** in `rust_perm_run_grants`: no permission, no kit, no credit. The push grants nothing for it and tells the notice once. Nothing reverts the step, so the row stays; it is a sentence that has been said. ### 12.6 Settled in this plan, and overrulable - **A blank message tells nobody**, as a kit reward's does (D333), rather than a default sentence. A permission's name (`nightvision.allowed`) means nothing to a player, so only the author can say what it does: *"You can use /nv until the event ends"*. `{permissions}` puts the names in. - **At most 10 permissions in one step**: enough for a perk bundle, and the price stays readable. - **A permission the server's catalogue lacks is refused, not skipped.** The plugin may be unloaded for the wipe, and a reward that silently gives less than it says is the worse failure. - **The for-good grant is scoped to the step's server**, not every server. An author who wants it everywhere adds a step per server. ### 12.7 Where the work lands, and in what order 1. **Website (core), on `main`:** §12.3, MODULE_API **1.13.0 → 1.14.0**, with its tests (`registries`, `spec`, the editor's multi-select) and Module-uo's frozen-manifest proof against the new core, as 1.11.0's and 1.13.0's were run. 2. **Module-Rust, on `edge`**, after the website PR merges: the option source, the two actions, the two budgets; `coreApi` `^1.14.0`; `ci/core-ref.json` re-pinned to the merge; `routes.manifest` regenerated. 3. **docs:** this section's *Built* notes; `website/MODULE_API.md`; `website/EVENTS.md`. **Not touched:** Rust-Plugins, Rust-Link, the protocol, the installer, Android-app (the app does not author events) and Module-uo. The Integration Kit's pin is at 1.12.0 and moves only when someone re-reads its chapters for a newer core; it skipped 1.13.0 the same way. ### 12.8 Decisions of record Answered by the org lead on 2026-10-12. | # | Decision | Rejected | § | |---|---|---|---| | **D342** | **An event reward may be any permission the server's plugins registered**, picked from the catalogue grouped by the plugin that registered it and sorted by plugin, then permission. `rust.backpack.size` stays beside it, with its sizes and its take-back warning. | A fixed list of perks; folding the backpack step into the generic one. | 12.2 | | **D343** | **No blocklist and no allowlist. The safety is a required "I am sure" toggle in the step**; unticked, the dry run and the run refuse, naming the permissions. | A pop-up when the step is saved (a core change); a blocklist of dangerous permissions. | 12.2, 12.4 | | **D344** | **One step gives several permissions, through a multi-select picker**: a `multiple` flag on an action param, added to core as MODULE_API 1.14.0. | One permission per step; five fixed picker slots; a comma-separated text box. | 12.3 | | **D345** | **A permission reward lasts until the run ends, or for good**, the author's choice. | Until the run ends only. | 12.4, 12.5 | | **D346** | **The two durations are two steps**, `rust.perm.lend` (`change`) and `rust.perm.give` (`irreversible`), each with its own budget, because core gives an action one risk class. | One step with a "keep it" switch, rated `irreversible` throughout. | 12.4, 12.5 | ### 12.9 The walk (deferred to the end, D331) On both rigs, with a plugin that registers a perk (NightVision or similar): - open the picker and see it grouped by plugin, sorted inside each, a permission missing from a server noted, and the unowned ones grouped by prefix; - run a *Permissions until the event ends* step with two permissions, unticked (refused) and ticked; see the perks work online and offline at the grant, and gone after the run ends, except for a player who already held one; - run a *Permissions for good* step; see it stay after the run ends, show in the permission manager with its note, and go when staff delete it; - unload the plugin and see the step refuse the permission by name. ### 12.10 Built (2026-10-12) Built to §12.2–§12.5 in two PRs, core first: website#213 (MODULE_API 1.14.0, the `multiple` flag and the editor's multi-select, with Module-uo's frozen-manifest proof in its body) and Module-Rust#42 (the source, the two steps and the two budgets, in a new `server/eventPermRewards.js`, with `ci/core-ref.json` pinned to #213 until it merges). A core at #213 with the module installed registered it, and its own spec path validated and priced both steps. These points the design left open were settled in the code, and are named here so they can be overruled: - **The two steps share one param list** (§12.5's "the same params"), each with its own message example: *"You can use /nv until the event ends"* and *"…from now on"*. `confirm` is the last param, and a new step starts with it off, so it must be ticked on purpose. - **Only a real `true` ticks it.** A step written by hand with `"confirm": "true"` is refused like an unticked one. A string is not the author's tick. - **A server that has reported no catalogue yet is a wait, not a refusal.** It has not had a permission sync since it was added, so the step is retried rather than refused for every permission by name. - **The picker uses the first owner any server reports** for a permission. Two servers that disagree about which plugin registered a name would be a renamed plugin, and the name is one row either way. - **A permission is matched without case and stored lower case**, which is how Oxide and Carbon store one. The picker shows the catalogue's own spelling. - **Give tells a retry from a staff member's grant by its note.** A grant already there that carries this step's note is counted as *given*, not *already held*, so a retry after a lost answer reports what the first attempt did. Lend finds its rows by step, as a kit reward does. - **Give's message goes to every recipient**, including one who already held every permission: the sentence is still true for them. - **The permission manager marks a for-good grant** with a chip, *given by event run N, step M*, from the grant's note. It is turned off like any staff grant. A lent permission keeps its *from an event* chip and its locked toggle, because the run's teardown takes it back. --- ## 13. Rust's own world events (D349) On 2026-10-14 the org lead asked for a survey of further plugins worth supporting, "especially considering events". The survey found that Rust's own world events already reach the site, but only as dots on the map. The org lead decided that they become part of the **base module** (D349): no plugin, nothing optional, on every server. They are the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops and locked crates. ### 13.1 What already exists | What | Where | What it does | |---|---|---| | The live map's `world` layer | PROTOCOL §17.3 | Draws `cargo`, `heli`, `chinook`, `bradley`, `supply` and `crate` (a locked crate adds `hackLeftSec` or `hacked`), kept by `OnEntitySpawned` / `OnEntityKill` | | Kill credit for the APC and both helicopters | the bridge's `CreditVehicle` (D174) | Credits the killing blow's player, or the last player to hurt the vehicle within 60 s when a helicopter crashes with nobody named. It counts `apcKills` / `heliKills` in `player.tally` for *Tankbreaker* and *Rotorbane* (§5.1) | | Places the step verbs may use | `WorldPrefabs` (D88) | Crates and NPCs only. **Vehicles are kept out** because a player can take one, and a revert would kill something a person may be sitting in | Nothing about these events reaches the feed, the event system's triggers, the run log or a leaderboard, apart from the two kill counts. ### 13.2 The events, as proposed The test phase (§14.2) confirms each one on the current Rust, Oxide and Carbon builds before the integration plan names a hook. The column says what each should carry. | Event | Carries | |---|---| | Patrol helicopter arrives; leaves without dying | grid, position | | **Patrol helicopter downed** | who (the D174 credit), grid, position | | Bradley spawns at Launch Site; **Bradley destroyed** | who, grid | | Cargo ship arrives; docks at a harbour; leaves | grid, harbour | | Chinook arrives; **drops its locked crate** | the monument it dropped at | | Airdrop called (supply signal or plane); lands; **first looted** | who called it, grid, who looted it | | **Locked crate: hack started; unlocked; looted** | who, where (small or large oil rig, cargo, Chinook drop, other), grid | | Oil rig: heavy scientists called | which rig | | Travelling Vendor arrives; leaves | grid | | Anything newer in the current build | found by scanning Assembly-CSharp for event prefabs, and Oxide and Carbon for hooks, as §5's mission hook was found | **On the wire** they would be one new frame kind with a `type` field. That keeps the public feed's allowlist one entry wide, and it is filtered per type. They are rare, a few an hour, so one frame per event is fine. Kill credit reuses D174's rule, so the feed and *Rotorbane* never disagree about who downed a helicopter. ### 13.3 What the site would do with them - **The feed.** Each event is a feed line, e.g. *"Patrol helicopter downed by Alice at G7"*. Who may see which line is Q12. - **Triggers.** Each event is an event-system trigger, like `rust.boss.killed` today. Q14. - **Steps that call a vanilla event.** For example a patrol helicopter, a cargo ship, a Chinook, or an airdrop at a grid or position, offered as a step. Q13. - **Titles and columns.** New counts from these events. Q15. - **The map** already shows them, and nothing changes there. ### 13.4 Questions for the org lead **Answered on 2026-10-14: every proposal below was accepted (D353–D357).** - **Q12 — Who sees each line in the feed?** For example, *"Patrol helicopter downed by Alice at G7"* and *"Bob started hacking the locked crate on Large Oil Rig"*. Everyone, or staff only? **Proposed:** the public feed shows the event, the grid and the player's name; the staff feed adds the Steam id. The map already shows these things publicly (D114 switches apply). - **Q13 — Can an event call one?** For example, a *Saturday Air Raid* event at 20:00 calls a patrol helicopter and three airdrops onto Launch Site. **Proposed:** yes, as a step whose result is kept (`reversible: 'none'`). When the run ends, nothing is removed, because a cargo ship or an airdrop may already carry players or their loot. This is why D88 kept vehicles out of the place verbs. It is a new verb, not an exception to D88. - **Q14 — Are they triggers?** For example, *"when anyone destroys Bradley, start the Launch Site Lockdown event"*, or *"when the cargo ship docks, announce it on Discord"*. **Proposed:** every event in §13.2 is a trigger. - **Q15 — Which new counts?** For example, *Safecracker* for 10 locked crates hacked this wipe, or *Scavenger* for 10 airdrops looted first. The PlayerRanks plugin the org lead set aside also counts barrels broken, K/D ratio and time played, which §5 does not. **Proposed:** locked crates hacked and airdrops looted first. The others only if the org lead wants them. - **Q16 — Which protocol?** **Proposed:** protocol 14, which is unreleased (D332), released with the rest after the end walk. --- ## 14. More first-class optional plugins (D350–D352) The same survey read the org lead's two old plugin folders and uMod's Rust catalogue (446 plugins, by downloads and 57 searches). The org lead chose **six free plugins as first-class optional integrations** (D350): **TruePVE, Dynamic PVP, Dangerous Treasures, Guarded Crate, ZLevels Remastered and Server Rewards.** *Optional* means D199's pattern: each is detected at hello, a step or screen that needs one refuses with a reason when it is absent, and the installer never requires it. Two rules from the org lead govern every one of them (D351): - **As few helper plugins as possible.** The source reading below finds a public route for every proposed use, so the target is **no new helper**. If the test phase finds one is needed, the integration plan says why and asks first. - **Nothing that runs afoul of a licence.** Runic Gateway never copies, modifies, bundles or redistributes a third-party plugin. The installer and the egg do not ship one, and operators install them from uMod. The bridge uses only their public hooks, their callable methods and their console commands (R2). Paid copies (RaidableBases' paid edition, D347) are never committed to any repository or scratch area that is shared. **And one way to test them (D352):** a **single test phase** covering all six plugins and §13's native events together, on both rigs. Then **one integration plan**, written from what it found, before any code. This replaces §11's pattern of a spike and a plan per plugin. ### 14.1 The six, as read from their source (2026-10-14) Each line was read from the plugin's current `umod.org/plugins/.cs`. The test phase confirms each one on the rigs. | Plugin | Version, release, licence | What integrating it would give | The route the source shows | |---|---|---|---| | **TruePVE** (nivex) | 2.4.6, 2026-10-02, **none listed on uMod** | §3's zone step gains *"PvP rules in this zone"*: a ruleset for the run's zone, removed at teardown | `AddOrUpdateMapping(zoneId, ruleset)` / `RemoveMapping(zoneId)`; it raises `OnUpdatedMappings` / `OnRemovedMappings` | | **Dynamic PVP** (HunterZ) | 5.2.0, 2026-08-31, MIT | Its temporary PvP zones around a downed helicopter, Bradley, a locked crate, a supply drop or cargo appear on the live map and in the feed | `OnCreatedDynamicPVP` / `OnDeletedDynamicPVP` (zone id, event name), `OnPlayerEnterPVP` / `ExitPVP`. It **requires ZoneManager and TruePVE** | | **Dangerous Treasures** (nivex) | 3.0.0, 2026-10-07, GPL-3 | Feed and map for a treasure event, a *treasures won* column and title, and a step that starts one | Hooks `OnDangerousEventStarted(pos)`, `Won(looter, invaders)`, `Ended(pos)`, `OnPlayerEntered/ExitedDangerousEvent`. Console `dtevent [difficulty] [count]`; **it picks the spot** | | **Guarded Crate** (Bazz3l) | 2.0.17, 2026-08-09, MIT | Feed and map, and a step that places a guarded crate at a spot | Hooks `OnGuardedCrateEventStart(pos)`, `Ended(player, instance)`, `Failed(pos)`. Console `gcrate start ` and `gcrate position x y z`. **`gcrate stop` clears every one**, and the handler checks `arg.IsRcon` | | **ZLevels Remastered** (nivex) | 3.3.1, 2026-09-10, MIT | Skill levels as leaderboard columns and title conditions. **XP boosts need no code:** its VIP XP multipliers are keyed by permission name, so §12's `rust.perm.lend` already lends one | `GetLevel(userId, skill)`, `api_GetPlayerInfo(userId)`. **No level-up hook**, so the level is read at tally time. Its only write, `api_SetPlayerInfo`, replaces the whole record and is not used | | **Server Rewards** (k1lly0u) | 2.0.9, 2026-09-27, MIT | The second common currency (RP). It mirrors §11.2: a pay step, balances on the site and app, staff adjust, and a column | `AddPoints` / `TakePoints` / `CheckPoints` (by Steam id), and `OnPointsUpdated(userId, balance)` | **Not included:** - **Duelist:** left out entirely by the org lead. Its name also clashes with §5's blade-kill title. - **SmoothRestarter:** not wanted. - **PlayerRanks:** not wanted, because the site's leaderboard and titles already do its job. Its stat list fed Q15. - **BotReSpawn:** paid ($40 on Codefling), with its own NPC AI overlapping RunicNPC (Q17). - **Event Manager and its modes, Quests, Bounty:** they raise no result hooks, so the site could only poll their data files, and most of Event Manager's modes were last released in 2021–2024. ### 14.2 The single test phase One phase covers everything in §13 and §14, on **both rigs (Oxide and Carbon)**. It is research, so it runs now, not with the deferred walks (D331). **Setup.** - Bring Rust, Oxide and Carbon up to date on both rigs, and record the builds. - Back up each rig's plugin, config and data directories. - Install the six from uMod at their current versions, recording each version and checksum. - Load one throwaway spike plugin that subscribes to every hook in §13.2 and §14.1, logs each call with its arguments, and calls each route in §14.1. It and the six are removed afterwards, and the rigs are restored from the backups. **What it records, per plugin:** | Plugin | Questions the phase answers | |---|---| | **Native events** | For each row of §13.2, forced from the rig console: the hook that fires on each framework, its arguments, and who it names. Does a downed helicopter's credit agree with D174? | | **TruePVE** | Do mapping a zone made by §3's zone step, and removing the mapping, change damage inside it as expected? Is the mapping saved across a TruePVE reload and a server restart? Can the ruleset names be read for the step's picker without the bridge reading the config file? | | **Dynamic PVP** | For each vanilla event it reacts to: the zone id and name it creates, the radius, and how long the zone lasts. **Does §1/§3's zone handling see its zones as foreign ones, and leave them alone?** Do its TruePVE mappings and ours coexist? | | **Dangerous Treasures** | `dtevent` per difficulty: where the event lands, every hook's arguments, the despawn timing, its map marker, and its own TruePVE and ZoneManager options. Boot cost | | **Guarded Crate** | Does `gcrate position` work from the bridge's server-side console run, given the `IsRcon` check? Are the hook arguments as read? Is there any way to end one instance? How do RunicNPC's factions (D254, D255) treat its guards, which are Rust's scientists? | | **ZLevels** | What does a level read cost for one player, and for a full server at tally time? Does a lent VIP permission change the XP rate at once? What happens at wipe, and with the no-wipe permission? | | **Server Rewards** | Do Add, Take and Check work for an offline player? `OnPointsUpdated`'s arguments. Its wipe behaviour | | **All together** | All six plus RunicNPC, ZoneDomes, Economics and Backpacks loaded at once: boot time, any errors, and hook overhead during a busy minute | **The phase ends with a findings section per item**, as §11.4.1 and §11.4.4 did, plus the questions it raises. It ships as one docs PR. #### 14.2.1 What the test phase found (2026-10-14) **Builds.** Rust **25902425** (protocol 2634.289.1, public since 2026-10-13), **Oxide 2.0.7830**, **Carbon 2.0.262** (the current production build, released before that Rust update). Both rigs were brought up to date first; the first restart skipped the Rust update again, as on 2026-10-09, and a second start took it. The six plugins came from uMod at the versions in §14.1; Guarded Crate's dependency **NpcSpawn 3.4.8** came from Codefling (D361); Economics 3.9.2 and Backpacks 3.17.8 were added for the all-together run. A throwaway spike plugin logged every hook named below with its arguments, forced the world events through the game's own schedule, and used stand-in players with Steam-shaped ids (the RunicNPC 9d pattern) to down, hack, loot and throw. It, the plugins and every file they wrote were removed afterwards and both rigs restored from backups taken first. Timers were shortened for the test (crate hack 900 → 20 s, helicopter lifetime 30 → 2 min, vendor 1800 → 120 s) and put back. **Oxide and Carbon raised the same hooks with the same arguments everywhere, with one exception that matters: on Carbon a plugin's console command cannot be run from another plugin (below, and Q19).** ##### Rust's own world events (§13) **The schedule.** Rust keeps one `EventSchedule` per scheduled event. This build has seven, and each runs the way the game runs it by calling `RunEvent` on its `TriggeredEvent` components. That is the route for D354's step, with no console command and no helper: | Schedule (`Key`) | Spawns | Default gap (game hours) | |---|---|---| | `patrol_heli` | `patrolhelicopter` | 36–54 | | `cargo` | `cargoshiptest` | 45–78 | | `chinook` | `ch47scientists.entity` | 27–54 | | `airdrop` | `cargo_plane` | 9–18 | | `travelling_vendor` | `TravellingVendor` (with four NPC turrets) | 45–78 | | **`bradley_road`** (`RoadBradleys`) | **12 Bradleys on the roads at once** | 7.5–18 | | **`f15`** | one `F15` jet flying over at 150 m | 15–30 | Two systems run outside the schedule: the **satellite** (players launch or steer it; `satellite.random` crashes one at a random spot) and the **Deep Sea** (`deepsea.open` / `deepsea.close`, also on its own timer). Launch Site's Bradley comes from `BradleySpawner` (`bradley.quickrespawn`). **What each §13.2 row raised:** | Event | Hook(s), as seen | Who and where | |---|---|---| | Patrol helicopter arrives | `OnEntitySpawned(PatrolHelicopter)` | Spawns at the map edge | | **Helicopter downed** | **`OnPatrolHelicopterKill(heli, info)` fires twice**: at the crippling hit, naming the player, and again at the crash, naming nobody. `OnEntityDeath` fires at the crash, naming nobody | The crippled helicopter flew **4.5 s (Oxide) and 34.7 s (Carbon)** before crashing. D174's 60 s last-hitter rule credited the right player both times, but the first `OnPatrolHelicopterKill` names them outright, with no window | | Helicopter leaves without dying | `OnHelicopterRetire(PatrolHelicopterAI)`, then `OnEntityKill` far off the map with full health (`IsDead()` false) | — | | Launch Site Bradley destroyed | `OnEntityDeath(BradleyAPC, info)` with the killer; `OnCrateSpawned(bradley, crate)` ×3 | Killer named directly | | **Road Bradleys** | `OnBradleyApcInitialize` + `OnEntitySpawned` per Bradley; death as above | **The same `BradleyAPC` class**: a road Bradley is told from Launch Site's only by where it is | | Cargo ship arrives | `OnEntitySpawned(CargoShip)`, then `OnCargoShipSpawnCrate` + `OnCrateDropped` per crate | — | | Cargo docks; leaves | `OnCargoShipHarborApproach(ship, CargoNotifier)` (43 min after spawn on the rig), `OnCargoShipHarborArrived` (5 min later, at Harbor, with a fresh crate), `OnCargoShipHarborLeave` (8 min docked). `OnCargoShipEgress` (leaving the map) did not come within the window | The harbour is the nearest `harbor_*` monument | | Chinook arrives; drops its crate | `OnEntitySpawned(CH47HelicopterAIController)`; **`OnHelicopterDropCrate(ch47)`** then `OnCrateDropped` + `OnCrateLanded` (17 min after the call, at Water Treatment) | Monument = nearest to the crate | | Airdrop called | **A supply signal:** `OnCargoPlaneSignaled(plane, signal)`, whose `signal.creatorEntity` is the thrower. **The schedule:** `OnAirdrop(plane, dropPosition)` only | — | | Airdrop lands; first looted | `OnSupplyDropDropped(drop, plane)` and `OnSupplyDropLanded(drop)`; **the drop's owner is 0**, so "who called it" means remembering plane → thrower. **`OnSupplyDropLanded` fired twice for one drop.** `OnLootEntity(player, drop)` for the loot | — | | **Locked crate** hack started; unlocked; looted | `CanHackCrate(player, crate)` names who; `OnCrateHack(crate)` names nobody; `OnCrateHackEnd(crate)`; `OnLootEntity(player, crate)` | **Where it is:** prefab `codelockedhackablecrate_oilrig` = an oil rig (nearest `oilrig_1` large / `oilrig_2` small); **parent `CargoShip`** = cargo; **`codelockedhackablecrate_ghostship`**, parent `GhostShip` = the Deep Sea; otherwise a Chinook drop. **`wasDropped` is true for cargo's crates too**, so it does not mean "Chinook" | | Oil rig scientists called | Nothing of its own. Starting a hack on an oil-rig crate spawns a reinforcement `CH47HelicopterAIController` at once, which drops 11 `scientistnpc_heavy` within 2 s | The crate's prefab says which rig | | Travelling Vendor arrives; leaves | `OnEntitySpawned(TravellingVendor)`; `OnEntityKill` when it goes. Carbon's catalogue lists `OnVendingShopOpen`/`Opened` for its shop (not exercised) | — | | **Satellite crash** (newer) | `OnEntitySpawned(SatelliteCrash)` as it is launched (about 5 min before impact), then `SatelliteCrashRemains` + five `satellite_crate_N` at impact. **No hook of its own** | Crates burn, unlootable, for `satellite.crate_fire_duration` (300 s) | | **Deep Sea** (newer) | `OnDeepSeaOpen` → `OnDeepSeaOpened` (2.5 min of staged spawning: a floating city, 6 islands, 4 ghost ships crewed by Gen2 `ScientistNPC2`) and `OnDeepSeaClose` → `OnDeepSeaClosed` (35 s). `OnDeepSeaTeleport` for crossings | **All of it is off the main map** (x −4,800 to −7,400 on a 6000 map), so a grid reference there means nothing | | F-15 flyover (newer) | `OnEntitySpawned(F15)` | Cosmetic; nothing to report (Q23) | **Hooks to avoid.** `OnHelicopterOutOfCrates` fires **every tick** for a Chinook that carries no crate (a reinforcement Chinook raised it 668 times in 20 s). `OnPatrolHelicopterTakeDamage` fires per hit. **Plugin crates are vanilla crates.** Guarded Crate drops an ordinary `HackableLockedCrate`, so every crate hook above fires for it too. §13 must not count a plugin's crate as Rust's own (the integration plan says how). ##### TruePVE 2.4.6 - **The zone step works as planned, with no helper.** Two stand-ins in a zone made like §3's (`rg-spike-1`) could not hurt each other under the default ruleset. After `AddOrUpdateMapping("rg-spike-1", "exclude")` returned `true`, a 10-damage hit landed. Stand-ins outside the zone stayed protected, and `RemoveMapping` returned `true` and protection came back at once. Same on both frameworks. - **Persistence.** The mapping is written to `data/TruePVE.json` about 1 s after the call. It survived a TruePVE reload and a full server restart on both rigs. **TruePVE prunes a mapping whose zone is gone only when the zone id is numeric.** Ours are `rg-…`, so a leftover is never pruned, and our teardown removes our mappings (D362). - **`OnUpdatedMappings(Dictionary)`** and `OnRemovedMappings` fire about 1 s after each change, carrying the whole table, Dynamic PVP's entries included. - **Ruleset names cannot be read.** There is no API and no console command that lists them (`tpve` with no argument does nothing). `AddOrUpdateMapping` returns **`false` for a name that does not exist**, and the special name `exclude` means "TruePVE does not apply here". The default config has one ruleset, `default`, with "players cannot hurt players" (Q24). - Memory: TruePVE held 123 MB after a reload, the largest of the six. ##### Dynamic PVP 5.2.0 - **Every event is off by default, and when switched on, Bradley, the helicopter and the others refuse to make a zone** ("both radius and size are less than or equal to zero"): their default radius is 0. Only cargo (sized to the ship) works out of the box. With a radius set, every general event made its zone. - **Its zones.** `OnCreatedDynamicPVP(zoneId, eventName, position, duration)`. The id is the entity's net id for parented events (cargo, a crate, a supply drop) or a generated number (Bradley, helicopter, satellite), **always numeric**. Cargo's lives as long as the ship (`duration -1`); the others last the configured time, then `OnDeleteDynamicPVP` / `OnDeletedDynamicPVP` and their mapping goes. Each zone is mapped in TruePVE, to `exclude` by default. - **Ours and theirs coexist.** Our mapping stayed in TruePVE's table through Dynamic PVP's creates and deletes, and neither the bridge nor RunicGatewayZones touched its zones. RunicGatewayZones' startup sweep enters connected players into every zone, theirs included, which is what Dynamic PVP needs anyway. - `OnPlayerEnterPVP(player, zoneId)` fired as stand-ins arrived. **`OnPlayerExitPVP` fired twice per player** when a zone was deleted. - A guarded crate's zone was made where its crate spawned, 500 m up, before it fell. A satellite crash made one too. ##### Dangerous Treasures 3.0.0 - **`dtevent ` works from the bridge's console route on Oxide, and DT picks the spot.** The levels are 0 Easy, 1 Medium, 2 Hard. All three started; a success replies with an empty string. - **The first request after every load fails for about ten minutes.** DT builds its spawn grid **lazily, on the first request**, at 1 ms per frame, about 2,800 positions a minute. A 6000 map has 31,188, and every request until the grid is done fails with *"Unable to obtain a valid position"*, the same text it gives when there really is no spot (Q21). - **Hooks, as read:** `OnDangerousEventStarted(Vector3)`, `OnPlayerEnteredDangerousEvent(player, pos, pvp)`, `OnDangerousEventWon(looter, List invaders)` when the chest is emptied, and `OnDangerousEventEnded(pos)`. The chest unlocks after 300–480 s and despawns 900 s after that if nobody loots it. The map marker is a vending marker. It allows PvP at its events through TruePVE. It can pay Economics or Server Rewards itself (off by default). - **Its NPCs are `HumanoidNPC : ScientistNPC`**, so RunicNPC treats them as the built-in `scientists` faction. - **Boot cost: its `OnServerInitialized` stalls the server for 8.8 s (Oxide) / 9.4 s (Carbon)**, at every load. - It treats ZoneManager zones, ours included, as places not to spawn. ##### Guarded Crate 2.0.17 (with NpcSpawn 3.4.8) - **It needs NpcSpawn**, a Codefling extension that §14.1 did not list, and it **unloads itself** if NpcSpawn is missing when an event starts. NpcSpawn in turn needs its image files in `data/Images/`, or it unloads too, and on load it **calls a version server at a bare IP address** (`37.153.157.216:5000`). Q20. - **`gcrate position x,y,z` works from the bridge's console route on Oxide.** The handler checks `arg.IsAdmin`, not `IsRcon` as §14.1 said, and the server console passes. A success replies with an empty string. `gcrate start ` picks a spot itself and replies "Event starting." The events are Easy (8 guards), Medium, Hard and Elite; the default duration is 1,800 s. - **The event completes when the last guard dies**, crediting that guard's killer: `OnGuardedCrateEventEnded(player, instance)`. **It also fires with player = null** when the guards vanish some other way (seen when NpcSpawn reloaded), which means "no winner". The crate is never locked. Looting it does not end the event. `OnGuardedCrateEventFailed(pos)` is the timeout. - **There is no way to end one instance.** `gcrate stop` with no second argument silently shows help; `gcrate stop all` ends every event and **raises no hook at all**. - **Its guards are NpcSpawn's `CustomScientistNpc : ScientistNPC`**, so RunicNPC's factions treat them as `scientists` (D254, D255): a profile hostile to scientists hunts them, an allied one leaves them alone. ##### ZLevels Remastered 3.3.1 - **`GetLevel(ulong, skill)` costs about 3 µs** per call on both rigs, so a full server's six skills at tally time is trivial. **`api_GetPlayerInfo` costs 60–80 µs and creates a record for a Steam id it has never seen.** The bridge reads with `GetLevel` only. - **Lent XP boosts take effect at once.** ZLevels reads the permission on every gain (`getXpMulti`). The default config has `zlevelsremastered.vip1`–`vip3` (×2, ×3, ×4) as permissions and `zlevelsvip1`–`3` (×5–×7) as group names, so §12's `rust.perm.lend` already lends one. - **Levels survive a wipe by default** (`Wipe Data OnNewSave: false`); the `zlevelsremastered.nowipes` permission keeps them when wiping is on. There is still no level-up hook. ##### Server Rewards 2.0.9 - **`AddPoints`, `TakePoints` and `CheckPoints` work for an offline player**, through both the `ulong` and `string` overloads. `TakePoints` refuses (false) more than the balance. **`OnPointsUpdated(ulong, int balance)`** follows every change. - **The trap: a negative amount through the id overloads is not refused.** `TakePoints(id, -10)` adds 10 and `AddPoints(id, -10)` takes 10, with no floor. Only the `BasePlayer` and `IPlayer` overloads check the sign. The bridge must refuse a non-positive amount itself. - **It has no wipe handling at all.** Balances carry across wipes until an operator clears them. ##### All together - **Everything loaded at once on both rigs** (the six, NpcSpawn, Economics, Backpacks, RunicNPC, ZoneDomes, ZoneManager, Kits and our three plugins): no errors on either. A full restart with all of them took about 8 min 50 s to "Server startup complete" on Oxide and about 11 min on Carbon, against about 8 min 15 s and 10 min for the same rigs restored (each start includes the egg's steamcmd check). That is under a minute of difference: noise against steamcmd, so no boot cost worth designing around. The slow starters were DT (8.8 s once), NpcSpawn 2.3 s, ZLevels 1.4 s and RunicNPC 0.26 s. - **A busy minute** (two helicopters, three Bradleys, an airdrop, two hits, a DT and a guarded crate, a hack; 108 logged hooks) cost, in hook time: NpcSpawn 0.19 s, TruePVE 0.08 s, RunicNPC 0.06 s, Guarded Crate 0.05 s, the bridge 0.04 s, Dynamic PVP 0.03 s, DT and ZoneManager 0.02 s each, the rest under 0.01 s. ##### Found on the way, outside this plan - **Carbon cannot run another plugin's console command, or its own, through `ConsoleSystem.Run` on this Rust build.** Every plugin command (`gcrate`, `dtevent`, `tpve`, `zl.info`) and Carbon's own (`c.version`, `c.reload`) answered *"Command '…' not found"*, while the same text typed at the console worked. Rust's own commands still run, with their replies. On Oxide everything ran. The same Rust update changed `ConsoleSystem` (`arg.Args` is now `StringView[]`; the spike had to stop using `?? new string[0]` on it), and Carbon 2.0.262 predates it, so this is most likely Carbon not yet updated. **It also breaks the bridge's own Carbon reload (D180), which runs `c.reload` exactly this way**: `c.reload PopupNotifications` run like the bridge runs it answered *"Command 'c.reload' not found"* and reloaded nothing. Carbon's own `CommandManager` (`Find`, `Execute`) is reachable by reflection. Q19. - On Carbon, a folder the panel's file API creates is not writable by the game (the known rig trap): NpcSpawn failed until its `data/NpcSpawn` folder was deleted and left for it to make. - RunicNPC's `IsScientist` is `ScientistNPC` only, so the Deep Sea's Gen2 `ScientistNPC2` crews are not `scientists` to its factions. Noted for RunicNPC, not this plan. ##### Questions the phase raises **All seven were answered on 2026-10-14 (D363–D369, §10).** Q19, Q21 and Q23 differ from the proposals below; D363, D365 and D367 say how. **Q19 — Carbon's console route.** On Carbon today the bridge cannot start a DT event or a guarded crate by console command, and its own `c.reload` (D180) silently does nothing. For example, a *Treasure Hunt* event's "start Dangerous Treasures" step would work on the Oxide rig and do nothing on the Carbon rig. **Proposed:** (a) report it to Carbon and re-test on their next production build; (b) meanwhile, on Carbon only, run commands through Carbon's `CommandManager` found by reflection (as D180 already reflects), keeping `ConsoleSystem.Run` on Oxide; (c) file the reload breakage against Rust-Plugins now, since it affects what is released. **Q20 — Saying NpcSpawn is missing.** D361 accepts the dependency; what is left is how an operator finds out. An operator who installs Guarded Crate from uMod must also install NpcSpawn from Codefling (a free download, no licence stated), which contacts its author's server at load. For example, the site would say *"Guarded Crate is loaded but NpcSpawn is missing: its events cannot start."* **Proposed:** the hello reports NpcSpawn beside Guarded Crate; the site refuses a guarded-crate step when NpcSpawn is absent, with that reason; the operator docs say what NpcSpawn does on load. **Q21 — Dangerous Treasures' warm-up.** After every restart, the first DT request starts a ten-minute grid build and fails. For example, a *Treasure Hunt* event at 20:00 after a 19:55 restart would fail its step. **Proposed:** the step's failure is reported as "Dangerous Treasures is not ready yet" (the bridge can tell, from DT's text plus a retry), and the run retries the step once after ten minutes. Not proposed: the bridge pre-warming DT at boot, which would need it to reach into DT. **Q22 — Two kinds of Bradley.** A road Bradley and Launch Site's are one class. For example, *"Bradley destroyed by Alice at Launch Site"* against *"Road Bradley destroyed by Alice at G7"*. **Proposed:** one event, `bradley.destroyed`, carrying `where: launch_site | road`, and one *Tankbreaker* count for both. **Q23 — The newer events.** **Proposed:** the satellite crash, the Deep Sea opening and closing, and a ghost ship's crate join §13 as events and triggers. The F-15 flyover does not (cosmetic). For example, *"A satellite crashed at M30"* and *"The Deep Sea is open"* in the feed. **Q24 — TruePVE ruleset names.** They cannot be listed without reading TruePVE's config file. For example, a zone step offering *"PvP rules: default / pvp-arena / exclude"* needs the names. **Proposed:** the step offers "no PvP protection here" (`exclude`) as a checkbox, plus an optional ruleset name typed by staff, which the bridge validates by the call's own `false`. **Q25 — Wipes.** ZLevels keeps levels across wipes and Server Rewards keeps points across wipes, by default. **Proposed:** the site shows what the plugin holds and does nothing at a wipe; ZLevels columns are "current level", not "this wipe". ### 14.3 The integration plan Written on 2026-10-14 from §14.2.1 and D353–D369, against the code on `edge` (bridge, rust-link and Module-Rust at protocol 14 with §2, §11.2, §11.3 and §12 in), and **answered by the org lead before any code**. For §13 and each of the six it says what hello reports, what goes on the wire, what the site does, which helper (none), the tests and the walk rows. §14.3.9 gathers the points the plan settles itself, so they can be overruled, and §14.3.10 asks Q26–Q32, answered as D370–D376; §14.3.12 and §14.3.13 are the two pieces of core work they add. **Everything here is protocol 14 (D357)**, specified in [PROTOCOL.md](../../rust-link/PROTOCOL.md) §20 as each piece lands, and released with the rest after the end walk. **No helper plugin is needed (D351):** every route below is a public hook, a callable method or a console command that §14.2 ran. **The installer and the egg are not touched:** `requires_plugins` stays Kits, ZoneManager and RunicNPC, and none of the six is shipped (D351). #### 14.3.1 Rust's own world events (§13; D349, D353–D356, D366, D367) **Hello.** Nothing new: the events need no plugin. Hello gains one fact the steps below need, **`console`**: `{ plugins: true | false }`, found at load by running one of the bridge's own read-only commands through `ConsoleSystem.Run`, the route a plugin command takes. On Carbon 2.0.262 it is `false` (D363), and the site refuses a step that needs another plugin's console command, with the reason and Rust-Plugins#34. When Carbon fixes it, the next load says `true` and the steps work, with no new code. **One new event kind, `world.event`, with a `type`.** It is rare (a few an hour), so it is one frame per event. Positions are `x`, `z`; **the site computes the grid**, as the map already does, so the grid has one source. A player is `steamId` and `name`. | `type` | Raised from (§14.2.1) | Carries | |---|---|---| | `heli.arrived` | `OnEntitySpawned(PatrolHelicopter)` | `x`, `z` | | `heli.left` | `OnHelicopterRetire` | `x`, `z` (last seen) | | `heli.downed` | the crash's `OnEntityDeath`, with the first `OnPatrolHelicopterKill`'s player kept as the killing blow | every credit candidate (D372, below), `x`, `z` | | `bradley.spawned` | `OnBradleyApcInitialize`. **Road Bradleys come twelve at once, so their spawns in one second are one frame with `count`** | `where: launch_site \| road`, `count` | | `bradley.destroyed` | `OnEntityDeath(BradleyAPC)` (D366) | every credit candidate (D372), `where`, `x`, `z` | | `cargo.arrived` / `cargo.docking` / `cargo.docked` / `cargo.undocked` | `OnEntitySpawned(CargoShip)`, `OnCargoShipHarborApproach`, `…HarborArrived`, `…HarborLeave` | `x`, `z`; the three harbour types add `harbour` (the nearest `harbor_*`) | | `cargo.left` | `OnCargoShipEgress`, which did not fire within §14.2's window; else the ship's `OnEntityKill`. Stage 1 confirms which | `x`, `z` | | `chinook.arrived` | `OnEntitySpawned(CH47HelicopterAIController)`, **except the oil-rig reinforcement**, which is told by the crate hack it answers | `x`, `z` | | `chinook.dropped` | `OnHelicopterDropCrate`, then the crate's `OnCrateLanded` | `monument` (nearest), `x`, `z` | | `airdrop.called` | `OnCargoPlaneSignaled` (who = the signal's thrower) or `OnAirdrop` (the schedule: nobody) | who if any, `x`, `z` (the drop point) | | `airdrop.landed` | `OnSupplyDropLanded`, **once per drop** (it fired twice) | `x`, `z`, the caller remembered from plane to drop | | `airdrop.looted` | the **first** `OnLootEntity` on a drop | who, `x`, `z` | | `crate.hack.started` | `CanHackCrate` (who) joined to the `OnCrateHack` that follows for the same crate | who, `where`, `x`, `z` | | `crate.unlocked` | `OnCrateHackEnd` | `where`, `x`, `z`, the hacker | | `crate.looted` | the first `OnLootEntity` on an unlocked crate | who, `where` | | `vendor.arrived` / `vendor.left` | `OnEntitySpawned` / `OnEntityKill` (`TravellingVendor`) | `x`, `z` | | `satellite.crashed` | the `SatelliteCrashRemains` spawn at impact (no hook of its own) | `x`, `z` | | `deepsea.opened` / `deepsea.closed` | `OnDeepSeaOpened` / `OnDeepSeaClosed` | **No position:** the Deep Sea is off the map | - **`where` for a locked crate** is `oilrig_large`, `oilrig_small`, `cargo`, `ghostship` or `chinook`, each told positively as §14.2.1 found (the `_oilrig` prefab and nearest rig, parent `CargoShip`, the `_ghostship` prefab, a crate landed after `OnHelicopterDropCrate`). **Anything else is `other` and raises no frame**, so a plugin's crate (§14.2.1: Guarded Crate's is an ordinary `HackableLockedCrate`) is never reported as Rust's own. A crate one of our steps placed is in the world ledger and is skipped as well. - **"Oil rig scientists called" is not a separate type.** Starting a hack at a rig is what calls them, at once, so `crate.hack.started` with `where: oilrig_*` says it. - **Hooks not subscribed:** `OnHelicopterOutOfCrates` (every tick), `OnPatrolHelicopterTakeDamage` (every hit; D174's hit list already comes from `OnEntityTakeDamage`), `OnDeepSeaTeleport`, the F-15 (D367). - **Bounded state.** The joins above (plane → thrower, crate → hacker, looted-once sets) are keyed by net id, dropped at the entity's kill, and swept every ten minutes; nothing is written to disk. **The tally** gains `cratesHacked` and `dropsLooted` (D356): a hack counts for its starter at `crate.hack.started`, once per crate, from any location except `other`; a drop counts for its first looter. New columns `crates_hacked`, `drops_looted` in `rust_player_wipe_stats`; title categories **`crateshacked` (*Safecracker*)** and **`dropslooted` (*Scavenger*)**, `sum` sources, renamable (D175). **Who gets the credit for a helicopter or a Bradley is the operator's choice (D372).** The plugin no longer decides. For each patrol helicopter and Bradley it keeps a small damage table from `OnEntityTakeDamage`, the hook that already feeds D174's last-hitter, after the type test it already does, and for those two classes only. `heli.downed` and `bradley.destroyed` carry every candidate: ```json {"kind":"world.event","type":"heli.downed","x":812.0,"z":-340.5, "credit":{"blow":"76561190000000001","last":"76561190000000002","most":"76561190000000002", "damagers":[{"steamId":"76561190000000002","damage":6120.0},{"steamId":"76561190000000001","damage":3880.0}]}, "names":{"76561190000000001":"Alice","76561190000000002":"Bob"}} ``` - `blow` is the killing blow's player: for the helicopter, the player the first `OnPatrolHelicopterKill` names at the crippling hit; for a Bradley, its death's `InitiatorPlayer`. `last` is the last player to hurt it, with no 60 s window. `most` is the player who dealt the most damage. Each is absent when nobody qualifies. - **The site applies the server's rule**, a setting beside the server's other Rust settings: | Rule | Credits | For example (Bob did 61 %, Alice the crippling hit) | |---|---|---| | **Killing blow** (default) | `blow`, else `last` | Alice | | **Last hit** | `last` | whoever hit it last: Alice or Bob | | **Most damage** | `most` | Bob | | **Everyone who hurt it** | every `damagers` entry, one each | Alice and Bob, one each | - **The same rule writes the feed line and the count**, so the line and *Rotorbane* / *Tankbreaker* never disagree. Under protocol 14 the site counts `heli_kills` and `apc_kills` from these frames, and the tally's `heliKills` / `apcKills` are no longer sent. Under *Everyone*, the line reads *"Patrol helicopter downed by Alice, Bob and 2 others at G7"*. - Changing the rule applies from then on; past counts are not recounted. **The step that calls one: `world.call`, site → game (D354, D367).** ```json {"cmd":"world.call","reqId":"r-40","key":"9:2","event":"airdrop","count":3,"x":-412.0,"z":1180.5} {"kind":"world.called","type":"reply","reqId":"r-40","key":"9:2","repeat":false,"called":3} ``` - `event` is one of the schedule keys §14.2.1 found (`patrol_heli`, `cargo`, `chinook`, `airdrop`, `travelling_vendor`, `bradley_road`, `f15`) or `satellite`. It runs the game's own `TriggeredEvent.RunEvent` (`satellite.random` for the satellite), so it works the same on Carbon: **no plugin console command is used.** - **A position** is offered only where the game has a route to one. §14.2 ran the events where the game puts them; placing them was not tested. The first build stage starts with a short spike (research, D331) on the airdrop's drop point, the patrol helicopter's first destination and the Chinook's drop target. An event whose route does not hold is offered without a position, and the step's form says the game decides where. - **Kept (`reversible: 'none'`)**: nothing is removed at teardown, because a cargo ship or a drop may already hold players or their loot (D354). `risk: 'change'`. - **Bounds:** `count` 1–5 (1 for cargo, the vendor and road Bradleys, which the game runs one at a time), at most `EventsMaxWorldCalls` per run on the plugin. A new budget, **`rust.world.calls`**, counts each call. Refusals are `world.error`: `events-disabled`, `bad-event`, `bad-count`, `not-placeable`, `too-many`. - The sidecar adds `POST /world/call`, a thin forward. **The website (Module-Rust).** - **The feed.** `world.event` joins the catalogue, filtered **per `type`**. Each line is written from the type, e.g. *"Patrol helicopter downed by Alice at G7"*, *"Bob started hacking the locked crate on Large Oil Rig"*, *"12 road Bradleys are out"*, *"The Deep Sea is open"*. **The event and its grid are public; the player's name shows only to the operator's presence audience (D376)**, so with presence left at staff a signed-out visitor reads *"Patrol helicopter downed at G8"*. Staff see the name and the Steam id (D353). A line with nobody in it (the cargo docking, the Deep Sea) is the same for everyone. - **Triggers (D355).** One engagement trigger per type, `rust.world.` (`rust.world.heli.downed`, `rust.world.crate.hack.started` …), `kind: 'event'`, ceiling `everyone`, cooldown subject the server and type (the crate types add `where`), with typed variables (`server`, `grid`, `player`, `where`, `harbour`, `monument`, `count`). Because a phase gate's `{ on: … }` reads the same catalogue, **"advance when Bradley is destroyed" works with no new code**. **Starting a run from one is new core work (D370, §14.3.12).** - **The step `rust.world.call`**, *"Call a world event"*: server, event, count, and the location params of the place verbs where the event allows a position (above). - **Titles and columns:** *Safecracker* and *Scavenger* (above); the leaderboard gains the two columns. - **The map** already draws these entities; nothing changes there. **Tests.** Bridge: `checkPlugin.js` (hook names, the console probe). Module-Rust: the catalogue holds every type against PROTOCOL.md's table (default deny for an unknown type); each feed line; the trigger declarations and their examples; the `world.call` verify and bounds; the two title categories and columns; the ingest of the two tally fields; each of D372's four rules against one `credit` block (one with no `blow`, one with nobody at all), for the line and the count alike; a presence-gated line with and without the name (D376). **Walk rows (D331).** On both rigs, with timers shortened as §14.2 did: force each type and read its feed line, staff line and trigger, signed out and as staff; down a helicopter with a crash long after the hit, and a Bradley with two players, under each of the four credit rules; hack a crate at each location and see *Safecracker* count; loot a drop first and see *Scavenger*; call an airdrop at a position and a patrol helicopter from a run, then cancel the run and see both stay; a Guarded Crate crate raising no Rust frame. #### 14.3.2 TruePVE in the zone step (D362, D368) **Hello.** `integrations.truePve`: `{ loaded, version }`. **The wire.** `world.zone` gains an optional **`truePve`**: `{ "exclude": true }` or `{ "ruleset": "pvp-arena" }` (exactly one). The plugin makes the zone, then calls `AddOrUpdateMapping(zoneId, "exclude" | ruleset)`. - **All or nothing.** A `false` from TruePVE (no such ruleset) erases the zone just made and answers `world.error` `truepve-ruleset` with the name. TruePVE absent answers `truepve-missing`. - **The unmapping lives with the zone, on the plugin (D362).** Every path that erases one of our zones (the zone's own deadline, which runs with the site down; teardown; the boot sweep of PROTOCOL.md §15.3) calls `RemoveMapping` first. When TruePVE is not loaded at that moment, the id is kept in `data/RunicGateway/truepve.json` and removed at the next `OnPluginLoaded(TruePVE)`. The registry entry remembers that the zone was mapped, so nothing else of TruePVE's table is ever touched. - `OnUpdatedMappings` / `OnRemovedMappings` are not subscribed: the call's own return says what happened. **The website.** `rust.zone.open` gains **`noPvpProtection`** (D334's three-button boolean, *"No PvP protection in this zone"*) and **`pvpRuleset`** (a string, *"or this TruePVE ruleset"*). Setting both is refused at verify. Either one refuses at verify where TruePVE is absent ("TruePVE is not installed on this server"). A name TruePVE does not know fails the step at perform, with the name, because only the call can tell (D368). **Tests.** The two params' verify (both set, absent TruePVE, blank); the wire shape; a `truepve-ruleset` reply classified as a non-retryable failure. **Walk rows.** Map `exclude` and a typed ruleset on both rigs and hurt a stand-in inside; type a wrong name; let a zone expire on its deadline with the site stopped and see the mapping go; unload TruePVE, end a run, load it and see the leftover removed. #### 14.3.3 Dynamic PVP **Hello.** `integrations.dynamicPvp`: `{ loaded, version }`. **The wire.** Two events, `pvp.zone.created` `{ zoneId, event, x, z, radius, durationSec }` (−1 for "as long as the ship") and `pvp.zone.deleted` `{ zoneId }`, from `OnCreatedDynamicPVP` and `OnDeletedDynamicPVP`. The radius is read from ZoneManager, since the hook does not carry it. `map.live` gains a **`pvp`** layer of the live ones, kept by the same two hooks and seeded at load from ZoneManager's zones whose ids TruePVE maps for Dynamic PVP. `OnPlayerEnterPVP` / `ExitPVP` are not subscribed: they would say who stands where, and exit fired twice. **The website.** The map draws the `pvp` layer as a red ring with its event's name. The feed's public line is *"A PvP zone opened around the downed patrol helicopter at G7"*, which names nobody. Its deletion is a staff line only. No trigger and no step: Dynamic PVP's zones are its own to make. **Tests.** The layer's ingest and the viewer filter; the feed lines; the default deny until the kinds are classified. **Walk rows.** With a radius set for the helicopter and Bradley: down each, see the ring and the line, and see both go when the zone lasts out; cargo's ring for the ship's life; one of our zones beside theirs, neither touched. #### 14.3.4 Dangerous Treasures (D365) **Hello.** `integrations.dangerousTreasures`: `{ loaded, version }`. **The wire.** - Events **`dt.started`** `{ x, z, key? }`, **`dt.won`** `{ steamId, name, invaders: [steamId…], x, z }`, **`dt.ended`** `{ x, z }`, from `OnDangerousEventStarted`, `…Won` and `…Ended`. `key` is set when the start answers one of our steps: the first `OnDangerousEventStarted` within ten seconds of our `dtevent`. - Command **`dt.start`** `{ key, level }` (0 Easy, 1 Medium, 2 Hard) runs `dtevent ` through `ConsoleSystem.Run`. An empty reply is success. *"Unable to obtain a valid position"* within the first fifteen minutes after DT loaded is **`dt-not-ready`**; after that, `dt-no-spot`. `console-unavailable` when hello's `console.plugins` is false (D363). The key is remembered like a payment's, so a retry never starts a second. - `map.live`'s `world` gains `kind: "treasure"` for each running event, from the same hooks. - The tally gains **`treasuresWon`** for the looter of `dt.won`. **The website.** - **The step `rust.dt.start`**, *"Start a Dangerous Treasures event"*: server and difficulty; DT picks the spot. `reversible: 'none'` (DT despawns its own), `risk: 'change'`, a new budget `rust.dt.events`. It refuses at verify where DT is absent or `console.plugins` is false, naming Rust-Plugins#34 for the second. - **D365.** `dt-not-ready` is reported as *"Dangerous Treasures is not ready yet"*, and the step answers `{ ok: false, retry: true, retryAfterMs: 600000 }`: **core's new per-failure retry delay (D371, §14.3.13)**, so the run tries once more ten minutes later. `dt-no-spot` retries on core's ordinary 60 s. The manual retry is core's own: the step's `on_failure` pauses the run, and staff press **Retry** on the paused step ([EVENTS.md](../../website/EVENTS.md) §I), which re-sends the same key. - **Feed and triggers.** *"A Dangerous Treasures event started at K12"*; *"Alice won the treasure at K12"*; ended is staff-only. `rust.dt.started` and `rust.dt.won` are triggers (§14.3.9). - **A column and a title**, `treasureswon`, default *Treasure Hunter* (D375). - The map draws a treasure marker. **Tests.** The step's verify (absent, console unavailable), each error's wording, the key join on `dt.started`, the column and category. **Walk rows.** On Oxide: start each difficulty from a run; start one in the first minutes after a restart and see *"not ready yet"*, the automatic retry and the manual retry; win one and see the line, the column and the title. On Carbon: the step refused with Rust-Plugins#34 (until D363 clears). #### 14.3.5 Guarded Crate, with NpcSpawn (D361, D364) **Hello.** `integrations.guardedCrate`: `{ loaded, version, npcSpawn: { loaded, version }, events: […] }`. `events` are the event names in Guarded Crate's config file (Easy, Medium, Hard and Elite by default), read as the bridge already reads Kits' and Economics' (PROTOCOL.md §16.3), and absent when the file cannot be read. **The wire.** - Events **`gc.started`** `{ x, z, key? }`, **`gc.ended`** `{ steamId?, name?, x, z }` (no player = no winner), **`gc.failed`** `{ x, z }`, from the three hooks. `key` as for DT. - Command **`gc.place`** `{ key, event, x, y, z }` runs `gcrate position x,y,z` (the handler checks `IsAdmin`, which the server console passes). It refuses with `npcspawn-missing`, `bad-event`, `console-unavailable` or `gc-missing`. - `map.live`'s `world` gains `kind: "guarded"` while one runs. - The tally gains **`guardedWon`** for the player `gc.ended` names: a column and a title, `guardedwon`, default *Warden* (D375). **The website.** - **The step `rust.gc.place`**, *"Place a guarded crate"*: server, event (the names from hello), and the place verbs' location params (monument, grid or point). **Kept: nothing is removed at teardown (D373).** There is no way to end one instance, and `gcrate stop all` would end other people's and raise no hook. The step's description says so, and when a run ends with one still standing, the run log notes *"a guarded crate at M4 was left standing; it ends when cleared or after its own time"*. A new budget `rust.gc.crates`. - **D364:** it refuses at verify where NpcSpawn is absent: *"Guarded Crate is loaded but NpcSpawn is missing: its events cannot start."* The servers page shows NpcSpawn beside Guarded Crate. - **Feed and triggers.** *"A guarded crate landed at M4"*; *"Alice cleared the guarded crate at M4"*; failed is a staff line. `rust.gc.started` and `rust.gc.ended` are triggers (§14.3.9). - **RunicNPC needs nothing:** its guards are `scientists` to factions (§14.2.1). - **The operator docs** ([OPERATING.md](OPERATING.md)) gain a section on Guarded Crate: install NpcSpawn from Codefling, its `data/Images/` files, and that **it contacts its author's server at load** (D364). **Tests.** Verify (NpcSpawn, a name not in `events`, console unavailable), the null-winner frame, the column. **Walk rows.** On Oxide: place one at a monument and at a point from a run, clear it and see the line, the column and the trigger; let one time out; cancel the run and see the crate stay; unload NpcSpawn and see the step refused with the reason. On Carbon: refused (D363). #### 14.3.6 ZLevels Remastered (D369, D374) **Hello.** `integrations.zLevels`: `{ loaded, version }`. **The leaderboard does not change (D374).** It gains no ZLevels column and no ZLevels title. Most of its six skills already line up with counts of ours: woodcutting with *Timberwright*, mining with *Prospector*, crafting with *Seamster* and *Armorer*, acquire (plants) with *Greenhand*, and skinning sits beside the animal kills (*Beastbane*). Fishing has no count of ours, and stays that way. So **no levels go on the wire and nothing is stored**: the `levels` tally field and the `rust_player_levels` table this plan first proposed are dropped. D369 still holds in what remains: nothing is done at a wipe. **What the integration is:** - **Hello reports it**, and the servers page shows it beside the other optional plugins. - **XP boosts need no code** (§14.2.1): `rust.perm.lend` already lends `zlevelsremastered.vip1`–`vip3`, and its picker lists them once ZLevels registers them (§12.2). A *Double XP weekend* is a lend step. **Tests.** The hello field and the servers page. **Walk rows.** Lend `vip2` from a run and see the XP rate change at once, and back at the run's end. #### 14.3.7 Server Rewards (D369) It mirrors §11.2.2 (Economics) wherever Server Rewards allows, and the code shares Economics' path where the two are the same. **Hello.** `integrations.serverRewards`: `{ loaded, version }`. Server Rewards has no starting balance, so a new player is at 0. **The wire.** - **`points.pay`** `{ key, amount, steamIds[], message, delivery }`, answered by `points.paid` with `paid` (before and after) and `failed`, the key remembered seven days, the message told once, exactly as `economics.pay`. **Whole numbers only, 1 to 1,000,000.** The bridge refuses anything else itself, because the id overloads accept a negative amount and invert it (§14.2.1). - **`points.balances`** `{ steamIds[] }` → `{ balances: [{ steamId, points }] }`, bounded like `economics.balances`. - **`points.adjust`** `{ key, steamId, op: add | take, amount }` → `{ before, after }`. There is no `set`, because Server Rewards has no call for it. `take` above the balance is refused with `insufficient`, as Server Rewards refuses it. - The tally gains **`points`**, a value, read at connect and from `OnPointsUpdated(ulong, int)`. - The sidecar adds `POST /points/pay`, `/points/balances`, `/points/adjust`. **The website.** - **The step `rust.points.pay`**, *"Pay RP"*: kept like coins (D335's reasoning), the same recipient modes, a new budget `rust.points`. - **Balances** in a new table, **`rust_player_points`** (`server_id`, `steam_id`, `points`), per server and not per wipe (D369). The player's Rust page, the user's admin page and the app (R10) show it beside the coins. - **Staff add and take** from the user's admin page, each an activity row, like Economics. - **A column and a title**, `points`, default *Hoarder*, a value like the richest (D375): ranked only above 0, on servers that report Server Rewards, and shown for every wipe view because the points do not belong to one (D369). **Tests.** The same set as Economics': the amount bounds (0, negative, fractional), a repeat key, `insufficient`, the value ingest, the admin route and its activity row, the app's screen. **Walk rows.** Pay three players, one offline, and see the popup on wake; retry the step and see no second payment; add and take from the admin page, including more than the balance; read the balance on the site and in the app; wipe and see the points stay. #### 14.3.8 The wire, all together | Kind | New in protocol 14 | |---|---| | Hello | `console.plugins`; `integrations.truePve`, `dynamicPvp`, `dangerousTreasures`, `guardedCrate` (with `npcSpawn`, `events`), `zLevels`, `serverRewards` | | Events | `world.event` (23 types); `pvp.zone.created` / `deleted`; `dt.started` / `won` / `ended`; `gc.started` / `ended` / `failed` | | Commands | `world.call`; `dt.start`; `gc.place`; `points.pay` / `balances` / `adjust`; `world.zone`'s `truePve` | | Tally | `cratesHacked`, `dropsLooted`, `treasuresWon`, `guardedWon`; `points` (a value). **`heliKills` and `apcKills` leave the tally**: the site counts them from `world.event`'s `credit` (D372) | | `map.live` | `world` kinds `treasure` and `guarded`; the `pvp` layer | | Sidecar | `POST /world/call`, `/dt/start`, `/gc/place`, `/points/pay`, `/points/balances`, `/points/adjust`, thin forwards that keep nothing | `PROTOCOL_VERSION` stays 14 (D332); `overlay.toml` already says 14. #### 14.3.9 Settled in this plan, and overrulable - **One `world.event` kind with a `type`**, filtered per type (§13.2's shape), rather than twenty-three kinds. - **The site computes the grid** from `x`, `z`; the Deep Sea's events carry no position. - **Road Bradleys' spawn is one line with a count**; Launch Site's is its own line. - **Oil-rig reinforcements are `crate.hack.started` at a rig**, not a type of their own. - **A crate is Rust's own only when its location is told positively**; anything else raises no frame. - **A hack counts for *Safecracker* when it starts**, for the player `CanHackCrate` named, once per crate. - **Dangerous Treasures' and Guarded Crate's starts and wins are triggers too**, as world events are (D355). Dynamic PVP's zones are feed and map only. - **A run's DT or guarded crate is told from the plugin's own** by the first start within ten seconds of our command, which is the only join the hooks allow. - **The DT and GC steps refuse on a server whose `console.plugins` is false**, rather than being hidden. - **Server Rewards' wire is `points.*`, beside `economics.*`**, not one verb with a currency field: protocol 14 is unreleased, but Economics' verbs are built and specified, and two names keep each plugin's refusals its own. #### 14.3.10 Questions for the org lead **All seven were answered on 2026-10-14 (D370–D376, §10), and D377 states the leaderboard rule behind Q30 and Q31.** Q26, Q27, Q29, Q31 and Q32 as proposed (Q26 is written as §14.3.12, Q27 as §14.3.13). **Q28 differs:** the operator chooses the credit rule, from four options (D372). **Q30 differs:** the leaderboard is not changed for ZLevels at all (D374). - **Q26 — Can a world event start a run?** D355 makes every world event a trigger, and §13.4's example was *"when anyone destroys Bradley, start the Launch Site Lockdown event"*. Today a trigger can **advance a phase** of a run already going (`{ on: 'rust.world.bradley.destroyed' }`) and can send a notice, but **core cannot start a run from a trigger**: a run starts by its schedule or by hand. **Proposed:** add it in core as a definition's *"start when"* (a trigger, conditions and a cooldown, so Bradley dying twice in a minute starts one run), on the website's own plan, in its own PR with the Module-uo proof, as §2 was. Or leave it out for now, and an event that needs it opens with a phase that waits for the trigger. - **Q27 — DT's ten-minute retry (D365).** Core retries a failed step every 60 s, three times, so a step cannot say "try me again in ten minutes". For example, a *Treasure Hunt* at 20:00 after a 19:55 restart fails at 20:00, 20:01 and 20:02, then pauses the run for staff. **Proposed:** a small core addition, `retryAfterMs` on a step's failure (bounded to 30 min, counted as one attempt), a MODULE_API minor bump. The other way is the bridge holding the request and starting DT itself ten minutes later, which means a step that "succeeded" before anything started. - **Q28 — Who downed the helicopter.** D174 credits the last player to hurt it within 60 s of the crash. The test found the game names the player at the **crippling hit**, and a crippled helicopter flew 34.7 s on Carbon before crashing. One that glides 70 s would credit nobody today. **Proposed:** credit the player named at the crippling hit, falling back to D174's rule, for the feed line and *Rotorbane* alike (amending D174). - **Q29 — A guarded crate outlives its run.** There is no way to end one guarded crate: `gcrate stop all` ends every one on the server, and raises no hook. For example, a run cancelled five minutes after placing one leaves it, with its eight guards, for up to 30 minutes, until it is cleared or times out. **Proposed:** accept that. The step says so, and the run log notes the crate left standing. - **Q30 — ZLevels on the leaderboard.** Six skills. For example: **(a)** one *Level* column, the total of the six, and one title *Grandmaster* for the highest total; **(b)** six columns (*Woodcutting*, *Mining* …) and six titles; **(c)** one *Level* column whose skill the viewer picks, with six title categories an admin may use or not. **Proposed:** (c). - **Q31 — Titles for the plugins' counts.** For example: *Treasure Hunter* for most DT treasures won, *Warden* (?) for most guarded crates cleared, *Hoarder* (?) for the most RP. **Proposed:** all three as title categories with those default names, renamable (D175), and a leaderboard column for each on servers that report the plugin. RP ranks only above 0. - **Q32 — A public line that names a player, and the presence rule.** The rule of 2026-09-22 says nothing tells who is online by default, and the public feed already hides `npc.boss.died` (it names the killer) below the operator's presence audience. D353 says the public line names the player. For example, with presence left at staff, does a signed-out visitor see *"Patrol helicopter downed by Alice at G7"*, *"Patrol helicopter downed at G7"*, or nothing? **Proposed:** the middle one. The event is public, and the name shows only to the presence audience, so a line with nobody in it (the cargo docking, the Deep Sea) is always public. #### 14.3.11 Order of building Each stage is built on `edge`, compiled on both rigs, and PR'd with its PROTOCOL.md section; nothing is walked until the end (D331, D359). The org lead is asked before each stage starts. 1. **§13, the world events**, starting with the placement spike for `world.call`. It has no plugin dependency and carries the console probe the later stages use, and D372's credit rule. 2. **Core: a step's retry delay** (§14.3.13, D371), a website PR with the Module-uo proof. It is small and comes before stage 5, which needs it. 3. **TruePVE** in the zone step. 4. **Dynamic PVP** (it needs TruePVE). 5. **Dangerous Treasures**, on stage 2's `retryAfterMs`. 6. **Guarded Crate**. 7. **ZLevels** (hello only, D374). 8. **Server Rewards**. 9. **Core: starting a run from a trigger** (§14.3.12, D370), a website PR of its own with the Module-uo proof, as §2 was. It can be built alongside any stage after 1; it is last here only because nothing else waits on it. Module-Rust needs no code for it, because the world events are already triggers. #### 14.3.12 Core: starting a run from a trigger (D370) **What it gives.** *"When anyone destroys Bradley on Main, start Launch Site Lockdown"*; *"when the Deep Sea opens, start Sea Raiders"*; on UO, *"when a champion's boss appears, start Champion's Call"*. Today a trigger can only advance a phase of a run already going, or send a notice (§14.3.1). This is core work on the website's own plan ([EVENTS.md](../../website/EVENTS.md) gains it when it is built). It is written here because this plan asked for it, and no module contract changes: the triggers it starts from are the ones modules already register. **The design, to be settled again in EVENTS.md at build time:** - **A definition's version gains `starts`**: a list of `{ on: , where: , cooldown }`, beside its schedule (a definition may have either, or both). `on` and `where` are a phase gate's own (EVENTS.md §E), the same catalogue, operators and form, so *"`where: { server: 'main', where: 'launch_site' }`"* is already a thing the editor can draw. - **The emit path starts it.** When a trigger fires, core finds the `ready` definitions whose published version starts on it and whose conditions match, and creates a run of that version **now**, with `scheduled_for` = the firing's time, so the existing `UNIQUE (definition_id, scope, scheduled_for)` keeps one firing from starting two runs. `started_by` is `trigger:`, and the firing's variables are stored on the run, so its page says *"Started because Bradley was destroyed at G8"*, and the audit says who and what. - **One run at a time, and a cooldown.** A firing while a run of that definition is going, or within its `cooldown` (default 30 minutes, 0 to 7 days) of the last trigger-started run, starts nothing. It is counted, and the definition's page shows *"2 firings ignored while running"*. This is what stops twelve road Bradleys dying in a minute from starting twelve runs. - **No loops.** Core's own `event.*` triggers cannot start a run, so a run starting can never start another. A run whose own steps cause its start trigger (an event that calls a patrol helicopter and starts on one being downed) is held by one run at a time and the cooldown. - **Everything else is a run like any other:** caps and budgets, verify before publish, the same roles that may schedule one (admin and editor), the same announcements and cleanup. A definition that starts from a trigger whose module is not loaded is dormant, as a phase gate on one is. **Tests (website).** A matching firing starts one run; a second firing during it, and one in the cooldown, start nothing and are counted; two instances emitting the same firing make one run; an `event.*` trigger is refused in `starts`; a dormant trigger; the run's origin on its page and in the audit. **Module-uo proof:** one definition starting on `uo.champ.boss_up`. **Walk rows.** On a Rust rig: *"start when Bradley is destroyed"* with a short cooldown. Destroy two in a minute and see one run and one ignored firing; destroy one after the cooldown and see a second run. #### 14.3.13 Core: a step's retry delay (D371) **What it gives.** A failed step may say when to try again: `{ ok: false, retry: true, retryAfterMs }`. Today every retry waits the flat `EVENT_STEP_RETRY_MS` (60 s). DT's warm-up (D365) is the first user: *"not ready yet; try in ten minutes"*. - **Bounded** 60 000 to 1 800 000 ms (one to thirty minutes). Outside that it is clamped, because this is the module's hint, not an author's value (D95 is about the second). Absent, the flat delay as today. - **It counts as one attempt** of `EVENT_STEP_MAX_ATTEMPTS`, and the run goes `degraded` on it as on any retry (EVENTS.md §L). The run console shows *"retrying at 20:10"*. - **A run that is paused or cancelled while it waits** does not retry; the existing staff Retry on a paused step still works at any time. - **MODULE_API minor bump** (additions only). [`STEAM_SIGNIN.md`](../../website/STEAM_SIGNIN.md) has claimed 1.15.0; whichever lands first takes it, the other the next minor. Module-uo needs no change. The Integration Kit's `coreApi` equality check turns red on the bump, as it is meant to, and its pin moves when the chapters are re-read. **Tests (website).** The delay honoured; clamped at both ends; counted as an attempt; no retry for a run paused or cancelled in the wait; `MODULE_API.md` and the classify rules updated. ### 14.4 Questions for the org lead **Answered on 2026-10-14:** Q17 as proposed, out (D358). **Q18 against the proposal: the end walk waits for RaidableBases (D359).** - **Q17 — Is BotReSpawn in or out?** It is paid ($40) and runs its own NPC AI, profiles and monument spawns, which RunicNPC already does. It cannot drive RunicNPC. The most it could add is its bots named in the feed and kill counts. **Proposed:** out. It can be added later under D348's rules, with a licensed copy for the rigs. - **Q18 — Does the end walk wait for RaidableBases?** D331 holds one walk until every item is coded, and RaidableBases now waits for a copy with no date (D347). For example, if the paid copy arrives in a month, do §2, §11.2, §11.3, §12, §13 and §14 wait a month for their walk? **Proposed:** no. The walk runs when §13 and §14 are coded, and RaidableBases gets its own walk after its build. --- ## 15. Steam sign-in (D360): on the website's plan The org lead asked on 2026-10-14 for Steam sign-in **in the same manner as the other providers**, and for it to stay on the website's plan (D171). It is planned in **[`website/STEAM_SIGNIN.md`](../../website/STEAM_SIGNIN.md)** and is on the roadmap as the `website` milestone *Steam sign-in*. It is core work, not an item of this plan, and it does not hold the end walk (D359). What this module takes from it: - **A verified Steam id for a signed-in account**, read through the module contract (MODULE_API 1.15.0 there). - **A Steam identity as a linked Rust player**, beside the in-game `/link` code (R1). Whether it counts as a link is that plan's Q1, and what unlinking removes is its Q2. - When it lands, Module-Rust's account screens and the link flow are updated in their own PR. Rust's grants already follow the website user to every linked Steam id (D28), so nothing else changes.