Files
docs/modules/rust/PLAN_REDESIGNS.md
wtclaude fa53526079 docs(rust): record the org lead's answers to the step-3 plan (D188-D198)
Groups are per server unless an admin shares them (D189); an in-game
change affects that server only, as an exception or a split (D190);
ZoneDomes stays, made safe by a domes helper (D194); presets are the
admins' own (D195); zone and reward messages get the popup choice
(D193); the NPC spike tries both routes (D197). The rest confirm the
plan: D188, D191, D192, D196, D198.

Refs RunicGateway/Module-Rust#21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-27 03:56:43 -05:00

43 KiB
Raw Blame History

module-rust — the redesigns, planned

Status: plan, written 2026-09-27. Its eleven questions were answered the same day: D188–D198 (§10). It is step 3 of 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. No code has been written for any of them yet.

This is a companion to PLAN_FIXES.md and 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 §4.7 Rust-Plugins, Module-Rust

Protocol. Every wire change here joins protocol 13, which is still unreleased on edge in all three repos. PLAN_FIXES.md §5 already lists the inventory verb, ZoneManager's flags, zone options and the new tally fields under it, so this adds no bump (§7).

Core. §2 is mostly core's editor, which is game-agnostic and serves Module-uo too. Its contract additions are MODULE_API 1.11.0 → 1.12.0, additions only (§8). 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 and 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). §3.1
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.


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: a toggle for boolean, 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.12.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.12.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: '<the longest duration in the phase>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.
  • Rewards. The reward actions' message to the player (rust.kit.entitle and the other eventRewards.js steps that tell a player what they won) gains the same delivery.
  • 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.12.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 <server>/default/<the kit's permission>, 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).

The warning reaches the core editor through an optional note on an option row (MODULE_API 1.12.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

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.


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.<flag> 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.


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.

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 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.


6. NPCs (§4.7)

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. Protocol 13, what this adds

All of these are additions to the unreleased protocol 13. PROTOCOL.md §19 gains a section for each.

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 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
the reward verbs' player message: delivery site → game 2.4
world.place for an NPC (the verb behind rust.npc.place): kit, name, if route A is chosen site → game 6
player.tally: animalKills, weaponKills, headshots, bestPvpM, bestPveM, apcKills, heliKills, crafted, built, repaired, healed, rockets, explosives, missions game → site 5.1

8. MODULE_API 1.12.0 (core, website main)

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, walked on both rigs, and PR'd with its spec, like steps 1 and 2:

  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): the two-route spike, then the org lead's pick, then the build.
  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.

Then PLAN_FIXES §8's re-walk on Oxide and Carbon, and the cutover.

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.12.0), 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