Commit Graph

27 Commits

Author SHA1 Message Date
8b4b65249f feat(bridge): read the whole permission store (perm.inventory), and sync group parents
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
The permission manager (PLAN_REDESIGNS section 1, D160): the site owns every
permission and group on the server, so the bridge now reads all of it.

- perm.inventory: every registered permission with the plugin that
  registered it (PermissionExists(name, plugin), public on both
  frameworks), every group with title, rank, parent and permissions,
  every holder's direct permissions and groups (not default), and the
  pairs event leases hold. Built a slice per tick, sent in pages under
  the 1 MiB line cap, paged from one snapshot. rg.inventory logs it.
- perm.sync sets an existing group's title and rank and every group's
  parent; an absent title or rank means "leave it" (the adopt policy's
  hold). The managed list and the foreign scan are gone: the site reads
  the store and decides.

Walked on both rigs: Oxide 85 permissions all owned, Carbon 104 with its
30 adminmodule.* unowned; ZoneManager owns ignoreflag.nokits.

Refs RunicGateway/Module-Rust#21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-28 06:58:39 -05:00
586ad7ceba fix(bridge): protocol 13 step 2 — expiry, gathering, raids, plugin loads, and a ZoneManager helper (F1 F3 F4 F8 F12 F13)
The bridge's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288).

- F13: `world.expired` carries the entity's kind as `what`. It used to write
  it over the frame's own `kind`, so every expiry left filed as "zone".
- F1 (D159): `gathered` also counts OnDispenserBonus (the final-hit bonus),
  OnCollectiblePickup (hemp, mushrooms, stones off the ground) and
  OnGrowableGathered (farmed plants); all three are void and in ExpectedHooks.
- F3: taking down a building block you own, or one on a cupboard you are
  authorised on, no longer counts toward `structures`. The frame still goes.
- F4: every remaining UserIDString is SteamIdOf(userID), so a player another
  plugin spawned is never `steamId: null`.
- F8 (D184): `plugin.loaded` / `plugin.unloaded`, once the world is ready,
  with the permissions that plugin added or removed (a diff of the registered
  list, taken a tick after the hook).
- F12 (D181, D182): RunicGatewayZones.cs, a D168 helper shipped beside the
  bridge, Harmony-postfixes ZoneManager's Zone.InitializeZone and, a tick
  later, enters every connected player ZoneManager's own IsPositionInZone
  places inside, through ZoneManager's own OnPlayerEnterZone. Without it (or
  when it cannot find what it patches) the bridge scores its zones by
  IsPositionInZone. Hello reports `zoneHelper: { state, version?, reason? }`.
- release.yml stages every helper beside the bridge and lists each .cs with
  its sha256 in manifest.files; the installer and the egg place them all.

Not yet compiled: this is walked on both rigs before it merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:36:04 -05:00
616f4fbca8 fix(bridge): protocol 13 — a configuration reload that works on Carbon (W1-W5)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
The step-1 rig walk (PLAYER_WALK configuration 2, 2b, 4 on both rigs) found:

- W3: Carbon's Oxide-compatible ReloadPlugin is not a reload. One call unloaded
  Kits and never compiled it again; the next did nothing. Carbon now reloads
  with `c.reload` on the server console (ConsoleSystem.Run), as an operator
  would at the panel. Oxide keeps ReloadPlugin, falling back to `oxide.reload`.
- W4: Carbon's log is carbon/logs/Carbon.Core.log, with no *.txt in the
  directory at all, so the log evidence never fired on Carbon. Chosen by name.
- W5: Carbon loads a plugin whose LoadConfig threw and raises Loaded in the
  same instant. OnPluginLoaded is now noted, and the job settles on the first
  log read a poll after it. Carbon's "Failed ILoadConfig" is on the word list.
- W2: Oxide's "Could not initialize plugin 'Kits v4.4.9' (JsonReaderException:
  ...)" is on the word list, so the outcome quotes the exception.
- W1: Oxide's idle compiler shutdown drops a queued reload with "Timed out
  waiting for plugin to be compiled: X". The reload is asked for once more;
  a second give-up settles at once by what is loaded.

rg.config prints the log file it follows (log=).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 20:21:06 -05:00
042727e5fe fix(bridge): protocol 13 opens — UTF-8 from the site, a reload that fails on evidence (F15, F9, F10)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 20s
F15: the read loop cast each byte to a char, decoding the site's UTF-8 as
Latin-1, so every non-ASCII announcement, title, kit name and saved config
file arrived mangled. Lines are now split as bytes and decoded whole, which
keeps a character that straddles two reads intact.

F9/F10 (D177, D178): config.write answers at once with pending, writeId and
ceilingMs, and the outcome follows as a config.outcome event. A pending
reload reads the framework log each second and rolls back the moment the
target fails to compile or initialise; a slow compile is waited for, under
a 30 s ceiling per phase. At the ceiling the plugin looks at what is loaded
before restoring anything, and a rollback waits for the restore's own
reload so the outcome says whether the plugin is back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 17:04:41 -05:00
3e928bb2f6 fix(bridge): a backoff that holds against a sidecar that refuses (D155)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m38s
The reconnect backoff waited on `Wake`, which `Emit` signals on every
frame, so it lasted only until the next hook fired. That was invisible
while a failed connect was silent; against a sidecar that now refuses a
plugin naming another server (D155) the phase 18 walk measured some 250
redials a second, each printing two console lines and rebuilding the
boards. It now waits on `_stopping`, the handle that already exists so a
CONNECT is not abandoned by `Emit`; Unload still ends the wait at once.

The backoff also resets only after a link has held for 10 s
(StableLinkMs). A connection closed on arrival used to reset it to 500 ms
on every accept, so a refusal loop never backed off at all.

Walked: a plugin aimed at another server's sidecar is refused every 5 s
(the ceiling), not continuously; a stable link whose sidecar restarts is
back in 0.49 s, as before; no Unload stall on reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 01:19:23 -05:00
04491e70af fix(bridge): read the config before the link thread dials (D154)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m29s
Init() started LinkLoop before Loaded() read the config, so the thread's
first dial saw _config == null and used the 7799 fallback on both Oxide and
Carbon. One server per host made that its own sidecar by coincidence. With
two on a host (the installer's named instances, phase 18), the second
server dialled the FIRST server's sidecar, sat in its accept backlog, and
the moment the first server's plugin reloaded that sidecar took the second
server's connection: the website showed server "alpha" with beta's
hostname and wipe.

LoadConfigValues() now runs in Init() before the thread starts; Loaded()
keeps its log line and the lease/world/reward loads.

Walked on the phase 18 rig: three instances (two Oxide, one Carbon 7801),
each now dials its own port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 01:07:41 -05:00
497b7c4f64 feat(bridge): server id from the environment, and the first release workflow (phase 18)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m35s
Module-rust phase 18, step 3 of docs/modules/rust/PLAN.md §34.2.7.

The environment seam (D150): when the plugin writes its default config and
RUSTLINK_SERVER_ID holds a valid id (the site's ^[a-z0-9][a-z0-9-]{0,63}$),
the file's ServerId is that value. It is read nowhere else: after that write
the file is canonical and the site's lock applies. An invalid value is logged
and ignored. This is how the egg names a server; not a message change, so the
protocol stays at 12.

The release (D145, §34.2.1): release.yml reuses servuo-plugins' release engine
unchanged (tag-only, secrets preflighted, orphan tags recovered and swept, the
installer's bundle.yml dispatched at the end). The adapter re-runs
checkPlugin.js and packages runicgateway-rust-plugin-<version>.tar.gz with a
framework-neutral layout: RunicGateway.cs, its [Info] version stamped to the
release, and a manifest.json folding in overlay.toml.

overlay.toml gains min_carbon_version = "2.0.259", the build R19 was proven on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 22:54:38 -05:00
5ff88610a5 fix(bridge): keep the chat titles across a reload (D144)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 22s
The walk found that a hot reload of this plugin emptied every title, and
the site never re-sent them: it re-sends on a new boot id or wipe id, and a
reload is neither, because the boot id names the server process. The set
is now written to titles.json with the wipe it was pushed on, and read at
OnServerInitialized only when that wipe is still the current one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 18:23:28 -05:00
8c6146259e feat(bridge): protocol 12 — BetterChat and popups (phase 17)
- titles.set: a whole set of chat titles, swapped in by reference and read
  by a BetterChat third-party title getter (one lookup, no socket). The
  getter is registered at load and again whenever BetterChat loads.
- perm.sync: a group may carry its BetterChat style. Each field is written
  through API_SetGroupField only when the game still holds what the site
  last pushed (or BetterChat's default); anything else is reported as
  drift. One `chat group set` per touched group saves it, because the API
  never does (D143). A retired style removes the group with `chat group
  remove` (D139). The report gains `chat`.
- chat.say: delivery chat|popup and an optional voice format. A popup with
  PopupNotifications absent is refused `popup-unavailable`. Every line said
  is logged, since the game logs neither a broadcast nor a popup.
- server.hello/status carry `integrations`; `rg.titles [steamId]` prints
  what BetterChat would render for a player.
- overlay.toml: protocol 12; PopupNotifications leaves requires_plugins
  (D141).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 17:48:05 -05:00
ef78da8ce6 fix(bridge): rg.map re-reads the picture rather than printing the last answer
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m35s
The walk emptied the Rust+ cache and rg.map still said `companion`: it
printed the picture the last map.info had seen. It now re-reads while the
world is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 06:31:42 -05:00
de5f0928bd fix(bridge): map.live names a player by userID, not UserIDString
The phase 14 walk read `steamId: null` for every sleeper made on the
server: UserIDString is a cached string the game fills for a player it has
seen connect. An id the map cannot name is an own dot and a clan mate the
site can never match, so the map reads the id from userID itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 01:05:51 -05:00
210c35f904 feat(bridge): protocol 11 — the map (phase 14)
Four verbs (PLAN.md §30.2), all outside EventsEnabled because none of them
changes the world:

- map.info: the map key, the picture's source (the Rust+ cache, a render
  kept on disk, or none), its size, SHA-256 and slice count, the game's own
  grid, and the monuments the in-game map shows. The cache is read by
  reflection by name only, never enumerated (it holds the Rust+ secret).
  The hash is computed on the thread pool.
- map.fetch: one 512 KiB base64 slice, encoded on the thread pool, refused
  `stale` when the key or hash moved.
- map.render: the admin fallback (D109). Answers `accepted` at once and
  renders on a later frame, keeping the result in data/RunicGateway/map.jpg
  beside its key so a restart within the map does not render again.
- map.live: every layer at once (world events, event-owned zones/crates/NPCs,
  players and sleepers, TCs and player vending machines as positions only).
  The sets are kept by OnEntitySpawned/OnEntityKill and seeded by one sweep
  at OnServerInitialized. MapMaxSleepers/MapMaxBases bound the two layers.

Also fixes Grid() (D119): it used a 146.3 m cell, but the game fits whole
cells to the world (20 x 150 m on a 3000 map), so rows could be one off.
It now asks MapHelper.PositionToString, the in-game map's own labeller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 00:33:51 -05:00
3d9998ce64 feat(bridge): protocol 10 — the tally, kit credits and chat (phase 13b)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 10s
- tally.open / .snapshot / .close: a run's participation tally, persisted
  in tallies.json. Presence accrues every 5 s from ZoneManager's own
  membership (or the whole server); kills are the last hit, gated by
  killsOf and the killer being inside the area (D98); the score is
  seconds, kills, or minutes + killWeight x kills (D99). Only a zone this
  run opened counts (D100). Gated by EventsEnabled (D107); forgotten
  seven days after opening (D108).
- kits.list: the kit source, from Kits' GetKitNames/GetKitInfo.
- perm.sync `credits`: one extra use of a kit per reward (D103), held in
  credits.json and spent LAST from OnKitRedeemed, because Kits'
  SetPlayerKitUses does nothing for a player with no usage record.
  Withdrawn when unredeemed; reset when Kits wipes its use counts.
- chat.say: one line to the server, remembered by key for 10 minutes.
- EventsMaxRecipients / EventsMaxTallies / EventsMaxChat bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 07:00:44 -05:00
0a7ad570f7 fix(bridge): refuse world commands until the world has loaded
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m29s
The link starts in Init, before the save loads. The phase 13a walk restarted
the rig mid-run: the website saw the new boot id at once, asked world.owned
into an empty world, and the plugin — finding no entity behind any net id —
pruned three live crates from its own registry. They stayed in the world,
owned by nothing, and a revert would have refused them.

world.owned, world.revert, world.monuments and every world write now answer
`not-ready` until OnServerInitialized, zones are not re-created before it,
and server.hello carries `worldReady` so the website can hold its reconcile
until the world it would be asking about exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 02:16:03 -05:00
bbaa7f346e feat(bridge): protocol 9 — the world verbs events place and give back (phase 13a)
world.monuments, world.zone, world.place, world.revert and world.owned
(PLAN.md §28, D86-D96):

- Zones through ZoneManager's temporary zones, owned by the bridge, with a
  required duration the plugin enforces itself (D96) and re-created from the
  bridge's own registry on every load and every ZoneManager reload (D92),
  because ZoneManager erases a plugin's zones when that plugin unloads.
- Crates and NPCs from a plugin-held allowlist (D88) at a monument by kind
  and instance, or at raw coordinates (D87, D93), bounded 25/20 per step (D95).
- A registry file keyed by the website's idempotency key, since this bridge
  has no at-most-once store: a repeat answers with the first call's ids.
- world.owned LOOKS by net id: crates survive a restart, NPCs do not, so a
  boot id proves nothing. A wipe drops the registry whole.
- EventsEnabled gates every world write; revert, owned and monuments never
  (D94). Revert refuses only a thing that exists and is not this run's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 01:22:06 -05:00
c106ba826b fix(bridge): grant with a null owner and read every grant back (D85)
EnsureGrant and EnsureGroupPermission passed `this` as the owner. Given an
owner, Oxide first asks PermissionExists(perm, owner) — whether THAT plugin
registered the name — and returns silently when it did not, so every
site-authored grant of another plugin's permission was a no-op on Oxide that
the report counted as applied (PLAN.md §27.6).

Both now pass null and read the store back. A grant the store does not hold
afterwards is reported in the new `notLanded` list instead of counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 00:37:45 -05:00
594fc09141 feat(bridge): protocol 8 — the leases (PLAN.md §27)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 20s
lease.list / lease.apply / lease.release and a lease.expired event. The plugin holds the allowlist (decay.scale, 18 populations, the two minimum spawn scalars, and group permissions), the seven-day ceiling (refused, never clamped) and the deadline, which it enforces on its own. Release is compare-and-set. Holds persist in the data directory with their boot: a restart ends a convar hold, while a group-permission hold survives it and is re-armed.

EventsEnabled (default off) gates apply only, and a write from the site's config editor now takes effect at once. The permission mirror defers to a leased pair: it is reported as leased, never foreign.

Grants pass a null owner: with an owner, Oxide silently refuses any permission another plugin registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 21:14:00 -05:00
77e9863e9b fix(bridge): the raid frame's attackerId comes from userID
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s
UserIDString is filled in only when a player connects, loads as a
sleeper, or is an engine bot. A player another plugin spawned has none,
and the phase-10 walk's raid arrived with attackerId null. The website
skips the raid alert when the attacker is authorised on the cupboard,
so a null would quietly defeat that exclusion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 13:37:05 -05:00
272e9e0f65 feat(bridge): clan.disbanded carries the roster it ended
The website tells a disbanded clan's members, but the clan board that
follows the event (and, after an outage, precedes the backlog) has
already removed that roster from its store. The game walks Members to
drop each membership and never empties the list, so it is still whole
when OnClanDisbanded fires. Part of protocol 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 05:55:52 -05:00
a3899f336f feat(bridge): protocol 7 — a raid frame that names who lives there
entity.destroyed now covers doors, external walls and gates, and the
tool cupboard itself as well as building blocks. A door was never a
BuildingBlock, so a door-only raid reached nobody. The frame gains
`structure`, the cupboard's `buildingId`, and `authorized`: each Steam
id on the cupboard with whether they are online now, bounded at 64.
Both fields are absent when there is no cupboard. The structures tally
still counts building blocks only, so the leaderboard column keeps its
meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 05:51:59 -05:00
568fc873b3 feat(bridge): protocol 6 — first-party clans, one board and five events
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 12s
The `clans` board carries every clan the game will list, with its roster
and roles, plus the facts the website needs to trust it: enabled,
backend, supported, truncated, and whether the optional uMod Clans
plugin is loaded. It is re-sent on connect and on the 60-second cadence,
and a few seconds after any membership change. The five events are
clan.created, clan.disbanded, clan.member.added, clan.member.left and
clan.member.kicked.

What the game's assemblies dictated:
- The game has no enumerate-all call. The clan leaderboard is the only
  listing, and the game clamps it to 100 clans. A board at that ceiling
  says `truncated`, as does one that would not fit in 768 KiB (the
  sidecar drops lines over 1 MiB).
- The local backend is synchronous behind ValueTask. A task that has not
  completed means another backend (Nexus), which is refused with a reason
  rather than waited on from the main thread.
- A clan id is not an identity, so every frame carries createdMs.
- OnClanMemberAdded hands over a bare id before the game's cache
  refreshes, so the clan is read back, with a fallback to the id alone.
- uMod Clans raises OnClanDisbanded(string, List<ulong>), which has the
  same name and arity as the game's hook, so the bridge types it exactly.

A member's LastSeen is never sent: it is presence (PLAN.md §23).
overlay.toml moves to protocol 6 and drops Clans from requires_plugins
(D47): Teams come from the game's own clans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 05:14:18 -05:00
48eeb56300 feat(bridge): protocol 5 — configuration from the site, and the reload that undoes itself
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 10s
R18. Three request/reply verbs — `config.list`, `config.read`, `config.write` —
and the first command on this bridge that writes to the game host's filesystem.

The walk is rooted at `Interface.Oxide.ConfigDirectory`, never a literal, so it
is right on Oxide, on Carbon and on a server whose operator moved it; the data
directory is never walked, because it holds live state and both frameworks'
permission stores, which is protocol 4's mirror one directory over.

A write is resolved, version-checked and parse-checked before the first byte
lands, then backed up, written and reloaded — and the reload is WATCHED. If
`OnPluginLoaded` does not arrive within four seconds the whole set is restored,
reloaded again, and reported with the tail of the newest log file. Without that
this is a web form that takes a required plugin off a production server one typo
at a time.

Four seconds is arithmetic, not taste: the worst path is two windows while a
caller holds the sidecar's ten-second RPC budget.

This plugin will not reload itself — the reload would close the link carrying
the answer — and the site composes the file's bytes, so nothing here has an
opinion about content beyond "it parses".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:53:45 -05:00
e041089080 feat(bridge): protocol 4 — the permission mirror, and the first command that writes
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 8s
R2, the game half. The website is the author of record for groups and grants;
this plugin makes the framework's own permission store match what it is sent.

**One verb, and the plugin does the diffing (D32).** `perm.sync` carries the
whole set the site authors for this server. The plugin compares it against the
live store and writes only what differs, then answers with a report: what it
applied, what was already right, the names it could not resolve, the memberships
waiting on a first connection, and every holder the site did not author.
`perm.catalogue` is the other half — a live read of what the loaded plugins have
registered, which is the option source the authoring form is built from.

**The first command whose work is unbounded, so it is drained rather than run.**
A community with two thousand linked players sends thousands of operations in one
frame, and applying them in the tick the frame arrives is a freeze an operator
will blame on the game. A sync compiles into a list of single-store operations
and drains two hundred at a time on a timer. That is §5's rule — nothing the far
side sends may cost the main thread an unbounded amount of work — applied to the
inbound half for the first time.

**The pre-check §12.2 rule 1 exists for.** `GrantUserPermission` returns void,
throws nothing and logs nothing for a name no plugin registered, so every grant
naming an unloaded plugin's permission would disappear without a trace. The plugin
checks `PermissionExists` first and reports the name as unresolved; it does NOT
register the name itself (D33), because that fabricates a permission the operator
never installed.

**`UserHasPermission` cannot answer the question the diff asks.** It is true for a
member of a group that carries the permission, so a direct grant would look
already applied the moment a group covered it — and a revoke already done.
`GetPermissionUsers` reads the user's own set, which is what a direct grant is.

Six permission hooks join the read path, all void, all in `ExpectedHooks`. They
are not the drift record — only the site's desired set can say whether a change is
foreign — they are a reason to reconcile sooner, and they are suppressed while
this plugin is writing, or every sync would announce itself as drift.

`rg.perms` reports the last report from the console, which is the fastest way to
tell "no plugin registered that name" from "the store has never seen that player".

Protocol bumped to 4 here and in overlay.toml, in the same change as the emitters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:27:08 -05:00
47692e8832 feat(bridge): protocol 3 — /link, /unlink, and the first command the site sends
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
R1's identity flow, in the shape the UO bridge already proved: the player asks
in game, the plugin mints a one-time code and hands it to them, and the website
confirms it back through the sidecar. The site is the author of record; this
plugin holds no link at all.

**`/link` is chat, not console, and the reply is private** (D21). Both frameworks
consume a `/` command rather than broadcasting it and `SendReply` addresses one
player, so neither the request nor the code reaches anybody else — a code read
off a stream is a code somebody else can spend.

**Pending codes live in memory and nowhere else** (D22, matching module-uo): six
characters from an alphabet with no O/0/I/1, five-minute TTL, a thirty-second
cooldown per player, one outstanding code each, and a purge timer because an
unconfirmed code is never looked up. A plugin reload drops them — phase 7b's
config editor will do that routinely — and the cost is typing `/link` again,
which is cheaper than a credential living in a second process.

**The code is deliberately NOT in `account.link.requested`.** That event exists so
an operator can see linking being used; the code travels through the player,
which is what makes typing it proof they are the one who asked.

`/unlink` emits rather than deletes, because there is nothing here to delete. It
is the escape hatch that makes the site's refusal to move an already-linked Steam
id safe: a player who linked the wrong account walks to a computer they are
already sitting at.

`link.confirm` consumes a code on the FIRST lookup whether or not it turns out to
be expired, so a code cannot be probed twice. `unknown` and `expired` stay
separate in the reply — an operator wants to know whether codes are being guessed
or going stale — and the website says the same sentence for both.

`checkPlugin.js` gains a check for the new surface, because a chat command binds
by reflection exactly like a hook and fails the same silent way. It asserts the
signature, the void return and the absence of duplicates, and — like the hook
parser before it — a test reads the real plugin so a regex that matches nothing
cannot pass. Verified by breaking it five ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 07:33:45 -05:00
551359c08e fix(bridge): resolve the wipe at OnServerInitialized, and bound the connect
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
Two defects the Carbon rig found, neither visible on a development loopback.

**The wipe id was null for every real session.** `Init` runs before the save is
loaded, so `SaveRestore.SaveCreatedTime` is not yet meaningful there and the id
resolved at load time stayed null for the life of the process — every frame
shipped without the field R12 splits history on. It was invisible because a
hot-reloaded plugin reads an already-loaded world and gets the right answer every
time; it took a server that BOOTED with the plugin installed, which is every real
one, to show `wipeId=none` beside a save sitting on disk. Resolved again at
`OnServerInitialized`, and lazily while still unknown.

**Unload blocked the main thread for two seconds against an unreachable sidecar.**
Observed as `hook 'Unload' took longer than 100ms [2002ms]` next to `link thread
did not stop cleanly` — phase 1's stall arriving by a different road. The link
thread sits in a blocking `TcpClient.Connect`, which has no timeout of its own
and cannot be woken; a host that REFUSES answers instantly, which is every
loopback test, and a host that DROPS does not answer at all. The connect is now
bounded, and waits on a stop handle of its own so a reload abandons it at once.
It cannot share `Wake`, which also means 'the queue has something in it' and is
signalled by every hook.

After: the same reload logs no slow-hook warning and no stranded thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:29:24 -05:00
0d9ec655f7 feat(bridge): protocol 2 — the read path, and the first CI this repo has had
Fifteen hooks: presence, deaths, chat, gathering, raided structures, bans,
reports, login attempts and the wipe. Every frame now carries an envelope —
`type`, `serverId` and `wipeId` — built in one place so nothing can emit a frame
without one.

Three rules the code enforces structurally rather than by intention:

  • A read-path hook never vetoes. Four of these are documented as "returning a
    non-null value overrides default behavior", so every hook is declared `void`
    and cannot answer. `CanUserLogin` is in the wave for what it observes.

  • A hook that can fire more than once a second per player is a counter.
    `OnDispenserGather` fires on every swing at a tree; it accumulates into a
    per-player tally flushed once a minute as one `player.tally` frame, as a
    delta rather than a running total.

  • `wipeId` is derived here, from the save's creation time, because this is the
    only component that can read it. PROTOCOL.md §8.2 reverses protocol 1 on
    that point deliberately.

`server.hello` becomes a board rather than a greeting, and `players.online`
joins it; both are re-sent on connect and every 60 seconds, which is what makes
a restarted sidecar repopulate itself without asking.

`rg.hooks` reports which hooks have actually fired. Hooks bind by name and arity
through reflection on both frameworks, so a rename by Facepunch and a name
Carbon's catalogue omits present identically — as silence. This is the standing
answer to both, and it outranks either catalogue because it is a measurement.

The repository had no `.gitea/workflows/` at all. `scripts/checkPlugin.js` asks
the three questions a compiler here cannot: every hook is in `ExpectedHooks`, so
`rg.hooks` can see it; every hook is `void`, unless answering is a decision
written down in `ANSWERS_DELIBERATELY`; and `ProtocolVersion` agrees with
`overlay.toml`, which is what stops a bundle that will not compose. The void
rule is inverted on purpose — a list of *vetoable* hooks would have to be
maintained against a catalogue in another repository, and the first one somebody
forgot to add is the one that would pass. Its own suite breaks it seven ways,
including the failure that would make the other six meaningless: a method parser
that silently matches nothing.

Proven on a live Oxide server: compiled, loaded, the envelope correct, both ban
hooks firing, and the boards repopulating a sidecar whose database had been
deleted 0.3 seconds earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:18:42 -05:00
b3711e6778 feat(bridge): the Oxide plugin — protocol 1
RunicGateway.cs dials out to a rust-link sidecar on loopback and speaks
newline-delimited JSON over it: server.hello on every connect, a pong to the
sidecar's heartbeat, and one correlated server.status.

The threading contract is the ServUO bridge's, unchanged, because the reason for
it is the same on both games:

* Emit is called from the main thread. It formats nothing, blocks on nothing and
  touches no socket -- it enqueues and returns, so a wedged or absent sidecar
  cannot stall the game. The queue is bounded, drop-oldest.
* One link thread owns the socket, which keeps event ordering intact.
* A reader thread marshals every inbound line to the main thread through
  Interface.Oxide.NextTick, and touches no Unity object, BasePlayer or ConVar.

Settings come from Oxide's own config (oxide/config/RunicGateway.json), so an
operator retunes the bridge the way they retune any other plugin -- and so it
lands inside the site-side config editor a later phase adds.

Four things the live rig corrected, none of which a unit test could have:

* A disconnect was silent in the game console. The teardown log sat in the
  catch, and a connection ending because the READER saw EOF leaves the writer to
  exit cleanly -- nothing throws, so nothing was logged. A log in a catch only
  covers the failures that throw, and an orderly peer shutdown is not one.
* Unload blocked the main thread for 1.9s (Oxide says so out loud), because the
  reconnect backoff was Thread.Sleep and Unload joins the link thread. Waiting on
  the AutoResetEvent that Unload already signals makes it immediate. The ServUO
  plugin has the same sleep and gets away with it only because ServUO does not
  hot-reload.
* Mono's SocketException.Message is NUL-padded on Windows -- around 200 \0 bytes
  in the middle of the sentence, from a fixed-size OS buffer. \0 is not
  whitespace, so Trim does not touch it and neither does a whitespace-only
  collapse; the flattener has to treat control characters as separators. It took
  od -c on the log to see at all.
* bootId regenerated on every PLUGIN load rather than every SERVER start. A
  fresh Guid at Init meant oxide.reload announced a brand new boot, and the
  website's reconcile design hangs off that value -- so every reload would have
  asked core to sweep its whole resource ledger for a world that never moved. It
  is now Process.StartTime: exact, identical on every read, and it changes when
  and only when the thing it names changes.

rg.link reports the link's own counters from the console or over RCON, which is
what separates 'the plugin is not loaded' from 'the plugin cannot reach the
sidecar' from 'the website cannot reach the sidecar'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 19:53:33 -05:00