29 Commits

Author SHA1 Message Date
5c4eaf5a31 Merge pull request 'chore(release): cut edge over to main — protocol 14' (#32) from edge into main
All checks were successful
Release sidecar / release (push) Successful in -2m18s
Reviewed-on: #32
2026-10-22 10:23:04 +00:00
a61d7794d9 Merge pull request 'feat(sidecar): protocol 14 — the raid base spawn and spot check forwards (PLAN_REDESIGNS §11.4)' (#31) from feat/raidable-bases into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 10m45s
Reviewed-on: #31
2026-10-17 06:32:54 +00:00
b8e860d85e feat(sidecar): protocol 14 — the raid base spawn and spot check forwards (PLAN_REDESIGNS §11.4)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 7m7s
POST /raid/spawn stamps the body raid.spawn and POST /raid/check stamps it
raid.check, and each forwards it. The plugin keeps the key and the run's
base in its world registry, so world.revert takes it back like a zone;
nothing is kept here. The five raid.* events ride the feed like any other.
Spec: docs rust-link/PROTOCOL.md §20.12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:51 -05:00
d61470503f Merge pull request 'feat(sidecar): protocol 14 — the console command run forward (PLAN_REDESIGNS §16)' (#30) from feat/command-steps into edge
Reviewed-on: #30
2026-10-16 07:47:50 +00:00
6bf8a3624e feat(sidecar): protocol 14 — the console command run forward (PLAN_REDESIGNS §16)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m21s
`POST /commands/run`, stamped `command.run` the way `world.call`,
`dt.start` and `gc.place` are (PROTOCOL.md §20.11). The plugin keeps the
key, fills {steamid} and {name} and decides who is online; the sidecar
moves the line and knows nothing of what a command does (D384, no
allowlist). Joins protocol 14, still unreleased on edge.

Stamp test: the body's key, commands and recipients pass through byte for
byte, placeholders and quotes included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 02:37:12 -05:00
532abc5761 Merge pull request 'feat(sidecar): protocol 14 — the guarded crate placement forward (PLAN_REDESIGNS §14.3.5)' (#29) from feat/guarded-crate into edge
Reviewed-on: #29
2026-10-15 23:18:55 +00:00
f96878441d feat(sidecar): protocol 14 — the guarded crate placement forward (PLAN_REDESIGNS §14.3.5)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m44s
POST /gc/place stamps the body gc.place and forwards it; the plugin keeps the
key and the crate is kept when the run ends, so nothing is kept here. Spec:
docs rust-link/PROTOCOL.md §20.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 12:26:47 -05:00
42d63121e0 Merge pull request 'feat(sidecar): protocol 14 — the Dangerous Treasures start forward (PLAN_REDESIGNS §14.3.4)' (#28) from feat/dangerous-treasures into edge
Reviewed-on: #28
2026-10-15 12:04:43 +00:00
1ab0cab80a feat(sidecar): protocol 14 — the Dangerous Treasures start forward (PLAN_REDESIGNS §14.3.4)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m46s
POST /dt/start, stamped dt.start whatever its body names, forwarded to the
plugin and answered with its dt.opened or dt.error. The plugin remembers the
key; DT despawns its own chest; nothing is kept here. dt.started, dt.won and
dt.ended ride the feed like any other event (docs rust-link/PROTOCOL.md §20.8).

72 tests (one new), fmt and clippy clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 06:45:27 -05:00
847c3baa07 Merge pull request 'feat(sidecar): protocol 14 — the world event call forward (PLAN_REDESIGNS §14.3.1)' (#27) from feat/world-events into edge
Reviewed-on: #27
2026-10-15 07:19:33 +00:00
4ad5cb0545 feat(sidecar): protocol 14 — the world event call forward (PLAN_REDESIGNS §14.3.1)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 5m49s
POST /world/call, a thin forward like /world/place: the body is stamped
`world.call` whatever it names, so the route cannot place, revert or zone
anything. What a call starts is kept, so nothing is remembered here; the
plugin keeps the call's key. The `world.event` frames (24 types, told apart by
`event`) ride the feed like any other event, and hello's `console` like any
other field. Still protocol 14 (D332); one new test, 71 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
5b2471b110 Merge pull request 'feat(sidecar): protocol 14 — the Backpacks forward (PLAN_REDESIGNS §11.3)' (#26) from feat/rust-backpacks into edge
Reviewed-on: #26
2026-10-12 19:41:45 +00:00
a95ecb8e25 feat(sidecar): protocol 14 — the Backpacks forward (PLAN_REDESIGNS §11.3)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m29s
POST /backpacks/read, a thin forward like /kits/uses: the body is stamped
`backpacks.read` whatever it names, so the route can only ever read. Nothing is
kept here. The bigger backpack an event lends is a permission in an ordinary
perm.sync (D337) and needs nothing new. Still protocol 14 (D332); one test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 14:35:54 -05:00
4eac5297e8 Merge pull request 'feat(sidecar): protocol 14 — the Economics forwards (PLAN_REDESIGNS §11.2)' (#25) from feat/protocol-14-economics into edge
Reviewed-on: #25
2026-10-12 12:40:38 +00:00
e720c21c09 feat(sidecar): protocol 14 — the Economics forwards (PLAN_REDESIGNS §11.2)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m7s
POST /economics/pay, /economics/balances and /economics/adjust, thin
forwards like /kits/uses: each body is stamped with its own command and
passed to the plugin, which remembers a payment's key. Nothing is kept here.
The tally's `balance` and the `economics.wiped` event ride the feed like any
frame. Still protocol 14 (D332); one forward test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 06:53:51 -05:00
21c9af645d Merge pull request 'feat(sidecar): protocol 14 — POST /kits/uses (PLAN_REDESIGNS §2.6)' (#24) from feat/protocol-14-step-editor into edge
Reviewed-on: #24
2026-10-12 09:52:22 +00:00
ef4fdb6e25 feat(sidecar): protocol 14 — POST /kits/uses (PLAN_REDESIGNS §2.6)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 16m11s
Opens protocol 14 (D332) for the step editor: one thin forward, POST /kits/uses
-> kits.uses ({kit, steamIds} in, counts out), for the kit weekend's warning.
perm.sync's new notices (D333) pass through untouched like the rest of the
body. PROTOCOL_VERSION 13 -> 14; one forward test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 02:13:15 -05:00
703245455b Merge pull request 'feat(sidecar): protocol 13, the egg with RunicNPC and the banner, edge → main (runicnpc 9e step 3, merge 2 of 3)' (#23) from edge into main
All checks were successful
Release sidecar / release (push) Successful in 14m22s
Reviewed-on: #23
2026-10-09 07:20:59 +00:00
f895eaa961 Merge pull request 'feat(egg): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)' (#22) from feat/9e-popup-banner into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m12s
Reviewed-on: #22
2026-10-09 06:29:39 +00:00
4eee1fbe17 feat(egg): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m43s
The install writes PopupNotifications.json into oxide/config or
carbon/configs as a banner across the top of the screen, by the installer's
rule: only where the file is missing or every setting is still the plugin's
own default (the Version aside). Anything else is kept whole. The plugin's
Version is written or kept, because PopupNotifications resets a config
without one. A failed write is said in the console and does not fail the
install: it is a look, not the bridge.

Tested in ghcr.io/ptero-eggs/installers:debian: missing, the rig's defaults,
defaults from a later release (version kept), four kinds of change, not
JSON, an existing banner, and an unmakeable directory. egg/build.sh builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-09 01:24:27 -05:00
13a6d33dfd Merge pull request 'feat: forward RunicNPC's profiles and placements; the egg installs RunicNPC (runicnpc stage 4)' (#21) from feat/runicnpc-stage4 into edge
Reviewed-on: #21
2026-09-30 19:52:29 +00:00
10221b7e20 feat(egg): install RunicNPC when the bundle carries it (runicnpc stage 4, D224)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m53s
The install script fetches RunicNPC's tarball with the rest, checks it against
the bundle's sha256, its API against the bundle and its file against its own
manifest, and places RunicNPC.cs before the bridge. Its data directory is left
for RunicNPC to make (runicnpc PLAN.md §1.5). rust-link/bundle.json names it.
Walked against a mock Gitea with and without RunicNPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 08:11:34 -05:00
3b9a986e3a feat(sidecar): forward RunicNPC's profiles and placements (runicnpc stage 4)
GET and POST /npc/profiles, GET /npc/placements and POST /npc/placement:
four thin forwards of the plugin's npc.* commands, stamped like every other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:21:53 -05:00
6cfb236125 Merge pull request 'feat(sidecar): POST /permissions/inventory forwards the plugin's store read' (#20) from feat/perm-inventory into edge
Reviewed-on: #20
2026-09-28 16:38:43 +00:00
3662363caf feat(sidecar): POST /permissions/inventory forwards the plugin's store read
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m40s
Protocol 13, the permission manager (PLAN_REDESIGNS section 1): one more
opaque forward. The body is {snapshotId?, page?}; paging is the plugin's
and the website's, and no page is kept here.

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:43 -05:00
4498baffe4 Merge pull request 'feat(egg): install every plugin file the release lists, helpers included (D182)' (#19) from feat/protocol-13-egg-helpers into edge
Reviewed-on: #19
2026-09-27 06:11:57 +00:00
fe7b2822c5 feat(egg): install every plugin file the release lists, helpers included (D182)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m58s
Rust-Plugins now releases a ZoneManager helper, RunicGatewayZones.cs, beside
the bridge (docs/modules/rust/PLAN_FIXES.md D181, D182), installed by default.
The egg copied only RunicGateway.cs out of the tarball.

It now takes every .cs the plugin manifest lists in `files`, checks each
against its own sha256 before anything is placed (the bridge's own checksum
was never checked by the egg before), and refuses a name that is not a plain
<Name>.cs. A release older than helpers lists only the bridge and installs
exactly what it did before. Exercised in an Alpine shell against both, a
tampered helper, a missing one, `../evil.cs` and `evil.dll`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:25:35 -05:00
0179ca52ec Merge pull request 'fix(sidecar): protocol 13 — a configuration write no longer waits for its reload (F9)' (#18) from fix/protocol-13-config-reload into edge
Reviewed-on: #18
2026-09-26 22:29:27 +00:00
522187ddd1 fix(sidecar): protocol 13 — a configuration write no longer waits for its reload (F9)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m48s
The plugin now answers config.write once the files are on disk and reports
the reload as a config.outcome event, which this process files and feeds by
its type like any other event. The reload window no longer has to fit
inside REPLY_TIMEOUT, so CONFIG_RELOAD_WINDOW and the test that asserted
the pairing are gone, and the 504 body stops pointing at a rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 17:08:37 -05:00
3 changed files with 550 additions and 52 deletions

View File

@@ -88,6 +88,69 @@ if [ $WORLD_SEED == "0" ]; then
fi fi
## ── The Runic Gateway bridge ───────────────────────────────────────────────── ## ── The Runic Gateway bridge ─────────────────────────────────────────────────
# PopupNotifications' recommended look (docs/runicnpc/PLAN.md D327): a banner
# across the top of the screen. PopupNotifications is optional and the operator
# installs it (D141), so its config is theirs: the banner goes in only where the
# file is missing or is still exactly the plugin's own defaults, every setting
# equal and the Version aside (D328). Anything else is kept whole. The Version
# is written too: PopupNotifications resets a config without one to defaults.
rg_popup() { # <the framework's config directory>
local file="$1/PopupNotifications.json"
local banner='{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {
"Position of the left side of notification (0.0 - 1.0)": 0.15,
"Position of the bottom of noticiation (0.0 - 1.0)": 0.87,
"Width (0.0 - 1.0)": 0.7,
"Height (0.0 - 1.0)": 0.07,
"Space between notification (0.0 - 1.0)": 0.005
},
"UI Options": {
"Show close button": false,
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": 0.8,
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "robotocondensed-bold.ttf",
"Font size": 18
},
"Version": { "Major": 0, "Minor": 2, "Patch": 1 }
}'
# PopupNotifications 0.2.1's GetBaseConfig(): a small grey box at the right.
local defaults='{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {
"Position of the left side of notification (0.0 - 1.0)": 0.8,
"Position of the bottom of noticiation (0.0 - 1.0)": 0.78,
"Width (0.0 - 1.0)": 0.19,
"Height (0.0 - 1.0)": 0.1,
"Space between notification (0.0 - 1.0)": 0.01
},
"UI Options": {
"Show close button": true,
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": 0.5,
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "droidsansmono.ttf",
"Font size": 12
}
}'
if [ ! -e "${file}" ]; then
mkdir -p "$1" || return 1
jq -n "${banner}" > "${file}" || return 1
echo "Runic Gateway: wrote the recommended PopupNotifications banner to ${file}"
elif jq -e --argjson d "${defaults}" 'type == "object" and del(.Version) == $d' "${file}" >/dev/null 2>&1; then
jq --argjson b "${banner}" '$b + { Version: (.Version // $b.Version) }' "${file}" > "${file}.rg" \
&& mv "${file}.rg" "${file}" || { rm -f "${file}.rg"; return 1; }
echo "Runic Gateway: ${file} was PopupNotifications' defaults; wrote the recommended banner"
else
echo "Runic Gateway: kept ${file} (not PopupNotifications' defaults)"
fi
}
# After the wipe, so REMOVE_FILES can never delete the plugin this just placed. # After the wipe, so REMOVE_FILES can never delete the plugin this just placed.
rg_install() { rg_install() {
set -euo pipefail set -euo pipefail
@@ -95,10 +158,10 @@ rg_install() {
# just the variables an egg declares, and this one is not declared. # just the variables an egg declares, and this one is not declared.
local api="${RUNICGATEWAY_BUNDLE_API:-https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents/v2/rust}" local api="${RUNICGATEWAY_BUNDLE_API:-https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents/v2/rust}"
local work=/tmp/runicgateway local work=/tmp/runicgateway
local plugins local plugins config
case "${FRAMEWORK:-vanilla}" in case "${FRAMEWORK:-vanilla}" in
oxide) plugins=/mnt/server/oxide/plugins ;; oxide) plugins=/mnt/server/oxide/plugins; config=/mnt/server/oxide/config ;;
carbon) plugins=/mnt/server/carbon/plugins ;; carbon) plugins=/mnt/server/carbon/plugins; config=/mnt/server/carbon/configs ;;
*) *)
# Not a failure (§34.4): failing would leave the operator without a game # Not a failure (§34.4): failing would leave the operator without a game
# server over a bridge they may not want yet. The startup skips the # server over a bridge they may not want yet. The startup skips the
@@ -138,6 +201,21 @@ rg_install() {
fetch '.sidecar.assets["linux-x86_64"]' rust-link-sidecar fetch '.sidecar.assets["linux-x86_64"]' rust-link-sidecar
fetch '.sidecar.launcher' with-sidecar.sh fetch '.sidecar.launcher' with-sidecar.sh
fetch '.payload.asset' plugin.tar.gz fetch '.payload.asset' plugin.tar.gz
# RunicNPC (docs/runicnpc/PLAN.md D224), when the bundle carries it: a third
# file beside the bridge, checked the same way before anything is placed.
local npc=""
if jq -e '.npc != null' "${work}/bundle.json" >/dev/null; then
npc="$(jq -r '.npc.tag' "${work}/bundle.json")"
fetch '.npc.asset' runicnpc.tar.gz
tar -xzf "${work}/runicnpc.tar.gz" -C "${work}"
local npc_manifest="${work}/runicnpc/manifest.json"
[ -f "${npc_manifest}" ] || { echo "Runic Gateway: the RunicNPC tarball has no manifest.json"; return 1; }
[ "$(jq -r '.api' "${npc_manifest}")" = "$(jq -r '.npc.api' "${work}/bundle.json")" ] \
|| { echo "Runic Gateway: RunicNPC answers API $(jq -r '.api' "${npc_manifest}"), the bundle says $(jq -r '.npc.api' "${work}/bundle.json") - refusing it"; return 1; }
[ -f "${work}/runicnpc/RunicNPC.cs" ] || { echo "Runic Gateway: the RunicNPC tarball has no RunicNPC.cs"; return 1; }
echo "$(jq -r '.files["RunicNPC.cs"]' "${npc_manifest}") ${work}/runicnpc/RunicNPC.cs" | sha256sum -c --quiet - \
|| { echo "Runic Gateway: RunicNPC.cs does not match its manifest's sha256 - refusing it"; return 1; }
fi
tar -xzf "${work}/plugin.tar.gz" -C "${work}" tar -xzf "${work}/plugin.tar.gz" -C "${work}"
local manifest="${work}/runicgateway-rust-plugin/manifest.json" local manifest="${work}/runicgateway-rust-plugin/manifest.json"
@@ -148,13 +226,42 @@ rg_install() {
mkdir -p /mnt/server/rust-link "${plugins}" mkdir -p /mnt/server/rust-link "${plugins}"
install -m 755 "${work}/rust-link-sidecar" /mnt/server/rust-link/rust-link-sidecar install -m 755 "${work}/rust-link-sidecar" /mnt/server/rust-link/rust-link-sidecar
install -m 755 "${work}/with-sidecar.sh" /mnt/server/rust-link/with-sidecar.sh install -m 755 "${work}/with-sidecar.sh" /mnt/server/rust-link/with-sidecar.sh
install -m 644 "${work}/runicgateway-rust-plugin/RunicGateway.cs" "${plugins}/RunicGateway.cs" # Every .cs the release's manifest lists: the bridge, and the helpers shipped
# beside it (docs/modules/rust/PLAN_FIXES.md D182 - today RunicGatewayZones.cs,
# the ZoneManager helper), each checked against its own sha256 first. A name
# is written into the plugins directory, so only a plain <Name>.cs is taken.
local file sha
for file in $(jq -r '.files | keys[]' "${manifest}"); do
case "${file}" in
*[!A-Za-z0-9_.]* | .* | *..* | *[!s] ) echo "Runic Gateway: the plugin manifest lists ${file}, which is not a plugin file - refusing it"; return 1 ;;
esac
[ "${file%.cs}" != "${file}" ] || { echo "Runic Gateway: the plugin manifest lists ${file}, which is not a .cs file - refusing it"; return 1; }
[ -f "${work}/runicgateway-rust-plugin/${file}" ] || { echo "Runic Gateway: the plugin manifest lists ${file} but the tarball has none"; return 1; }
sha="$(jq -r --arg f "${file}" '.files[$f]' "${manifest}")"
echo "${sha} ${work}/runicgateway-rust-plugin/${file}" | sha256sum -c --quiet - \
|| { echo "Runic Gateway: ${file} does not match the plugin manifest's sha256 - refusing it"; return 1; }
done
[ -f "${work}/runicgateway-rust-plugin/RunicGateway.cs" ] || { echo "Runic Gateway: the plugin tarball has no RunicGateway.cs"; return 1; }
# RunicNPC before the bridge: the bridge reports it at hello. Its data directory
# is NOT made here - one made from outside the game is not writable by it
# (runicnpc PLAN.md §1.5); RunicNPC makes its own on first load.
if [ -n "${npc}" ]; then
install -m 644 "${work}/runicnpc/RunicNPC.cs" "${plugins}/RunicNPC.cs"
fi
for file in $(jq -r '.files | keys[]' "${manifest}"); do
install -m 644 "${work}/runicgateway-rust-plugin/${file}" "${plugins}/${file}"
done
# A look, not the bridge: if it cannot be written, say so and finish the install.
if ! rg_popup "${config}"; then
echo "Runic Gateway: could not write the PopupNotifications banner to ${config}; the bridge is installed regardless"
fi
# What is installed, readable from the panel's file manager. # What is installed, readable from the panel's file manager.
jq --arg framework "${FRAMEWORK}" --arg installed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ jq --arg framework "${FRAMEWORK}" --arg installed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ bundle, protocol, framework: $framework, installed: $installed, '{ bundle, protocol, framework: $framework, installed: $installed,
sidecar: { tag: .sidecar.tag }, plugin: { tag: .payload.tag, commit: .payload.commit } }' \ sidecar: { tag: .sidecar.tag }, plugin: { tag: .payload.tag, commit: .payload.commit } }
+ (if .npc == null then {} else { runicnpc: { tag: .npc.tag, api: .npc.api } } end)' \
"${work}/bundle.json" > /mnt/server/rust-link/bundle.json "${work}/bundle.json" > /mnt/server/rust-link/bundle.json
echo "Runic Gateway: installed sidecar $(jq -r '.sidecar.tag' "${work}/bundle.json") and plugin $(jq -r '.payload.tag' "${work}/bundle.json") (${FRAMEWORK})" echo "Runic Gateway: installed sidecar $(jq -r '.sidecar.tag' "${work}/bundle.json") and plugin $(jq -r '.payload.tag' "${work}/bundle.json")${npc:+ and RunicNPC ${npc}} (${FRAMEWORK})"
echo "Runic Gateway: add this server under Admin -> Rust -> Servers; the console prints its URL and, on first boot, its token." echo "Runic Gateway: add this server under Admin -> Rust -> Servers; the console prints its URL and, on first boot, its token."
} }
# In a subshell so `set -e` inside cannot leak into the rest of this script, and # In a subshell so `set -e` inside cannot leak into the rest of this script, and

View File

@@ -101,11 +101,9 @@ use tracing_subscriber::EnvFilter;
/// for the reload to announce itself, and **puts the old files back automatically** if it does /// for the reload to announce itself, and **puts the old files back automatically** if it does
/// not. /// not.
/// ///
/// Two things about that reach this process. The write is the only route here that causes a write /// The write is the only route here that causes a write on the game host. Since protocol 13 its
/// on the game host, and it is the only one whose reply routinely spends seconds rather than /// reply comes back as soon as the files are on disk, marked `pending`; how the reload went follows
/// milliseconds — the plugin holds the correlation open across a reload and, at worst, across a /// later as a `config.outcome` event, which this process files and feeds like any other.
/// rollback as well. `web::CONFIG_RELOAD_WINDOW` is that budget, mirrored from the plugin, and a
/// test asserts the pairing rather than trusting it.
/// ///
/// # Protocol 6 — first-party clans /// # Protocol 6 — first-party clans
/// ///
@@ -165,10 +163,68 @@ use tracing_subscriber::EnvFilter;
/// keeps it, and positions are asked for while somebody is looking and never touch the database /// keeps it, and positions are asked for while somebody is looking and never touch the database
/// (D111). Which layer a viewer may see is decided on the website, never here. /// (D111). Which layer a viewer may see is decided on the website, never here.
/// ///
/// # Protocol 13 — the first player walk's fixes
///
/// Opened by the configuration save (the module's PLAN_FIXES.md F9, D177): `POST /config/write`
/// answers `pending` at once instead of holding the RPC across a reload, and the outcome arrives
/// as a `config.outcome` event. Nothing new to route — an event is filed and fed by its `type`,
/// whatever its kind — so what changes here is what no longer has to fit: the reload window that
/// had to sit inside [`rpc::REPLY_TIMEOUT`] is gone.
///
/// The permission manager's redesign (the module's PLAN_REDESIGNS.md §1, D160) adds one route,
/// `POST /permissions/inventory`: the plugin's whole store, in pages it sizes to the game link's
/// line cap. Forwarded like every other object; the pages are never kept here.
///
/// # Protocol 14 — the step editor (opened 2026-10-12)
///
/// Opened by the module's PLAN_REDESIGNS.md §2 (D332), after protocol 13 shipped with RunicNPC.
/// One route, `POST /kits/uses`: how many of a list of players have used a kit up, or are waiting
/// out its cooldown — counts, never a list. And `perm.sync` gains `notices`, the one-time "you
/// earned a kit" messages (D333), with `noticesTold` in its report; both pass through untouched
/// like the rest of that body. The first-class optional plugins (§11) join this protocol too.
///
/// Economics (§20.3, the module's PLAN_REDESIGNS.md §11.2) adds three forwards:
/// `POST /economics/pay` (coins from an event step, kept once paid), `POST /economics/balances`
/// and `POST /economics/adjust` (staff). The plugin remembers a payment's key; this process keeps
/// nothing. The tally's `balance` and the `economics.wiped` event ride the feed like any frame.
///
/// Backpacks (§20.4, the module's PLAN_REDESIGNS.md §11.3) adds one: `POST /backpacks/read`,
/// one player's backpack, read live. The plugin never writes a backpack, and a bigger one an event
/// lends is a `backpacks.size.N` permission in an ordinary `perm.sync`, so it needs nothing here.
///
/// Rust's own world events (§20.5, the module's PLAN_REDESIGNS.md §14.3.1) add one forward,
/// `POST /world/call`: run one of the game's own world events now, for a run. It is kept, so the
/// plugin remembers only the call's key and its run, and this process nothing. The `world.event`
/// frames (24 of them, told apart by `event`) ride the feed like any other event, and hello's
/// `console` like any other field.
///
/// Dangerous Treasures (§20.8, the module's PLAN_REDESIGNS.md §14.3.4) adds one forward,
/// `POST /dt/start`: open one treasure event for a run's step. The plugin remembers the key so a
/// retry never opens a second; DT despawns its own chest. `dt.started`, `dt.won` and `dt.ended`
/// ride the feed like any other event.
///
/// Guarded Crate (§20.9, the module's PLAN_REDESIGNS.md §14.3.5) adds one forward,
/// `POST /gc/place`: place one guarded crate for a run's step. The plugin remembers the key so a
/// retry never places a second; the crate is kept when the run ends (D373). `gc.started`,
/// `gc.ended` and `gc.failed` ride the feed like any other event.
///
/// Console commands as event steps (§20.11, the module's PLAN_REDESIGNS.md §16) add one forward,
/// `POST /commands/run`: the commands a step's author typed, run once or once per online
/// recipient. The plugin remembers the key so nothing runs twice; this process keeps nothing and
/// knows nothing of what a command does.
///
/// RaidableBases (§20.12, the module's PLAN_REDESIGNS.md §11.4) adds two forwards: `POST
/// /raid/spawn`, one base for a run's step, and `POST /raid/check`, a spot's checks with nothing
/// spawned. The plugin keeps the key and the run's base in its world registry, so `world.revert`
/// takes it back like a zone and `world.expired` says when the plugin ended it first. `raid.started`,
/// `raid.completed`, `raid.ended`, `raid.purchased` and `raid.locked` ride the feed like any other
/// event.
///
/// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the
/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs, /// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs,
/// §16 the rewards, §17 the map, §18 the optional mods; this constant is one of its four declaration sites. /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes, §20 the step editor;
pub const PROTOCOL_VERSION: u32 = 12; /// this constant is one of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 14;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) { let args = match cli::parse(std::env::args().skip(1)) {

View File

@@ -95,6 +95,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// plugin will do with any of it. It puts `cmd` and `reqId` on the object and forwards it, // plugin will do with any of it. It puts `cmd` and `reqId` on the object and forwards it,
// exactly as it forwards a link code. // exactly as it forwards a link code.
.route("/permissions/sync", post(perm_sync)) .route("/permissions/sync", post(perm_sync))
// Protocol 13 (D160): the whole store, owners included, in pages. The body is
// `{snapshotId?, page?}` and is forwarded as it is; paging is the plugin's and the
// website's business, never this process's, which keeps no copy of any page.
.route("/permissions/inventory", post(perm_inventory))
// Protocol 5 (R18): the plugin's own view of the game host's configuration tree. All // Protocol 5 (R18): the plugin's own view of the game host's configuration tree. All
// three are correlated round trips and all three fail when the game is down, because // three are correlated round trips and all three fail when the game is down, because
// "what is on that host's disk" has no stale answer worth giving — and, unlike a board, // "what is on that host's disk" has no stale answer worth giving — and, unlike a board,
@@ -102,9 +106,8 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// an operator edited over SSH and the website then overwrote. // an operator edited over SSH and the website then overwrote.
.route("/config/files", get(config_files)) .route("/config/files", get(config_files))
.route("/config/file", get(config_file)) .route("/config/file", get(config_file))
// The only route on this sidecar that causes a WRITE on the game host, and the only one // The only route on this sidecar that causes a WRITE on the game host. It answers once the
// whose reply can take most of the RPC budget: the plugin holds it open across a reload // files are on disk; the reload's outcome follows as a `config.outcome` event (protocol 13).
// and, at worst, across a rollback as well. See `CONFIG_RELOAD_WINDOW`.
.route("/config/write", post(config_write)) .route("/config/write", post(config_write))
// Protocol 8 (the module's PLAN.md §27): an event borrowing a value and giving it back. // Protocol 8 (the module's PLAN.md §27): an event borrowing a value and giving it back.
// Three correlated round trips, and the sidecar knows nothing about any of them — not // Three correlated round trips, and the sidecar knows nothing about any of them — not
@@ -120,6 +123,23 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/world/zone", post(world_zone)) .route("/world/zone", post(world_zone))
.route("/world/place", post(world_place)) .route("/world/place", post(world_place))
.route("/world/revert", post(world_revert)) .route("/world/revert", post(world_revert))
// Protocol 14 (§20.5): one of the game's own world events, now. Kept, never reverted.
.route("/world/call", post(world_call))
// Protocol 14 (§20.8): open one Dangerous Treasures event. The plugin remembers the key;
// DT picks the spot and despawns its own chest, so nothing here is kept either.
.route("/dt/start", post(dt_start))
// Protocol 14 (§20.9): place one guarded crate. The plugin remembers the key; the crate is
// kept when the run ends (Guarded Crate cannot end one alone), so nothing is kept here.
.route("/gc/place", post(gc_place))
// Protocol 14 (§20.11): console commands a step's author typed. The plugin keeps the key
// so nothing runs twice, fills the placeholders and decides who is online; this process
// moves the line and knows nothing of what any command does (no allowlist, D384).
.route("/commands/run", post(commands_run))
// Protocol 14 (§20.12): one RaidableBases base for a run, and a spot's checks. The plugin
// keeps the key and the run's base in its world registry, so `world.revert` takes it back
// like a zone; nothing is kept here.
.route("/raid/spawn", post(raid_spawn))
.route("/raid/check", post(raid_check))
// Protocol 10 (§29): who took part, what they may redeem, and a line in chat. The tally // Protocol 10 (§29): who took part, what they may redeem, and a line in chat. The tally
// is counted and held by the plugin, the kit catalogue is Kits' own, and whether a chat // is counted and held by the plugin, the kit catalogue is Kits' own, and whether a chat
// line was already said is the plugin's memory. These move lines, like everything above. // line was already said is the plugin's memory. These move lines, like everything above.
@@ -127,6 +147,17 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/tally/snapshot", get(tally_snapshot)) .route("/tally/snapshot", get(tally_snapshot))
.route("/tally/close", post(tally_close)) .route("/tally/close", post(tally_close))
.route("/kits", get(kits)) .route("/kits", get(kits))
// Protocol 14 (the module's PLAN_REDESIGNS.md §2.6): how many of the given players have
// used a kit up or are on its cooldown, for the warning beside a kit weekend. The plugin
// reads Kits and answers counts; the list of players is the website's and is not kept.
.route("/kits/uses", post(kits_uses))
// Protocol 14, Economics (§20.3): thin forwards. The plugin remembers a payment's key so a
// retry never pays twice; nothing about a payment or a balance is kept here.
.route("/economics/pay", post(economics_pay))
.route("/economics/balances", post(economics_balances))
.route("/economics/adjust", post(economics_adjust))
// Protocol 14, Backpacks (§20.4): one player's backpack, read live and never kept here.
.route("/backpacks/read", post(backpacks_read))
.route("/chat", post(chat)) .route("/chat", post(chat))
// Protocol 11 (§30): the map. The picture passes through in slices and is never kept // Protocol 11 (§30): the map. The picture passes through in slices and is never kept
// here, and positions are asked for while somebody is looking and never filed (D111). The // here, and positions are asked for while somebody is looking and never filed (D111). The
@@ -139,6 +170,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// held by the plugin in memory and read by BetterChat on the chat path. Nothing here knows // held by the plugin in memory and read by BetterChat on the chat path. Nothing here knows
// what a title is. // what a title is.
.route("/titles", post(titles)) .route("/titles", post(titles))
// Protocol 13, RunicNPC (runicnpc PLAN.md stage 4): the site's NPC profiles and the
// server's placements. RunicNPC is called by the plugin, never by this process, and nothing
// here knows a profile from a placement. Four more thin forwards.
.route("/npc/profiles", get(npc_profiles).post(npc_profiles_set))
.route("/npc/placements", get(npc_placements))
.route("/npc/placement", post(npc_placement))
.route_layer(middleware::from_fn_with_state(state.clone(), gate)); .route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new() let app = Router::new()
@@ -436,6 +473,13 @@ async fn perm_sync(State(st): State<AppState>, Json(body): Json<Value>) -> Respo
respond(st.rpc.call(&st.game, command, &req_id).await) respond(st.rpc.call(&st.game, command, &req_id).await)
} }
/// One page of the plugin's permission inventory (protocol 13). Page 0 without a `snapshotId`
/// starts a fresh read; any other page names the snapshot page 0 answered with. A `perm.error`
/// (`busy`, `stale`, `too-large`) is an answer, and arrives as a `200` like the sync's.
async fn perm_inventory(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "perm.inventory", "an inventory request").await
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ConfigPathQuery { struct ConfigPathQuery {
path: String, path: String,
@@ -527,6 +571,50 @@ async fn world_revert(State(st): State<AppState>, Json(body): Json<Value>) -> Re
forward_object(&st, body, "world.revert", "a revert").await forward_object(&st, body, "world.revert", "a revert").await
} }
/// Run one of the game's own world events for a run (protocol 14): a patrol helicopter, a cargo
/// ship, an airdrop and the rest. `world.called` (`called`, and `repeat` for a key already seen)
/// or `world.error` with a reason. What it starts is kept, so nothing is remembered here.
async fn world_call(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "world.call", "a world event call").await
}
/// Open one Dangerous Treasures event for a run's step (protocol 14). `dt.opened` (where it
/// opened, and `repeat` for a key already seen) or `dt.error` with a reason, `dt-not-ready` among
/// them. DT despawns its own chest, so nothing is remembered here.
async fn dt_start(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "dt.start", "a Dangerous Treasures start").await
}
/// Place one guarded crate for a run's step (protocol 14). `gc.placed` (where it landed, and
/// `repeat` for a key already seen) or `gc.error` with a reason, `npcspawn-missing` among them.
/// Guarded Crate ends its own event, so nothing is remembered here.
async fn gc_place(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "gc.place", "a guarded crate placement").await
}
/// Run the console commands a step's author typed (protocol 14): once, or once per online
/// recipient. `command.ran` (how many ran, the console's replies, who was skipped as offline, and
/// `repeat` for a key already seen) or `command.error` with a reason, `unknown-command` among
/// them. A step's `teardown` rides its run and the plugin keeps it with the key; `teardownOf`
/// asks for the teardown kept under another key (D385). The plugin remembers the key, so nothing
/// is remembered here.
async fn commands_run(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "command.run", "a console command run").await
}
/// Spawn one RaidableBases base for a run's step (protocol 14). `raid.queued` (its id, when the
/// raids helper made it the run's, and `repeat` for a key already seen) or `raid.error` with a
/// reason. The plugin's world registry holds the run's base; nothing is remembered here.
async fn raid_spawn(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "raid.spawn", "a raid base spawn").await
}
/// Check a spot for a raid base without spawning one (protocol 14): `raid.checked`, with the
/// refusal's reason and the warnings, or `raid.error` when the spot cannot be checked at all.
async fn raid_check(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "raid.check", "a raid base spot check").await
}
/// Start counting who takes part in a run. `tally.ok` or `tally.error` with a reason. /// Start counting who takes part in a run. `tally.ok` or `tally.error` with a reason.
async fn tally_open(State(st): State<AppState>, Json(body): Json<Value>) -> Response { async fn tally_open(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "tally.open", "a tally").await forward_object(&st, body, "tally.open", "a tally").await
@@ -563,6 +651,38 @@ async fn kits(State(st): State<AppState>) -> Response {
respond(st.rpc.call(&st.game, command, &req_id).await) respond(st.rpc.call(&st.game, command, &req_id).await)
} }
/// How many of a list of players have used one kit up, and how many are on its cooldown (protocol
/// 14). The body is `{kit, steamIds}` and is forwarded as it is; the answer is counts. A POST
/// because the list rides in the body — it can be as long as a server's linked players.
async fn kits_uses(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "kits.uses", "a kit-use question").await
}
/// Coins to a list of players from an event step (protocol 14). The body is `{key, amount,
/// steamIds, message?, delivery?}`; the answer is `economics.paid` with each player's balance
/// before and after, or `economics.error` with a reason.
async fn economics_pay(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "economics.pay", "a payment").await
}
/// The balances of a list of players, with the server's starting balance (protocol 14).
async fn economics_balances(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "economics.balances", "a balance question").await
}
/// Staff deposit, withdraw or set one player's balance (protocol 14). The body is `{key, steamId,
/// op, amount}`; the answer is `economics.adjusted` with the balance before and after.
async fn economics_adjust(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "economics.adjust", "a balance adjustment").await
}
/// One player's backpack (protocol 14). The body is `{steamId}`; the answer is
/// `backpacks.contents` (`exists`, and with a backpack its `capacity`, `pageSize` and `items`), or
/// `backpacks.error` with a reason. The plugin only reads, and nothing is kept here.
async fn backpacks_read(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "backpacks.read", "a backpack read").await
}
/// Say one line in the server's chat. A repeated key is answered `ok` by the plugin and not said /// Say one line in the server's chat. A repeated key is answered `ok` by the plugin and not said
/// again, so a retried step never repeats itself. /// again, so a retried step never repeats itself.
async fn chat(State(st): State<AppState>, Json(body): Json<Value>) -> Response { async fn chat(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
@@ -575,6 +695,32 @@ async fn titles(State(st): State<AppState>, Json(body): Json<Value>) -> Response
forward_object(&st, body, "titles.set", "a title set").await forward_object(&st, body, "titles.set", "a title set").await
} }
/// The server's RunicNPC profiles as RunicNPC holds them: whether a site manages them, each profile,
/// and those refused. The website reads this before its first push, to adopt them (D244). Live.
async fn npc_profiles(State(st): State<AppState>) -> Response {
let req_id = st.rpc.next_req_id();
let command = json!({ "cmd": "npc.profiles", "reqId": req_id });
respond(st.rpc.call(&st.game, command, &req_id).await)
}
/// Replace the whole profile set (a push). `npc.ok` lists the profiles RunicNPC refused.
async fn npc_profiles_set(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "npc.profiles.set", "a profile push").await
}
/// Every placement, the routes one may walk and the cost warning. Live: an admin's `/rnpc` in game
/// changes it at any moment.
async fn npc_placements(State(st): State<AppState>) -> Response {
let req_id = st.rpc.next_req_id();
let command = json!({ "cmd": "npc.placements", "reqId": req_id });
respond(st.rpc.call(&st.game, command, &req_id).await)
}
/// One change to one placement, by `op`: add, set, remove, rename or respawn.
async fn npc_placement(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "npc.placement", "a placement change").await
}
/// What this map is, where its picture comes from, and its monuments (protocol 11, stage one). /// What this map is, where its picture comes from, and its monuments (protocol 11, stage one).
/// Live, and never cached here: a wipe changes the answer, and the module compares its key and /// Live, and never cached here: a wipe changes the answer, and the module compares its key and
/// hash against what it holds to decide whether to fetch at all. /// hash against what it holds to decide whether to fetch at all.
@@ -694,19 +840,14 @@ async fn config_file(State(st): State<AppState>, Query(q): Query<ConfigPathQuery
respond(st.rpc.call(&st.game, command, &req_id).await) respond(st.rpc.call(&st.game, command, &req_id).await)
} }
/// The window the plugin gives a reload to announce itself, mirrored here from
/// `ConfigReloadWindowSeconds` in `overlay/oxide/plugins/RunicGateway.cs`.
///
/// It is duplicated rather than negotiated because the two numbers are a *pairing*, like the
/// protocol version: the plugin owns the behaviour and this end owns the budget it has to fit in.
/// The test below is what keeps them honest — a worst-case write is two windows, and a
/// [`REPLY_TIMEOUT`](crate::rpc::REPLY_TIMEOUT) that does not cover both would abandon the caller
/// precisely when a rollback had just saved them, leaving the website to report a timeout over a
/// server that is perfectly healthy.
pub const CONFIG_RELOAD_WINDOW: Duration = Duration::from_secs(4);
/// Replace a set of configuration files and reload whatever owns them. /// Replace a set of configuration files and reload whatever owns them.
/// ///
/// Since protocol 13 the reply does not wait for the reload. The plugin answers once the files are
/// on disk (`pending: true`, a `writeId`, and the `ceilingMs` it will wait), and reports the reload
/// as a `config.outcome` event when it settles. Protocol 12 held this RPC across the reload, which
/// forced the plugin's window under [`REPLY_TIMEOUT`](crate::rpc::REPLY_TIMEOUT) — and a cold
/// compile outlasted it and had a valid edit rolled back (the module's PLAN_FIXES.md F9).
///
/// Opaque body, exactly as `/permissions/sync`: `cmd` and `reqId` are written over whatever the /// Opaque body, exactly as `/permissions/sync`: `cmd` and `reqId` are written over whatever the
/// caller sent, and nothing else about the object is read here. What the website sends is whole /// caller sent, and nothing else about the object is read here. What the website sends is whole
/// file *text* rather than a key and a value (D35), so there is nothing on this hop that could /// file *text* rather than a key and a value (D35), so there is nothing on this hop that could
@@ -750,16 +891,15 @@ async fn config_write(State(st): State<AppState>, Json(body): Json<Value>) -> Re
let result = st.rpc.call(&st.game, command, &req_id).await; let result = st.rpc.call(&st.game, command, &req_id).await;
if matches!(result, Err(RpcError::Timeout)) { if matches!(result, Err(RpcError::Timeout)) {
// A bare `504` on a route that writes reads as "did my change land, and is the plugin // A bare `504` on a route that writes reads as "did my change land?" — and unlike every
// still up?" — and unlike every other timeout on this sidecar, that question has a good // other timeout on this sidecar, that question has a good answer. The plugin writes a
// answer. The plugin writes a whole set or restores a whole set, never half of either, so // whole set or none of it, and restores a whole set or none of it, so a re-read settles
// a re-read settles it; and a write that is still in flight is most likely inside the // it. Since protocol 13 the reply no longer waits for a reload, so this is a slow host or
// rollback this window pays for. // a busy main thread, not a rollback in progress.
return ( return (
StatusCode::GATEWAY_TIMEOUT, StatusCode::GATEWAY_TIMEOUT,
Json(json!({ Json(json!({
"error": "the plugin did not report within the write budget", "error": "the plugin did not report within the write budget",
"reloadWindowSeconds": CONFIG_RELOAD_WINDOW.as_secs(),
"hint": "re-read the files: the plugin writes the whole set or restores it", "hint": "re-read the files: the plugin writes the whole set or restores it",
})), })),
) )
@@ -1037,25 +1177,6 @@ mod tests {
assert_eq!(command["grants"], json!([])); assert_eq!(command["grants"], json!([]));
} }
/// A command larger than the game link's own line cap is refused here, where the caller learns
/// why. Forwarded, it would be discarded by both ends without a word and present as a `504`.
/// The pairing in `CONFIG_RELOAD_WINDOW`'s own words, asserted rather than trusted.
///
/// The worst path through one configuration write is two windows — wait for the edited
/// plugin's reload, give up, restore the files, reload again — plus the write, the reads and
/// a line each way. If that does not fit inside the RPC timeout, the caller is abandoned at
/// exactly the moment the rollback saved it, and the website reports a timeout over a server
/// that is healthy and has the old config back.
#[test]
fn a_rollback_fits_inside_the_rpc_budget() {
let worst = CONFIG_RELOAD_WINDOW * 2;
assert!(
worst + Duration::from_secs(1) <= crate::rpc::REPLY_TIMEOUT,
"two reload windows ({worst:?}) plus a second of slack must fit in {:?}",
crate::rpc::REPLY_TIMEOUT
);
}
/// Protocol 8's routes cannot choose their own command or correlation id either, and they keep /// Protocol 8's routes cannot choose their own command or correlation id either, and they keep
/// everything else the caller sent, unread. /// everything else the caller sent, unread.
#[test] #[test]
@@ -1107,6 +1228,220 @@ mod tests {
assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none());
} }
/// RunicNPC's writes are stamped like every other forward: a placement change cannot become a
/// profile push, or anything else, by naming one.
#[test]
fn a_runicnpc_command_cannot_choose_its_own_command() {
let body = json!({ "cmd": "npc.profiles.set", "reqId": "theirs", "op": "remove", "id": "bandit-1" });
let stamped = stamp(body, "npc.placement", "r-9").expect("an object is stamped");
assert_eq!(stamped["cmd"], "npc.placement");
assert_eq!(stamped["reqId"], "r-9");
assert_eq!(stamped["op"], "remove");
assert_eq!(stamped["id"], "bandit-1");
let body = json!({ "cmd": "npc.placement", "profiles": { "warden": { "health": 250 } } });
let stamped = stamp(body, "npc.profiles.set", "r-10").expect("an object is stamped");
assert_eq!(stamped["cmd"], "npc.profiles.set");
assert_eq!(stamped["profiles"]["warden"]["health"], 250);
assert!(stamp(json!("warden"), "npc.profiles.set", "r-11").is_none());
}
/// Protocol 13: an inventory page request keeps its paging fields and cannot become a sync.
#[test]
fn an_inventory_request_cannot_choose_its_own_command() {
let body =
json!({ "cmd": "perm.sync", "reqId": "theirs", "snapshotId": "abc123", "page": 2 });
let stamped = stamp(body, "perm.inventory", "r-7").expect("an object is stamped");
assert_eq!(stamped["cmd"], "perm.inventory");
assert_eq!(stamped["reqId"], "r-7");
assert_eq!(stamped["snapshotId"], "abc123");
assert_eq!(stamped["page"], 2);
assert!(stamp(json!([0]), "perm.inventory", "r-8").is_none());
}
/// Protocol 14: a kit-use question keeps its list and cannot become another command, and a
/// sync's `notices` pass through it untouched like every other part of the body.
#[test]
fn a_protocol_14_kit_use_question_and_notices_pass_through() {
let body = json!({ "cmd": "perm.sync", "kit": "vip", "steamIds": ["76561190000000001", "76561190000000002"] });
let stamped = stamp(body, "kits.uses", "r-12").expect("an object is stamped");
assert_eq!(stamped["cmd"], "kits.uses");
assert_eq!(stamped["reqId"], "r-12");
assert_eq!(stamped["kit"], "vip");
assert_eq!(stamped["steamIds"][1], "76561190000000002");
assert!(stamp(json!(["vip"]), "kits.uses", "r-13").is_none());
let body = json!({
"setId": "s-2",
"notices": [{ "key": "run:4:step:9", "steamId": "76561190000000001", "message": "You earned kit vip — type /kit vip", "delivery": "popup" }],
});
let stamped = stamp(body, "perm.sync", "r-14").expect("an object is stamped");
assert_eq!(
stamped["notices"][0]["message"],
"You earned kit vip — type /kit vip"
);
assert_eq!(stamped["notices"][0]["delivery"], "popup");
}
/// Protocol 14, Economics: each body keeps its fields and is stamped with its own command, so
/// a payment cannot be sent as an adjustment by naming one in its body.
#[test]
fn protocol_14_economics_bodies_pass_through() {
let body = json!({ "cmd": "economics.adjust", "key": "7:4", "amount": 500.0, "steamIds": ["76561190000000001"], "message": "You earned {amount} coins", "delivery": "popup" });
let stamped = stamp(body, "economics.pay", "r-31").expect("an object is stamped");
assert_eq!(stamped["cmd"], "economics.pay");
assert_eq!(stamped["reqId"], "r-31");
assert_eq!(stamped["key"], "7:4");
assert_eq!(stamped["amount"], 500.0);
assert_eq!(stamped["message"], "You earned {amount} coins");
let stamped = stamp(
json!({ "steamIds": ["76561190000000001"] }),
"economics.balances",
"r-32",
)
.expect("an object is stamped");
assert_eq!(stamped["cmd"], "economics.balances");
assert_eq!(stamped["steamIds"][0], "76561190000000001");
let body = json!({ "key": "adj-1", "steamId": "76561190000000001", "op": "withdraw", "amount": 340.5 });
let stamped = stamp(body, "economics.adjust", "r-33").expect("an object is stamped");
assert_eq!(stamped["cmd"], "economics.adjust");
assert_eq!(stamped["op"], "withdraw");
assert_eq!(stamped["amount"], 340.5);
assert!(stamp(json!([1]), "economics.pay", "r-34").is_none());
}
/// Protocol 14, Backpacks: a read is stamped `backpacks.read` whatever its body names, so the
/// route can only ever read.
#[test]
fn protocol_14_backpacks_read_is_stamped() {
let body = json!({ "cmd": "backpacks.write", "steamId": "76561198000000001" });
let stamped = stamp(body, "backpacks.read", "r-40").expect("an object is stamped");
assert_eq!(stamped["cmd"], "backpacks.read");
assert_eq!(stamped["reqId"], "r-40");
assert_eq!(stamped["steamId"], "76561198000000001");
assert!(stamp(json!("76561198000000001"), "backpacks.read", "r-41").is_none());
}
/// Protocol 14, world events: a call is stamped `world.call` whatever its body names, so the
/// route cannot place, revert or zone anything, and its params pass through untouched.
#[test]
fn protocol_14_world_call_is_stamped() {
let body = json!({ "cmd": "world.revert", "reqId": "theirs", "runId": "9", "key": "9:2",
"event": "airdrop", "count": 3, "x": -412.0, "z": 1180.5 });
let stamped = stamp(body, "world.call", "r-50").expect("an object is stamped");
assert_eq!(stamped["cmd"], "world.call");
assert_eq!(stamped["reqId"], "r-50");
assert_eq!(stamped["event"], "airdrop");
assert_eq!(stamped["count"], 3);
assert_eq!(stamped["x"], -412.0);
assert!(stamp(json!("airdrop"), "world.call", "r-51").is_none());
}
/// Protocol 14, Dangerous Treasures: a start is stamped `dt.start` whatever its body names, so
/// the route can open a treasure event and nothing else, and its key and level pass through.
#[test]
fn protocol_14_dt_start_is_stamped() {
let body = json!({ "cmd": "world.call", "reqId": "theirs", "key": "12:3", "level": 2 });
let stamped = stamp(body, "dt.start", "r-60").expect("an object is stamped");
assert_eq!(stamped["cmd"], "dt.start");
assert_eq!(stamped["reqId"], "r-60");
assert_eq!(stamped["key"], "12:3");
assert_eq!(stamped["level"], 2);
assert!(stamp(json!(2), "dt.start", "r-61").is_none());
}
/// Protocol 14, Guarded Crate: a placement is stamped `gc.place` whatever its body names, so
/// the route can place a guarded crate and nothing else, and its key, event and location pass
/// through.
#[test]
fn protocol_14_gc_place_is_stamped() {
let body = json!({ "cmd": "dt.start", "reqId": "theirs", "key": "12:4", "event": "Elite", "monument": "powerplant_1", "offsetX": 10.0 });
let stamped = stamp(body, "gc.place", "r-62").expect("an object is stamped");
assert_eq!(stamped["cmd"], "gc.place");
assert_eq!(stamped["reqId"], "r-62");
assert_eq!(stamped["key"], "12:4");
assert_eq!(stamped["event"], "Elite");
assert_eq!(stamped["monument"], "powerplant_1");
assert_eq!(stamped["offsetX"], 10.0);
assert!(stamp(json!("Elite"), "gc.place", "r-63").is_none());
}
/// Protocol 14, console commands: a run is stamped `command.run` whatever its body names, so
/// the route runs commands and nothing else, and its key, commands and recipients pass through
/// byte for byte; the placeholders are the plugin's to fill.
#[test]
fn protocol_14_command_run_is_stamped() {
let body = json!({ "cmd": "gc.place", "reqId": "theirs", "key": "12:5",
"commands": ["sr add {steamid} 100", "say \"{name} won\""],
"recipients": [{ "steamId": "76561198000000001", "name": "Some \"One\"" }] });
let stamped = stamp(body, "command.run", "r-64").expect("an object is stamped");
assert_eq!(stamped["cmd"], "command.run");
assert_eq!(stamped["reqId"], "r-64");
assert_eq!(stamped["key"], "12:5");
assert_eq!(stamped["commands"][0], "sr add {steamid} 100");
assert_eq!(stamped["commands"][1], "say \"{name} won\"");
assert_eq!(stamped["recipients"][0]["name"], "Some \"One\"");
assert!(stamp(json!(["rbe"]), "command.run", "r-65").is_none());
// D385: a step's teardown rides its run, and a teardown asked for by key alone names the
// run it is for; both are the plugin's to read.
let run = stamp(
json!({ "key": "12:6", "commands": ["rbe 1"], "teardown": ["rbe stop"] }),
"command.run",
"r-66",
)
.expect("an object is stamped");
assert_eq!(run["teardown"][0], "rbe stop");
let teardown = stamp(
json!({ "key": "12:6:teardown", "teardownOf": "12:6" }),
"command.run",
"r-67",
)
.expect("an object is stamped");
assert_eq!(teardown["cmd"], "command.run");
assert_eq!(teardown["teardownOf"], "12:6");
}
/// Protocol 14, RaidableBases: a spawn is stamped `raid.spawn` and a check `raid.check`
/// whatever their bodies name, so each route does its one thing; the plugin's own names for
/// difficulties, profiles and bases (D386), the spot and the settings pass through untouched.
#[test]
fn protocol_14_raid_spawn_and_check_are_stamped() {
let body = json!({ "cmd": "world.revert", "reqId": "theirs", "key": "12:7", "runId": "12",
"difficulty": "Wobbly", "profile": "Wobbly Bases", "base": "wobbly1",
"x": 650.0, "z": 2037.0, "heightAdjust": 5.0,
"pvp": false, "npcs": true, "npcCount": 0, "despawnMinutes": 90, "lock": true });
let spawn = stamp(body.clone(), "raid.spawn", "r-68").expect("an object is stamped");
assert_eq!(spawn["cmd"], "raid.spawn");
assert_eq!(spawn["reqId"], "r-68");
assert_eq!(spawn["key"], "12:7");
assert_eq!(spawn["difficulty"], "Wobbly");
assert_eq!(spawn["base"], "wobbly1");
assert_eq!(spawn["heightAdjust"], 5.0);
assert_eq!(spawn["npcCount"], 0);
assert_eq!(spawn["pvp"], false);
assert!(stamp(json!("Wobbly"), "raid.spawn", "r-69").is_none());
let check = stamp(body, "raid.check", "r-70").expect("an object is stamped");
assert_eq!(check["cmd"], "raid.check");
assert_eq!(check["x"], 650.0);
assert_eq!(check["profile"], "Wobbly Bases");
}
/// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's
/// and the plugin's business, so it must arrive byte for byte as the website composed it. /// and the plugin's business, so it must arrive byte for byte as the website composed it.
#[test] #[test]