Commit Graph

5 Commits

Author SHA1 Message Date
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