diff --git a/modules/rust/PLAN_REDESIGNS.md b/modules/rust/PLAN_REDESIGNS.md index 4cf223c..2b10439 100644 --- a/modules/rust/PLAN_REDESIGNS.md +++ b/modules/rust/PLAN_REDESIGNS.md @@ -927,7 +927,8 @@ pulls) is research, not a walk, so it still comes first: 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 (§14.3), which starts from D361–D369. Then the build, §13 first. + 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 @@ -1077,6 +1078,19 @@ The org lead answered the phase's Q19–Q25 on 2026-10-14: | **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 @@ -2334,17 +2348,471 @@ not "this wipe". ### 14.3 The integration plan -Written after §14.2, in one docs PR, and answered by the org lead before any code. For §13 and for each of the -six it says: -- what hello reports (`integrations` gains each plugin's `{ loaded, version }`); -- the wire additions, in protocol 14 unless Q16 says otherwise; -- what the site does: feed, map, steps, triggers, titles, columns, configuration; -- any helper, and why, which D351 makes the exception; -- the tests; -- the walk rows, which join the end-of-plan walk (D331). +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. -It also proposes a build order. The working assumption is §13 first (no dependencies), then TruePVE, then Dynamic -PVP, then the other four. +**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