Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c4eaf5a31 | |||
| a61d7794d9 | |||
| b8e860d85e | |||
| d61470503f | |||
| 6bf8a3624e | |||
| 532abc5761 | |||
| f96878441d | |||
| 42d63121e0 | |||
| 1ab0cab80a | |||
| 847c3baa07 | |||
| 4ad5cb0545 | |||
| 5b2471b110 | |||
| a95ecb8e25 | |||
| 4eac5297e8 | |||
| e720c21c09 | |||
| 21c9af645d | |||
| ef4fdb6e25 | |||
| 703245455b | |||
| f895eaa961 | |||
| 4eee1fbe17 | |||
| 13a6d33dfd | |||
| 10221b7e20 | |||
| 3b9a986e3a | |||
| 6cfb236125 | |||
| 3662363caf | |||
| 4498baffe4 | |||
| fe7b2822c5 | |||
| 0179ca52ec | |||
| 522187ddd1 | |||
| e9b6778b78 | |||
| af0e6be9f5 | |||
| 6c46d9388a | |||
| 08ad7b93ce | |||
| bc1919ce38 | |||
| 3f5ed059b8 |
@@ -5,7 +5,7 @@
|
|||||||
"update_url": null
|
"update_url": null
|
||||||
},
|
},
|
||||||
"exported_at": "2026-09-26T00:00:00+00:00",
|
"exported_at": "2026-09-26T00:00:00+00:00",
|
||||||
"name": "Rust (Runic Gateway)",
|
"name": "runicgateway-rust-autowipe",
|
||||||
"author": "ci@whitlocktech.com",
|
"author": "ci@whitlocktech.com",
|
||||||
"description": "Egg 18 \"Rust Autowipe\" with the Runic Gateway bridge: the rust-link sidecar runs beside the game and the plugin is placed for Oxide or Carbon, both from a published, checksum-verified bundle at install time. FRAMEWORK=vanilla installs no bridge. Built from RunicGateway/Rust-Link egg/; see docs/rust-link/INSTALL.md.",
|
"description": "Egg 18 \"Rust Autowipe\" with the Runic Gateway bridge: the rust-link sidecar runs beside the game and the plugin is placed for Oxide or Carbon, both from a published, checksum-verified bundle at install time. FRAMEWORK=vanilla installs no bridge. Built from RunicGateway/Rust-Link egg/; see docs/rust-link/INSTALL.md.",
|
||||||
"features": null,
|
"features": null,
|
||||||
|
|||||||
119
egg/install.sh
119
egg/install.sh
@@ -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
|
||||||
|
|||||||
@@ -16,6 +16,29 @@
|
|||||||
# the game's: nothing the bridge gets wrong — an unwritable log, a sidecar that will not start — may
|
# the game's: nothing the bridge gets wrong — an unwritable log, a sidecar that will not start — may
|
||||||
# keep the server from booting. Each step reports its own failure and the script goes on to `exec`.
|
# keep the server from booting. Each step reports its own failure and the script goes on to `exec`.
|
||||||
|
|
||||||
|
# Carbon's entrypoint puts LD_PRELOAD=libdoorstop.so in front of the WHOLE startup string, and with
|
||||||
|
# Carbon's DOORSTOP_* environment that preloader breaks this shell's own `$(...)`: the child's output
|
||||||
|
# goes straight to the console and the capture comes back empty. The phase 18 walk saw exactly that
|
||||||
|
# on the first Carbon server built from the egg — the config JSON, token included, dumped raw to the
|
||||||
|
# console, and an empty "server id 'main', sidecar URL http://<ip>:" line. Dropping LD_PRELOAD for
|
||||||
|
# the sidecar alone is not enough; the shell doing the capturing has it too. So the script runs
|
||||||
|
# once more without it, keeps it aside, and gives it back to the game — the one process that needs
|
||||||
|
# it — in `run_game` below.
|
||||||
|
if [ -n "${LD_PRELOAD-}" ] && [ -z "${RUNICGATEWAY_LAUNCHER_CLEAN-}" ]; then
|
||||||
|
exec env -u LD_PRELOAD RUNICGATEWAY_GAME_PRELOAD="$LD_PRELOAD" RUNICGATEWAY_LAUNCHER_CLEAN=1 \
|
||||||
|
sh "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The game BECOMES this process: the panel console keeps its stdin and stdout, and stop still
|
||||||
|
# stops the server, which takes the sidecar down with the container.
|
||||||
|
run_game() {
|
||||||
|
if [ -n "${RUNICGATEWAY_GAME_PRELOAD-}" ]; then
|
||||||
|
export LD_PRELOAD="$RUNICGATEWAY_GAME_PRELOAD"
|
||||||
|
fi
|
||||||
|
unset RUNICGATEWAY_GAME_PRELOAD RUNICGATEWAY_LAUNCHER_CLEAN
|
||||||
|
exec "$@"
|
||||||
|
}
|
||||||
|
|
||||||
RL=/home/container/rust-link
|
RL=/home/container/rust-link
|
||||||
mkdir -p "$RL" 2>/dev/null
|
mkdir -p "$RL" 2>/dev/null
|
||||||
|
|
||||||
@@ -31,6 +54,23 @@ for v in RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS RUSTLINK_WEB
|
|||||||
if [ -z "$val" ]; then unset "$v"; fi
|
if [ -z "$val" ]; then unset "$v"; fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The server id follows the PLUGIN's config once it exists. The plugin reads RUSTLINK_SERVER_ID only
|
||||||
|
# when it writes its first config; after that the file is canonical and the website locks it (D150).
|
||||||
|
# The sidecar refuses a plugin that names another server (D155), so a variable edited after the
|
||||||
|
# first boot would otherwise take the bridge down instead of changing nothing. Stock framework paths,
|
||||||
|
# the same ones the installer writes. `sed`, because the game image has no jq.
|
||||||
|
for f in /home/container/oxide/config/RunicGateway.json /home/container/carbon/configs/RunicGateway.json; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
ID="$(sed -n 's/^[[:space:]]*"ServerId":[[:space:]]*"\([^"]*\)".*$/\1/p' "$f" | head -n 1)"
|
||||||
|
if [ -n "$ID" ]; then
|
||||||
|
if [ -n "${RUSTLINK_SERVER_ID-}" ] && [ "$RUSTLINK_SERVER_ID" != "$ID" ]; then
|
||||||
|
echo "[rust-link] RUSTLINK_SERVER_ID is '$RUSTLINK_SERVER_ID' but the plugin's config names '$ID'; using '$ID', the id the website knows. The variable is read once, at the first boot."
|
||||||
|
fi
|
||||||
|
export RUSTLINK_SERVER_ID="$ID"
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
|
||||||
# The website-facing bind. Pterodactyl tells a container nothing about its extra allocations, so
|
# The website-facing bind. Pterodactyl tells a container nothing about its extra allocations, so
|
||||||
# the port is typed into the egg, and it must be one of this server's allocations — a port that is
|
# the port is typed into the egg, and it must be one of this server's allocations — a port that is
|
||||||
# not fails as a bind the website never reaches, which the lines below make visible (§34.1).
|
# not fails as a bind the website never reaches, which the lines below make visible (§34.1).
|
||||||
@@ -41,14 +81,14 @@ fi
|
|||||||
SIDECAR="$RL/rust-link-sidecar"
|
SIDECAR="$RL/rust-link-sidecar"
|
||||||
if [ ! -x "$SIDECAR" ]; then
|
if [ ! -x "$SIDECAR" ]; then
|
||||||
echo "[rust-link] $SIDECAR is missing - reinstall the server to fetch the bridge. Starting the game without it."
|
echo "[rust-link] $SIDECAR is missing - reinstall the server to fetch the bridge. Starting the game without it."
|
||||||
exec "$@"
|
run_game "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Provision first, so the token can be shown. `--print-config` resolves the configuration exactly as
|
# Provision first, so the token can be shown. `--print-config` resolves the configuration exactly as
|
||||||
# a start does, writing sidecar.toml with a generated token when there is none; the JSON it prints
|
# a start does, writing sidecar.toml with a generated token when there is none; the JSON it prints
|
||||||
# says whether it generated one on THIS call, which is what makes "print once" true (D152).
|
# says whether it generated one on THIS call, which is what makes "print once" true (D152).
|
||||||
# Tracing is off on that path, so stdout is only the document. LD_PRELOAD is dropped for the
|
# Tracing is off on that path, so stdout is only the document. LD_PRELOAD is already gone from this
|
||||||
# sidecar in both calls: Carbon's entrypoint puts its Mono preloader in front of the whole startup.
|
# shell (see the top); `env -u` stays so a preload set some other way cannot reach the sidecar.
|
||||||
if CFG="$(env -u LD_PRELOAD "$SIDECAR" --print-config 2>&1)"; then
|
if CFG="$(env -u LD_PRELOAD "$SIDECAR" --print-config 2>&1)"; then
|
||||||
# The Nth `"key": "value"` line of the pretty-printed JSON. `bind` appears twice — game, then web.
|
# The Nth `"key": "value"` line of the pretty-printed JSON. `bind` appears twice — game, then web.
|
||||||
field() { printf '%s\n' "$CFG" | sed -n "s/^ *\"$1\": \"\([^\"]*\)\",\{0,1\}\$/\1/p" | sed -n "${2:-1}p"; }
|
field() { printf '%s\n' "$CFG" | sed -n "s/^ *\"$1\": \"\([^\"]*\)\",\{0,1\}\$/\1/p" | sed -n "${2:-1}p"; }
|
||||||
@@ -81,6 +121,4 @@ else
|
|||||||
echo "[rust-link] $RL is not writable - the game starts without the sidecar."
|
echo "[rust-link] $RL is not writable - the game starts without the sidecar."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The game BECOMES this process: the panel console keeps its stdin and stdout, and stop still
|
run_game "$@"
|
||||||
# stops the server, which takes the sidecar down with the container.
|
|
||||||
exec "$@"
|
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ where
|
|||||||
|
|
||||||
// Game link: events in, commands out.
|
// Game link: events in, commands out.
|
||||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<game::GameEvent>();
|
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<game::GameEvent>();
|
||||||
let (handle, _bound) = game::serve(&cfg.game.bind, event_tx).await?;
|
let (handle, _bound) =
|
||||||
|
game::serve(&cfg.game.bind, cfg.game.server_id.clone(), event_tx).await?;
|
||||||
|
|
||||||
// Live feed: every game event fans out to all connected website WebSocket clients.
|
// Live feed: every game event fans out to all connected website WebSocket clients.
|
||||||
let (bcast_tx, _) = broadcast::channel::<String>(1024);
|
let (bcast_tx, _) = broadcast::channel::<String>(1024);
|
||||||
@@ -145,7 +146,6 @@ where
|
|||||||
// waiting caller and stop. Everything else is filed by its `type`, and by its `type` alone:
|
// waiting caller and stop. Everything else is filed by its `type`, and by its `type` alone:
|
||||||
// that is what keeps this process a dumb forwarder while the catalogue grows (PROTOCOL.md
|
// that is what keeps this process a dumb forwarder while the catalogue grows (PROTOCOL.md
|
||||||
// §8.1). Ten new event kinds are no change here.
|
// §8.1). Ten new event kinds are no change here.
|
||||||
let configured_server_id = cfg.game.server_id.clone();
|
|
||||||
let feed_tx = bcast_tx.clone();
|
let feed_tx = bcast_tx.clone();
|
||||||
let route_rpc = rpc.clone();
|
let route_rpc = rpc.clone();
|
||||||
let event_store = store.clone();
|
let event_store = store.clone();
|
||||||
@@ -194,7 +194,6 @@ where
|
|||||||
// `type` exists to prevent, and it is invisible until somebody wonders why the
|
// `type` exists to prevent, and it is invisible until somebody wonders why the
|
||||||
// presence board has four thousand rows.
|
// presence board has four thousand rows.
|
||||||
if ev.kind == store::SERVER_BOARD {
|
if ev.kind == store::SERVER_BOARD {
|
||||||
check_server_id(&configured_server_id, &ev.value);
|
|
||||||
info!(kind = %ev.kind, "{}", line);
|
info!(kind = %ev.kind, "{}", line);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(kind = %ev.kind, "board {}", line);
|
tracing::debug!(kind = %ev.kind, "board {}", line);
|
||||||
@@ -260,24 +259,6 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The plugin's `serverId` is the authority; `[game].server_id` is a cross-check. A disagreement is
|
|
||||||
/// almost always two game servers pointed at one sidecar by a copied config, which is silent in
|
|
||||||
/// every other design and produces one server's history under another's name.
|
|
||||||
fn check_server_id(configured: &str, hello: &serde_json::Value) {
|
|
||||||
if configured.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let announced = hello.get("serverId").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
if !announced.is_empty() && announced != configured {
|
|
||||||
warn!(
|
|
||||||
configured,
|
|
||||||
announced,
|
|
||||||
"the connected plugin announces a different serverId than this sidecar is configured \
|
|
||||||
for; keeping the plugin's. Two servers sharing one sidecar is the usual cause."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A frame's own timestamp, falling back to ours. The plugin stamps `t` at the moment the world was
|
/// A frame's own timestamp, falling back to ours. The plugin stamps `t` at the moment the world was
|
||||||
/// read, which is earlier than the moment we saw the line and is the one worth keeping.
|
/// read, which is earlier than the moment we saw the line and is the one worth keeping.
|
||||||
fn frame_t(value: &serde_json::Value) -> i64 {
|
fn frame_t(value: &serde_json::Value) -> i64 {
|
||||||
|
|||||||
@@ -17,9 +17,11 @@
|
|||||||
//! R8 makes the platform multi-server: one sidecar per game server, and every row the module
|
//! R8 makes the platform multi-server: one sidecar per game server, and every row the module
|
||||||
//! stores carries the server it came from. The plugin declares its own `serverId` in `server.hello`
|
//! stores carries the server it came from. The plugin declares its own `serverId` in `server.hello`
|
||||||
//! and that is the authority. This setting is a **cross-check**, not a second source of truth: when
|
//! and that is the authority. This setting is a **cross-check**, not a second source of truth: when
|
||||||
//! both are set and they disagree, the sidecar logs the disagreement loudly and keeps the plugin's.
|
//! both are set and they disagree, the sidecar **refuses the plugin** — it closes the connection
|
||||||
//! Two servers pointed at one sidecar by a copy-pasted config is the mistake this catches, and it
|
//! before the frame is filed, and logs both ids at ERROR (D155). Two servers dialling one sidecar is
|
||||||
//! is silent in every other design.
|
//! the mistake this catches, and it is silent in every other design. It was a warning that kept the
|
||||||
|
//! plugin's id until the phase 18 walk showed what that costs: one server's history filed under
|
||||||
|
//! another's name, visible on the website. Left blank, nothing is checked.
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use serde_json::Value;
|
|||||||
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
use tracing::{info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
/// The longest line the sidecar will accept from the plugin, in bytes.
|
/// The longest line the sidecar will accept from the plugin, in bytes.
|
||||||
///
|
///
|
||||||
@@ -181,8 +181,12 @@ impl GameHandle {
|
|||||||
/// The **bound** address is returned alongside the handle rather than assumed to be the one asked
|
/// The **bound** address is returned alongside the handle rather than assumed to be the one asked
|
||||||
/// for: `127.0.0.1:0` is a legitimate thing to configure (and what the tests use), and a log line
|
/// for: `127.0.0.1:0` is a legitimate thing to configure (and what the tests use), and a log line
|
||||||
/// echoing the request rather than the result is the kind that is wrong exactly when it matters.
|
/// echoing the request rather than the result is the kind that is wrong exactly when it matters.
|
||||||
|
///
|
||||||
|
/// `server_id` is `[game].server_id`. When it is set, a plugin that names another server is
|
||||||
|
/// refused — see [`foreign_server_id`].
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
addr: &str,
|
addr: &str,
|
||||||
|
server_id: String,
|
||||||
event_tx: mpsc::UnboundedSender<GameEvent>,
|
event_tx: mpsc::UnboundedSender<GameEvent>,
|
||||||
) -> std::io::Result<(GameHandle, std::net::SocketAddr)> {
|
) -> std::io::Result<(GameHandle, std::net::SocketAddr)> {
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
@@ -197,7 +201,9 @@ pub async fn serve(
|
|||||||
match listener.accept().await {
|
match listener.accept().await {
|
||||||
Ok((stream, peer)) => {
|
Ok((stream, peer)) => {
|
||||||
info!(%peer, "plugin connected");
|
info!(%peer, "plugin connected");
|
||||||
if let Err(e) = handle_connection(stream, &event_tx, &accept_handle).await {
|
if let Err(e) =
|
||||||
|
handle_connection(stream, &server_id, &event_tx, &accept_handle).await
|
||||||
|
{
|
||||||
warn!(error = %e, "plugin connection ended");
|
warn!(error = %e, "plugin connection ended");
|
||||||
} else {
|
} else {
|
||||||
info!("plugin disconnected");
|
info!("plugin disconnected");
|
||||||
@@ -224,17 +230,44 @@ pub async fn serve(
|
|||||||
Ok((handle, bound))
|
Ok((handle, bound))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server a frame names, when it is not the one this sidecar is configured for.
|
||||||
|
///
|
||||||
|
/// `None` means the frame may pass: no `server_id` is configured, or the frame names none, or it
|
||||||
|
/// names this one. The plugin stamps `serverId` on every frame, so the first line of a connection
|
||||||
|
/// is enough to tell — and the check runs before that line reaches the store or the feed, because
|
||||||
|
/// by the time it is filed, one server's history is already under another's name (D155). That is
|
||||||
|
/// what the phase 18 walk saw when this was a warning: a second server's plugin, parked in this
|
||||||
|
/// listener's backlog, was accepted the moment the first one reloaded, and the website showed the
|
||||||
|
/// first server with the second's hostname and wipe.
|
||||||
|
fn foreign_server_id<'a>(configured: &str, frame: &'a Value) -> Option<&'a str> {
|
||||||
|
if configured.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match frame.get("serverId").and_then(|v| v.as_str()) {
|
||||||
|
Some(announced) if !announced.is_empty() && announced != configured => Some(announced),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_connection(
|
async fn handle_connection(
|
||||||
stream: tokio::net::TcpStream,
|
stream: tokio::net::TcpStream,
|
||||||
|
server_id: &str,
|
||||||
event_tx: &mpsc::UnboundedSender<GameEvent>,
|
event_tx: &mpsc::UnboundedSender<GameEvent>,
|
||||||
handle: &GameHandle,
|
handle: &GameHandle,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
stream.set_nodelay(true).ok();
|
stream.set_nodelay(true).ok();
|
||||||
let (read_half, mut write_half) = stream.into_split();
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
|
|
||||||
// Install the outbound command channel for this connection.
|
// The outbound command channel for this connection. With a `server_id` configured it is
|
||||||
|
// installed only once a frame has named this server: until then the peer could be a plugin
|
||||||
|
// about to be refused, and a command sent in that window — a permission grant, a world write —
|
||||||
|
// would land on the wrong server. `/health` reads the same handle, so it reports the plugin
|
||||||
|
// connected only from that moment too.
|
||||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<String>();
|
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<String>();
|
||||||
handle.set(Some(cmd_tx)).await;
|
let mut pending_tx = Some(cmd_tx);
|
||||||
|
if server_id.is_empty() {
|
||||||
|
handle.set(pending_tx.take()).await;
|
||||||
|
}
|
||||||
|
|
||||||
let mut reader = BufReader::new(read_half);
|
let mut reader = BufReader::new(read_half);
|
||||||
let mut lines = LineReader::default();
|
let mut lines = LineReader::default();
|
||||||
@@ -258,6 +291,24 @@ async fn handle_connection(
|
|||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
match serde_json::from_str::<Value>(trimmed) {
|
match serde_json::from_str::<Value>(trimmed) {
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
|
if let Some(announced) = foreign_server_id(server_id, &value) {
|
||||||
|
error!(
|
||||||
|
configured = server_id,
|
||||||
|
announced,
|
||||||
|
"refusing a plugin that names another server: this \
|
||||||
|
sidecar is '{server_id}', the plugin says \
|
||||||
|
'{announced}'. Two servers are dialling one game \
|
||||||
|
port — check each plugin config's Port against its \
|
||||||
|
sidecar's [game].bind"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if pending_tx.is_some()
|
||||||
|
&& value.get("serverId").and_then(|v| v.as_str())
|
||||||
|
== Some(server_id)
|
||||||
|
{
|
||||||
|
handle.set(pending_tx.take()).await;
|
||||||
|
}
|
||||||
let kind = value
|
let kind = value
|
||||||
.get("kind")
|
.get("kind")
|
||||||
.and_then(|k| k.as_str())
|
.and_then(|k| k.as_str())
|
||||||
@@ -396,7 +447,7 @@ mod tests {
|
|||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
let (handle, addr) = serve("127.0.0.1:0", tx).await.unwrap();
|
let (handle, addr) = serve("127.0.0.1:0", String::new(), tx).await.unwrap();
|
||||||
|
|
||||||
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||||
client
|
client
|
||||||
@@ -415,4 +466,67 @@ mod tests {
|
|||||||
let n = client.read(&mut buf).await.unwrap();
|
let n = client.read(&mut buf).await.unwrap();
|
||||||
assert_eq!(&buf[..n], b"{\"cmd\":\"ping\"}\n");
|
assert_eq!(&buf[..n], b"{\"cmd\":\"ping\"}\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_server_id_names_only_a_disagreement() {
|
||||||
|
let beta = serde_json::json!({ "kind": "server.hello", "serverId": "beta" });
|
||||||
|
let alpha = serde_json::json!({ "kind": "server.hello", "serverId": "alpha" });
|
||||||
|
let anonymous = serde_json::json!({ "kind": "pong" });
|
||||||
|
let blank = serde_json::json!({ "kind": "pong", "serverId": "" });
|
||||||
|
|
||||||
|
assert_eq!(foreign_server_id("alpha", &beta), Some("beta"));
|
||||||
|
assert_eq!(foreign_server_id("alpha", &alpha), None);
|
||||||
|
assert_eq!(foreign_server_id("alpha", &anonymous), None);
|
||||||
|
assert_eq!(foreign_server_id("alpha", &blank), None);
|
||||||
|
// Nothing configured: nothing to check against.
|
||||||
|
assert_eq!(foreign_server_id("", &beta), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// D155, over a real socket: the plugin that names another server is closed on its first
|
||||||
|
/// frame, the frame is never forwarded, and no command could have reached it meanwhile.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_plugin_naming_another_server_is_refused_before_anything_is_filed() {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let (handle, addr) = serve("127.0.0.1:0", "alpha".to_string(), tx).await.unwrap();
|
||||||
|
|
||||||
|
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||||
|
// Accepted, but not yet vetted: no command may go to it.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
assert!(!handle.is_connected().await);
|
||||||
|
|
||||||
|
client
|
||||||
|
.write_all(b"{\"kind\":\"server.hello\",\"type\":\"board\",\"serverId\":\"beta\"}\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut buf = [0u8; 16];
|
||||||
|
let n = tokio::time::timeout(std::time::Duration::from_secs(2), client.read(&mut buf))
|
||||||
|
.await
|
||||||
|
.expect("the sidecar closes a refused plugin")
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert_eq!(n, 0);
|
||||||
|
|
||||||
|
// The only thing forwarded is the listener's own link.down, never beta's hello.
|
||||||
|
let ev = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(ev.kind, "link.down");
|
||||||
|
assert!(!handle.is_connected().await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_command_channel_opens_on_the_first_frame_naming_this_server() {
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let (handle, addr) = serve("127.0.0.1:0", "alpha".to_string(), tx).await.unwrap();
|
||||||
|
|
||||||
|
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||||
|
client
|
||||||
|
.write_all(b"{\"kind\":\"server.hello\",\"serverId\":\"alpha\"}\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ev = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(ev.value["serverId"], "alpha");
|
||||||
|
assert!(handle.is_connected().await);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user