docs(modules): phase 0 as built — the rig is current, and four plan claims were wrong
Brings D:\rust to build 25230300 with Oxide 2.0.7716 and the four base plugins loaded, and records what the rig disproved. - §4: start.bat never updated anything. +force_install_dir must precede +login; the 2026-09-15 fix corrected the path and left the order, so every update run in the rig's history errored without updating. Also: app_update reverts Oxide's patched Assembly-CSharp.dll while leaving Oxide's own assemblies, producing a server that looks Oxided and loads no plugins. Phase 18's doctor should check for this. - R2: permission.GrantUserPermission silently no-ops for an unregistered permission — void, no throw, no log. Since R2 recovers by re-pushing the full set on connect, a re-push into a server missing one plugin is a silent partial. Plus: grants and groups have different reach for a player who has never connected. - R5: the Clans plugin raises nine hooks, not three, and three carry full member lists. The "exactly three, none a membership transition" claim was a grep artefact — the hook name is a const at the call site. The decision stands on a different reason; phase 17 gains event-driven leadership as a sharpening rather than a replacement. - R17: EraseTemporaryZone's owner scoping refuses only a *different* owner. An unowned zone — every zone an operator made by hand — is erased by anyone and returns true. Phase 12 must gate on its own id map. - §12.5: the zone-occupancy criterion stays open. A console command has no BasePlayer, so every plugin's permission check short-circuits to allowed and no console session can observe a gate closing; NPCs are excluded from zone player lists by construction. This is a standing property of the rig, relevant at phases 7 and 12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -93,6 +93,16 @@ Three properties fall out, and they are the reason this shape is worth its cost:
|
||||
drift, and drift is surfaced to an operator — the same posture a lease's `restore()` takes when it
|
||||
finds a value a human has moved ([kit][kit] ch. 5).
|
||||
|
||||
**Phase 0 exercised all three against the real store and they hold — but it found four rules the
|
||||
push path has to obey (§12.2).** The one that would have cost the most:
|
||||
**`permission.GrantUserPermission` silently no-ops when the permission is not registered.** It
|
||||
returns `void`, throws nothing and logs nothing; the grant simply does not happen. A permission is
|
||||
registered by the plugin that declares it, so **every grant naming an unloaded, renamed or
|
||||
uninstalled plugin's permission disappears without a trace** — and since R2's whole recovery story is
|
||||
"the site re-pushes the full set on connect", a re-push into a server missing one plugin is a silent
|
||||
partial. The push must check `PermissionExists` (or register the name itself) and report the
|
||||
difference as drift rather than assuming a write landed.
|
||||
|
||||
**This is a direction the Integration Kit has no chapter for, and that is a finding.** Chapters 3 and
|
||||
4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a
|
||||
teardown. This is neither: it is *continuously reconciled state where the website is authoritative*,
|
||||
@@ -171,12 +181,30 @@ direction worth stating plainly: for Teams, the plugin is *worse* than first-par
|
||||
`Clans` v0.2.10 (k1lly0u, MIT, 2,692 lines) publishes **fifteen `[HookMethod]`s and every one of
|
||||
them is a mutation** — `CreateClan`, `JoinClan`, `LeaveClan`, `KickPlayer`, `PromotePlayer`,
|
||||
`DemotePlayer`, `DisbandClan`, and the eight alliance verbs. **There is no read API whatsoever**: no
|
||||
`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`. And it raises exactly **three**
|
||||
hooks — `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — **none of which is a membership
|
||||
transition**.
|
||||
`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`.
|
||||
|
||||
So it cannot answer any of core's three provider questions from its published surface, while
|
||||
first-party clans answer all three. Feeding the Team provider from first-party clans is therefore
|
||||
> **Corrected in phase 0 (§12.3).** This paragraph continued *"it raises exactly **three** hooks —
|
||||
> `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — none of which is a membership transition"*, and
|
||||
> concluded that the plugin cannot answer core's provider questions at all. **That was a grep
|
||||
> artefact and it is wrong.** `Clans` 0.2.10 raises **nine** hooks. The missing six are invisible to
|
||||
> a search for `CallHook("OnClan…` because the name is a `const` at the call site:
|
||||
>
|
||||
> ```csharp
|
||||
> const string HOOK_NAME = "OnClanMemberJoined";
|
||||
> Interface.CallHook(HOOK_NAME, tag, ulong.Parse(joining), RustMemberList);
|
||||
> ```
|
||||
>
|
||||
> They are `OnClanMemberJoined(tag, joining, members)`, `OnClanMemberGone(tag, leaving, members)`,
|
||||
> `OnClanDisbanded(tag, members)`, `OnClanAllianceCreated`, `OnClanAllianceDissolved`, and
|
||||
> `OnClanUpdate(tag)`. The first three **carry the full member list**, so the plugin *can* answer
|
||||
> "who is in this clan" without any read API, and `OnClanUpdate` fires on promote and demote — the
|
||||
> exact transitions first-party lacks.
|
||||
|
||||
**The decision does not change, but its reason does.** First-party remains the Team provider's source
|
||||
because it is the system the *game* maintains and every server has it; the plugin is an optional
|
||||
install that not every shard will run, and feeding a provider from something optional makes Teams
|
||||
conditional on a mod. It is no longer true that the plugin *cannot* answer the questions — only that
|
||||
it should not be the one asked. Feeding the Team provider from first-party clans is still
|
||||
**permanent, not a first step**.
|
||||
|
||||
What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in
|
||||
@@ -263,6 +291,12 @@ kicked and left — but **no promote or leader-changed event**. Core's provider
|
||||
transitions. That makes phase 9 *partly* snapshot-driven where the dry run predicted it would be
|
||||
fully event-driven — a small correction to that document, recorded here rather than silently.
|
||||
|
||||
Phase 0 verified the seven against `agent/hooks.tsv` and the gap is real. It also found that the
|
||||
**Clans plugin closes it** — `OnClanUpdate(tag)` fires on both promote and demote (§12.3). That does
|
||||
not move the provider off first-party, but it does mean phase 17's adapter can offer *event-driven
|
||||
leadership* on servers that run the plugin, over a snapshot baseline on servers that do not. Design
|
||||
phase 9's snapshot so phase 17 can sharpen it rather than replace it.
|
||||
|
||||
### R7 — the notifications and engagement set ships in v1
|
||||
|
||||
**Decided 2026-09-15 (org lead).** `registerNotificationStreams`, `registerEventTriggers`,
|
||||
@@ -519,7 +553,7 @@ someone goes looking for one that is not there.
|
||||
| **`GetPlayerZoneIDsNoAlloc(player, List<string>)`** | the allocation-free variant — **the one to use on any sweep** |
|
||||
| `CreateOrUpdateZone(zoneId, args, position)` | make a zone |
|
||||
| **`CreateOrUpdateTemporaryZone(..., Plugin owner)`** | make a zone *owned by our plugin* |
|
||||
| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one, **scoped to the owner** |
|
||||
| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one; owner-scoped **only against another plugin's zone**, not against an unowned one (§12.4) |
|
||||
| `GetZoneIDs` / `GetZoneName` / `GetZoneLocation` / `CheckZoneID` | the catalogue, for an option source |
|
||||
|
||||
And nine hooks raised: `OnEnterZone` / `OnExitZone`, `OnEntityEnterZone` / `OnEntityExitZone`,
|
||||
@@ -543,10 +577,30 @@ expensive and less accurate.
|
||||
**`rust.zone.open` moves from the optional tier into the base catalogue, and it can be
|
||||
`reversible: 'ledger'` honestly.** This is the nicer half: `CreateOrUpdateTemporaryZone` takes a
|
||||
**`Plugin owner`** and `EraseTemporaryZone` is **scoped to that owner**, so ZoneManager already has a
|
||||
first-class notion of a zone belonging to the plugin that made it. That is most of the persisted
|
||||
ownership registry [kit][kit] ch. 4 demands — we still keep our own map from core's resource
|
||||
reference to the zone id, but we are not inventing ownership, we are borrowing a concept the plugin
|
||||
already has. And erasing a zone that is gone is a success, which is what `revert` needs.
|
||||
first-class notion of a zone belonging to the plugin that made it. And erasing a zone that is gone is
|
||||
a success, which is what `revert` needs.
|
||||
|
||||
> **Narrowed in phase 0 (§12.4), and this one is a safety correction.** The scoping is real but it is
|
||||
> *one-directional*: it stops us erasing a zone owned by **another plugin**, and does nothing at all
|
||||
> for a zone owned by **nobody**.
|
||||
>
|
||||
> ```csharp
|
||||
> // Only compare zone owner if the owner param is provided so users can remove temporary zones
|
||||
> // without needing to unload the plugin that created them
|
||||
> if (owner && zoneOwner && owner != zoneOwner)
|
||||
> return false;
|
||||
> ```
|
||||
>
|
||||
> `zoneOwner` is null for every *permanent* zone — which is every zone an operator made by hand. So
|
||||
> `EraseTemporaryZone(us, "<operator's zone>")` **deletes it and returns `true`**, indistinguishable
|
||||
> from erasing our own. Observed live: a zone created with `CreateOrUpdateZone` and no owner was
|
||||
> erased by an `EraseTemporaryZone` call from an unrelated plugin.
|
||||
>
|
||||
> So this is **less** of ch. 4's persisted ownership registry than the paragraph above claims. We
|
||||
> still keep our own map from core's resource reference to the zone id, and that map is now
|
||||
> **load-bearing rather than convenient**: phase 12 must refuse to erase any zone id it did not
|
||||
> record creating. ZoneManager will not refuse on our behalf, and the `true` it returns is not
|
||||
> evidence the zone was ours.
|
||||
|
||||
**One trap to design against, and it is chapter 4's rule meeting a chatty hook.** `OnEnterZone` and
|
||||
`OnExitZone` fire on the game thread and a large zone with a busy server produces a great many of
|
||||
@@ -635,22 +689,42 @@ online. The persisted pending-grant queue that question was weighing is not need
|
||||
|
||||
## 4. The test rig
|
||||
|
||||
`D:\rust` on the org lead's workstation. It has been booted, it has a generated world
|
||||
(procedural, seed 1234, size 4000, save v287) and Oxide **2.0.7585** matched to its build, and its
|
||||
Oxide permission store already holds a `default` and an `admin` group with one admin user — which
|
||||
means R2's mechanism can be exercised on day one.
|
||||
`D:\rust` on the org lead's workstation. **Brought current in phase 0 (see §12):** build
|
||||
**25230300**, Oxide **2.0.7716**, a fresh procedural world (seed 1234, size 4000) generated for this
|
||||
wipe, and all four base plugins loaded. Its Oxide permission store holds a `default` and an `admin`
|
||||
group with one admin user, so R2's mechanism was exercised on day one and works.
|
||||
|
||||
Two traps recorded here because both cost time before they were understood:
|
||||
A wipe keeps `server/server1/cfg/`. That directory holds `users.cfg`, and `users.cfg` holds the
|
||||
`ownerid` line — delete the whole identity directory and you silently remove the operator's own
|
||||
ownership along with the map.
|
||||
|
||||
- **`D:\rust\start.bat` updated the wrong directory — fixed 2026-09-15.** It ran
|
||||
`steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launched
|
||||
`D:\rust\RustDedicated.exe`. The server that boots had never been updated by its own script, which
|
||||
is why a second, never-booted install exists at `C:\rustserver` and why `D:\rust` is a wipe behind.
|
||||
Now reads `+force_install_dir d:\rust\`; the original is kept at `D:\rust\start.bat.bak`.
|
||||
`D:\rust\steamapps\appmanifest_258550.acf` was already present at the same buildid, so the first
|
||||
corrected run is a delta to the current wipe rather than a 5.9 GB re-download.
|
||||
Three traps recorded here because each cost time before it was understood:
|
||||
|
||||
- **`start.bat` never updated anything — the 2026-09-15 diagnosis was wrong, corrected in phase 0.**
|
||||
The script was read as *"updates `C:\rustserver`, runs `D:\rust`"*, and the fix changed the path to
|
||||
`d:\rust\`. The path was never the problem. **steamcmd requires `+force_install_dir` before
|
||||
`+login`**, and the script had it after:
|
||||
|
||||
```
|
||||
steamcmd.exe +login anonymous +force_install_dir d:\rust\ +app_update 258550 +quit
|
||||
→ Please use force_install_dir before logon!
|
||||
→ Error! App '258550' state is 0x486 after update job.
|
||||
```
|
||||
|
||||
So the flag was discarded, the update ran against steamcmd's own directory, and the job errored out
|
||||
every single time. It updated **no** directory, ever — which is the actual reason `D:\rust` fell a
|
||||
wipe behind, and why `C:\rustserver` sits at the *same* stale buildid rather than a newer one.
|
||||
Now reads `+force_install_dir d:\rust\ +login anonymous +app_update 258550 +quit`; the pristine
|
||||
original is kept at `start.bat.bak` and the path-only fix at `start.bat.broken-order-20260915`.
|
||||
With the ordering right, the run is a delta and takes minutes.
|
||||
|
||||
- **`app_update` silently un-Oxides the server.** Oxide ships a *patched* `Assembly-CSharp.dll`, and
|
||||
a Steam update restores Facepunch's. It does not remove `Oxide.Core.dll` and friends, so the
|
||||
install still *looks* Oxided — it simply loads no plugins and raises no hook. **Re-extract
|
||||
`Oxide.Rust.zip` over the tree after every `app_update`, and check the size**: on build 25230300
|
||||
vanilla is 9,758,544 bytes and Oxide 2.0.7716's is 9,953,280.
|
||||
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched
|
||||
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it
|
||||
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,953,280, so copying it
|
||||
over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it.
|
||||
|
||||
**Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match**, so "is the
|
||||
@@ -673,7 +747,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
||||
|
||||
| # | Phase | Repos | Done when |
|
||||
|---|---|---|---|
|
||||
| 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set — Kits, Clans, PopupNotifications, ZoneManager (R6, R17) — prove a console grant reaches a plugin | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
|
||||
| 0 | **The rig.** ✅ **Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end. One acceptance criterion is **open**: zone occupancy needs a connected player and cannot be closed headlessly (§12.5) | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
|
||||
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the **`extensions`** declaration (§11.3), per-server sidecar tokens through **`ctx.secretBox`** (§11.4), the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
|
||||
| 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
||||
| 3 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history |
|
||||
@@ -925,7 +999,7 @@ cleanly, reads back cleanly, and does nothing at all, and neither core nor revie
|
||||
| `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes |
|
||||
| `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes |
|
||||
| `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted |
|
||||
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner` and `EraseTemporaryZone` is scoped to it, so the undo is real |
|
||||
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner`, so the undo is real. Our own id map decides what may be erased, not ZoneManager's owner check (§12.4) |
|
||||
|
||||
**Rewards are not a contract member.** `EVENTS.md` deleted a `registerEventRewards` registry because
|
||||
it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action —
|
||||
@@ -1096,4 +1170,146 @@ So R10's capability probe already has the behaviour the app wants: a Rust module
|
||||
makes the app render a site *without* those screens, rather than one advertising screens that `503`.
|
||||
The app needs no failure handling for this case because core does not expose the failure.
|
||||
|
||||
## 12. Phase 0 as built — the rig, 2026-09-15
|
||||
|
||||
The rig is current and the base set runs. Five things were learned that the plan had either wrong or
|
||||
had never asked, and three of them change work in later phases.
|
||||
|
||||
### 12.0 What the rig is now
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| Server build | `24613624` (2026-08-13) | **`25230300`** (2026-09-10) |
|
||||
| Oxide | `2.0.7585` | **`2.0.7716`** (`OxideMod/Oxide.Rust`, 2026-09-11) |
|
||||
| World | seed 1234 save v287, previous wipe | regenerated for this wipe; `cfg/` preserved |
|
||||
| `oxide/plugins/` | empty | Kits 4.4.9 · Clans 0.2.10 · Popup Notifications 0.2.1 · Zone Manager 3.1.14 |
|
||||
|
||||
All four compiled and loaded first time on the new build, at exactly the versions R6 and R17 name —
|
||||
pulled fresh from `https://umod.org/plugins/<Name>.cs`, which still serves those versions and needs
|
||||
no Cloudflare workaround. Server protocol `2633.288.1`.
|
||||
|
||||
The Oxide permission store survived the update untouched (`oxide/` is not a Steam depot directory),
|
||||
so `76561198038695917` is still in `default` and `admin`.
|
||||
|
||||
Two instruments were built and are kept in the phase-0 scratchpad rather than committed: a
|
||||
dependency-free **WebSocket RCON driver** (Node's global `WebSocket`, no `ws` package), and
|
||||
**`RGProbe.cs`**, a throwaway Oxide plugin that exposes Oxide's permission API and ZoneManager's
|
||||
by-name API as console commands. The probe is what made §12.2 and §12.4 observable; phase 1's plugin
|
||||
skeleton can start from it.
|
||||
|
||||
> **One thing the RCON driver had to learn.** Oxide tags its own `Puts()` output and its warnings
|
||||
> with the **identifier of the command being run**, so a first-match-wins client reads a plugin's log
|
||||
> line as if it were the reply and discards the real one. It cost two wrong readings before it was
|
||||
> spotted. Collect every frame in a window; do not correlate one reply per identifier.
|
||||
|
||||
### 12.1 The rig's own scripts were broken in a way the earlier diagnosis missed
|
||||
|
||||
Recorded in §4. In short: `start.bat` put `+force_install_dir` **after** `+login`, steamcmd discarded
|
||||
it, and every update run in the rig's history errored out without updating anything. The 2026-09-15
|
||||
"fix" changed the path and left the order, so it fixed nothing. Separately, `app_update` reverts
|
||||
Oxide's patched `Assembly-CSharp.dll` while leaving Oxide's own assemblies in place, which produces a
|
||||
server that looks Oxided and loads no plugins.
|
||||
|
||||
Both matter beyond this workstation: **phase 18's installer will hit the second one.** An
|
||||
operator who updates their Rust server has silently uninstalled Oxide, and therefore our plugin.
|
||||
`doctor` should check the `Assembly-CSharp.dll` against the Oxide build, not merely that `oxide/`
|
||||
exists.
|
||||
|
||||
### 12.2 Four rules the R2 permission push must obey
|
||||
|
||||
Verified live against the real store, granting and revoking through both the console command and the
|
||||
API:
|
||||
|
||||
1. **`permission.GrantUserPermission` silently no-ops for an unregistered permission.** `void`, no
|
||||
throw, no log. The console `oxide.grant` at least answers `Permission 'x' doesn't exist`; the API
|
||||
path R2 uses says nothing at all. This is the finding with teeth — see R2.
|
||||
2. **A permission exists only because a loaded plugin registered it.** Kits registers `kits.admin`
|
||||
and, dynamically, **every kit's `RequiredPermission`** (`Kits.cs:1225`, `:2895`) — which is what
|
||||
makes R16's entitlement model real. Unload Kits and those names stop existing.
|
||||
3. **`RegisterPermission` warns about a foreign prefix but registers anyway.**
|
||||
`Missing plugin name prefix 'rgprobe' for permission 'someplugin.vip'` is a warning, not a
|
||||
refusal — the permission was created and granted successfully. So the site *can* make a grant
|
||||
stick for a plugin that is not currently loaded, at the cost of a console warning. Whether it
|
||||
*should* is a phase 7 decision; the mechanism exists.
|
||||
4. **A player who has never connected is in no group, but can hold direct grants.** A grant to an
|
||||
unseen SteamID64 works and reads back immediately. Group membership does not exist for them yet,
|
||||
so **anything the site expresses as group membership does not reach a player until their first
|
||||
connection**, while a direct grant does. R16's offline entitlement is safe; a group-shaped
|
||||
entitlement is not.
|
||||
|
||||
Point 4 is the one to carry into phase 7's design: grants and groups have **different reach** for
|
||||
offline players, and the site's model currently treats them as two spellings of the same thing.
|
||||
|
||||
### 12.3 R5's claim about the Clans plugin was a grep artefact
|
||||
|
||||
Corrected in R5. The plugin raises nine hooks, not three, and three of them carry full member lists;
|
||||
the six that were missed are invisible to a literal search because the hook name is a `const` at the
|
||||
call site. The decision stands on a different reason — first-party is what every server has, the
|
||||
plugin is optional — and phase 17 gains event-driven leadership as a sharpening rather than a
|
||||
replacement.
|
||||
|
||||
Two smaller things from the same read, both worth having before phase 9 and 17:
|
||||
|
||||
- **`Clans` raises the same hook name twice per transition**, once Rust-typed
|
||||
(`string, ulong, List<ulong>`) and once Universal-typed (`string, string, List<string>`), plus two
|
||||
deprecated arities. Oxide binds by name **and** arity, and both live forms are arity 3 — so a
|
||||
loosely typed subscriber catches both and double-counts every join and leave. Type the parameters
|
||||
precisely and pick one.
|
||||
- **`Clans` calls `API_RegisterThirdPartyTitle` itself.** R15's BetterChat integration will be the
|
||||
*second* title provider on any server running both, not the first.
|
||||
|
||||
Also confirmed, since the plan rests on it: the first-party set is exactly the **seven** hooks in
|
||||
`agent/hooks.tsv`, all "no return behavior", with no promote and no leader-changed.
|
||||
|
||||
### 12.4 ZoneManager's owner scoping is narrower than R17 assumed
|
||||
|
||||
Corrected in R17. `EraseTemporaryZone(owner, id)` refuses only when the zone has a *different*
|
||||
owner; an **unowned** zone — every permanent zone, including every zone an operator made by hand — is
|
||||
erased by anyone and returns `true`. Phase 12 must gate erasure on its own id map.
|
||||
|
||||
Three more things the source and the live rig agreed on:
|
||||
|
||||
- **ZoneManager's entire API is plain private methods**, no `[HookMethod]` anywhere in 3.1.14 — so
|
||||
`Call()` by name is the only way in, and a typo is silence. Confirmed working live for
|
||||
`CreateOrUpdateZone`, `CreateOrUpdateTemporaryZone`, `EraseTemporaryZone`, `GetZoneIDs` and
|
||||
`GetPlayersInZone`. The three-conventions finding holds: Kits declares `[HookMethod]` (23 of them),
|
||||
ZoneManager declares nothing, BetterChat will use `API_` prefixes.
|
||||
- **`GetPlayersInZone` cannot distinguish an unknown zone from an empty one** — both return an empty
|
||||
list, not null. The participation ledger R17 wants to feed therefore cannot use this call alone to
|
||||
answer "is this zone still there", and must check `GetZoneIDs` separately. This is the same
|
||||
absence-of-an-answer / answer-of-absence trap earlier phases of other workstreams hit.
|
||||
- **NPCs never appear in a zone's player list.** `baseEntity is BasePlayer { IsNpc: false }` routes
|
||||
them to the zone's *entity* list instead. Useful to know before designing a condition that counts
|
||||
"players at the monument" on a server with scientists.
|
||||
|
||||
### 12.5 The one criterion phase 0 could not close, and why
|
||||
|
||||
> `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it
|
||||
|
||||
The first half is **done** — proven for both an online-known and a never-seen player, in both
|
||||
directions, through the same `UserHasPermission` call every third-party plugin makes.
|
||||
|
||||
The second half is **open, and not by choice of method.** A plugin's own permission check reads:
|
||||
|
||||
```csharp
|
||||
private bool HasPermission(ConsoleSystem.Arg arg, string perm)
|
||||
{
|
||||
BasePlayer player = arg.Player();
|
||||
return !player || permission.UserHasPermission(player.UserIDString, perm);
|
||||
}
|
||||
```
|
||||
|
||||
`!player ||` — a command from RCON has no `BasePlayer`, so **the server console is unconditionally
|
||||
allowed**. This is the standard Oxide idiom, not a ZoneManager quirk, and it means no console session
|
||||
can ever observe a gate closing. Zone occupancy has the same shape from the other end: NPCs are
|
||||
excluded by construction, so nothing but a real connected player puts a name in that list.
|
||||
|
||||
Everything up to the body was proven: a zone was created through the API, listed by both the probe
|
||||
and ZoneManager's own `zone_list`, read for occupancy (a real empty list, so the call resolved), and
|
||||
erased. What remains is one person, connected, standing inside it.
|
||||
|
||||
**This is a standing property of the rig, not a phase-0 leftover.** Any phase whose acceptance
|
||||
depends on a permission actually *refusing* somebody, or on presence, needs a client attached. Worth
|
||||
knowing at phase 7 and phase 12 rather than discovering there.
|
||||
|
||||
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit
|
||||
|
||||
Reference in New Issue
Block a user