main
24 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| dfdb0a3f63 |
docs(modules): phase 5 as built — Android leg A, and the capability a client had to be given
module-rust's surface gets its second client. `modules/rust/PLAN.md` §18 records the phase; `android/PLAN.md` M14 records the app's half, as every Android milestone does. The decision worth the most words is D16. This module declared five capability strings and every one named a SURFACE — `servers`, `killfeed`, `leaderboard`, `presence`, `wipes` — while a client gating a navigation group needs one that names the MODULE. Core flattens every started module's capabilities into a single list, so `servers` is a word another module could declare tomorrow and silently reveal these screens on a site that does not run Rust. Gating on the module `id` was considered and rejected in as many words: `id` is a mount prefix, §2.9 forbids inferring a route from a capability, and letting a client gate on `id` makes the two the same value in practice. Also recorded: D17 (poll while RESUMED — the phone's Page Visibility gate), D18 (the Rust repositories move to `edge`, releases at the cutover), D19 (the drawer badge, and NavPaths learning `/rust`), why D15's footer slot had to be translated rather than copied, and §18.4's three defects — none of which a green suite of 644 could see, because each is about what a screen looks like or when a number is re-read. §18.6 is deliberately short and honest: the website's own pages have still not been read at phone width, and the badge's non-zero case was shown with a seeded count rather than by people playing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 788100e048 |
docs(modules): phase 4 as built — the first pages, and the four defects a browser walk found
Records PLAN.md section 17 and marks the phase row done. Four decisions (D12-D15), what is on the pages, and the four defects only a live walk could find — two of them in code phase 3 had already shipped: an unreachable refresh that erased the server's description, and a "last reported" line reading the timestamp of our own poll rather than of the server's last frame. Also written down: why core's useAsync cannot poll, the real per-page cost of a live count in the footer slot, and the test fake that was *nearly* core. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| f80b9f95c8 |
docs(modules): the ingest walk, and the two-writer trap it walked into
The module half exercised against the live rig rather than only its own tests:
the working tree installed into a running core, pointed at the workstation
sidecar with a real Rust server behind it. The fresh-install path logged
`cursor started at the feed tail`, a console banid/unban arrived as two
rust_events rows carrying the wipe id, and the wipe row was created by being
mentioned rather than by any "a wipe started" call.
Then the boundary on real rows: with both ban events in the table, the public
events route answered {"events":[]} — and answered the same when asked for
player.banned BY NAME.
The trap is worth more than the proof. The state row read as online=1 with
hostname=NULL, a shape no code path writes. Two cores shared one database: a
second instance still held the phase 2 release in memory, spoke protocol 1, was
refused 409, and rewrote the row every thirty seconds. Nothing was wrong with
either. What made it look like a defect was two SELECTs in two round trips
assuming the state did not move between them — the same mistake the Pterodactyl
work recorded, and a single atomic read caught the coherent row alternating with
the other writer's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
|
|||
| 30e72adfcf |
docs(rust-link): protocol 2 — the read path, and phase 3 as built
The specification the other three repositories are held against, plus the phase record. `PROTOCOL.md` §8 is the new contract. Its centre is one field: every frame now carries `type` — `event`, `snapshot`, `reply`, `control` — and the sidecar files on that and nothing else. That is the dumb-forwarder property made structural rather than intended: ten new event kinds are zero change in Rust-Link, and only a version adding an indexed column touches it at all. Also in §8: the fifteen-kind catalogue and what each frame carries; `wipeId` derived by the plugin, which REVERSES §3.2's "deriving one is the website's job" and says why; boards re-sent on connect and on a cadence; the aggregate rule (a hook that can fire more than once a second per player is a counter, not an event); the void rule that stops a read-path hook vetoing a death or a login; and `GET /feed`, a cursor route separate from `/events` because one route with two orderings serves the wrong one to every caller that forgets the parameter. §8.5 is the part to read twice. The classification of a kind as public or staff is NOT on the wire, deliberately: a boundary declared by the sender is one a compromised or out-of-date game host can widen, so the module holds a default-deny allowlist and this table is what its test holds it against. §8.8 corrects a catalogue rather than a defect: PLAN.md §10 sources `rust.login.denied` from `CanUserLogin`, and that hook fires on every attempt — the only way to learn of a denial from it is to be the denier. A denial is the absence of an approval, and protocol 2 emits both facts so phase 10 can pair them. `PLAYER_WALK.md` is new, and it exists because half this catalogue cannot fire without somebody holding a mouse. Ten steps, what each one should produce, and what counts as a pass — written so the walk can be run without watching the output live, and so the answer afterwards is readable as a transcript. PLAN.md §16 is phase 3 as built: the four decisions, the two defects only a server that BOOTED with the plugin could find (a wipe id that was null for every real session, and a two-second main-thread stall on unload), what was proven and how, and — stated plainly rather than implied — the three measurements still queued on the org lead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 5274d5a744 |
docs(modules): phase 2 as built — packaging, release and the frozen manifest
PLAN.md gains §15 and the phase-2 row is marked done. What the phase found worth recording: - The starting position was worse than the row implied: phase 1 built five guards and ran every one by hand, because the repo had no .gitea/workflows at all. So the org lead widened the phase to include the gate, not only the release (D4), and the frozen-manifest job shipped with it rather than waiting. - That job answers a question §13 had to take on trust. Core mounts /status and /version at a tier root where the loader's collision probe cannot see them, so phase 1 could only check /rust by reading. Locally and in CI, against the same pinned core: 280 core routes, 6 added, all documented, none of core's moved. - The bundle carries no node_modules (D5), and checkBundle.js asserts the emptiness rather than assuming it — the day a dependency appears is meant to be a loud day. Verified by breaking the include list. - No Sonar for the three Rust repos until phase 19 (D6). - The criterion is met. v0.1.0 published, downloaded, hashed independently (7296c76b… matches the manifest), and installed from its manifest URL through Admin → Modules into a core with the phase-1 directory and row torn down first. The volume received exactly the twenty files the include list names; the row reads started with a source and a sha where phase 1's had nulls; the public route answers 200, the player and admin tiers 401, and all five /rust paths merged into core's own spec. - REGISTRY_TOKEN was already configured — the one prerequisite this work could not check for itself, since the bot can read no Actions secrets. - One rig finding: Docker Desktop's Linux engine was found dead and answered every call with a bare 500, which presents as the website being broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 22159ec78f |
docs(modules): the Carbon rig, R19 proven, and one refuted claim
rust-carbon (id 18, 87fb1f67) is built on the same egg with FRAMEWORK=carbon, a clean install rather than a converted one. Install 122s, boot 543s. R19 is proven. The byte-identical RunicGateway.cs that runs on the Oxide rig, with no conditional compilation in it at all, loaded on Carbon 2.0.259.0 and behaved identically - same startup line, same no-stall retry against an absent sidecar. That contract now holds on three platforms from one source file: Windows/Mono, Linux/Oxide, Linux/Carbon. R18's amendment is confirmed the best way it could have been. The plugin's own config, written by the same Oxide-compat API on both rigs, landed at oxide/config/RunicGateway.json on one and carbon/configs/RunicGateway.json on the other. Had the R18 editor used a literal path it would not have found its own plugin's config on half of all installs, and no test would have caught it. One claim is REFUTED, and it was wrong about Oxide as well as Carbon. CARBON.md said Oxide stores permissions as JSON and Carbon as Protobuf or SQLite. Both rigs say otherwise: both store Protobuf, under IDENTICAL filenames (oxide.users.data, oxide.groups.data), differing only in directory - Carbon writes its own data into files named after Oxide. This strengthens R2's API-only rule rather than weakening it: a file reader would have worked on both rigs today and broken silently for the one operator who ran c.migrate_perms_sql. Two things nobody had thought to claim, found by looking. Carbon auto-creates a third default group, moderator, which R2's full-set push must tolerate or report drift for ever. And c.plugins exposes per-plugin hook telemetry that is most of section 6's fired-hook mechanism, free, and only on Carbon. Also: a wrong console command is indistinguishable from success at the API. Pterodactyl's command endpoint returns 204 either way and Carbon prints nothing for an unknown command, so oxide.plugins on Carbon looks exactly like it worked. Anything driving the console must read a log to know. CARBON.md gains a scorecard (section 10) and drops its unproven banner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 069e715b1b |
docs(modules): the Pterodactyl rig as built, R22, and a correction
Section 14 records R21's first rig, made with the application key and driven with the client key. Both work; neither can do the other's job, and there is no egg-write endpoint at all, so a published egg is a JSON file a human imports. The tier-2 loop was run rather than described: phase 1's real RunicGateway.cs pushed from the working tree, oxide.reload through the client API, the edit observed in Oxide's own log, source restored. About ten seconds end to end. Three things worked that were not certain to - files/write creates missing parents, a plugin placed before Oxide exists survives Oxide arriving, and the plugin compiles and loads on Linux, which no previous phase had established. Two findings that change decisions. The image installs the framework on EVERY boot, Carbon from the rolling production_build tag and Oxide from releases/latest, so a restart is a framework upgrade and neither is pinnable through the egg. And R20's "wrapper launches the sidecar then RustDedicated" does not survive Carbon: the entrypoint prepends LD_PRELOAD to the whole startup string, so the preload would land on the sidecar and the server would start cleanly, report nothing, and be silently unmodded. R22 (org lead): the sidecar's configuration moves into the egg's variables. Nearly free - rust-link already reads all five keys from the environment with env-over-file-over-defaults precedence - but the game bind must not be operator-editable, the web bind must derive from an allocation, and the db path must not be able to agree with REMOVE_FILES. Also corrects an earlier claim in this branch. The client key listing zero servers and includes returning empty were both CORRECT; the servers were being deleted between reads. A differential diagnosis across two API calls assumes the state did not move between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| a29cdf0fae |
docs(modules): record the Pterodactyl client key and its empty-server-list problem
The token file now carries both keys. The client key authenticates as the admin owner and then lists zero servers, on every listing type and for both the short identifier and the full UUID, so tier 2 of R21's deployment loop is not usable yet. Reinstall and SFTP are unaffected. Records the diagnosis rather than only the symptom, including one trap worth not repeating: includes are independently broken on this panel, so servers?include=user returns an empty list where the same route without the include returns six. Nothing built on an include is evidence about ownership. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 5890da633f |
docs(modules): module-rust supports Carbon too, and ships a Pterodactyl egg
Three new decisions of record, and a new reference for the second modding framework. R19 - the bridge plugin is framework-agnostic from now, not ported later. Carbon is not a fork of Oxide but a separate loader shipping an Oxide compatibility layer, so one .cs in the Oxide.Plugins namespace serves both, with #if CARBON only where the APIs genuinely differ. Three existing decisions take an amendment and none is reversed: R18's config walk roots at Interface.Oxide.ConfigDirectory rather than a literal oxide/config (Carbon uses carbon/configs AND lets an operator relocate every directory from the command line); R2's permission store is Protobuf or SQLite on Carbon, which permanently closes the file-reading shortcut it never planned to take, while the PermissionExists pre-check survives because Carbon's bool return is the one thing we cannot read portably; R4's doctor asks which framework rather than whether Oxide, and gets a weaker "current enough" claim because Carbon ships rolling release tags. R20 - a Pterodactyl egg is a third supported deployment path beside the installer and the hand install, derived from the community "Rust Autowipe" egg, which already carries a FRAMEWORK variable offering vanilla/carbon/oxide. The sidecar runs inside the game's container, which is what lets D2 stand unchanged: a container's 127.0.0.1 is genuinely private, so the game link stays loopback and stays unauthenticated. Lands in phase 18 beside the installer. R21 - both rigs move to the Pterodactyl panel, because Oxide and Carbon cannot coexist in one install and so a single server cannot prove R19. Also retires the wipe-day maintenance that dominated section 4, and makes the rig Linux where every prior finding came from Windows and Mono. New: modules/rust/CARBON.md, the difference list - file layout, the permission store, the c. commands, 30 Carbon-only hooks, and 13 uMod names Carbon's catalogue omits (at least two of which look like renames). Sourced from Carbon's own published metadata and source at main, and labelled throughout as not yet proven on a live Carbon server. One outstanding request, recorded in section 3: the panel token on disk is an application key and Pterodactyl puts files, power and console on the client API, so iteration needs a ptlc_ key only the account holder can mint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| bd83b34614 |
docs(modules): module-rust phase 1 as built — the transport, and three org-lead decisions
Adds docs/rust-link/, the canonical spec for the Rust bridge: PROTOCOL.md (the game link and the website API) and INTEGRATION.md (standing it up by hand, and which of the three components is wrong when it does not work). A new top-level directory mirroring link/, which is decision D1 below — it keeps the uo/link symmetry and keeps module docs separate from bridge docs. Records phase 1 in PLAN.md as section 13. Both criteria met: a server.hello produced by the live Rust rig travelled game → sidecar → module → the public website API, killing the sidecar left the game untouched, and all five guards are green on the module skeleton. Three org-lead decisions this phase needed, none settled by section 2: * D1 — the bridge docs live at docs/rust-link/. * D2 — loopback is the ONLY trust boundary on the game link, no token, exactly as on the ServUO bridge. Argued the other way on the grounds that Rust servers are far more often on GSPs; overruled, and the consequence is now written down as the mistake rather than defended against. * D3 — the plugin reads Oxide's own config file, so it lands inside the phase-7b config editor for free. The argument against — that editing Host/Port from the website could cut the link carrying the edit — becomes that phase's guard rather than a reason for a second config mechanism. Section 11.3 is corrected in place: it read module.json's "extensions" array as held against reality by the loader in the way "mounts" is. Only half true. The loader checks that a named slot EXISTS and never that the module filled it — checkDeclared covers "mounts" alone — and only ONE of R13's two slots can be declared there at all, because site.footer.status is a CLIENT slot and naming it fails the load outright. Section 13.3 records five defects a running server found that no test could, including a bootId that regenerated on every plugin load rather than every server start — which would have asked core to reconcile its whole ledger on every oxide.reload, for a world that never moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| b12c6dfde4 |
docs(modules): phase 0 fully closed — both zone transitions observed live
The org lead connected, so the occupancy criterion is met: a zone created on the player's own position reported OnEnterZone, occupancy=1 by name, and OnExitZone. R17's "presence transitions as events" is verified, which is what the participation ledger and advance conditions rest on. The exit was produced by relocating the zone rather than walking the player out — CreateOrUpdateZone on an existing id moves the trigger volume and fires OnExitZone. Useful for testing presence unattended. Two ceilings recorded, both constraining later phases: - No console session can observe a gate (the !player || idiom). - An admin account cannot see a refusal from most plugins either, and the bypass is NOT uniform. PopupNotifications (player.IsAdmin) and ZoneManager (authLevel > 0) hard-bypass; Kits' RequiredPermission gate is config-gated with a shipped default of false, and Kits' own IsAdmin is a permission rather than auth level. So phase 13 is demonstrable on this rig as it stands and phase 7 is not — phase 7 needs a second, non-admin Steam account. Adds §12.6: R18's two trees as they actually look now the configs exist. Its data/ inventory was exactly right, but config/ is FLAT on a fresh base set — it is data/ that nests, i.e. the nesting R18 cites to justify the recursive walk is currently only visible in the tree it must never walk. Also: oxide/data/ holds Oxide's own permission store, so straying one directory over would edit R2's mirror underneath itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 98bed8ff3d |
docs(modules): re-install Oxide after an update is routine, not a finding
Org lead correction: every Rust host already updates the server and re-installs Oxide together. Presenting that as something an operator would be caught by talks down to the audience. Keeps the one narrow consequence that is actually ours: app_update leaves Oxide.Core.dll and the rest in place, so a phase-18 doctor check that tests for oxide/ or for Oxide's assemblies passes on a server that is mid-routine. Compare Assembly-CSharp.dll against the Oxide build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 947e1c1c67 |
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 |
|||
| dbc0455490 |
docs(modules): a second contract pass over MODULE_API.md, member by member
Section 7 audited the plan against the Integration Kit and the template. This pass reads MODULE_API.md itself and enumerates every member rather than grepping for registration names. It found one regression, one mispriced decision, one missing declaration, and a set of ctx members the plan had never mentioned. THE REGRESSION. ctx.events.reconcile() and an action's reconcile() appear nowhere in the document. The twelve-phase first draft had them - phase 6, reconcile and the boot-id watch - and the rewrite to twenty phases lost them. That is a regression in the plan, not a decision. It matters MORE for Rust than for the game the contract was written against: the kit rates reconcile the one omission that is merely a lower standard rather than a broken promise, but that assumes a world which persists. Rust wipes monthly and a wipe invalidates every ledgered resource for that server at once, and core cannot tell a wedged sidecar from a game that rebooted and lost everything - it sees ok:false retry:true either way. It asks once at its own boot and otherwise waits to be told. ctx.events.reconcile() is being told, and the trigger is a watch on the game's boot id changing, which is also the only way to tell a game restart from a sidecar reconnect. Restored to phase 13. R9 WAS MISPRICED. It says the map's per-layer switches work through the existing visibility framework, which reads as reuse. It is not reuse: section 6.3 records shardVisibility as module-owned, and the tree confirms it - the util, both models, the admin controller and its tests all live under module-uo/server/ and there is nothing by that name left in website/server. Section 2.7 forbids a module requiring outside its own directory, so module-rust cannot import a line of it and builds its own. Not a reason to change the decision; phase 14 now carries a visibility layer of its own rather than a configuration of somebody else's. A MISSING DECLARATION. R13 claims two extension slots and never says where they are declared. module.json has an extensions array and the dry run's own manifest carried it. Like mounts it is a statement of surface the loader holds against reality, so phase 1 adds it. THE CTX MEMBERS. ctx has 29 members and the plan named a handful. The ones that change work: secretBox, because each configured server's sidecar token is a secret at rest and core already has the posture for it; middleware.rateLimit, which is the mechanism R1 asked for without naming; uploads, which is where R9's map image actually lands - the plan described fetching it and never said where it goes; activity.log, which both R2 and R18 owe an audit trail to; the three ctx.teams calls, since Teams is more than the provider; ctx.posts behind the announce leg and post hook; and the three send paths section 10's catalogue implies. Also records section 6.8, a constraint on phase 10 and on purge: a trigger, a rule and an audience outlive the module that declared them. engagement_rules.trigger_id is a plain VARCHAR with no foreign key and no cascade, so a module can be removed and reinstalled without destroying an operator's rules - and a rule whose trigger or audience is unregistered shows DORMANT, never an error and never auto-deleted. An audience resolving to the empty set is not the same answer as resolved to nobody and must not be rendered as if it were. The failure that prevents is exact: an id that stops resolving must never silently become a send to a different set of people. And the two client lists in full, since section 7 said UI kit discipline without saying what is in it; plus one confirmation for R10, that /api/v1/public/modules returns only started modules with no state and no failure reason, so a Rust module that failed to boot makes the app render a site without those screens rather than one advertising screens that 503. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 6392f39512 |
docs(modules): R18 discovery is a recursive walk, and it stops at oxide/config
Configuration is not one flat oxide/config/<Plugin>.json per plugin. Plugins nest - oxide/config/<Mod>/whatever.json and deeper - and one plugin may own several files. So discovery is a recursive walk and the UI groups by plugin rather than assuming one file each. Four things follow, and the first is a boundary rather than a detail. oxide/data/ is NOT the settings surface and must not be walked into. DataFileSystem writes there and that is live state, not configuration. The base set makes the point by itself: Kits keeps Kits/kits_data.json and Kits/player_data.json, ZoneManager keeps ZoneManager/zone_data.json, and Clans keeps clan_data.json with a legacy clans_data.json beside it - which is also a reminder that these names are not stable. Editing those from a web form edits players' kit cooldowns and the live zone definitions, a running plugin overwrites the change on its next save, and oxide.reload does not make most plugins safely re-read them. Different problem, different answer, deliberately out of scope. The reload target cannot be inferred from the path. oxide/config/Foo/bar.json may belong to plugin Foo or to something else; the folder name is convention, not contract. So the target is an explicit field with the folder name as its default guess. Infer it silently and the failure is the nastiest kind available here: we reload the wrong plugin, observe OnPluginLoaded for IT, and report success while the plugin that was actually edited never re-read anything. A relative path from a web form is a path-traversal surface. Canonicalise the resolved path, assert it is under the config root, reject absolute paths, reject symlinks resolving outside. Before this amendment the feature addressed files by plugin name; addressing them by path is exactly the change that introduces the bug class. And bound it: depth limit, file-count limit, per-file size cap - a pathological tree must not be enumerated and a multi-megabyte JSON must not be loaded into a form. Because one plugin can own several files, the backup and rollback operate on the whole set a save touches rather than one file at a time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 82f2b0d18d |
docs(modules): R18 edits plugin configuration from the site, with a generated form and an auto-reload
An admin edits any loaded plugin's configuration from the website and it reloads
automatically. Base tier generates a form from the config VALUES themselves -
boolean to toggle, number to numeric field, string to text, array to list,
nested object to group - so it works for whatever plugins happen to be
installed, including ones added after we ship. Advanced tier is raw JSON.
Same posture as R2, the site as authority over the game host, but a different
SHAPE: R2 is continuously reconciled state pushed on connect, this is
request/reply on demand. It must not be built on the permission mirror.
The mechanics, all verified: configs at oxide/config/<Plugin>.json, oxide.reload
rereads one, and OnPluginLoaded / OnPluginUnloaded are real hooks in the Server
category - so whether a reload actually SUCCEEDED is observable rather than
assumed. That is what makes the feature safe.
The trap that would silently corrupt every float: JavaScript cannot tell 1 from
1.0, and Oxide configs deserialize into typed C# classes. JSON.parse of
{"Rate":1.0} yields the number 1 and JSON.stringify writes it back as 1, so a
naive read-modify-write rewrites every whole-numbered float as an integer, on
fields nobody touched. Newtonsoft may coerce it or may throw, and a throw at
load means the plugin does not come back. So never parse the whole document,
mutate and re-serialize - edit textually, or use a parser that preserves number
literals. The fields at risk are exactly the ones a Rust server tunes: gather
rates, multipliers, scales.
Five more limits of inferring a schema from values are recorded, since the
feature's whole promise is that it works without knowing the plugin: empty
arrays and null carry no type; enum-like strings are indistinguishable from free
text; there are no descriptions, minimums or maximums, so the key name is the
entire label; nested objects need recursion with a depth limit and a raw-JSON
fallback; and the file after a reload may not be what we wrote, because Oxide
merges missing defaults and saves.
Safety needs more than usual here, because a bad config does not fail the write,
it fails the next LOAD and the plugin stays down - and R6/R17 make four plugins
required, so a broken ZoneManager config takes event participation with it. The
write path is: read with a version and require it back on write so a concurrent
on-disk edit conflicts rather than being clobbered; validate it parses; back up,
write, reload; then watch for OnPluginLoaded within a window and, if it does not
arrive, restore the backup and reload again AUTOMATICALLY. That rollback is the
feature's real content - without it this is a web form that can take the shard's
plugins down one typo at a time.
Two more obligations. Plugin configs routinely hold API keys and Discord
webhooks, so a config reader hands those to anyone who can open the page: mask
values whose key matches key/token/secret/password/webhook and treat them
write-only, as the platform already treats the uo-link token. And gate it on its
own site permission with an audit trail of who changed which key from what to
what and whether the reload succeeded - it is an admin writing to the game
host's filesystem, the most powerful thing the site can do to a server.
One distinction kept explicit: editing a config FILE is not a lease. A lease
borrows a convar for a while and the game restores it on a deadline; this writes
a file and is permanent until someone changes it back. They look similar from a
web form and an event should never reach for this one.
Lands as phase 7b, beside permissions, since it shares the admin surface and the
gating.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
|
|||
| 1138fd36dd |
docs(modules): R17 makes ZoneManager a base plugin, and it settles two loose ends
ZoneManager (k1lly0u, 3.1.14, MIT, ~218k downloads) is a fourth REQUIRED plugin rather than an optional one. It is what makes an event able to answer where a player is, and reading its source changed two things this plan had been vague about while promoting one action out of the optional tier. Its API is private methods reached through Oxide's reflection Call() - no HookMethod, no API_ prefix. That makes THREE conventions among the four base plugins: Kits declares HookMethod, BetterChat uses API_ prefixed methods, and ZoneManager uses plain private methods resolved by name. All are reached the same way from our side, but only the first is greppable as a declared API, which is worth knowing before someone goes hunting for one that is not there. Participation stops being the hard part. EVENTS.md section H rates participation the hard part for UO and substantially easier for Rust because hooks carry attacker and victim. ZoneManager makes it exact rather than merely easier: OnEnterZone and OnExitZone are presence transitions delivered as events, so the participation ledger is fed from what happened rather than reconstructed from a sweep. That is where an action's participants envelope member gets its content. Advance conditions become expressible. A phase that waits until ten players are at the monument is a real gate that reads zone membership, rather than distance arithmetic against a point recomputed on a timer. And rust.zone.open moves from the optional tier into the base catalogue with an honest reversible: ledger. 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 chapter 4 demands - we still keep our own map from core's resource reference to the zone id, but we are borrowing a concept rather than inventing one. Erasing a zone that is gone is a success, which is what revert needs. One trap recorded, chapter 4's rule meeting a chatty hook: OnEnterZone and OnExitZone fire on the game thread and a large zone on a busy server produces a great many. The emit path already enqueues and returns so the game cannot stall, but the bridge should subscribe SELECTIVELY rather than forwarding every transition in every zone - a zone no event cares about should cost nothing on the wire. Decide the filter at phase 12 with the hooks in front of you, and measure it: a sweep over GetPlayerZoneIDsNoAlloc is cheap and a flood of wire traffic is not. Also adds rust.options.zones, makes the rust.zone.minutes budget dimension real, and takes phase 0's install step to four curls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 601ee4e05f |
docs(modules): the event catalogue and the engagement catalogue
Phases 12-13 described the event MECHANISM and never the CATALOGUE - one budget and one lease as a proof of life, which is a skeleton rather than a product. Same omission for engagement: R7 settled that the set ships and nothing said what goes in it. Two new sections fix both. EVENTS.md section H is a Rust/Oxide compatibility section that already sketched the event half, and it should have been read before the phase list was written. Its thesis is the one to design around: the lease is the primitive that travels, not the spawn. Double gather rate for the weekend is the canonical Rust community event and it is exactly lease-with-expiry. It also rates Rust the EASIER case than UO, because Oxide's convars are live by default where ServUO's are mostly cached at boot - an argument for expecting them to work, never a substitute for verifying each key live. Section 9 declares four budget dimensions, five option sources, seven leases and four actions, taking section H's ids rather than inventing a parallel set. Two things in it are load-bearing. Caps are PER RUN, and R8 makes that matter: run.scope is part of a run's unique key, so one definition fanning out to six servers is six separate budgets rather than one shared pool. An operator setting a cap of 30 is setting it per server. And rust.group.membership is the lease section H names that R16 did not - the pair is the whole reward design. A permanent earned entitlement is an ACTION with reversible: ledger (R16). A time-limited group is genuinely core.lease, held with a deadline the game enforces on its own. Same permission mirror, two shapes, and choosing wrong is the mistake: a weekend VIP implemented as a grant is a VIP for ever if the website goes away. Section 10 is the engagement catalogue - eleven triggers with their ceilings, three audiences, and the seed grouping. The ceiling lattice is containment and not size, so every ceiling is chosen against that rather than against a ladder. rust.base.destroyed is the one that matters most and is most likely to be got wrong. The offline raid alert is the single most-wanted notification in Rust and its ceiling is OWNER - the player whose base it was. Ceilinged staff it is useless to the person who needs it; ceilinged everyone it broadcasts base locations to the server. Exactly the case the lattice exists for. Three hooks carry data that must never widen: CanUserLogin and OnUserApproved carry IP addresses, OnPlayerReported carries player reports. README.md section 5 already flags these as admin-channel-only on the live feed and the same judgement binds their triggers. One design note flagged rather than decided: PopupNotifications gives the module an IN-GAME alert surface, which is not one of core's channels. An in-game popup is the module publishing to its own surface off its own trigger, not a fourth channel core learns about. A raid alert that reaches a phone and pops on screen next login is two mechanisms and only one of them is core's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 43bf73bae5 |
docs(modules): R15 the optional tier with BetterChat, R16 rewards grant the right to redeem
R16 changes a design rather than adding to it. The reward action no longer calls GiveKit; it grants the permission that GATES a kit, and the player redeems it themselves in game. Kits already has exactly this model built in - every kit carries a RequiredPermission, GiveKit's own path checks it, the in-game menu renders an ungated kit as locked rather than hiding it, and GetKitInfo returns the permission so the module can read which kits are gated. What that removes is most of the hard part. The offline-grant problem disappears. GiveKit needed a connected BasePlayer, so an event firing at 2am rewarded only whoever was online; an entitlement waits. That CLOSES the open question section 3 carried - no pending-grant queue, no second at-most-once store. It is the same machinery as R2 rather than a second mechanism: a reward becomes a permission grant authored by the site and mirrored into Oxide, which phase 7 already builds. One permission authority, one drift story, one audit trail. reversible: 'ledger' becomes honest where a direct grant could only ever be 'none'. revert revokes the permission, removing one that is not there is a success, and it is idempotent by construction. A player who redeemed before the revert keeps the items, and that is correct: the ledgered resource is the GRANT, so reverting withdraws the entitlement rather than the consumption. cost() counts grants and is exactly knowable before dispatch, which removes the whole declare-the-maximum-because-you-cannot-know class of problem from chapter 5 section 4. And the idempotency key largely stops mattering: chapter 5 draws the line itself - a key is for a write whose repetition would be a second EFFECT, and a permission grant is a SET. The earlier section arguing a kit grant can only be reversible: none is rewritten rather than deleted, with the correction stated: it was right about a direct grant, and the reusable part is that the action had been declared around the wrong noun. What the event makes is not loot, it is an entitlement. One design note carried into the option source: a kit with an EMPTY RequiredPermission is open to everybody, so granting a permission for it rewards nobody. The dropdown must surface which kits are gated and refuse or warn on one that is not. R15 opens an optional-integration tier, with BetterChat (LaserHydra, 5.2.15, MIT, Universal) as the first member, for leaderboard-earned chat titles. Its integration point is a PULL - API_RegisterThirdPartyTitle registers a callback BetterChat invokes per player - so a title is a pure function of state we already hold, with nothing written into it and no drift to reconcile. The trap is chapter 4's rule applied to somebody else's callback: that getter runs synchronously on the chat path and must be a cheap in-memory lookup, never a socket call. Its API_AddGroup and API_SetGroupField pair naturally with R2's site-authored groups, but that direction is a push and would need R2's drift posture, so it is a phase 17 decision rather than a given. Section 3 is now empty. Clans-in-the-base-set was confirmed complementary, and the offline-grant question was dissolved rather than answered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 5dcb8088ad |
docs(modules): R7-R14 close the audit, and the phase list roughly doubles
Every question the contract audit raised was answered the same day. Eight new decisions of record, and the schedule goes from twelve phases to twenty. R7 the notifications and engagement set ships in v1 - streams, triggers, audiences, seeds, announce leg, post hook - as ONE phase because they are a matched set in the template rather than three independent gaps. The three rules it lives on: ceiling is required and is not a ladder (a staff ceiling does not permit owner, because one person for a cheat-detection event is the player it was detected on); subjectKey must name a declared variable or every subject shares undefined; and a rule group is offered once, so a rule appended to an existing group reaches fresh installs only. R8 multi-server from the start - the dry run's finding 2 taken at face value. R12 per-wipe detail plus all-time rollups, with the truncation as a runtime route and never a schema statement, since the fragment replays every boot. R14 /rust on all three tiers, chosen deliberately because prefixes share one namespace with core's and the collision probe cannot see core's root-mounted endpoints. R13 two extension slots. R11 a small read-only slash command set, with ephemerality fixed at the deferral so a refusal must defer ephemeral. R9 is the one that split in two. The map IMAGE is static content on the game host regenerated only on a wipe - the ch.3 2b case, so request/reply, one in flight, two stages, its own derivation version, and no import on boot - while everything moving on it is live state down the ordinary read path. Every layer is an operator switch, and that is a security boundary rather than a preference: public player positions in Rust locate players and let anyone infer base positions. Default is monuments and world events public, players and bases admin-only. This is the ONLY asset-bridge work in scope; item icons and the 2,590 skin ids stay out of v1. R10 the Android app is in this workstream, deciding its screens from /api/v1/public/modules capabilities, with each leg trailing the website surface it consumes by one phase. Records the two endpoints that are not this and are easy to confuse with it: /api/v1/public/status is site mode plus a version block, and /api/health is an internal liveness probe. Section 7 now carries the phase each previously-unplanned element lands in, and the honest cost: twelve phases, from reading the chapters that describe the game bridge and treating the module as the thin part when the kit says in its first paragraph that the module is most of the work. |
|||
| a4544ba5f2 |
docs(modules): audit the plan against the whole contract, not just the game-facing chapters
The first draft of the phase list was built from chapters 3-5 and under-planned the website module by a wide margin. The template registers eight of the ten non-event registrations; the plan covered three. Unplanned and now recorded: registerExtension, registerNotificationStreams, registerEventTriggers, registerAudiences, registerEngagementSeeds, registerAnnounceLeg, registerPostHook, registerSlashCommands - with triggers, audiences and seeds being a matched set rather than three independent gaps. Also unplanned: purge.sql, mount prefix choice, the swagger fragment and its staleness check, the vite alias/shim mechanism the kit calls the highest-risk mechanical detail in the system, checkImports, and the entire packaging and release path - there was no packaging phase at all. On the client: declareModuleSlot, which the kit says is needed the moment a game has anything like a guild, so it belongs in the Teams phase; registerFeatureProvider; and the UI kit discipline that has caught this project twice already (PublicLayout needs shell, PageHeader silently drops unknown props). Beyond the module: the asset bridge, which Rust's item icons and 2,590 skin ids fit exactly; the .profile landing page, owed whenever repos are added and three just were; runicgateway.com, for which a second game is a headline change; and the Android app, which feature-detects capabilities and must render a site whose module it has never heard of. Section 8 carries the eight questions the plan cannot answer for itself. |
|||
| 839e5d05a8 |
docs(modules): R6 names the base mod set, and reading Clans settles R5 the other way
The three repos exist and are named: Module-Rust, Rust-Link, Rust-Plugins. Notes that a repository name is not a module id - Module-uo ships id `uo`, this ships `rust`, and 2.1 requires id to equal the directory core loads it from. R6 - the required base set is Kits 4.4.9, Clans 0.2.10 and PopupNotifications 0.2.1, all k1lly0u, all MIT, each with a direct .cs download, so phase 0's install step is three curls rather than manual retrieval. Clans is listed as a Universal plugin, written against Covalence, which is why its API takes IPlayer rather than BasePlayer. Kits is a good dependency: 23 HookMethods, including GiveKit for the reward action, GetKitNames/GetAllKits for the authoring form's OPTION SOURCE so an operator picks a kit from the live server instead of typing an identifier, and per-player uses and cooldown for eligibility. It also raises OnKitRedeemed, which the bridge can report as an ordinary event whoever triggered it. Two traps in GiveKit, both found by reading it rather than reasoning about it. It returns null on a failure path - `if (!player) return null` - and null is Oxide's idiom for "no opinion", so the reflex of treating null as fine reports a reward as delivered when there was no player to deliver it to. Success is the literal true and a refusal is a message string, which drops straight into chapter 5's `error`. And GiveKit takes a BasePlayer, so there is no offline grant: an event rewarding participants at 2am rewards only whoever is online. Phase 8 chooses between accepting online-only and keeping a persisted pending-grant queue, which is a second at-most-once store and is not free. R5 is settled far more firmly, and the other way round from how it was argued. For Teams the Clans plugin is WORSE than first-party, not richer: it publishes fifteen HookMethods and every one is a mutation, with no read API at all - no GetClan, no GetClanMembers, no GetAllClans - and it raises exactly three hooks, none of them a membership transition. It cannot answer any of core's three provider questions from its published surface, while first-party clans answer all three. So first-party is PERMANENT for the provider, not a first step. What the plugin actually adds is alliances and clan/alliance chat - richer in features, not in roster data - which is what phase 9 surfaces beside the provider rather than under it. Reading its own data files is recorded as deliberately not taken: that is another plugin's private storage, not an API. Two questions left open in 3, neither blocking: whether Clans being in the base set while the provider reads first-party is the intended reading (an interpretation, not something stated), and the offline-grant choice, deferred to phase 8 on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 7d589ff5f2 |
docs(modules): R4 the installer grows a game dimension, R5 Teams come from first-party clans
R4 - Rust reaches an operator through the shipped installer behind --game servuo|rust, not a second binary and not a shared-core refactor. The shape of the work is set by where the coupling already is: service.rs and main.rs mention ServUO zero times, while install.rs, doctor.rs, overlay.rs and tier.rs carry nearly all of it, plus the bundle manifest where OverlayComponent and ServUoCompat name the game in the schema itself. So the game payload becomes a variant and ServUoCompat a per-game compat block - a schema change on the published bundles branch, which is the part to design before touching code. The Rust payload is much simpler than the UO one (no source tree, no patches, no patch tier - one .cs into oxide/plugins) and what it gains instead is an Oxide prerequisite check, which is doctor's shape rather than a new concept. R5 - Teams come from Rust's FIRST-PARTY clans; the uMod Clans plugin is the richer optional tier in phase 9. Three distinctions that are easy to collapse and expensive to get wrong: the seven first-party clan hooks are all "no return behavior", which is exactly what a read-only bridge wants; Rust's first-party TEAMS are a different system entirely (twelve mostly-vetoable hooks, the transient squad rather than the persistent organisation) and are not what core's Team provider should be fed; and the uMod Clans plugin's API is not in our mirror at all, since HOOKS.md is the game's 477 hooks and a plugin's API is its own documentation. The gap R5 has to design around: the seven hooks carry created, disbanded, added, kicked and left, but no promote or leader-changed event. So getTeamLeaders reads leadership off LocalClan at snapshot time, which makes phase 6 partly snapshot-driven where the dry run predicted fully event-driven. Recorded as a correction to that document rather than silently. Also records the start.bat fix on the rig: it now updates D:\rust rather than C:\rustserver, the original is kept at start.bat.bak, and the appmanifest already present at the same buildid means the first corrected run is a delta rather than a 5.9 GB re-download. Phases are now 0-11; the installer is phase 10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| a5881d5a55 |
docs(modules): the module-rust plan, its decisions of record and its rig
The dry run designed this module on paper and deliberately did not build it. This is the document that builds it: eleven phases, three new repos, and the three decisions the org lead settled on 2026-09-15. R1 identity is an in-game link code for v1. There is still no registerAuthProvider at MODULE_API_VERSION 1.10.0, so "Sign in with Steam" is not reachable from a module. What changed since the dry run is the stakes, not the options: the dry run rated this survivable because the module only read, and R2/R3 make the site the author of who may do what and the thing that hands out loot. A weak link is now a privilege-escalation path. R2 site-authored permissions mirror into Oxide's own permission store, so every third-party plugin honours them with no adapter and a wipe stops being a data-loss event for permissions. This is a direction the Integration Kit has no chapter for - not the read path, not a ledgered one-shot, but continuously reconciled state where the website is authoritative. Its nearest relative is the Team provider inverted. Whether that deserves a sixth chapter is phase 10's question. R3 the Kits reward action always registers and refuses with a reason in `error`, rather than vanishing from the form or refusing to boot. Phase 8 must declare reversible: 'none' honestly - there is no way to un-grant loot a player has spent - and count cost() per kit actually granted. The rig is D:\rust, which has been booted and carries a matched Oxide 2.0.7585. Two traps recorded: its start.bat updates C:\rustserver and launches D:\rust, so the server that boots has never been updated by its own script; and C:\oxide_files is a 2025-04-23 Oxide whose bundled Assembly-CSharp.dll would downgrade a real install. One question left open: which plugins besides Kits are in the required base set. oxide/plugins/ is empty, so all of it is a fresh install either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |