Commit Graph

3 Commits

Author SHA1 Message Date
adb9df6a2e feat(bridge): Rust's own world events on protocol 14 (PLAN_REDESIGNS §14.3.1)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m55s
Stage 1 of the §14.3 integration plan: the patrol helicopter, Bradley, the
cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the
satellite and the Deep Sea, with no plugin (D349).

- `world.event`, one frame kind whose `event` names which of 24 types
  (PROTOCOL.md §20.5). The type is in `event`, never `type`: `type` is the
  envelope's, and the rig's sidecar dropped every world event as untyped when
  the plan's example put it there. Nothing is said before the world loads or
  while the server quits. Positions are x/z; the site works out the grid.
- Credit (D372, D379): a damage table for the patrol helicopter, the Chinook
  and Bradley; `heli.downed`, `chinook.downed` and `bradley.destroyed` carry
  every candidate (blow, last, most, damagers) and the plugin credits nobody.
  D174's `CreditVehicle` and the tally's apcKills/heliKills are gone.
- Locked crates told positively (oil rigs, cargo, ghost ship, a Chinook drop
  joined at the crate's landing); anything else says nothing. A Bradley's
  `where` comes from the game's spawners, not `RoadSpawned` (which defaults to
  true). `airdrop.called` comes from the cargo plane's spawn: `supply.call`
  raised neither OnAirdrop nor OnCargoPlaneSignaled.
- The tally gains cratesHacked and dropsLooted (Safecracker, Scavenger, D356).
- `world.call` (D354, D367, D378): one of a schedule's TriggeredEvents at
  random, without Trigger (which would reset its clock); satellite.random for
  the satellite. A position for the airdrop (InitDropPosition) and the patrol
  helicopter (SetInitialDestination) only, both set before the spawn, as the
  stage-1 spike measured (0-1 m and 10-54 m). Kept; the key is remembered seven
  days in world-calls.json; EventsMaxWorldCalls (default and ceiling 10) per run.
- Hello/status `console.plugins` (D363): the bridge runs its own rg.probe
  through ConsoleSystem.Run from a timer. Carbon 2.0.262 refuses plugin
  commands only inside a console command (or a NextTick one queued), which is
  how §14.2's spike tested; from a timer, and from the link reader's NextTick,
  it finds them. Reads true on both rigs.
- checkPlugin.js: the probe must name a [ConsoleCommand] the plugin declares.

Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon
2.0.262); heli.arrived, cargo.arrived and airdrop.called read back from each
rig's sidecar store. Rigs restored. 18 check tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 00:39:00 -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
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