Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24307a95d4 | |||
| ec90d2d5ae | |||
| fb7511d700 | |||
| 161457dbf3 | |||
| 5c9cefdbc2 | |||
| 1f4ad25edb | |||
| 228e178475 | |||
| 0cdb046d30 | |||
| 791f13235e | |||
| c61f9f29ec | |||
| 7a0d0842ba | |||
| bc529962b4 | |||
| c4729315cd | |||
| 55a9ea4649 | |||
| 2e80a21597 | |||
| 3741c954f5 | |||
| 3fac860949 | |||
| 42bd6db6ad | |||
| 3453bc1867 | |||
| 72726838b5 | |||
| 9c35906d0e | |||
| 7304d68d0a | |||
| 3aa142bf06 | |||
| d69c2108c6 | |||
| 8aff9b33a6 | |||
| 5decd5cc05 | |||
| 885485d153 | |||
| 47da3a2db8 | |||
| 8172454683 | |||
| b89039142e | |||
| f93ba86883 | |||
| 481837e937 | |||
| 49d9e42a23 | |||
| 5686d9fd17 | |||
| 619081ec65 | |||
| 9c82a0c437 | |||
| 8be7e5607e | |||
| da1d74bdff | |||
| 140bc67fe1 | |||
| bfe9b5b18d | |||
| 6802dbf5f9 | |||
| 549b3a5ffd | |||
| b5f7fa0f13 | |||
| e3cc95f8ab | |||
| 4af731288a | |||
| 176ded67a8 | |||
| e7d5c8ed1a | |||
| f65d96ebb6 | |||
| 11d32436a4 | |||
| aefd752372 | |||
| 63c82d5a2f | |||
| 89557ddf66 | |||
| e4591af906 | |||
| 69ec0f6c46 | |||
| 845caef817 | |||
| 1777cb6ce1 | |||
| a64ae68158 | |||
| 21e40053ab | |||
| d21a9e2c0d | |||
| be8c09e6b3 | |||
| 65cef3e7c0 | |||
| e1789c1df4 | |||
| 4ad38d443b | |||
| 8b6e96086f | |||
| 1188a3142e | |||
| f71a2907fb | |||
| d140d5c66d | |||
| 72f6c557d2 | |||
| 5162496d74 | |||
| 3c3d154b36 | |||
| 34ca84f6be | |||
| 33c5178ef9 | |||
| bb3e25b5d7 | |||
| f2ca314d10 | |||
| 620ea63b54 | |||
| 11619a4dc6 | |||
| b83fa8174c | |||
| a5b9bf434f | |||
| 7eae4a1e62 | |||
| fa16f0ad2e | |||
| 564a234890 | |||
| b0143801a2 | |||
| 4035c523cb | |||
| b4f71c05cc | |||
| 0da8f44b55 | |||
| eb184aaac8 | |||
| 837b84580e | |||
| ea2a40320a | |||
| d89ebbdd23 | |||
| 8361b19c24 | |||
| cf63b902bf | |||
| aea94b5d7a | |||
| afb16112b0 | |||
| e0d13e73db | |||
| 77c90db338 | |||
| 0fb6767692 | |||
| 65b9ccd161 | |||
| 263be1df45 | |||
| 31b99fab61 | |||
| 478c52e7a1 | |||
| b10f11b057 | |||
| 80c05a3c1e | |||
| fee0b294a9 | |||
| 654a24585d | |||
| 36eae7ffa8 | |||
| 0efd5644f6 | |||
| 1b70cef5be | |||
| fb5a581a94 | |||
| cddba957d3 | |||
| 0670341198 | |||
| cf4d183181 | |||
| 0cb9bdd1f0 | |||
| ac0bcd850a | |||
| 42029734ad | |||
| cc185db26b | |||
| 9753dda4af | |||
| ba636c8939 | |||
| fab31f23e8 | |||
| a3bcec9cde | |||
| 36a5cb975a | |||
| d973db7a43 | |||
| 2f561b1103 | |||
| 648d3fd2e1 | |||
| 285db0baa7 | |||
| dc3c9689b4 | |||
| c94271104f | |||
| da1a393702 | |||
| be44839896 | |||
| 480a99f661 | |||
| c4dda5f85c | |||
| 383e89442e | |||
| 47756d392a | |||
| e54ae3afb9 | |||
| b1abd87c3d | |||
| f35e70e7d3 | |||
| 43147b796a | |||
| a1b6d155a1 | |||
| 0876a1d568 | |||
| f3e274b33d | |||
| 0a1e558942 | |||
| baffaa46c9 | |||
| 28e46771b1 | |||
| 8df850f73e | |||
| 7e1f037aad | |||
| 22fd8c5da7 | |||
| 5ce711048c | |||
| f211969ee1 |
@@ -130,6 +130,9 @@ jobs:
|
||||
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
|
||||
run: npm run check:swagger --prefix server
|
||||
|
||||
- name: Check the engagement freeze is current (PLAN.md §25)
|
||||
run: npm run check:engagement --prefix server
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -336,10 +336,13 @@ jobs:
|
||||
cp -r "server/$d" "$OUT/server/"
|
||||
done
|
||||
|
||||
# The client half is the BUILT chunk only. `client/src` is source an
|
||||
# operator has no use for and core will never read.
|
||||
# The client half is the BUILT chunks only. `client/src` is source an
|
||||
# operator has no use for and core will never read. Every `.js`, not
|
||||
# `entry.js` by name: since D120 the Map tab imports Leaflet's split
|
||||
# chunk from beside it, and a release that shipped the entry alone
|
||||
# would load everywhere and spin for ever on that tab.
|
||||
mkdir -p "$OUT/client/dist"
|
||||
cp client/dist/entry.js "$OUT/client/dist/"
|
||||
cp client/dist/*.js "$OUT/client/dist/"
|
||||
|
||||
# Prove the bundle is loadable before it is published: these are the
|
||||
# paths core's loader resolves out of module.json, and a release whose
|
||||
@@ -381,6 +384,22 @@ jobs:
|
||||
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
|
||||
|
||||
# The wire protocol this release speaks, for the public demo's wipe
|
||||
# (website/DEMO_MODE.md §7.5, DM12). The wipe picks the newest release
|
||||
# whose `protocol` EQUALS the Rust bundle's. The Rust link has no 409,
|
||||
# so a plugin on another protocol would mis-parse rather than be
|
||||
# refused. Read from the one declaration (`server/sidecarClient.js`);
|
||||
# a release that cannot find it fails here rather than ship a manifest
|
||||
# the wipe would skip. Core's installer reads only the fields it names
|
||||
# (modules/install.js), so the extra key changes nothing there.
|
||||
PROTOCOL="$(node -e '
|
||||
const src = require("fs").readFileSync("server/sidecarClient.js", "utf8");
|
||||
const m = /^const PROTOCOL_VERSION = (\d+)$/m.exec(src);
|
||||
if (!m) { console.error("no PROTOCOL_VERSION in server/sidecarClient.js"); process.exit(1); }
|
||||
console.log(m[1]);
|
||||
')"
|
||||
echo "protocol ${PROTOCOL}"
|
||||
|
||||
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||
# the model this project already has and a second one would be a second
|
||||
@@ -393,9 +412,10 @@ jobs:
|
||||
--arg artifact "module-rust-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--argjson protocol "$PROTOCOL" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size, protocol:$protocol}' \
|
||||
> "dist/module-rust-${VERSION}.json"
|
||||
|
||||
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
|
||||
178
README.md
178
README.md
@@ -36,17 +36,179 @@ rows here; the website core never learns there is more than one.
|
||||
| Surface | Route |
|
||||
|---|---|
|
||||
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
|
||||
| Player | `GET /api/v1/player/rust/servers` — the same, on the authenticated tier |
|
||||
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
|
||||
| Page | `/rust/servers` |
|
||||
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
|
||||
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
|
||||
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles, and on one wipe of a server with Economics their coin balance |
|
||||
| Public | `GET …/servers/:id/wipes` and `…/online` |
|
||||
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
|
||||
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
|
||||
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
|
||||
| Player | `GET /api/v1/player/rust/economics` — your coin balances on every server, per linked account |
|
||||
| Player | `GET /api/v1/player/rust/backpacks?serverId=&steamId=` — your own backpack on one server, read live |
|
||||
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
|
||||
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
|
||||
| Admin | `PUT …/servers/:id/titles`, `GET …/servers/:id/integrations`, `GET/PUT /api/v1/admin/rust/voice` — chat titles, the optional mods a server has, and the announcement voice |
|
||||
| Pages | `/rust` — the server list, and the module's landing page |
|
||||
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
|
||||
| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
|
||||
| Pages | `/admin/rust/servers` — add, edit, test and remove servers, set each one's wipe schedule and chat titles, and choose the announcement voice |
|
||||
| Discord | `/status`, `/wipe`, `/top`, `/online`, `/clan` — read-only, answered from this module's tables |
|
||||
| Teams | The deployment's Team provider: a first-party Rust clan is a Team |
|
||||
| Slot | `site.footer.status` — a live server/player count in core's footer |
|
||||
|
||||
Two tables, `rust_servers` (configuration) and `rust_server_state` (what each sidecar reported).
|
||||
**Nothing names who is online by default.** The Online list, every feed item that says a named
|
||||
player was on the server (connects, respawns, deaths, chat, gather tallies) and the leaderboard's
|
||||
"last seen" reach **staff** unless an operator widens them in Admin → Rust visibility — fleet-wide,
|
||||
with an optional override per server. How many players are online is public at every setting. The
|
||||
viewer's standing is re-read from the database on each request, so a demotion or a ban applies at
|
||||
once rather than when a token expires.
|
||||
|
||||
The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications,
|
||||
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
|
||||
Every page reads this module's own tables and never calls a game server, which is what lets the
|
||||
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
|
||||
sort all live in the URL, so any view of it is a link.
|
||||
|
||||
Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_presence` (observed
|
||||
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
|
||||
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
|
||||
|
||||
**Teams come from Rust's own clans**, not from the uMod Clans plugin, which is optional and whose
|
||||
clans never become Teams. A clan's roster reaches its own members and staff unless an operator
|
||||
widens it in Admin → Rust visibility; its name, colour, score and count are public. The game lists
|
||||
at most 100 clans per server, and a server at that ceiling answers core partially, so core never
|
||||
removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs
|
||||
one module**: core's installer refuses a second.
|
||||
|
||||
**The next wipe is the operator's to state** (Admin → Rust servers): a rule — the monthly forced
|
||||
wipe only, weekly or every other week, each in the server's own time zone and always including the
|
||||
forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date that replaces the next
|
||||
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
|
||||
The server list, the server page, the Android app and `/wipe` all show it.
|
||||
|
||||
**Rust's own world events need no plugin** (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.1). The patrol
|
||||
helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the
|
||||
satellite and the Deep Sea reach the feed (a **World events** filter) and each is an engagement trigger
|
||||
(`rust.world.<event>`), so a notice or an event's phase can follow one. The event and its grid are public;
|
||||
a player's name in it only for the presence audience, a Steam id only for staff. **Who downs a helicopter
|
||||
or a Bradley** is the operator's rule per server — killing blow (the default), last hit, most damage, or
|
||||
everyone who hurt it — and the same rule writes the line and counts toward **Rotorbane** and
|
||||
**Tankbreaker**. **Safecracker** (locked crates hacked) and **Scavenger** (airdrops looted first) are
|
||||
title categories with leaderboard columns, and the **Call a world event** step (`rust.world.call`) calls
|
||||
one now, kept when the run ends: an airdrop or a patrol helicopter can be sent to a point.
|
||||
|
||||
**Four uMod plugins are optional, and the module works without any of them** (`docs/modules/rust/PLAN.md`
|
||||
§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.
|
||||
|
||||
- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3
|
||||
playtime", "#1 kills" on the current wipe — shown in game chat and beside the name on the web and
|
||||
app leaderboards; and a **chat style** on any permission group this site authors, all twelve of
|
||||
BetterChat's group fields, mirrored like the group's permissions (a field changed in game is
|
||||
reported, never overwritten). Without it the titles still show on the web and the app, and the
|
||||
styles wait until it is installed.
|
||||
- **PopupNotifications** (k1lly0u, 0.2.1). With it: `rust.announce` and a server's news posts can be
|
||||
a popup instead of a chat line. Without it a popup is refused with a sentence saying so, and chat
|
||||
works as before.
|
||||
- **Economics** (Wulf, 3.9.2). With it: the **Pay coins** event step (`rust.coins.pay`, kept once
|
||||
paid), each player's balance on their account page and in the app, staff deposit / withdraw / set
|
||||
from a user's admin page, a **Coins** leaderboard column and the **Magnate** title — both ranking
|
||||
only balances above the server's starting balance. Without it each of those says "Economics is not
|
||||
installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2).
|
||||
- **Backpacks** (WhiteThunder, 3.17.8). With it: staff open any player's backpack from their admin
|
||||
page and a player sees their own on the account page and in the app — read live, never stored, and
|
||||
never written — and the **Bigger backpack** event step (`rust.backpack.size`) lends one of the
|
||||
server's `backpacks.size.N` sizes until the run ends, pushed like a kit reward. Without it each of
|
||||
those says "Backpacks is not installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.3).
|
||||
- **TruePVE** (nivex, 2.4.6). With it: the **Open a zone** step (`rust.zone.open`) can say **No PvP
|
||||
protection in this zone**, or name one of the server's TruePVE rulesets, which holds for the zone's
|
||||
life and goes with it. A ruleset TruePVE does not have fails the step when it runs, with the name.
|
||||
Without it either one says "TruePVE is not installed on this server"
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.2).
|
||||
- **Dynamic PVP** (HunterZ, 5.2.0; needs ZoneManager and TruePVE). With it: the PvP zones it opens
|
||||
around a downed patrol helicopter, a Bradley, the cargo ship and the rest show on the live map's
|
||||
**PvP zones** layer (public by default, set in Admin → Rust visibility) and as a public feed line —
|
||||
*"A PvP zone opened around the downed patrol helicopter · G7 · for 10 min"* — that names nobody.
|
||||
Its zones are its own: no step and no trigger (`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.3).
|
||||
- **Dangerous Treasures** (nivex, 3.0.0). With it: a treasure event starting and being won are public
|
||||
feed lines and triggers (*"Alice won the treasure · K12 · 3 players fought for it"*, the name only
|
||||
for the presence audience), a running chest is on the live map's world layer, the **Treasures**
|
||||
leaderboard column and the *Treasure Hunter* title count the chests a player emptied, and the step
|
||||
**Start a Dangerous Treasures event** opens one at a chosen difficulty where DT picks. In the first
|
||||
ten minutes after DT loads the step says "Dangerous Treasures is not ready yet" and tries again ten
|
||||
minutes later. Refused where DT is absent, or where the framework cannot run DT's console command
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.4).
|
||||
- **Guarded Crate** (Bazz3l, 2.0.17), with **NpcSpawn** (Codefling) for its guards. With both: a
|
||||
guarded crate landing and being cleared are public feed lines and triggers (*"Alice cleared the
|
||||
guarded crate · M4"*, the name only for the presence audience), a running crate is on the live map's
|
||||
world layer, the **Crates cleared** leaderboard column and the *Warden* title count the crates a
|
||||
player cleared by killing the last guard, and the step **Place a guarded crate** places one of its
|
||||
events at a monument or a point. The crate is kept when the run ends — Guarded Crate cannot end one
|
||||
alone — and the run log says so. Refused where Guarded Crate or NpcSpawn is absent, where the event
|
||||
is not one the server has, or where the framework cannot run its console command
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.5).
|
||||
- **RaidableBases** (nivex, 3.2.2, free or paid), with **CopyPaste** to paste its bases, and the
|
||||
**raids helper** `RunicGatewayRaids.cs` the plugin ships beside the bridge. With them: a base
|
||||
appearing, raided, bought or locked to a player are public feed lines (*"Alice and Bob raided a Hard
|
||||
raid base · G7"*, the names only for the presence audience), every live base is on the live map's own
|
||||
**Raid bases** layer (public by default, set in Admin → Rust visibility), the **Raid bases**
|
||||
leaderboard column and the *Warlord* title count the bases a player raided, and the step **Spawn a
|
||||
raid base** spawns one: at a difficulty, a profile or a named base, where RaidableBases picks or at a
|
||||
spot, with its own PvP, NPCs, despawn time and lock. That base is the event's, despawned when the run
|
||||
ends. A dry run checks a spot and says what is in the way, as advice. Every difficulty, profile and
|
||||
base name is the server's own. Without the helper the step can still ask RaidableBases for a base
|
||||
where it picks, but it despawns on its own timer, and the step says so. RaidableBases' profiles are
|
||||
editable from **Mod configuration**, reloaded and rolled back like any config
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §11.4).
|
||||
- **ZLevels Remastered** (nivex, 3.3.1). The servers page says whether it is loaded, and that is all:
|
||||
its levels are never read, and the leaderboard has no column for them. Its XP boosts are its own
|
||||
permissions (`zlevelsremastered.vip1`–`vip3`), so a *Double XP weekend* is a **Permissions until the event ends**
|
||||
step, which takes effect at once and is taken back when the run ends
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.6).
|
||||
|
||||
**Any other plugin, by console command.** **Run console commands** runs the commands an admin types
|
||||
(at most ten) once on one server, such as RaidableBases' `rbe`, and optional teardown commands when
|
||||
the run ends or is cancelled. **Reward by console command** runs them once for each person the kit
|
||||
reward's picker picks, with `{steamid}` and `{name}` filled in by the bridge, such as `sr add {steamid} 100`.
|
||||
Anyone offline at that moment is skipped and named in the run log. There is no allowlist. Both steps are
|
||||
rated `irreversible`, so they are off until an admin switches them on, only an admin can author them,
|
||||
and each needs its "I am sure" toggle ticked. A command the server does not know stops the step as
|
||||
`unknown-command`. The run log shows what the console answered: "it ran" is not "it worked". The
|
||||
site learns nothing afterwards, so none of it reaches the feed, the map or a title
|
||||
(`docs/modules/rust/PLAN_REDESIGNS.md` §16).
|
||||
|
||||
News and event lines said in chat can wear one styled group's title and colours — the
|
||||
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no
|
||||
player as its sender, so it looks the same with or without BetterChat.
|
||||
|
||||
**The Discord commands answer from the tables, never from a game server**, inside core's three-second
|
||||
budget. Every refusal is private. **An answer narrower than public goes to the caller alone:** a
|
||||
moderator's `/online` in a public channel shows the names to the moderator, never to the channel, and
|
||||
the same for a clan roster. Everything else is posted where it was asked.
|
||||
|
||||
The rest of the module arrives phase by phase. **Nothing is registered before it
|
||||
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
|
||||
both surfaces an operator can configure and then wait on, which is worse than an absent one.
|
||||
|
||||
### What a client feature-detects on
|
||||
|
||||
`module.json` declares six capability strings, and `GET /api/v1/public/modules` hands them to any
|
||||
client that asks — the website's own nav, and the Android app (`docs/modules/rust/PLAN.md` R10).
|
||||
Five of them name a surface: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`.
|
||||
|
||||
The sixth is `rust`, and it names **the module itself**. It looks redundant beside `id`, and it is
|
||||
not, for two reasons worth writing down before somebody tidies it away:
|
||||
|
||||
- **A client that asks "is this module installed" has nowhere else to ask.** Core flattens every
|
||||
started module's capabilities into one list, so `servers` alone is a word another module could
|
||||
declare tomorrow and silently reveal this one's screens. `rust` is the string that can only mean
|
||||
this module, and it is the single gate a whole navigation group hangs on — exactly the job `shard`
|
||||
does for `module-uo`.
|
||||
- **`id` answers a different question.** It is a *mount prefix* (§2.1 requires it to equal the
|
||||
directory core loads the module from), and `MODULE_API.md` §2.9 is explicit that a client must
|
||||
never infer a route from a capability. Gating on `id` would quietly make the two the same thing,
|
||||
and the day a client builds `/<id>/servers` from it, the contract that lets this module move its
|
||||
own pages is gone.
|
||||
|
||||
An unknown capability is absent, and no route is ever derived from one.
|
||||
|
||||
## Build and check
|
||||
|
||||
```bash
|
||||
@@ -128,8 +290,8 @@ directory; a symlink answers no and the module is skipped in complete silence.
|
||||
|
||||
Either way, the module appears when the process restarts: the volume is read at require time.
|
||||
|
||||
Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar
|
||||
printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
|
||||
Then, in Admin → Rust servers, add a server: its name, the sidecar's base URL, and the token the
|
||||
sidecar printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
|
||||
stored encrypted through core's own secret box and never returned to any client; the panel reports
|
||||
only whether one is set.
|
||||
|
||||
|
||||
@@ -28,15 +28,45 @@
|
||||
],
|
||||
"server": [
|
||||
"boot.js",
|
||||
"catalogue.js",
|
||||
"commands",
|
||||
"configEdit.js",
|
||||
"core.js",
|
||||
"dangerousTreasures.js",
|
||||
"db",
|
||||
"demo.js",
|
||||
"demoSeed.js",
|
||||
"dynamicPvp.js",
|
||||
"eventCommands.js",
|
||||
"engagement",
|
||||
"eventDangerousTreasures.js",
|
||||
"eventGuardedCrate.js",
|
||||
"eventLeases.js",
|
||||
"eventPermRewards.js",
|
||||
"eventRaidableBases.js",
|
||||
"eventProgress.js",
|
||||
"eventRewards.js",
|
||||
"eventTemplates.js",
|
||||
"eventWorld.js",
|
||||
"eventWorldCall.js",
|
||||
"guardedCrate.js",
|
||||
"index.js",
|
||||
"ingest.js",
|
||||
"liveStatus.js",
|
||||
"mapImages.js",
|
||||
"mapLive.js",
|
||||
"model",
|
||||
"npcSync.js",
|
||||
"package.json",
|
||||
"permSync.js",
|
||||
"raidableBases.js",
|
||||
"router",
|
||||
"sidecarClient.js"
|
||||
"sidecarClient.js",
|
||||
"titleSync.js",
|
||||
"worldEvents.js"
|
||||
],
|
||||
"root": [
|
||||
"engagement-triggers.json",
|
||||
"swagger-fragment.json",
|
||||
"LICENSE.md",
|
||||
"README.md"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet.",
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change).",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "efa9db73304552dd8bb7a84030b258c6320f79f7",
|
||||
"refName": "main @ MODULE_API 1.10.0, the Asset Bridge cutover 2 of 5 (website#202)"
|
||||
"ref": "69998ec54854d46681def92985a2b299ee3582a6",
|
||||
"refName": "website main @ website#224 merge, MODULE_API 1.19.0"
|
||||
}
|
||||
|
||||
8
client/package-lock.json
generated
8
client/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"license": "GPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"leaflet": "1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
@@ -1448,6 +1449,13 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.",
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"leaflet": "1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
|
||||
@@ -82,9 +82,10 @@ export function stringMask(src) {
|
||||
return inString
|
||||
}
|
||||
|
||||
// Static and dynamic imports that survived into the output. A relative or
|
||||
// absolute specifier is a chunk that was split, which this build does not do —
|
||||
// `lib` mode with one entry emits one file — so anything here is a bare name.
|
||||
// Static and dynamic imports that survived into the output. A relative
|
||||
// specifier is a chunk that was split — since D120 there is one, Leaflet's,
|
||||
// which the Map tab imports with `import()` — and is `relativeImports`'s
|
||||
// concern below; a bare name is this check's.
|
||||
//
|
||||
// **This pattern used to require whitespace after `import`, and so could not see
|
||||
// the one shape the build actually emits.** Minified Rollup output is
|
||||
@@ -116,6 +117,28 @@ export function bareImports(chunk) {
|
||||
return [...bare]
|
||||
}
|
||||
|
||||
/**
|
||||
* Every relative specifier the chunk imports — its split chunks (D120).
|
||||
*
|
||||
* Each one is a file the browser will ask for beside `entry.js`, and core
|
||||
* serves that directory, so it works in development. Whether it SHIPS is
|
||||
* `release.yml`'s business, which is why the script below also asserts every
|
||||
* one of them exists in `dist/`, and `test/build.test.js` asserts the release
|
||||
* copies every `.js` in `dist/` rather than naming `entry.js`. A split chunk the
|
||||
* release forgot is a Map tab that spins for ever on an operator's site while
|
||||
* every check here passes.
|
||||
*/
|
||||
export function relativeImports(chunk) {
|
||||
const masked = stringMask(chunk)
|
||||
const found = new Set()
|
||||
for (const match of chunk.matchAll(IMPORTS)) {
|
||||
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
||||
if (masked[keywordAt]) continue
|
||||
if (match[1].startsWith('./')) found.add(match[1].slice(2))
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
// Fingerprints from the shared libraries' own source. Each is a string those
|
||||
// packages ship and this module has no other reason to contain.
|
||||
//
|
||||
@@ -161,12 +184,30 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
|
||||
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
|
||||
const dist = path.dirname(CHUNK)
|
||||
const entry = fs.readFileSync(CHUNK, 'utf8')
|
||||
const problems = []
|
||||
|
||||
// Every chunk, not only the entry: a split chunk that bundled a second React
|
||||
// would load on the tab that imports it and fail there, and nowhere else.
|
||||
for (const file of fs.readdirSync(dist).filter((f) => f.endsWith('.js'))) {
|
||||
for (const p of problemsWith(fs.readFileSync(path.join(dist, file), 'utf8'))) problems.push(`${file}: ${p}`)
|
||||
}
|
||||
for (const name of relativeImports(entry)) {
|
||||
if (!fs.existsSync(path.join(dist, name))) {
|
||||
problems.push(`entry.js imports ./${name}, which is not in dist/ — the chunk would load and that import would fail`)
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||
for (const p of problems) console.error(` - ${p}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||
const split = relativeImports(entry)
|
||||
console.log(
|
||||
`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency` +
|
||||
(split.length ? `; its ${split.length} split chunk(s) (${split.join(', ')}) are present and clean.` : '.'),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,74 @@ const { request: req, BASE } = rg.api
|
||||
// registers under the `/rust` prefix `module.json` declares.
|
||||
export const servers = {
|
||||
list: () => req('/public/rust/servers'),
|
||||
|
||||
// One server, and the only route under `/servers/:id` that can answer "no such
|
||||
// server": the four below answer an empty list for an id nobody ever
|
||||
// configured, because an unknown server genuinely has no events.
|
||||
get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`),
|
||||
|
||||
// `kind` is a comma-separated list and `wipe` a wipe id; both are optional and
|
||||
// both are built here rather than in a page, so the query string this module
|
||||
// sends exists in one file.
|
||||
events: (id, { kinds = null, wipe = null, limit = null } = {}) =>
|
||||
req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({
|
||||
kind: kinds && kinds.length ? kinds.join(',') : null,
|
||||
wipe,
|
||||
limit,
|
||||
})}`),
|
||||
|
||||
leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) =>
|
||||
req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`),
|
||||
|
||||
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
|
||||
|
||||
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
|
||||
|
||||
// Phase 9. The clan list is public (D58): name, colour, score and member count
|
||||
// name nobody. `board` says whether the list can be trusted right now.
|
||||
clans: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/clans`),
|
||||
|
||||
// Phase 14. The map's picture address, geometry and which layers this viewer
|
||||
// gets; then what moves on it, already cut down to this viewer on the server.
|
||||
map: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map`),
|
||||
mapLive: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map/live`),
|
||||
|
||||
// RunicNPC (runicnpc stage 4). The profiles a leaderboard can rank by, one
|
||||
// profile's ranking counted as the profile says (D247, D250), and one
|
||||
// player's kills by profile, for an opened leaderboard row (D252).
|
||||
npcProfiles: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/npc-profiles`),
|
||||
npcLeaderboard: (id, { profile, wipe = null, limit = null } = {}) =>
|
||||
req(`/public/rust/servers/${encodeURIComponent(id)}/npc-leaderboard${query({ profile, wipe, limit })}`),
|
||||
npcKills: (id, steamId, { wipe = null } = {}) =>
|
||||
req(`/public/rust/servers/${encodeURIComponent(id)}/players/${encodeURIComponent(steamId)}/npc-kills${query({ wipe })}`),
|
||||
}
|
||||
|
||||
// One clan. Its roster comes back only for a viewer inside the operator's roster
|
||||
// audience (D48) — clan members and staff by default — and `roster.visible`
|
||||
// says which answer this was, so a page can explain an empty roster rather than
|
||||
// imply an empty clan.
|
||||
//
|
||||
// The id carries colons (`<server>:<clan>:<created>`). They are legal in a path
|
||||
// segment, and encoded anyway so that a server slug is never read as structure.
|
||||
export const clans = {
|
||||
get: (externalId) => req(`/public/rust/clans/${encodeURIComponent(externalId)}`),
|
||||
}
|
||||
|
||||
/**
|
||||
* A query string from the parameters that have a value, or `''`.
|
||||
*
|
||||
* **An absent parameter must be absent, not empty.** `?wipe=` is not the same
|
||||
* question as no `wipe` at all — the first asks for a wipe whose id is the empty
|
||||
* string — and a page that sends one because a `<select>` is on "All time" gets
|
||||
* an empty leaderboard and no error.
|
||||
*/
|
||||
function query(params) {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
|
||||
}
|
||||
const string = search.toString()
|
||||
return string ? `?${string}` : ''
|
||||
}
|
||||
|
||||
// ── player ────────────────────────────────────────────────────────────────
|
||||
@@ -35,6 +103,44 @@ export const playerServers = {
|
||||
list: () => req('/player/rust/servers'),
|
||||
}
|
||||
|
||||
// Your own kills of each RunicNPC profile, current wipes, across your linked accounts (D252).
|
||||
export const playerNpcKills = {
|
||||
list: () => req('/player/rust/npc-kills'),
|
||||
}
|
||||
|
||||
// R1's identity link, from the signed-in player's side.
|
||||
//
|
||||
// **The code is the whole of what goes up.** The site has no idea which server
|
||||
// minted it — nothing in six characters says — so the server half asks each
|
||||
// configured server in turn (D24). A page that asked the player to pick would be
|
||||
// asking them a question the site can answer itself, and a wrong pick would come
|
||||
// back indistinguishable from a wrong code.
|
||||
export const playerLinks = {
|
||||
list: () => req('/player/rust/links'),
|
||||
confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }),
|
||||
remove: (steamId) =>
|
||||
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// What the site has given the caller in game (phase 8). Read-only, and beside
|
||||
// `playerLinks` rather than under it: an entitlement exists whether or not an
|
||||
// account is linked yet, which is exactly the state worth showing.
|
||||
export const playerPermissions = {
|
||||
list: () => req('/player/rust/permissions'),
|
||||
}
|
||||
|
||||
// Your coin balances on every server, per linked account (PLAN_REDESIGNS §11.2.2):
|
||||
// live where a server answers, the last one it reported where it does not.
|
||||
export const playerEconomics = {
|
||||
list: () => req('/player/rust/economics'),
|
||||
}
|
||||
|
||||
// Your own backpack on one server, from one of your linked accounts
|
||||
// (PLAN_REDESIGNS §11.3.2): read live from the game, once per open.
|
||||
export const playerBackpacks = {
|
||||
read: (serverId, steamId) => req(`/player/rust/backpacks${query({ serverId, steamId })}`),
|
||||
}
|
||||
|
||||
// ── admin ─────────────────────────────────────────────────────────────────
|
||||
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
||||
// and a save that omits the field leaves the stored credential alone — so an
|
||||
@@ -48,10 +154,234 @@ export const admin = {
|
||||
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
testServer: (id) =>
|
||||
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
|
||||
// Phase 14: fetch a server's map picture again, or ask a server with no
|
||||
// picture to draw one — which stalls that game for seconds (D109).
|
||||
fetchMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/fetch`, { method: 'POST' }),
|
||||
renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }),
|
||||
// Phase 17: a server's chat titles, what optional mods it has, and the voice.
|
||||
saveTitles: (id, body) => req(`/admin/rust/servers/${encodeURIComponent(id)}/titles`, { method: 'PUT', body }),
|
||||
// PLAN_REDESIGNS §5.5 (D175): a category's title for the whole site; '' resets it.
|
||||
saveTitleCategory: (stat, text) =>
|
||||
req(`/admin/rust/title-categories/${encodeURIComponent(stat)}`, { method: 'PUT', body: { text } }),
|
||||
integrations: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/integrations`),
|
||||
voice: () => req('/admin/rust/voice'),
|
||||
saveVoice: (group) => req('/admin/rust/voice', { method: 'PUT', body: { group } }),
|
||||
}
|
||||
|
||||
// ── admin · permissions (R2) ──────────────────────────────────────────────
|
||||
//
|
||||
// The authoring surface. Every call here writes to the SITE, and none of them
|
||||
// reaches a game server — the mirror's own loop does that on its own cadence.
|
||||
// `sync` is the exception and says so in its name: it runs the pass now and
|
||||
// answers with what each server reported, which is the only call on this screen
|
||||
// that can be slow or fail because a game host is down.
|
||||
//
|
||||
// A write is followed by a re-read rather than a local edit of the model: what
|
||||
// the screen is showing is partly the game's answer, and the honest way to learn
|
||||
// the new one is to ask.
|
||||
const P = '/admin/rust/permissions'
|
||||
const enc = encodeURIComponent
|
||||
|
||||
export const adminPermissions = {
|
||||
overview: () => req(P),
|
||||
catalogue: () => req(`${P}/catalogue`),
|
||||
|
||||
// One server, as PermissionsManager shows one (D162).
|
||||
server: (serverId) => req(`${P}/servers/${enc(serverId)}`),
|
||||
players: (serverId, q) => req(`${P}/servers/${enc(serverId)}/players?q=${enc(q || '')}`),
|
||||
setPolicy: (serverId, policy) => req(`${P}/servers/${enc(serverId)}/policy`, { method: 'PUT', body: { policy } }),
|
||||
|
||||
// A subject is `{ steamId }` or `{ userId }`; `everywhere` reaches every server.
|
||||
grant: (serverId, subject, permissions, everywhere = false) =>
|
||||
req(`${P}/servers/${enc(serverId)}/grant`, { method: 'POST', body: { ...subject, permissions, everywhere } }),
|
||||
revoke: (serverId, subject, permissions, everywhere = false) =>
|
||||
req(`${P}/servers/${enc(serverId)}/revoke`, { method: 'POST', body: { ...subject, permissions, everywhere } }),
|
||||
removeException: (id) => req(`${P}/exceptions/${enc(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// Groups by id (D189). `here` is `{ onlyHere: true, serverId }` to split a
|
||||
// shared group's copy off first (D190), or null to change it everywhere.
|
||||
createGroup: (serverId, body) => req(`${P}/servers/${enc(serverId)}/groups`, { method: 'POST', body }),
|
||||
updateGroup: (id, body, here = null) => req(`${P}/groups/${enc(id)}`, { method: 'PATCH', body: { ...body, ...(here || {}) } }),
|
||||
deleteGroup: (id) => req(`${P}/groups/${enc(id)}`, { method: 'DELETE' }),
|
||||
setGroupPermissions: (id, permissions, here = null) =>
|
||||
req(`${P}/groups/${enc(id)}/permissions`, { method: 'PUT', body: { permissions, ...(here || {}) } }),
|
||||
setGroupServers: (id, body) => req(`${P}/groups/${enc(id)}/servers`, { method: 'PUT', body }),
|
||||
splitGroup: (id, serverId) => req(`${P}/groups/${enc(id)}/split`, { method: 'POST', body: { serverId } }),
|
||||
addMember: (id, subject, here = null) =>
|
||||
req(`${P}/groups/${enc(id)}/members`, { method: 'POST', body: { ...subject, ...(here || {}) } }),
|
||||
removeMember: (id, subject, here = null) =>
|
||||
req(`${P}/groups/${enc(id)}/members/remove`, { method: 'POST', body: { ...subject, ...(here || {}) } }),
|
||||
clearMembers: (id, here = null) => req(`${P}/groups/${enc(id)}/members/clear`, { method: 'POST', body: { ...(here || {}) } }),
|
||||
|
||||
// What waits for a person (D161).
|
||||
adoptDrift: (id) => req(`${P}/drift/${enc(id)}/adopt`, { method: 'POST' }),
|
||||
revokeDrift: (id) => req(`${P}/drift/${enc(id)}/revoke`, { method: 'POST' }),
|
||||
acceptDrift: (id) => req(`${P}/drift/${enc(id)}/accept`, { method: 'POST' }),
|
||||
restoreDrift: (id) => req(`${P}/drift/${enc(id)}/restore`, { method: 'POST' }),
|
||||
dismissDrift: (id) => req(`${P}/drift/${enc(id)}/dismiss`, { method: 'POST' }),
|
||||
|
||||
sync: (serverId = null) => req(`${P}/sync`, { method: 'POST', body: serverId ? { serverId } : {} }),
|
||||
}
|
||||
|
||||
// ── admin · visibility ────────────────────────────────────────────────────
|
||||
//
|
||||
// Who may see who is online. The org lead's rule is that nothing names who is
|
||||
// online by default; this is where an operator deliberately widens it. A save
|
||||
// answers the whole new state, so the screen re-renders from the server's word
|
||||
// rather than from what it sent.
|
||||
export const adminVisibility = {
|
||||
read: () => req('/admin/rust/visibility'),
|
||||
save: (body) => req('/admin/rust/visibility', { method: 'PUT', body }),
|
||||
}
|
||||
|
||||
// ── admin · zone presets (PLAN_REDESIGNS §3.1, D210) ───────────────────────
|
||||
//
|
||||
// An admin's named sets of ZoneManager flags and settings. `read` also carries
|
||||
// what each server's last hello said about ZoneManager and ZoneDomes, so the
|
||||
// page works while a server is off.
|
||||
export const adminZones = {
|
||||
read: () => req('/admin/rust/zones'),
|
||||
create: (body) => req('/admin/rust/zones/presets', { method: 'POST', body }),
|
||||
update: (id, body) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
||||
remove: (id) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// ── admin · NPCs (runicnpc stage 4) ─────────────────────────────────────────
|
||||
//
|
||||
// The site's RunicNPC profiles (per server, shared or fleet), pushed to each
|
||||
// server; and each server's placements, which live on the server (D222), so
|
||||
// every placement call is a live round trip that fails while the game is off.
|
||||
const npcServer = (serverId) => `/admin/rust/npcs/servers/${encodeURIComponent(serverId)}`
|
||||
export const adminNpcs = {
|
||||
read: () => req('/admin/rust/npcs'),
|
||||
setFactions: (factions) => req('/admin/rust/npcs/factions', { method: 'PUT', body: { factions } }),
|
||||
create: (body) => req('/admin/rust/npcs/profiles', { method: 'POST', body }),
|
||||
update: (id, body) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
||||
remove: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
restore: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}/restore`, { method: 'POST' }),
|
||||
push: (serverId) => req(`${npcServer(serverId)}/push`, { method: 'POST' }),
|
||||
placements: (serverId) => req(`${npcServer(serverId)}/placements`),
|
||||
place: (serverId, body) => req(`${npcServer(serverId)}/placements`, { method: 'POST', body }),
|
||||
setPlacement: (serverId, id, body) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
||||
removePlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
renamePlacement: (serverId, id, to) =>
|
||||
req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/rename`, { method: 'POST', body: { to } }),
|
||||
respawnPlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/respawn`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
// ── admin · mod configuration (R18) ───────────────────────────────────────
|
||||
//
|
||||
// Every call here is a LIVE round trip to a game host, which makes this the only
|
||||
// section of this file where a call can be slow, or fail because a server is
|
||||
// off. Nothing is cached anywhere between the browser and the host's disk: a
|
||||
// cached config is an edit an operator made over SSH that this website then
|
||||
// silently overwrote.
|
||||
//
|
||||
// `save` carries a `version` the host issued with the file. Send a stale one and
|
||||
// the answer is a 409 with the current file attached, rather than an overwrite
|
||||
// of whatever somebody else changed in the meantime.
|
||||
export const adminConfig = {
|
||||
files: (serverId) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/files`),
|
||||
|
||||
file: (serverId, path) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file${query({ path })}`),
|
||||
|
||||
// Two tiers, one route. `edits` is the generated form — pointers and literals,
|
||||
// type-preserving — and `text` is the raw document. A number travels as TEXT
|
||||
// in both: `1.0` parsed into a JavaScript number and sent back as `1` is the
|
||||
// whole failure this feature was designed around.
|
||||
save: (serverId, body) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file`, { method: 'POST', body }),
|
||||
|
||||
writes: (serverId, limit = null) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`),
|
||||
|
||||
// One write, polled while its reload settles (protocol 13). A save that
|
||||
// reloads a plugin answers before the reload has finished — behind a cold
|
||||
// compile that can be many seconds — and this is where the outcome lands.
|
||||
write: (serverId, id) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes/${encodeURIComponent(id)}`),
|
||||
}
|
||||
|
||||
// ── the admin.users.detail extension slot ─────────────────────────────────
|
||||
//
|
||||
// The client half of R13's first slot. Core hands the component a `userId` and
|
||||
// NOTHING else — not a client — so an extension builds its own bindings for the
|
||||
// routes it registered at the other end (§3.5). These two are the only calls in
|
||||
// this file whose path is core's rather than this module's: the resource is
|
||||
// core's user, and the module's own segment is the part after it.
|
||||
export const adminUserLinks = {
|
||||
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`),
|
||||
remove: (userId, steamId) =>
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
}
|
||||
|
||||
// The same panel's phase 7 half: what this person may do in game. The id in the
|
||||
// path is the one the slot handed the component, so these send `userId` rather
|
||||
// than a name — the screen already knows who it is looking at.
|
||||
export const adminUserPermissions = {
|
||||
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`),
|
||||
grant: (userId, body) =>
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
revoke: (userId, grantId) =>
|
||||
req(
|
||||
`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
}
|
||||
|
||||
// The same panel's Economics half (§11.2.2): this person's coins, and staff's
|
||||
// hand on them. `key` is one per submission, so a resent form moves nothing.
|
||||
export const adminUserEconomics = {
|
||||
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`),
|
||||
adjust: (userId, body) =>
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`, { method: 'POST', body }),
|
||||
}
|
||||
|
||||
// …and its Backpacks half (§11.3.2): what this person carries, read live. There
|
||||
// is no write, here or in the game (R2).
|
||||
export const adminUserBackpacks = {
|
||||
read: (userId, serverId, steamId) =>
|
||||
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
|
||||
}
|
||||
|
||||
// ── core's public event calendar ───────────────────────────────────────────
|
||||
// The one CORE route this module reads, and core published `runId` on it for
|
||||
// exactly this: a map marker holds a run id and nothing else, and the calendar is
|
||||
// how a client finds the event it belongs to (website EVENTS.md §I, Phase 14a).
|
||||
// It lists only announced events, so an unannounced one stays unnamed here too.
|
||||
export const coreEvents = {
|
||||
calendar: () => req('/public/events'),
|
||||
}
|
||||
|
||||
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||
// download link, an EventSource. Reach for `request` first.
|
||||
export { BASE }
|
||||
export { BASE, query }
|
||||
|
||||
export default { servers, playerServers, admin, BASE }
|
||||
export default {
|
||||
servers,
|
||||
clans,
|
||||
playerServers,
|
||||
playerLinks,
|
||||
playerPermissions,
|
||||
playerEconomics,
|
||||
playerBackpacks,
|
||||
admin,
|
||||
adminPermissions,
|
||||
adminConfig,
|
||||
adminVisibility,
|
||||
adminZones,
|
||||
adminNpcs,
|
||||
playerNpcKills,
|
||||
adminUserLinks,
|
||||
adminUserPermissions,
|
||||
adminUserEconomics,
|
||||
adminUserBackpacks,
|
||||
coreEvents,
|
||||
BASE,
|
||||
}
|
||||
|
||||
153
client/src/components/Backpack.jsx
Normal file
153
client/src/components/Backpack.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
// ── A Backpacks backpack, read live (PLAN_REDESIGNS §11.3.2) ──────────────
|
||||
//
|
||||
// One component for both readers — staff on a user's admin page, a player on
|
||||
// their own account page — because the backpack is the same thing to both and
|
||||
// only the route differs. The caller hands over `read(serverId, steamId)`.
|
||||
//
|
||||
// **Opening reads once; nothing polls.** A read runs on the game's main thread
|
||||
// (§11.3.1), so the panel asks when somebody asks it to and not again until
|
||||
// they do. Nothing is stored on the site either.
|
||||
//
|
||||
// **No icons (D340).** The module has no item art (R9 kept it out of the asset
|
||||
// bridge), so a slot says the game's own English name, which the plugin sends.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { count, shortId } from '../lib/format.js'
|
||||
import { backpackPages, overflowCount, stackLabel } from '../lib/backpack.js'
|
||||
|
||||
const CELL = 92
|
||||
|
||||
/** One slot: its name, amount, and what rides inside it. */
|
||||
function Cell({ cell }) {
|
||||
const { stack, overflow } = cell
|
||||
const border = overflow ? '1px dashed #d89b3c' : '1px solid var(--line-soft)'
|
||||
const title = stack
|
||||
? [stackLabel(stack), stack.name ? `“${stack.name}”` : null, stack.skin ? `skin ${stack.skin}` : null, overflow ? 'past the capacity' : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: overflow
|
||||
? 'past the capacity'
|
||||
: `slot ${cell.slot + 1}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
title={title}
|
||||
style={{
|
||||
border,
|
||||
borderRadius: 4,
|
||||
minHeight: 58,
|
||||
padding: '4px 6px',
|
||||
fontSize: '0.72rem',
|
||||
lineHeight: 1.25,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
background: stack ? 'transparent' : 'var(--line-soft)',
|
||||
opacity: stack ? 1 : 0.35,
|
||||
}}
|
||||
>
|
||||
{stack && (
|
||||
<>
|
||||
<span style={{ color: 'var(--head)', overflowWrap: 'anywhere' }}>{stackLabel(stack)}</span>
|
||||
{stack.name && <span className="dim" style={{ overflowWrap: 'anywhere' }}>“{stack.name}”</span>}
|
||||
{(stack.contents || []).map((c) => (
|
||||
<span key={c.slot} className="dim" style={{ overflowWrap: 'anywhere' }}>+ {stackLabel(c)}{c.amount > 1 ? ` ×${count(c.amount)}` : ''}</span>
|
||||
))}
|
||||
<span style={{ display: 'flex', justifyContent: 'space-between', gap: 4 }}>
|
||||
<span className="dim">{stack.skin ? 'skinned' : ''}</span>
|
||||
{stack.amount > 1 && <strong>×{count(stack.amount)}</strong>}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The grid itself, one block per page. */
|
||||
export function BackpackGrid({ backpack }) {
|
||||
if (!backpack.exists) {
|
||||
return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No backpack on {backpack.server} yet — it has never been opened.</p>
|
||||
}
|
||||
|
||||
const pages = backpackPages(backpack)
|
||||
const waiting = overflowCount(backpack)
|
||||
const held = (backpack.items || []).length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 8px' }}>
|
||||
{count(held)} stack{held === 1 ? '' : 's'} in {count(backpack.capacity)} slots on {backpack.server}
|
||||
{waiting > 0 && (
|
||||
<span style={{ color: '#d89b3c' }}>
|
||||
{' '}· {count(waiting)} past the capacity, moved to the owner’s inventory (or dropped at their feet if it is full) the next time they open it
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{pages.map((page) => (
|
||||
<div key={page.index} style={{ marginBottom: 10 }}>
|
||||
{pages.length > 1 && <div className="field-label" style={{ margin: '0 0 6px', fontSize: '0.7rem' }}>Page {page.index + 1}</div>}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(auto-fill, minmax(${CELL}px, 1fr))`, gap: 4 }}>
|
||||
{page.cells.map((cell) => (
|
||||
<Cell key={cell.slot} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a server and an account, open, read once. `servers` is `[{ id, name }]`,
|
||||
* `accounts` `[{ steamId, name }]`, and `read` the route's binding.
|
||||
*/
|
||||
export default function Backpack({ servers, accounts, read }) {
|
||||
const [serverId, setServerId] = useState(servers.length ? servers[0].id : '')
|
||||
const [steamId, setSteamId] = useState(accounts.length ? accounts[0].steamId : '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [backpack, setBackpack] = useState(null)
|
||||
|
||||
if (!servers.length || !accounts.length) return null
|
||||
|
||||
async function open(event) {
|
||||
event.preventDefault()
|
||||
if (busy || !serverId || !steamId) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
setBackpack(await read(serverId, steamId))
|
||||
} catch (err) {
|
||||
// "Backpacks is not installed on this server (Main)" is the server's sentence (D199).
|
||||
setBackpack(null)
|
||||
setError(err.message || 'That backpack could not be read.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={open} style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{servers.length > 1 && (
|
||||
<select className="input" value={serverId} onChange={(e) => setServerId(e.target.value)} aria-label="Server">
|
||||
{servers.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{accounts.length > 1 && (
|
||||
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
|
||||
{accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || shortId(a.steamId)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
{busy ? 'Reading…' : backpack ? 'Read again' : 'Open backpack'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '0 0 6px' }}>{error}</p>}
|
||||
{backpack && <BackpackGrid backpack={backpack} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
client/src/components/Clans.jsx
Normal file
118
client/src/components/Clans.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
// ── The clans on one server ───────────────────────────────────────────────
|
||||
//
|
||||
// Rust's OWN clans (R5), best score first. Public at every setting (D58): a
|
||||
// clan's name, colour, score and member count name nobody. Who is IN a clan is
|
||||
// the roster, and that lives on the clan's own page behind the operator's
|
||||
// roster audience (D48).
|
||||
//
|
||||
// **The list is only as good as the board it came from**, and the answer says
|
||||
// how good that is. Three cases would all look like an empty list if rendered
|
||||
// bare, and they are three different sentences:
|
||||
//
|
||||
// • the bridge cannot read this server's clans at all (an older plugin, or a
|
||||
// Nexus server whose clans live elsewhere) — "unavailable";
|
||||
// • the game's clan system is switched off — "this server has no clans";
|
||||
// • it can, and there are none — "nobody has founded one yet".
|
||||
//
|
||||
// And a board at the game's 100-clan ceiling (D55) says there may be more.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { count } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function Clans({ serverId }) {
|
||||
const { data, loading, error } = useAsync(() => api.servers.clans(serverId), [serverId])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
const clans = (data && data.clans) || []
|
||||
const board = (data && data.board) || {}
|
||||
|
||||
if (clans.length === 0) {
|
||||
if (!board.supported) {
|
||||
return (
|
||||
<Empty
|
||||
title="Clans are unavailable for this server"
|
||||
message={board.reason ? `${capitalise(board.reason)}.` : 'The bridge has not reported this server’s clans yet.'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (board.enabled === false) {
|
||||
return <Empty title="This server has no clans" message="Its operator has switched the game’s clan system off." />
|
||||
}
|
||||
return <Empty title="No clans yet" message="Nobody on this server has founded a clan." />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{board.truncated && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
The game lists at most 100 clans, by score, so there may be more on this server than are shown here.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{clans.map((clan, index) => (
|
||||
<li
|
||||
key={clan.externalId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
padding: '10px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', minWidth: 22, textAlign: 'right' }}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<Swatch color={clan.color} />
|
||||
<Link to={clanPath(clan.externalId)} style={{ fontWeight: 600, flex: 1, minWidth: 0 }}>
|
||||
{clan.name}
|
||||
</Link>
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
|
||||
{count(clan.memberCount)} {clan.memberCount === 1 ? 'member' : 'members'}
|
||||
</span>
|
||||
<span className="sans" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap', minWidth: 70, textAlign: 'right' }}>
|
||||
{count(clan.score)} pts
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Where a clan's page is: the same template the Team provider hands core. */
|
||||
export function clanPath(externalId) {
|
||||
return `/rust/clans/${encodeURIComponent(externalId)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A clan's colour, as a small square. The server has already checked it is a
|
||||
* `#rrggbb` — it ends up in a style — and a clan with no colour gets an outline
|
||||
* rather than a guess.
|
||||
*/
|
||||
export function Swatch({ color, size = 12 }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
flex: 'none',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 3,
|
||||
background: color || 'transparent',
|
||||
border: color ? 'none' : '1px solid var(--line)',
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function capitalise(text) {
|
||||
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text
|
||||
}
|
||||
26
client/src/components/Empty.jsx
Normal file
26
client/src/components/Empty.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// ── An empty state with a heading and a sentence ──────────────────────────
|
||||
//
|
||||
// Core's `EmptyState` renders its CHILDREN and nothing else. This module passed
|
||||
// it `title` and `message` from phase 4 onwards — the shape the Integration Kit's
|
||||
// template teaches — and React drops an unknown prop without a word, so every
|
||||
// empty panel in the module rendered as a blank box: "Nobody is on", "No scores
|
||||
// yet", "No servers yet", all of them. Found by the presence fix's browser walk,
|
||||
// when the "12 players online" it depended on came out as nothing.
|
||||
//
|
||||
// Fixed here rather than in core: core's component is shared by every module,
|
||||
// and a module-side wrapper changes nothing anybody else renders. The client
|
||||
// suite (`test/uiKitProps.test.js`) refuses a titled EmptyState so the mistake
|
||||
// cannot come back.
|
||||
|
||||
import { EmptyState } from '../core.js'
|
||||
|
||||
export default function Empty({ title, message }) {
|
||||
return (
|
||||
<EmptyState>
|
||||
{title && (
|
||||
<strong style={{ display: 'block', color: 'var(--head)', marginBottom: message ? 6 : 0 }}>{title}</strong>
|
||||
)}
|
||||
{message && <span>{message}</span>}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
157
client/src/components/Feed.jsx
Normal file
157
client/src/components/Feed.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
// ── The feed: what happened on one server ─────────────────────────────────
|
||||
//
|
||||
// Rows come from `/public/rust/servers/:id/events`, which serves a default-deny
|
||||
// ALLOWLIST (`server/catalogue.js`). Everything carrying an IP address, a
|
||||
// player's report about another player, or the grid square somebody's base is in
|
||||
// is stored and never answered here — so this component cannot leak one by
|
||||
// forgetting to filter, which is the point of the boundary living on the server.
|
||||
//
|
||||
// It polls (org lead, phase 4): every twenty seconds while the tab is visible,
|
||||
// paused when it is not. `usePolled` keeps the rows on screen across a refresh —
|
||||
// see the comment at the top of that file for why core's `useAsync` cannot do
|
||||
// this job.
|
||||
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||
import { ago, clock } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
import { hiddenMessage } from './Online.jsx'
|
||||
|
||||
const TONE = {
|
||||
kill: 'var(--accent-bright)',
|
||||
death: 'var(--muted)',
|
||||
join: 'var(--mode-live, #5fb98a)',
|
||||
leave: 'var(--dim)',
|
||||
chat: 'var(--text)',
|
||||
server: 'var(--mode-maint, #e6c26a)',
|
||||
other: 'var(--muted)',
|
||||
}
|
||||
|
||||
export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
const kinds = kindsFor(filter)
|
||||
|
||||
const { data, error, loading, at } = usePolled(
|
||||
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
|
||||
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
|
||||
// because what is on screen is an answer to a different one; a poll tick
|
||||
// does not, because it is the same question asked again.
|
||||
{ key: `${serverId}|${wipeId || ''}|${filter}`, intervalMs: 20_000 },
|
||||
)
|
||||
|
||||
const events = data ? data.events : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 16 }}
|
||||
>
|
||||
<label style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||
Showing{' '}
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => onFilter(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
{FILTERS.map((f) => (
|
||||
<option key={f.id} value={f.id}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* What a refresh is FOR: saying when the page last managed one. Without
|
||||
it a feed that stopped updating looks exactly like a quiet server. */}
|
||||
{at && (
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.74rem' }}>updated {ago(at)}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
|
||||
the last refresh failed — showing what we had
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
|
||||
{/* An error with nothing to fall back on is the only case that takes over
|
||||
the panel. A failed REFRESH keeps the rows and says so in the line
|
||||
above, because a site whose premise is "it renders while the game is
|
||||
off" must not blank itself the first time a request does. */}
|
||||
{error && !data && <ErrorState error={error} />}
|
||||
|
||||
{/* Below the operator's presence audience the server withholds every item
|
||||
that names a player who was on — the killfeed, chat, joins — and keeps
|
||||
only the server's own story. Said once, above the rows, so a thin feed
|
||||
reads as withheld rather than as a quiet server. */}
|
||||
{data && data.presenceHidden && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
Joins, deaths and chat are not shown. {hiddenMessage(data.presenceAudience, 'what players did')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && events.length === 0 && (
|
||||
<Empty
|
||||
title="Nothing here yet"
|
||||
message={
|
||||
data.presenceHidden
|
||||
? 'Nothing this server has reported about itself matches.'
|
||||
: 'Nothing this server has reported matches. A server that has just been added has no history until it says something.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{events.map((event) => {
|
||||
const line = describe(event)
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<time
|
||||
className="sans"
|
||||
dateTime={new Date(event.t).toISOString()}
|
||||
title={new Date(event.t).toLocaleString()}
|
||||
style={{ flex: 'none', color: 'var(--dim)', fontSize: '0.74rem', minWidth: '5.6rem' }}
|
||||
>
|
||||
{clock(event.t)}
|
||||
</time>
|
||||
<span style={{ color: TONE[line.tone] || 'var(--muted)', fontSize: '0.92rem' }}>
|
||||
{line.actor && <strong style={{ color: 'var(--ink)' }}>{line.actor}</strong>}
|
||||
{line.actor && (line.join || ' ')}
|
||||
{line.verb}
|
||||
{line.subject && ' '}
|
||||
{line.subject && <strong style={{ color: 'var(--ink)' }}>{line.subject}</strong>}
|
||||
{line.detail && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
{' · '}
|
||||
{line.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.78rem',
|
||||
}
|
||||
73
client/src/components/FooterStatus.jsx
Normal file
73
client/src/components/FooterStatus.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// ── This module's fill for core's `site.footer.status` slot ───────────────
|
||||
//
|
||||
// R13, and the contract is MODULE_API.md §3.7. Core owns the position in the
|
||||
// footer's info row and the separator around it, and passes `linkStyle` so the
|
||||
// row stays visually one row. **The label, the destination, the data and whether
|
||||
// anything renders at all are this component's** — that is the whole division,
|
||||
// and it is why the slot is named for a place rather than for a meaning.
|
||||
//
|
||||
// ── The live count, and what it costs ─────────────────────────────────────
|
||||
//
|
||||
// The org lead chose a live count ("3 servers · 42 online") over a static link,
|
||||
// so this fetches. Be clear-eyed about where it fetches from: core renders
|
||||
// `SiteFooter` inside `PublicLayout`, and every public page renders
|
||||
// `PublicLayout` ITSELF (§3.3) — so this component mounts once per public page
|
||||
// view, not once per session. Every public page on the site therefore carries one
|
||||
// `/public/rust/servers` request, including pages that have nothing to do with
|
||||
// Rust.
|
||||
//
|
||||
// Two things keep that honest rather than merely cheap:
|
||||
//
|
||||
// • **It renders NOTHING until it has an answer, and nothing again if the
|
||||
// request fails.** An unfilled slot renders nothing and core's `wrap` takes
|
||||
// the separator with it, so a failed fetch degrades to exactly the footer an
|
||||
// instance with no module installed has. A spinner in a footer would be worse
|
||||
// than silence on every page of the site.
|
||||
// • **It never polls.** One request per page view is a cost; a timer in the
|
||||
// footer of every page would be a different kind of thing entirely.
|
||||
//
|
||||
// If that per-page request ever shows up in an operator's logs as a problem, the
|
||||
// fix is a short-lived module-scope cache here — the decision to keep the number
|
||||
// live stays intact, and nothing else on the site has to change.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function FooterStatus({ linkStyle }) {
|
||||
const [summary, setSummary] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
|
||||
api.servers
|
||||
.list()
|
||||
.then(({ servers }) => {
|
||||
if (!live) return
|
||||
// `online` already accounts for staleness — the model refuses to let a
|
||||
// row that has not been written in five minutes claim a server is up —
|
||||
// so this is a sum, not a judgement.
|
||||
setSummary({
|
||||
servers: servers.length,
|
||||
players: servers.reduce((total, server) => total + (server.online ? server.players : 0), 0),
|
||||
})
|
||||
})
|
||||
// Silence, deliberately. This is the footer of every page on the site; a
|
||||
// module that cannot reach its own API has nothing to say there.
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!summary || summary.servers === 0) return null
|
||||
|
||||
return (
|
||||
<Link to="/rust" style={linkStyle}>
|
||||
{summary.servers === 1 ? '1 server' : `${summary.servers} servers`}
|
||||
{' · '}
|
||||
{summary.players === 1 ? '1 online' : `${summary.players} online`}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
302
client/src/components/Leaderboard.jsx
Normal file
302
client/src/components/Leaderboard.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
// ── The leaderboard ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Per wipe when a wipe is selected, all-time when it is not (R12). The two are
|
||||
// the same rows summed differently rather than two sets of counters, so they can
|
||||
// never disagree — which is worth knowing here because it means "All time" is
|
||||
// not a slower or less accurate answer, it is the same table without a WHERE.
|
||||
//
|
||||
// It does NOT poll. A leaderboard moves on the scale of a session; a table that
|
||||
// re-sorted itself under the reader's cursor every twenty seconds would be worse
|
||||
// than one that is four minutes old, and the page has a `Refresh` on the tab
|
||||
// strip for anybody who disagrees.
|
||||
//
|
||||
// RunicNPC (runicnpc stage 4). Where the server has NPC profiles, "Rank by"
|
||||
// offers each one's kills, counted as the profile says (D247, D250), and opening
|
||||
// a player's row shows their kills of each profile for the wipe shown (D252).
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { ago, coins, contrastInk, count, duration, shortId } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
|
||||
// and the column it maps to is this file's. Keeping them in one list is what
|
||||
// stops a header that sorts by something other than what it says.
|
||||
const COLUMNS = [
|
||||
{ key: 'kills', label: 'Kills', sort: 'kills', value: (r) => count(r.kills) },
|
||||
{ key: 'deaths', label: 'Deaths', sort: 'deaths', value: (r) => count(r.deaths) },
|
||||
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
|
||||
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
|
||||
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
|
||||
// Rust's own world events (protocol 14, D356, D377): the counts Safecracker
|
||||
// and Scavenger rank, on every server, since the game itself raises them.
|
||||
{ key: 'cratesHacked', label: 'Crates hacked', sort: 'cratesHacked', value: (r) => count(r.cratesHacked) },
|
||||
{ key: 'dropsLooted', label: 'Drops looted', sort: 'dropsLooted', value: (r) => count(r.dropsLooted) },
|
||||
]
|
||||
|
||||
// The richest (PLAN_REDESIGNS §11.2.2): the last known Economics balance. The
|
||||
// server adds it only for one wipe on a server that reports Economics, and says
|
||||
// so with `economics` on the answer — so the column is there exactly when the
|
||||
// rows carry it. Sorting by it ranks only balances above the starting balance
|
||||
// (D336), which is why a fresh wipe's sort shows nobody.
|
||||
// Treasure Hunter (PLAN_REDESIGNS §14.3.4, D375): Dangerous Treasures won. The
|
||||
// server says `dangerousTreasures` where the server reports the plugin, or the
|
||||
// rows already count a treasure, so the column is there exactly then.
|
||||
const TREASURES = { key: 'treasuresWon', label: 'Treasures', sort: 'treasuresWon', value: (r) => count(r.treasuresWon) }
|
||||
|
||||
// Warden (PLAN_REDESIGNS §14.3.5, D375): guarded crates cleared, shown on the
|
||||
// same rule from the server's `guardedCrate`.
|
||||
const GUARDED = { key: 'guardedWon', label: 'Crates cleared', sort: 'guardedWon', value: (r) => count(r.guardedWon) }
|
||||
|
||||
// Warlord (PLAN_REDESIGNS §11.4, D202): RaidableBases bases completed, shown on
|
||||
// the same rule from the server's `raidableBases`.
|
||||
const RAIDS = { key: 'raidsCompleted', label: 'Raid bases', sort: 'raidsCompleted', value: (r) => count(r.raidsCompleted) }
|
||||
|
||||
const BALANCE = { key: 'balance', label: 'Coins', sort: 'balance', value: (r) => coins(r.balance) }
|
||||
|
||||
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
const { data: npc } = useAsync(() => api.servers.npcProfiles(serverId).catch(() => ({ profiles: [] })), [serverId])
|
||||
const [profileId, setProfileId] = useState('')
|
||||
const profiles = (npc && npc.profiles) || []
|
||||
const picked = profiles.find((p) => String(p.id) === String(profileId))
|
||||
|
||||
const picker = profiles.length > 0 && (
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: '0.82rem', marginBottom: 10 }}>
|
||||
<span style={{ color: 'var(--dim)' }}>Rank by</span>
|
||||
<select
|
||||
value={profileId}
|
||||
onChange={(e) => setProfileId(e.target.value)}
|
||||
style={{ background: 'transparent', color: 'var(--ink)', border: '1px solid var(--line)', borderRadius: 6, padding: '4px 6px', font: 'inherit' }}
|
||||
>
|
||||
<option value="">Overall</option>
|
||||
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} kills</option>)}
|
||||
</select>
|
||||
{picked && <span style={{ color: 'var(--dim)' }}>{SCOPE_NOTE[picked.killsScope] || ''}</span>}
|
||||
</label>
|
||||
)
|
||||
|
||||
if (picked) {
|
||||
return (
|
||||
<div>
|
||||
{picker}
|
||||
<ProfileBoard serverId={serverId} wipeId={wipeId} profile={picked} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{picker}
|
||||
<Overall serverId={serverId} wipeId={wipeId} sort={sort} onSort={onSort} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** How a profile's kills are counted, in the reader's words (D247). */
|
||||
const SCOPE_NOTE = {
|
||||
server: 'Counted on this server.',
|
||||
name: 'Counted on every server with this NPC.',
|
||||
profile: 'Counted wherever this NPC is placed.',
|
||||
}
|
||||
|
||||
function Overall({ serverId, wipeId, sort, onSort }) {
|
||||
const [open, setOpen] = useState(null)
|
||||
const { data, loading, error } = useAsync(
|
||||
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
|
||||
[serverId, wipeId, sort],
|
||||
)
|
||||
|
||||
const rows = data ? data.leaderboard : []
|
||||
const columns = [
|
||||
...COLUMNS,
|
||||
...(data && data.dangerousTreasures ? [TREASURES] : []),
|
||||
...(data && data.guardedCrate ? [GUARDED] : []),
|
||||
...(data && data.raidableBases ? [RAIDS] : []),
|
||||
...(data && data.economics ? [BALANCE] : []),
|
||||
]
|
||||
// Present on every row or on none — the server decides per request.
|
||||
const showLastSeen = rows.some((row) => 'lastSeen' in row)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title="No scores yet"
|
||||
message={
|
||||
sort === 'balance' && wipeId
|
||||
? 'Nobody holds more coins than the starting balance on this wipe yet.'
|
||||
: wipeId
|
||||
? 'Nobody has done anything countable on this wipe yet.'
|
||||
: 'This server has not reported anything countable yet.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
|
||||
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
|
||||
{column.sort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column.sort)}
|
||||
aria-label={`Sort by ${column.label}`}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
color: column.sort === sort ? 'var(--accent-bright)' : 'var(--dim)',
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</button>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
{/* The server withholds `lastSeen` below the operator's presence
|
||||
audience — a gather tally refreshes it every minute somebody plays,
|
||||
so it would name who is online. The column goes with it rather
|
||||
than rendering a row of dashes that look like "never". */}
|
||||
{showLastSeen && (
|
||||
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => [
|
||||
<tr
|
||||
key={row.steamId}
|
||||
onClick={() => setOpen(open === row.steamId ? null : row.steamId)}
|
||||
title="Open for this player’s kills of each NPC"
|
||||
style={{ borderTop: '1px solid var(--line-soft, var(--line))', cursor: 'pointer' }}
|
||||
>
|
||||
<td style={cell}>
|
||||
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{open === row.steamId ? '▾' : '▸'} {index + 1}</span>
|
||||
{/* A player this module has never seen NAMED is shown by the tail
|
||||
of their id rather than as a blank: the row is real, and a
|
||||
nameless one reads as a rendering fault. */}
|
||||
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
|
||||
{/* The chat titles this player holds now (phase 17, D137) — the
|
||||
same ones the game shows, ranked on the current wipe whichever
|
||||
wipe this table is showing. Absent on an older module. */}
|
||||
{(row.titles || []).map((title, i) => (
|
||||
<span
|
||||
key={`${title.text}-${i}`}
|
||||
className="sans"
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
background: title.color,
|
||||
color: contrastInk(title.color),
|
||||
}}
|
||||
>
|
||||
{title.text}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
|
||||
{column.value(row)}
|
||||
</td>
|
||||
))}
|
||||
{showLastSeen && (
|
||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||
)}
|
||||
</tr>,
|
||||
open === row.steamId && (
|
||||
<tr key={`${row.steamId}-npcs`}>
|
||||
<td colSpan={columns.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
|
||||
<PlayerNpcKills serverId={serverId} steamId={row.steamId} wipeId={wipeId} />
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
])}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** An opened row (D252): the player's kills of each RunicNPC profile, for the wipe shown. */
|
||||
function PlayerNpcKills({ serverId, steamId, wipeId }) {
|
||||
const { data, loading, error } = useAsync(() => api.servers.npcKills(serverId, steamId, { wipe: wipeId }), [serverId, steamId, wipeId])
|
||||
if (loading) return <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>Loading…</span>
|
||||
if (error) return <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>Their NPC kills could not be loaded.</span>
|
||||
const kills = (data && data.kills) || []
|
||||
if (kills.length === 0) return <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>No kills of this server’s NPC profiles{wipeId ? ' this wipe' : ''}.</span>
|
||||
return (
|
||||
<span className="sans" style={{ fontSize: '0.82rem' }}>
|
||||
{kills.map((k, i) => (
|
||||
<span key={k.profile}>
|
||||
{i > 0 && <span style={{ color: 'var(--dim)' }}> · </span>}
|
||||
{k.label} <strong style={{ color: 'var(--ink)' }}>{count(k.kills)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** One profile's ranking (D250): who has killed the most of it. */
|
||||
function ProfileBoard({ serverId, wipeId, profile }) {
|
||||
const { data, loading, error } = useAsync(
|
||||
() => api.servers.npcLeaderboard(serverId, { profile: profile.id, wipe: wipeId, limit: 50 }),
|
||||
[serverId, wipeId, profile.id],
|
||||
)
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
const rows = (data && data.leaderboard) || []
|
||||
if (rows.length === 0) {
|
||||
return <Empty title="No kills yet" message={`Nobody has killed a ${profile.label}${wipeId ? ' this wipe' : ''} yet.`} />
|
||||
}
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||
<th style={cell}>Player</th>
|
||||
<th style={{ ...cell, textAlign: 'right' }}>{profile.label} kills</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<td style={cell}>
|
||||
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
|
||||
{(row.titles || []).map((title, i) => (
|
||||
<span
|
||||
key={`${title.text}-${i}`}
|
||||
style={{ marginLeft: 6, padding: '1px 6px', borderRadius: 999, fontSize: '0.7rem', fontWeight: 600, background: title.color, color: contrastInk(title.color) }}
|
||||
>
|
||||
{title.text}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td style={{ ...cell, textAlign: 'right' }}>{count(row.kills)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }
|
||||
454
client/src/components/MapView.jsx
Normal file
454
client/src/components/MapView.jsx
Normal file
@@ -0,0 +1,454 @@
|
||||
// ── The live map ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// R9's page, as PLAN.md §30.2 drew it: the picture of the current map under
|
||||
// four layers, each with its own switch, polled every ten seconds while the tab
|
||||
// is visible (D14) and not at all while it is hidden.
|
||||
//
|
||||
// **Nothing here decides who may see what.** The server sends only the layers
|
||||
// this viewer may see — a hidden one is absent from the answer, not present and
|
||||
// hidden — so the checkboxes below are a reader's convenience and never a
|
||||
// boundary. A layer the viewer cannot see is still LISTED, disabled, with who
|
||||
// can: "staff only" explains an empty map where silence would imply an empty
|
||||
// server (§23.3's shape).
|
||||
//
|
||||
// Leaflet arrives in a chunk of its own when this tab first mounts (D120, see
|
||||
// `lib/leaflet.js`). The page draws with circle and div markers only, so
|
||||
// Leaflet's image assets are never needed.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import { ago } from '../lib/format.js'
|
||||
import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
|
||||
import { eventTip, isBoss, pvpTip, raidTip, titlesFrom, unknownRuns } from '../lib/mapEvents.js'
|
||||
import api, { BASE } from '../api.js'
|
||||
|
||||
const STYLE_ID = 'rust-leaflet-css'
|
||||
|
||||
/** The narrowest a grid cell may be on screen, in pixels, and still carry its label. */
|
||||
const LABEL_MIN_CELL_PX = 30
|
||||
|
||||
/** The layers, in the order the legend lists them, with their marker colours. */
|
||||
const LAYERS = [
|
||||
{ id: 'world', label: 'Monuments & world events' },
|
||||
{ id: 'events', label: 'Site events' },
|
||||
{ id: 'players', label: 'Players' },
|
||||
{ id: 'bases', label: 'Bases' },
|
||||
{ id: 'pvp', label: 'PvP zones' },
|
||||
{ id: 'raids', label: 'Raid bases' },
|
||||
]
|
||||
|
||||
const COLOURS = {
|
||||
monument: '#e8d9a8',
|
||||
cargo: '#4fc3f7',
|
||||
heli: '#ef5350',
|
||||
chinook: '#ffa726',
|
||||
bradley: '#a1887f',
|
||||
supply: '#66bb6a',
|
||||
crate: '#ffee58',
|
||||
treasure: '#ffab00',
|
||||
guarded: '#ff7043',
|
||||
event: '#ce93d8',
|
||||
boss: '#d500f9',
|
||||
online: '#ffffff',
|
||||
sleeping: '#9e9e9e',
|
||||
tc: '#ff7043',
|
||||
vending: '#26a69a',
|
||||
self: '#00e5ff',
|
||||
mate: '#7cffb2',
|
||||
pvp: '#ff1744',
|
||||
raid: '#ff9100',
|
||||
raidOurs: '#ffd600',
|
||||
}
|
||||
|
||||
const WORLD_NAMES = {
|
||||
cargo: 'Cargo ship',
|
||||
heli: 'Patrol helicopter',
|
||||
chinook: 'Chinook',
|
||||
bradley: 'Bradley APC',
|
||||
supply: 'Supply drop',
|
||||
crate: 'Locked crate',
|
||||
// Dangerous Treasures (§14.3.4): its running chests.
|
||||
treasure: 'Dangerous Treasures chest',
|
||||
// Guarded Crate (§14.3.5): a crate whose guards still stand.
|
||||
guarded: 'Guarded crate',
|
||||
boss: 'Boss',
|
||||
}
|
||||
|
||||
const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
|
||||
|
||||
/**
|
||||
* `onPick` and `pins` are for an admin page that places things on the map (the NPC
|
||||
* placements page, runicnpc stage 4, D245): a click answers the world point under
|
||||
* it, and `pins` (`{ x, z, label, colour, ring }`) are drawn above every layer. The
|
||||
* public page passes neither.
|
||||
*/
|
||||
export default function MapView({ serverId, online, onPick = null, pins = null }) {
|
||||
const { data: meta, loading, error } = useAsync(() => api.servers.map(serverId), [serverId])
|
||||
const geometry = meta ? meta.geometry : null
|
||||
|
||||
const [leaflet, setLeaflet] = useState(null)
|
||||
const [leafletError, setLeafletError] = useState(null)
|
||||
const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, pvp: true, raids: true, mates: true })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
import('../lib/leaflet.js')
|
||||
.then((mod) => {
|
||||
if (!alive) return
|
||||
if (typeof document !== 'undefined' && !document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = STYLE_ID
|
||||
style.textContent = mod.css
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
setLeaflet(mod)
|
||||
})
|
||||
.catch((err) => alive && setLeafletError(err))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const anyLive = Boolean(meta && (LAYERS.some((l) => meta.layers[l.id].visible) || meta.mates.visible))
|
||||
const live = usePolled(() => api.servers.mapLive(serverId), {
|
||||
key: serverId,
|
||||
intervalMs: (meta && meta.pollMs) || 10_000,
|
||||
enabled: Boolean(geometry) && anyLive,
|
||||
})
|
||||
|
||||
// Event titles by run id, from core's public calendar (runicnpc D303): asked
|
||||
// when the events layer names a run this page does not know, and at most once
|
||||
// a minute, so a run that is not announced is not asked about on every poll.
|
||||
const [titles, setTitles] = useState({})
|
||||
const askedAt = useRef(0)
|
||||
const liveEvents = live.data && live.data.events
|
||||
useEffect(() => {
|
||||
if (!unknownRuns(liveEvents, titles).length || Date.now() - askedAt.current < 60_000) return undefined
|
||||
askedAt.current = Date.now()
|
||||
let active = true
|
||||
api.coreEvents
|
||||
.calendar()
|
||||
.then((calendar) => {
|
||||
if (!active) return
|
||||
// Every run asked about is filed, named or not, so it is not asked again.
|
||||
const known = titlesFrom(calendar)
|
||||
setTitles((prev) => {
|
||||
const next = { ...prev, ...known }
|
||||
for (const id of unknownRuns(liveEvents, prev)) if (!(id in next)) next[id] = null
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [liveEvents, titles])
|
||||
|
||||
const container = useRef(null)
|
||||
const mapRef = useRef(null)
|
||||
const groups = useRef(null)
|
||||
const pickRef = useRef(onPick)
|
||||
pickRef.current = onPick
|
||||
|
||||
// The map itself: made once per server and geometry, torn down with them.
|
||||
useEffect(() => {
|
||||
if (!leaflet || !geometry || !container.current) return undefined
|
||||
const { L } = leaflet
|
||||
const bounds = boundsOf(geometry)
|
||||
const map = L.map(container.current, {
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -4,
|
||||
maxZoom: 2,
|
||||
zoomSnap: 0.25,
|
||||
attributionControl: false,
|
||||
// Some things sail off the edge: the rig's cargo ship spent the probe
|
||||
// outside the picture entirely. The view may follow them a little way.
|
||||
maxBounds: L.latLngBounds(bounds).pad(0.5),
|
||||
})
|
||||
if (meta.picture) L.imageOverlay(`${BASE}${meta.picture.path}`, bounds).addTo(map)
|
||||
map.fitBounds(bounds)
|
||||
|
||||
const made = {}
|
||||
for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'pvp', 'raids', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map)
|
||||
// The labels are a layer of their own inside the grid's, shown only when a cell
|
||||
// is wide enough on screen to hold one: at the fitted zoom a 20-cell map's
|
||||
// labels overlap into a wall of text (found on the phase 14 walk).
|
||||
const labels = L.layerGroup()
|
||||
const cellPx = () => geometry.gridCellSize * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize) * 2 ** map.getZoom()
|
||||
const fitLabels = () => {
|
||||
const want = cellPx() >= LABEL_MIN_CELL_PX
|
||||
if (want && !made.grid.hasLayer(labels)) made.grid.addLayer(labels)
|
||||
if (!want && made.grid.hasLayer(labels)) made.grid.removeLayer(labels)
|
||||
}
|
||||
map.on('zoomend', fitLabels)
|
||||
|
||||
const g = grid(geometry)
|
||||
for (const [[x1, z1], [x2, z2]] of g.lines) {
|
||||
L.polyline([toLatLng(geometry, x1, z1), toLatLng(geometry, x2, z2)], {
|
||||
color: '#ffffff',
|
||||
weight: 1,
|
||||
opacity: 0.18,
|
||||
interactive: false,
|
||||
}).addTo(made.grid)
|
||||
}
|
||||
for (const label of g.labels) {
|
||||
L.marker(toLatLng(geometry, label.x, label.z), {
|
||||
interactive: false,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="font:600 10px/1 system-ui,sans-serif;color:rgba(255,255,255,.55);padding:2px 3px;display:block;white-space:nowrap">${label.text}</span>`,
|
||||
iconSize: null,
|
||||
iconAnchor: [0, 0],
|
||||
}),
|
||||
}).addTo(labels)
|
||||
}
|
||||
fitLabels()
|
||||
|
||||
for (const m of meta.monuments || []) {
|
||||
L.circleMarker(toLatLng(geometry, m.x, m.z), {
|
||||
radius: 4,
|
||||
color: '#000',
|
||||
weight: 1,
|
||||
fillColor: COLOURS.monument,
|
||||
fillOpacity: 0.9,
|
||||
})
|
||||
.bindTooltip(`${escape(m.label)} · ${escape(m.grid || gridLabel(geometry, m.x, m.z) || '')}`)
|
||||
.addTo(made.monuments)
|
||||
}
|
||||
|
||||
mapRef.current = map
|
||||
groups.current = made
|
||||
pickRef.current = onPick
|
||||
if (onPick) {
|
||||
container.current.style.cursor = 'crosshair'
|
||||
map.on('click', (e) => {
|
||||
const at = fromLatLng(geometry, e.latlng.lat, e.latlng.lng)
|
||||
const half = geometry.worldSize / 2
|
||||
if (at && pickRef.current && Math.abs(at.x) <= half && Math.abs(at.z) <= half) {
|
||||
pickRef.current({ x: Math.round(at.x * 10) / 10, z: Math.round(at.z * 10) / 10, grid: gridLabel(geometry, at.x, at.z) })
|
||||
}
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
groups.current = null
|
||||
}
|
||||
// `meta` is replaced only when the server changes, with the geometry.
|
||||
}, [leaflet, geometry]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// What moves: every group cleared and redrawn from the latest answer. The
|
||||
// counts are small — a busy server is a few hundred markers — and a redraw is
|
||||
// simpler to get right than a diff.
|
||||
useEffect(() => {
|
||||
const made = groups.current
|
||||
if (!leaflet || !made || !geometry) return
|
||||
const { L } = leaflet
|
||||
const answer = live.data || {}
|
||||
const at = (p) => toLatLng(geometry, p.x, p.z)
|
||||
for (const id of ['world', 'events', 'players', 'bases', 'pvp', 'raids', 'mates']) made[id].clearLayers()
|
||||
|
||||
for (const w of answer.world || []) {
|
||||
let tip = WORLD_NAMES[w.kind] || w.kind
|
||||
if (w.kind === 'crate' && w.hackLeftSec != null) tip += ` — ${countdown(w.hackLeftSec)} left on the hack`
|
||||
if (w.kind === 'crate' && w.hacked) tip += ' — hacked'
|
||||
// A RunicNPC boss outside any event (D302): its name, drawn as big as a ship.
|
||||
if (w.kind === 'boss' && w.name) tip = `${w.name} (boss)`
|
||||
const size = w.kind === 'cargo' || isBoss(w) ? 7 : 5
|
||||
dot(L, at(w), COLOURS[w.kind] || COLOURS.crate, size, isBoss(w) ? 2 : 1).bindTooltip(escape(tip)).addTo(made.world)
|
||||
}
|
||||
|
||||
const px = (metres) => metres * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize)
|
||||
for (const e of answer.events || []) {
|
||||
if (e.kind === 'zone') {
|
||||
L.circle(at(e), { radius: px(Number(e.radius) || 0), color: COLOURS.event, weight: 2, fillOpacity: 0.12 })
|
||||
.bindTooltip(escape(e.name ? `Event zone · ${e.name}` : 'Event zone'))
|
||||
.addTo(made.events)
|
||||
} else {
|
||||
// A name and its event; a boss bigger, never its health (D303).
|
||||
dot(L, at(e), isBoss(e) ? COLOURS.boss : COLOURS.event, isBoss(e) ? 8 : 5, isBoss(e) ? 2 : 1)
|
||||
.bindTooltip(escape(eventTip(e, titles)))
|
||||
.addTo(made.events)
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of answer.players || []) {
|
||||
dot(L, at(p), p.online ? COLOURS.online : COLOURS.sleeping, p.online ? 5 : 4)
|
||||
.bindTooltip(escape(`${p.name || p.steamId}${p.online ? (p.sleeping ? ' · sleeping' : '') : ' · asleep, offline'}`))
|
||||
.addTo(made.players)
|
||||
}
|
||||
|
||||
for (const b of answer.bases || []) {
|
||||
dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases)
|
||||
}
|
||||
|
||||
// Dynamic PVP's own zones (§14.3.3): a red ring with its event's name.
|
||||
for (const z of answer.pvp || []) {
|
||||
L.circle(at(z), { radius: px(Number(z.radius) || 0), color: COLOURS.pvp, weight: 2, fillOpacity: 0.1 })
|
||||
.bindTooltip(escape(pvpTip(z)))
|
||||
.addTo(made.pvp)
|
||||
}
|
||||
|
||||
// RaidableBases' bases (§11.4): orange, and yellow for one this site's event spawned.
|
||||
for (const r of answer.raids || []) {
|
||||
dot(L, at(r), r.runId ? COLOURS.raidOurs : COLOURS.raid, 7, 2)
|
||||
.bindTooltip(escape(raidTip(r, Date.now())))
|
||||
.addTo(made.raids)
|
||||
}
|
||||
|
||||
for (const m of answer.mates || []) {
|
||||
dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2)
|
||||
.bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
|
||||
.addTo(made.mates)
|
||||
}
|
||||
}, [leaflet, geometry, live.data, titles])
|
||||
|
||||
// An admin page's own pins, above everything else.
|
||||
useEffect(() => {
|
||||
const made = groups.current
|
||||
if (!leaflet || !made || !geometry) return
|
||||
const { L } = leaflet
|
||||
made.pins.clearLayers()
|
||||
for (const p of pins || []) {
|
||||
const marker = dot(L, toLatLng(geometry, p.x, p.z), p.colour || '#ffd54f', p.ring ? 9 : 6, p.ring ? 3 : 1)
|
||||
if (p.label) marker.bindTooltip(escape(p.label))
|
||||
marker.addTo(made.pins)
|
||||
}
|
||||
}, [leaflet, geometry, pins])
|
||||
|
||||
// The legend's checkboxes: a group is on the map or off it.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
const made = groups.current
|
||||
if (!map || !made) return
|
||||
const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, pvp: shown.pvp, raids: shown.raids, mates: shown.mates }
|
||||
for (const [id, on] of Object.entries(want)) {
|
||||
if (on && !map.hasLayer(made[id])) made[id].addTo(map)
|
||||
if (!on && map.hasLayer(made[id])) map.removeLayer(made[id])
|
||||
}
|
||||
}, [shown, leaflet, geometry])
|
||||
|
||||
const status = useMemo(() => liveStatus(live, anyLive, online), [live, anyLive, online])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
if (!geometry) {
|
||||
return (
|
||||
<Empty
|
||||
title="No map yet"
|
||||
message="This server has not described its map to the site yet. It will appear once the server is up and has said which map it is on."
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (leafletError) return <ErrorState error={leafletError} />
|
||||
|
||||
return (
|
||||
<div className="sans">
|
||||
{!meta.picture && (
|
||||
<p style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
This server has no picture of its map, so the layers are drawn on a plain background.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 0 }}>
|
||||
<div
|
||||
ref={container}
|
||||
role="application"
|
||||
aria-label="Map of the server"
|
||||
style={{
|
||||
height: 'min(70vh, 760px)',
|
||||
minHeight: 320,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--line)',
|
||||
background: geometry.background || '#0b3b4a',
|
||||
}}
|
||||
/>
|
||||
{!leaflet && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center' }}>
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p style={{ color: 'var(--dim)', fontSize: '0.78rem', margin: '8px 0 16px' }}>{status}</p>
|
||||
|
||||
<Legend meta={meta} live={live.data} shown={shown} onToggle={(id) => setShown((s) => ({ ...s, [id]: !s[id] }))} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Legend({ meta, live, shown, onToggle }) {
|
||||
const row = (id, label, swatches, visible, note) => (
|
||||
<li key={id} style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: visible ? 'pointer' : 'default', color: visible ? 'var(--ink)' : 'var(--dim)' }}>
|
||||
<input type="checkbox" checked={visible && shown[id]} disabled={!visible} onChange={() => onToggle(id)} />
|
||||
{label}
|
||||
</label>
|
||||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||||
{swatches.map((c) => (
|
||||
<span key={c} aria-hidden="true" style={{ width: 10, height: 10, borderRadius: '50%', background: c, display: 'inline-block', border: '1px solid rgba(0,0,0,.4)' }} />
|
||||
))}
|
||||
</span>
|
||||
{note && <span style={{ color: 'var(--dim)', fontSize: '0.76rem', marginLeft: 'auto', textAlign: 'right' }}>{note}</span>}
|
||||
</li>
|
||||
)
|
||||
|
||||
const hiddenNote = (layer) => {
|
||||
const words = AUDIENCE_WORDS[layer.audience] || AUDIENCE_WORDS.staff
|
||||
if (layer.audience === 'signed_in') return 'Sign in to see this layer.'
|
||||
return `Shown to ${words}.`
|
||||
}
|
||||
|
||||
const swatches = {
|
||||
world: [COLOURS.monument, COLOURS.cargo, COLOURS.heli, COLOURS.crate],
|
||||
events: [COLOURS.event],
|
||||
players: [COLOURS.online, COLOURS.sleeping],
|
||||
bases: [COLOURS.tc, COLOURS.vending],
|
||||
pvp: [COLOURS.pvp],
|
||||
raids: [COLOURS.raid, COLOURS.raidOurs],
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem' }}>
|
||||
{row('grid', 'Grid', [], true, null)}
|
||||
{LAYERS.map((l) => {
|
||||
const layer = meta.layers[l.id]
|
||||
let note = layer.visible ? null : hiddenNote(layer)
|
||||
if (l.id === 'players' && layer.cappedByPresence && !layer.visible) {
|
||||
note = `${note} Limited by who may see who is online.`
|
||||
}
|
||||
if (layer.visible && l.id === 'players' && live && live.playersTruncated) note = 'Not every sleeper is shown.'
|
||||
if (layer.visible && l.id === 'bases' && live && live.basesTruncated) note = 'Not every base is shown.'
|
||||
return row(l.id, l.label, swatches[l.id], layer.visible, note)
|
||||
})}
|
||||
{meta.mates.visible &&
|
||||
row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], true, 'Your own position, and clan mates who are online.')}
|
||||
{!meta.mates.visible && meta.mates.on && meta.mates.signedIn && !meta.mates.linked &&
|
||||
row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], false, 'Link your Steam account to see yourself and your clan here.')}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function dot(L, latlng, colour, radius, weight = 1) {
|
||||
return L.circleMarker(latlng, { radius, color: '#000', weight, fillColor: colour, fillOpacity: 0.95 })
|
||||
}
|
||||
|
||||
/** Tooltips are HTML in Leaflet, and a player's name is text they typed. */
|
||||
function escape(text) {
|
||||
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
||||
}
|
||||
|
||||
function liveStatus(live, anyLive, online) {
|
||||
if (!anyLive) return 'No moving layers are shown to you on this server.'
|
||||
if (live.loading) return 'Asking the server where things are…'
|
||||
const data = live.data
|
||||
if (data && data.live === false) {
|
||||
return online
|
||||
? 'The server did not say where things are just now; it is asked again every ten seconds.'
|
||||
: 'The server is offline, so nothing is moving on its map.'
|
||||
}
|
||||
if (live.error && !data) return 'Positions could not be loaded.'
|
||||
return live.at ? `Positions as of ${ago(new Date(live.at).toISOString())}, refreshed every ten seconds while this tab is open.` : ''
|
||||
}
|
||||
122
client/src/components/Online.jsx
Normal file
122
client/src/components/Online.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// ── Who is on the server right now ────────────────────────────────────────
|
||||
//
|
||||
// Read from the presence BOARD, not counted from connect and disconnect events:
|
||||
// the bridge re-sends the whole board on every connect and every sixty seconds,
|
||||
// so this is right even after the website has missed something (PROTOCOL.md
|
||||
// §8.3). Counting transitions instead would drift, and drift in the direction
|
||||
// people notice — players who never left.
|
||||
//
|
||||
// It polls with the feed, because "who is on" is the one thing on this page that
|
||||
// is a live question.
|
||||
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { duration, shortId } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function Online({ serverId, online }) {
|
||||
const { data, error, loading } = usePolled(() => api.servers.online(serverId), {
|
||||
key: serverId,
|
||||
intervalMs: 20_000,
|
||||
})
|
||||
|
||||
const players = data ? data.players : []
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !data) return <ErrorState error={error} />
|
||||
|
||||
// Nothing names who is online by default (the org lead's rule). Below the
|
||||
// operator's audience the server answers a count and no names, and the page
|
||||
// says so — an empty list here would read as "nobody is on", which is a
|
||||
// different claim and a false one.
|
||||
if (data && data.hidden) {
|
||||
const count = Number(data.count) || 0
|
||||
return (
|
||||
<Empty
|
||||
title={online ? `${count.toLocaleString()} ${count === 1 ? 'player' : 'players'} online` : 'The server is offline'}
|
||||
message={hiddenMessage(data.audience)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (players.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title={online ? 'Nobody is on' : 'The server is offline'}
|
||||
message={
|
||||
online
|
||||
? 'The server is up and the island is empty. Somebody has to be first.'
|
||||
: 'Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* A board is the last one that ARRIVED, and an unreachable sidecar does not
|
||||
clear it — deliberately, because the rows are still the best answer
|
||||
anybody has. But presented bare they read as "these people are on right
|
||||
now", which is the one thing an offline server cannot be saying. The
|
||||
page walk found this with a fixture server whose header said Offline
|
||||
above three apparently-connected players. */}
|
||||
{!online && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
This server is offline. Below is the last board it sent, not who is on it now.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{players.map((player) => (
|
||||
<li
|
||||
key={player.steamId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{player.name || shortId(player.steamId)}</strong>
|
||||
{/* Sleeping is not idle and not offline — a sleeping player's body is
|
||||
in the world and can be killed, which is why the board carries the
|
||||
flag at all. */}
|
||||
{player.sleeping && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}> · sleeping</span>
|
||||
)}
|
||||
</span>
|
||||
{/* `connectedAt` is absent for a player who was already on when the
|
||||
plugin loaded — an unknown session length, which is not a session of
|
||||
no length. Saying nothing is the honest render of that. */}
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
{player.connectedAt ? `on for ${sessionSoFar(player.connectedAt)}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** How long a player has been on, from the DATETIME the board reported. */
|
||||
function sessionSoFar(connectedAt) {
|
||||
const since = Date.parse(connectedAt)
|
||||
if (Number.isNaN(since)) return ''
|
||||
return duration((Date.now() - since) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why something was withheld, in words a visitor can act on.
|
||||
*
|
||||
* `what` completes the sentence — "who they are", "what players did". The
|
||||
* audience is the operator's (`staff` unless widened), and only `signed_in` is
|
||||
* something a visitor can do anything about.
|
||||
*/
|
||||
export function hiddenMessage(audience, what = 'who they are') {
|
||||
if (audience === 'signed_in') return `Sign in to see ${what}.`
|
||||
if (audience === 'public') return `This site is not showing ${what} right now.`
|
||||
return `Only this site’s staff can see ${what}.`
|
||||
}
|
||||
63
client/src/components/Tabs.jsx
Normal file
63
client/src/components/Tabs.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// ── Tabs, bundled rather than borrowed ────────────────────────────────────
|
||||
//
|
||||
// The shared kit is nine members and it is CLOSED (MODULE_API.md §3.4): layout,
|
||||
// headings, the three data-page states, the fetch hook, the session, the site
|
||||
// and `Slot`. A tab strip is not in it, so it is here — which is the kit working
|
||||
// as designed rather than a gap in it. What the kit guarantees is that a module
|
||||
// page looks like the site while it loads and while it fails; everything a page
|
||||
// builds on top of that is the module's own.
|
||||
//
|
||||
// It is styled with core's CSS VARIABLES and its `.pill` class rather than with
|
||||
// colours of its own, so it re-themes with the instance (THEMING_AND_NAV.md).
|
||||
// The one class this module must never write by hand is the shell wrapper —
|
||||
// `PublicLayout`'s `shell` prop exists precisely so that one stays core's.
|
||||
//
|
||||
// **The selected tab lives in the URL, not in this component.** A tab strip that
|
||||
// owned its own state would make every panel on this page unlinkable: "look at
|
||||
// the leaderboard for this server" would be a sentence rather than a link, back
|
||||
// would leave the page entirely, and a refresh would land on the first tab. So
|
||||
// this is a controlled component and `ServerDetail` keeps the state in a search
|
||||
// parameter.
|
||||
|
||||
export default function Tabs({ tabs, active, onSelect, label = 'Sections' }) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
borderBottom: '1px solid var(--line)',
|
||||
paddingBottom: 12,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === active
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 'var(--radius-pill, 999px)',
|
||||
fontSize: '0.82rem',
|
||||
letterSpacing: '0.04em',
|
||||
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: selected ? 'var(--blue)' : 'transparent',
|
||||
color: selected ? 'var(--accent-bright)' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
client/src/components/WipeSelect.jsx
Normal file
54
client/src/components/WipeSelect.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// ── "This wipe" or "All time" ─────────────────────────────────────────────
|
||||
//
|
||||
// One control, used by two panels, because the wipe is a property of the PAGE
|
||||
// rather than of the feed or the leaderboard — a reader who has chosen last
|
||||
// month's map means it for both, and two selects that could disagree is a page
|
||||
// that shows one wipe's kills next to another's leaderboard.
|
||||
//
|
||||
// It loads the wipe list itself. That is a second request for the same list the
|
||||
// Wipes tab fetches, and it is the right trade: the alternative is the page
|
||||
// fetching it on mount for a control most visitors never touch, on every visit,
|
||||
// for every server.
|
||||
|
||||
import { useAsync } from '../core.js'
|
||||
import { day } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
/** The value that means "no wipe filter at all". Never the empty string — see `api.js`'s `query`. */
|
||||
export const ALL_TIME = 'all'
|
||||
|
||||
export default function WipeSelect({ serverId, value, onChange, currentWipeId }) {
|
||||
const { data } = useAsync(() => api.servers.wipes(serverId), [serverId])
|
||||
const wipes = data ? data.wipes : []
|
||||
|
||||
// A server with one wipe has nothing to choose between, so the control is not
|
||||
// offered. "All time" and "this wipe" are the same answer there, and a select
|
||||
// with one real option is furniture that invites a question with no answer.
|
||||
if (wipes.length < 2) return null
|
||||
|
||||
return (
|
||||
<label className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
||||
Wipe{' '}
|
||||
<select
|
||||
value={value || ALL_TIME}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
style={{
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
<option value={ALL_TIME}>All time</option>
|
||||
{wipes.map((wipe) => (
|
||||
<option key={wipe.wipeId} value={wipe.wipeId}>
|
||||
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||
{wipe.wipeId === currentWipeId ? ' (current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
86
client/src/components/Wipes.jsx
Normal file
86
client/src/components/Wipes.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── Every wipe this server has had ────────────────────────────────────────
|
||||
//
|
||||
// The list is what makes the rest of the page navigable — picking a wipe here
|
||||
// filters the feed and the leaderboard — and it is also the proof R12 asks for:
|
||||
// a wipe that ended is still here, with its record still attached. A Rust server
|
||||
// wipes monthly, and a community site that forgot the previous map every time
|
||||
// would throw away most of what it knows about its own players.
|
||||
//
|
||||
// `wipeId` is derived by the bridge PLUGIN from the save's creation time and
|
||||
// stamped on every frame (PROTOCOL.md §8.2), so the id in this list is the same
|
||||
// id the events and the leaderboard filter by. There is no second derivation
|
||||
// anywhere that could disagree.
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { ago, day } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
|
||||
const { data, loading, error } = useAsync(() => api.servers.wipes(serverId), [serverId])
|
||||
const wipes = data ? data.wipes : []
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (wipes.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
title="No wipes recorded"
|
||||
message="A wipe appears here once this server has reported something during it."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{wipes.map((wipe) => {
|
||||
const current = wipe.wipeId === currentWipeId
|
||||
const active = wipe.wipeId === selected
|
||||
return (
|
||||
<li key={wipe.wipeId} style={{ borderBottom: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(wipe.wipeId)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: 12,
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 6px',
|
||||
cursor: 'pointer',
|
||||
background: active ? 'var(--blue)' : 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>
|
||||
{/* The save's creation time is the wipe's own date; `firstSeen`
|
||||
is when THIS website first heard about it, and they differ
|
||||
by however long the module was not installed. The first is
|
||||
the wipe, so it leads. */}
|
||||
{day(wipe.saveCreatedAt || wipe.firstSeen)}
|
||||
</strong>
|
||||
{current && (
|
||||
<span className="sans" style={{ color: 'var(--mode-live, #5fb98a)', fontSize: '0.74rem' }}>
|
||||
{' · current'}
|
||||
</span>
|
||||
)}
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem' }}>
|
||||
{wipe.wipeId}
|
||||
</span>
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
last heard {ago(wipe.lastSeen)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,19 @@
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
|
||||
import Servers from './routes/public/Servers.jsx'
|
||||
import ServerDetail from './routes/public/ServerDetail.jsx'
|
||||
import Clan from './routes/public/Clan.jsx'
|
||||
import Account from './routes/player/Account.jsx'
|
||||
import Permissions from './routes/admin/Permissions.jsx'
|
||||
import ModConfig from './routes/admin/ModConfig.jsx'
|
||||
import Visibility from './routes/admin/Visibility.jsx'
|
||||
import ServerSettings from './routes/admin/ServerSettings.jsx'
|
||||
import ZonePresets from './routes/admin/ZonePresets.jsx'
|
||||
import NpcProfiles from './routes/admin/NpcProfiles.jsx'
|
||||
import NpcPlacements from './routes/admin/NpcPlacements.jsx'
|
||||
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||
import FooterStatus from './components/FooterStatus.jsx'
|
||||
import { IconEye, IconKey, IconLink, IconNpc, IconPlacement, IconServer, IconSliders, IconZone } from './icons.jsx'
|
||||
|
||||
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||
// and prefixes every route path with it.
|
||||
@@ -33,7 +46,7 @@ const ID = 'rust'
|
||||
// installed side by side cannot collide, and an operator can see from a URL which
|
||||
// module served it.
|
||||
//
|
||||
// So this page is at `/rust/servers`.
|
||||
// So the list below is at `/rust` and the detail page at `/rust/servers/:id`.
|
||||
//
|
||||
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
|
||||
// available and core applies it as its own `RoleGate`; supplying your own is not
|
||||
@@ -41,11 +54,72 @@ const ID = 'rust'
|
||||
// see what, and they only do if one thing decides.
|
||||
//
|
||||
// R8's landing page is the server list, and `/rust/servers/:id` hangs beneath it.
|
||||
// The detail route is a later phase's, and it is deliberately not stubbed here: a
|
||||
// registered route that renders nothing is a 200 with a blank page, which is
|
||||
// worse than the 404 an unregistered one gives.
|
||||
//
|
||||
// **The list is registered with an EMPTY path**, which core renders as the
|
||||
// module's namespace root: `/rust`. The prefixing code strips the separator it
|
||||
// would otherwise leave behind (`registry.js`: `${id}/${path}` with trailing
|
||||
// slashes trimmed), so a module can own its own root without being able to spell
|
||||
// its way out of it. Phase 1 served this page at `/rust/servers` and left `/rust`
|
||||
// to core's CMS catch-all; the org lead settled it at `/rust` in phase 4, so the
|
||||
// address an operator links to is the module's name.
|
||||
//
|
||||
// React Router ranks a static segment above a dynamic one, so `/rust` wins
|
||||
// against core's `/:slug` CMS route without depending on registration order.
|
||||
//
|
||||
// The player route is registered with an empty path for the same reason the
|
||||
// public list is: `/player/rust` is the whole of what this module asks a player
|
||||
// to do, and a landing page above one page is a page nobody wants. Core applies
|
||||
// its own portal chrome and its own auth gate to the tier, so the component
|
||||
// renders no layout and re-implements no check.
|
||||
//
|
||||
// **The admin route arrives in phase 7 and is this module's first.** Everything
|
||||
// before it was configured through the API — the server rows still are — because
|
||||
// nothing until now had to be AUTHORED. A permission model is different in kind:
|
||||
// it is a thing an operator composes and keeps looking at, and there is no
|
||||
// version of "grant somebody VIP" that belongs in a terminal.
|
||||
//
|
||||
// It is registered with an empty path, so it lands at `/admin/rust`, and core
|
||||
// applies the admin tier's own gate. The routes underneath it are stricter than
|
||||
// that gate (`requireRole('admin')` on every one), which is a server-side answer
|
||||
// rather than a client one: a moderator who reached this page would see it fail
|
||||
// honestly rather than be quietly shown a page that cannot save.
|
||||
registry.registerRoutes(ID, {
|
||||
public: [{ path: 'servers', element: <Servers /> }],
|
||||
public: [
|
||||
{ path: '', element: <Servers /> },
|
||||
{ path: 'servers/:id', element: <ServerDetail /> },
|
||||
// Phase 9 (D56). Not nested under its server: core links here from Team
|
||||
// notification email through `pageUrlTemplate`, which substitutes
|
||||
// `{externalId}` and nothing else — and the server is inside that id.
|
||||
{ path: 'clans/:externalId', element: <Clan /> },
|
||||
],
|
||||
player: [{ path: '', element: <Account /> }],
|
||||
admin: [
|
||||
{ path: '', element: <Permissions /> },
|
||||
// Phase 7b (R18). A second admin page rather than a tab on the first: the
|
||||
// permission mirror decides who may do what inside the game, and this edits
|
||||
// the game host's own files. They are neighbours, not halves of one screen,
|
||||
// and the nav says so with two rows.
|
||||
//
|
||||
// A static segment under the module's namespace, so it lands at
|
||||
// `/admin/rust/config` and core's admin gate applies to it exactly as it
|
||||
// does to the page above.
|
||||
{ path: 'config', element: <ModConfig /> },
|
||||
// Who may see who is online — a third neighbour. The org lead's rule is that
|
||||
// nothing names who is online by default; this is where an operator widens
|
||||
// it on purpose, fleet-wide or per server.
|
||||
{ path: 'visibility', element: <Visibility /> },
|
||||
// The servers themselves (phase 16, D133): the page that was missing. Until
|
||||
// it, a server row was written only through the API, and D130's wipe
|
||||
// schedule needed somewhere to be typed.
|
||||
{ path: 'servers', element: <ServerSettings /> },
|
||||
// Zone presets (PLAN_REDESIGNS §3.1, D210). Core's step editor has single-
|
||||
// value fields, so a zone's flags are ticked here and a step copies the set.
|
||||
{ path: 'zones', element: <ZonePresets /> },
|
||||
// RunicNPC (runicnpc stage 4): the site's NPC profiles, pushed to each
|
||||
// server, and each server's placements, created by clicking its live map.
|
||||
{ path: 'npcs', element: <NpcProfiles /> },
|
||||
{ path: 'npcs/placements', element: <NpcPlacements /> },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||
@@ -67,9 +141,80 @@ registry.registerRoutes(ID, {
|
||||
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
||||
registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [{ label: 'Servers', to: '/rust/servers' }],
|
||||
items: [{ label: 'Servers', to: '/rust' }],
|
||||
})
|
||||
|
||||
// The player portal's row. It carries an `icon` because core draws one on every
|
||||
// portal row — a row without one is the only text in a column of glyphs, and
|
||||
// core used to render `<n.icon />` unguarded, which blanked the whole portal.
|
||||
//
|
||||
// No `order`: an unordered row appends after core's own rather than claiming a
|
||||
// position it was not given. Account, appeals and notifications are what a player
|
||||
// came to the portal for; linking a game account is what they do once.
|
||||
registry.registerNav(ID, {
|
||||
area: 'player',
|
||||
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
|
||||
})
|
||||
|
||||
// The admin sidebar's row. `group` names an existing core group — an unknown name
|
||||
// appends a new group at the end rather than dropping the row, which is the
|
||||
// failure mode to avoid here: a row nobody can find is a feature nobody has.
|
||||
//
|
||||
// It carries an icon for the same reason the player row does: core draws one on
|
||||
// every sidebar row, and the one without is the only text in a column of glyphs.
|
||||
registry.registerNav(ID, {
|
||||
area: 'admin',
|
||||
items: [
|
||||
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
|
||||
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
|
||||
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
|
||||
{ label: 'Rust servers', to: '/admin/rust/servers', icon: IconServer },
|
||||
{ label: 'Rust zone presets', to: '/admin/rust/zones', icon: IconZone },
|
||||
{ label: 'Rust NPC profiles', to: '/admin/rust/npcs', icon: IconNpc },
|
||||
{ label: 'Rust NPC placements', to: '/admin/rust/npcs/placements', icon: IconPlacement },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Extension slots ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Core declares a slot, only core may declare one, and at most one module may
|
||||
// fill it (§3.7). `site.footer.status` is the status-ish spot in core's footer
|
||||
// info row: core owns the position and passes `linkStyle`; the label, the
|
||||
// destination, the data and whether anything renders at all are the module's.
|
||||
//
|
||||
// It is a CLIENT slot and cannot be named in `module.json`'s `extensions` —
|
||||
// that array is validated against the SERVER registry, and naming a client slot
|
||||
// there fails the load outright with `unknown extension slot`. Phase 1 found
|
||||
// that the hard way; the two halves of R13 are declared in different places on
|
||||
// purpose.
|
||||
registry.registerExtension(ID, 'site.footer.status', FooterStatus)
|
||||
|
||||
// R13's other slot, and the one that IS named in `module.json` — because it has
|
||||
// a server half too (`server/router/admin/usersRust.router.js`). The two halves
|
||||
// carry one name on purpose: a module that adds routes under
|
||||
// `/api/v1/admin/users/:id` is the module with something to show on that page.
|
||||
//
|
||||
// Core passes `userId` and nothing else, so the component builds its own client
|
||||
// for the routes the server half registered. It renders NOTHING for a user with
|
||||
// no linked Steam account, which is most of them.
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserRustSections)
|
||||
|
||||
// ── Inverted slots: core's Team contributions on OUR clan page ─────────────
|
||||
//
|
||||
// §3.7a. A clan is a Team (R5), and core renders no Team page because it does
|
||||
// not own the word "clan". So the page is `routes/public/Clan.jsx` and core
|
||||
// contributes the three things only it can render — into places this module
|
||||
// names, in this module's vocabulary. Core offers a CONTRIBUTION; it never names
|
||||
// a slot, which is what lets a second game use the same contract as module-uo.
|
||||
//
|
||||
// One slot per PLACE (D56): a slot holds one component, and a collapsed slot
|
||||
// would hand core the decision about where each part sits on a page it does not
|
||||
// own. Asking for a contribution core does not offer throws here, at
|
||||
// registration — a typo fails loudly rather than rendering nothing for ever.
|
||||
registry.declareModuleSlot(ID, 'rust.clan.header', { core: 'team.notify' })
|
||||
registry.declareModuleSlot(ID, 'rust.clan.detail', { core: 'team.activity' })
|
||||
registry.declareModuleSlot(ID, 'rust.clan.forum', { core: 'team.forum' })
|
||||
|
||||
// `module.json`'s `coreApi` range was checked by the loader before this file was
|
||||
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
|
||||
// between the core that validated the manifest and the core that published this
|
||||
|
||||
116
client/src/hooks/usePolled.js
Normal file
116
client/src/hooks/usePolled.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── A poll that keeps what it already had ─────────────────────────────────
|
||||
//
|
||||
// **Why this is not `useAsync`.** Core's hook (MODULE_API.md §3.4, and
|
||||
// `client/src/lib/useAsync.js` in core) is `useState({loading:true,error:null,data:null})`
|
||||
// re-run on a dependency change — and the first thing it does on every run is
|
||||
// blank `data` and set `loading`. That is right for a page load and wrong for a
|
||||
// poll: bumping a dependency every twenty seconds would clear the killfeed,
|
||||
// render `<Loading />` in its place and re-fill it, four times a minute, for ever.
|
||||
//
|
||||
// So a poll needs a hook whose refresh is INVISIBLE when it succeeds. It keeps
|
||||
// the previous rows on screen, replaces them when the new ones arrive, and keeps
|
||||
// them *and* reports the error when the fetch fails — because a site whose whole
|
||||
// premise is "it renders while the game is off" must not blank the page the
|
||||
// first time a request does.
|
||||
//
|
||||
// `useAsync` is still the right hook for everything that loads once, and the
|
||||
// pages here use it for exactly that. Bundling this beside it is the kit working
|
||||
// as intended: the nine shared members are the chrome every module must share,
|
||||
// not a ceiling on what a module may write.
|
||||
//
|
||||
// ── Two behaviours worth knowing ──────────────────────────────────────────
|
||||
//
|
||||
// 1. **A backgrounded tab does not poll.** Page Visibility, plus an immediate
|
||||
// refresh when the viewer comes back — which is also the moment stale rows
|
||||
// are most visible. A tab left open overnight is otherwise a request every
|
||||
// twenty seconds until the laptop dies.
|
||||
// 2. **`key` resets, dependencies do not.** Switching server or wipe SHOULD
|
||||
// blank the rows: what is on screen belongs to a different question. That is
|
||||
// what `key` is for, and it is separate from the interval.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* @param {() => Promise<any>} fetcher called with no arguments; must not throw synchronously
|
||||
* @param {object} options
|
||||
* @param {string} options.key changes when the QUESTION changes, blanking the answer
|
||||
* @param {number} options.intervalMs 0 disables polling — the hook then loads once
|
||||
* @param {boolean} options.enabled false while the page has nothing to ask about yet
|
||||
*/
|
||||
export function usePolled(fetcher, { key = '', intervalMs = 20000, enabled = true } = {}) {
|
||||
const [state, setState] = useState({ data: null, error: null, loading: enabled, at: null })
|
||||
|
||||
// The fetcher is rebuilt on every render — it closes over props — and a hook
|
||||
// that listed it as a dependency would restart its interval every render. The
|
||||
// ref is how the timer keeps calling the CURRENT one without depending on it.
|
||||
const latest = useRef(fetcher)
|
||||
latest.current = fetcher
|
||||
|
||||
// Guards a reply from a question nobody is asking any more: a slow request
|
||||
// whose page has moved on, or one still in flight at unmount.
|
||||
const generation = useRef(0)
|
||||
|
||||
const run = useCallback(
|
||||
async (mine) => {
|
||||
try {
|
||||
const data = await latest.current()
|
||||
if (mine !== generation.current) return
|
||||
setState({ data, error: null, loading: false, at: Date.now() })
|
||||
} catch (error) {
|
||||
if (mine !== generation.current) return
|
||||
// `data` is carried forward deliberately. A failed refresh is a page that
|
||||
// says "this is what we last knew, and it did not refresh", which is the
|
||||
// same promise the server list makes about a game server being down.
|
||||
setState((prev) => ({ data: prev.data, error, loading: false, at: prev.at }))
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const refresh = useCallback(() => run(generation.current), [run])
|
||||
|
||||
useEffect(() => {
|
||||
generation.current += 1
|
||||
const mine = generation.current
|
||||
|
||||
if (!enabled) {
|
||||
setState({ data: null, error: null, loading: false, at: null })
|
||||
return undefined
|
||||
}
|
||||
|
||||
setState({ data: null, error: null, loading: true, at: null })
|
||||
run(mine)
|
||||
|
||||
if (!intervalMs) return () => { generation.current += 1 }
|
||||
|
||||
let timer = null
|
||||
|
||||
const visible = () => typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
|
||||
const start = () => {
|
||||
if (timer === null) timer = setInterval(() => run(mine), intervalMs)
|
||||
}
|
||||
const stop = () => {
|
||||
if (timer !== null) { clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
const onVisibility = () => {
|
||||
if (visible()) { run(mine); start() } else stop()
|
||||
}
|
||||
|
||||
if (visible()) start()
|
||||
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
|
||||
|
||||
return () => {
|
||||
// Bumping the generation on teardown is what makes an in-flight reply from
|
||||
// the old question land nowhere. Clearing the timer alone would not.
|
||||
generation.current += 1
|
||||
stop()
|
||||
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [key, intervalMs, enabled, run])
|
||||
|
||||
return { ...state, refresh }
|
||||
}
|
||||
|
||||
export default usePolled
|
||||
148
client/src/icons.jsx
Normal file
148
client/src/icons.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
// ── The nav glyph for this module's player-portal row ─────────────────────
|
||||
//
|
||||
// `icon` is part of the nav-item contract (MODULE_API.md §3.3, 1.3.0): core
|
||||
// renders whatever component a row carries, exactly as it renders its own rows'
|
||||
// icons — and core's player portal draws a glyph on every row, so a row without
|
||||
// one reads as breakage rather than as a design. The client suite asserts it.
|
||||
//
|
||||
// The public header is text buttons and carries no icons, which is why this file
|
||||
// arrives with the player row and not before it.
|
||||
//
|
||||
// **The frame is copied from core's `PlayerPortalLayout`, deliberately and by
|
||||
// copy rather than by import** — 16px, `currentColor`, stroke 2. Four attributes
|
||||
// of presentation are not a component: putting them in the shared kit would
|
||||
// freeze core's icon sizing into the contract, where changing it later would be a
|
||||
// major bump. A module that wants to look like the nav it is in matches that nav.
|
||||
|
||||
const Icon = ({ children }) => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* A chain link — what the row is for.
|
||||
*
|
||||
* Not a gem, a person or a server: the portal's rows say what a player does
|
||||
* there, and what a player does at `/player/rust` is link an account. Core's own
|
||||
* neighbours are a gear (account), a shield (appeals) and a bell (notifications),
|
||||
* so the row has to read as a verb in that company.
|
||||
*/
|
||||
export const IconLink = () => (
|
||||
<Icon>
|
||||
<path d="M10 13a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11.5 4.5" />
|
||||
<path d="M14 11a5 5 0 00-7.07 0L4.1 13.83a5 5 0 007.07 7.07L12.5 19.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* A key — the admin sidebar's row for the permission mirror.
|
||||
*
|
||||
* Core's admin groups are labelled by subject and drawn with glyphs of the same
|
||||
* weight, so this is the same 16px frame as the portal's. A key rather than a
|
||||
* shield: a shield is protection from something, and this row is about handing
|
||||
* somebody the right to do something.
|
||||
*/
|
||||
export const IconKey = () => (
|
||||
<Icon>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.7 12.3L20 3" />
|
||||
<path d="M17 6l2.5 2.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* Sliders — the admin sidebar's row for the mod-configuration editor.
|
||||
*
|
||||
* Not a gear: core's account row is a gear, and two gears in one sidebar say
|
||||
* "settings" twice without saying whose. Sliders read as values being tuned,
|
||||
* which is exactly what that page does to somebody else's game host.
|
||||
*/
|
||||
export const IconSliders = () => (
|
||||
<Icon>
|
||||
<path d="M4 6h10" />
|
||||
<path d="M18 6h2" />
|
||||
<circle cx="16" cy="6" r="2" />
|
||||
<path d="M4 12h4" />
|
||||
<path d="M12 12h8" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="M4 18h10" />
|
||||
<path d="M18 18h2" />
|
||||
<circle cx="16" cy="18" r="2" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* An eye — the admin sidebar's row for who may see who is online.
|
||||
*
|
||||
* The page decides what the public can SEE, so the glyph is the act of seeing.
|
||||
*/
|
||||
export const IconEye = () => (
|
||||
<Icon>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* Two stacked units — the admin sidebar's row for the servers themselves.
|
||||
*
|
||||
* The one row that is about the machines rather than what happens on them: the
|
||||
* sidecar each one answers through, and when each one wipes.
|
||||
*/
|
||||
export const IconServer = () => (
|
||||
<Icon>
|
||||
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||
<rect x="3" y="13" width="18" height="7" rx="1.5" />
|
||||
<path d="M7 7.5h.01" />
|
||||
<path d="M7 16.5h.01" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export default { IconLink, IconKey, IconSliders, IconEye, IconServer }
|
||||
|
||||
/**
|
||||
* A circle inside a dashed ring — the admin sidebar's row for zone presets.
|
||||
*
|
||||
* A zone is an area with rules at its edge, so the glyph is a place and its
|
||||
* boundary rather than a map pin, which the live map already means.
|
||||
*/
|
||||
export const IconZone = () => (
|
||||
<Icon>
|
||||
<circle cx="12" cy="12" r="9" strokeDasharray="3 3" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* A figure — the admin sidebar's row for RunicNPC's profiles (runicnpc stage 4).
|
||||
* A head and shoulders: the page is about who the NPCs are.
|
||||
*/
|
||||
export const IconNpc = () => (
|
||||
<Icon>
|
||||
<circle cx="12" cy="8" r="3.5" />
|
||||
<path d="M5 20c0-3.9 3.1-7 7-7s7 3.1 7 7" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* A figure on a spot — the admin sidebar's row for NPC placements: where they stand.
|
||||
*/
|
||||
export const IconPlacement = () => (
|
||||
<Icon>
|
||||
<circle cx="12" cy="6" r="2.5" />
|
||||
<path d="M8 15c0-2.2 1.8-4 4-4s4 1.8 4 4" />
|
||||
<ellipse cx="12" cy="19" rx="7" ry="2" />
|
||||
</Icon>
|
||||
)
|
||||
52
client/src/lib/backpack.js
Normal file
52
client/src/lib/backpack.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// ── A backpack as a grid (PLAN_REDESIGNS §11.3.2) ─────────────────────────
|
||||
//
|
||||
// Pure, like `format.js`, so the suite can ask it without a DOM. The page that
|
||||
// draws a backpack only maps over what these return.
|
||||
//
|
||||
// The read's `slot` runs across pages: page two of a 96-slot backpack starts at
|
||||
// slot 48 when the page size is 48 (§11.3.1). A stack at or past the capacity
|
||||
// is overflow — it waits for the owner's next open to be moved to their
|
||||
// inventory, or the ground (D338) — so the grid draws those slots too, after
|
||||
// the backpack's own, marked.
|
||||
|
||||
/**
|
||||
* The pages to draw: `[{ index, cells: [{ slot, stack, overflow }] }]`.
|
||||
* Enough slots for the capacity and for every stack, whichever reaches
|
||||
* further; a backpack that does not exist is no pages at all.
|
||||
*/
|
||||
export function backpackPages(backpack) {
|
||||
if (!backpack || !backpack.exists) return []
|
||||
|
||||
const items = Array.isArray(backpack.items) ? backpack.items : []
|
||||
const capacity = Math.max(0, Number(backpack.capacity) || 0)
|
||||
const pageSize = Math.max(1, Number(backpack.pageSize) || 48)
|
||||
const bySlot = new Map(items.map((s) => [Number(s.slot), s]))
|
||||
const furthest = items.reduce((max, s) => Math.max(max, Number(s.slot) + 1), 0)
|
||||
const total = Math.max(capacity, furthest)
|
||||
|
||||
const pages = []
|
||||
for (let start = 0; start < total; start += pageSize) {
|
||||
const cells = []
|
||||
for (let slot = start; slot < Math.min(start + pageSize, total); slot++) {
|
||||
cells.push({ slot, stack: bySlot.get(slot) || null, overflow: slot >= capacity })
|
||||
}
|
||||
pages.push({ index: pages.length, cells })
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
/**
|
||||
* What a slot says: the game's name (D340), "… blueprint" for a blueprint
|
||||
* (D341), then the shortname, then the bare item id for an item the game no
|
||||
* longer knows.
|
||||
*/
|
||||
export function stackLabel(stack) {
|
||||
if (!stack) return ''
|
||||
const name = stack.display || stack.shortname || (stack.itemId !== undefined ? `item ${stack.itemId}` : 'unknown item')
|
||||
return stack.blueprint ? `${name} blueprint` : name
|
||||
}
|
||||
|
||||
/** How many stacks wait to be moved out at the owner's next open. */
|
||||
export function overflowCount(backpack) {
|
||||
return backpack && Array.isArray(backpack.items) ? backpack.items.filter((s) => s.overflow).length : 0
|
||||
}
|
||||
465
client/src/lib/feed.js
Normal file
465
client/src/lib/feed.js
Normal file
@@ -0,0 +1,465 @@
|
||||
// ── One stored frame as one line of a feed ────────────────────────────────
|
||||
//
|
||||
// `GET /public/rust/servers/:id/events` answers rows shaped
|
||||
// `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame
|
||||
// the plugin emitted — this module stores what it is given and indexes only the
|
||||
// columns it serves (PROTOCOL.md §8.4, and the `raw` column in schema.sql). So
|
||||
// everything a killfeed line needs is in `frame`, under the names the plugin
|
||||
// wrote, and this file is the one place that knows them.
|
||||
//
|
||||
// **It returns PARTS, not a sentence.** A component wants the names emphasised
|
||||
// and the detail muted, and a function returning `"Alice killed Bob"` forces
|
||||
// either a `dangerouslySetInnerHTML` or a re-parse. Parts also make this
|
||||
// testable without a DOM, which is the whole reason it is not a component.
|
||||
//
|
||||
// ── The rule for an unknown kind ──────────────────────────────────────────
|
||||
//
|
||||
// It renders as itself. A later protocol adds kinds, an operator's module may be
|
||||
// older than their game host, and a feed that DROPPED what it did not recognise
|
||||
// would be a page that quietly says less than the truth. The server's allowlist
|
||||
// has already decided this row may be seen (`server/catalogue.js`); what is left
|
||||
// here is presentation, and the honest presentation of a kind we have no words
|
||||
// for is its own name.
|
||||
|
||||
import { attacker, duration, weapon } from './format.js'
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
*
|
||||
* `player.tally` is public and deliberately NOT here: it is an aggregate the
|
||||
* plugin flushes every sixty seconds per active player (§8.6), so a feed
|
||||
* including it would be mostly wood counts. It is the leaderboard's input, and
|
||||
* the leaderboard is where it shows up.
|
||||
*/
|
||||
export const FEED_KINDS = Object.freeze([
|
||||
'player.death',
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.chat',
|
||||
'server.wipe',
|
||||
'server.initialized',
|
||||
'server.shutdown',
|
||||
// RunicNPC bosses (runicnpc stage 6): the game says both to the whole server.
|
||||
// The death names its killer, so the server shows it only where presence is on.
|
||||
'npc.boss.spawned',
|
||||
'npc.boss.died',
|
||||
// Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1): the server
|
||||
// reads each one for the viewer, so a name is here only where it may be.
|
||||
'world.event',
|
||||
// Dynamic PVP's zones opening (§14.3.3). A closing (`pvp.zone.deleted`) is
|
||||
// staff's and is not asked for here; the map shows a zone go.
|
||||
'pvp.zone.created',
|
||||
// Dangerous Treasures (§14.3.4): a chest opening, and one won. The server reads
|
||||
// each for the viewer, so the winner's name is here only where it may be; the
|
||||
// ending (`dt.ended`) is staff's, and the map shows the chest go.
|
||||
'dt.started',
|
||||
'dt.won',
|
||||
// Guarded Crate (§14.3.5): a crate landing, and one cleared, read per viewer
|
||||
// like a treasure. A timeout (`gc.failed`) is staff's; the map shows it go.
|
||||
'gc.started',
|
||||
'gc.ended',
|
||||
// RaidableBases (§11.4): a base appearing, raided, bought or locked to a
|
||||
// player, read per viewer; its despawn (`raid.ended`) is staff's, and the map shows it go.
|
||||
'raid.started',
|
||||
'raid.completed',
|
||||
'raid.purchased',
|
||||
'raid.locked',
|
||||
])
|
||||
|
||||
/** The filters the feed offers, and the kinds each one asks the API for. */
|
||||
export const FILTERS = Object.freeze([
|
||||
{ id: 'all', label: 'Everything', kinds: FEED_KINDS },
|
||||
{ id: 'kills', label: 'Kills', kinds: ['player.death'] },
|
||||
{ id: 'chat', label: 'Chat', kinds: ['player.chat'] },
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Comings and goings',
|
||||
kinds: ['player.connected', 'player.disconnected', 'player.respawned'],
|
||||
},
|
||||
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
|
||||
{ id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] },
|
||||
{ id: 'world', label: 'World events', kinds: ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended', 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked'] },
|
||||
])
|
||||
|
||||
export function kindsFor(filterId) {
|
||||
const filter = FILTERS.find((f) => f.id === filterId)
|
||||
return (filter || FILTERS[0]).kinds
|
||||
}
|
||||
|
||||
/**
|
||||
* One row as `{ tone, actor, join, verb, subject, detail }`.
|
||||
*
|
||||
* `actor` and `subject` are names and are emphasised; `verb` and `detail` are
|
||||
* prose. Any of them may be empty. `tone` is the row's category, for the small
|
||||
* colour the component gives it — never for deciding what a row means.
|
||||
*
|
||||
* `join` is what goes between the actor and the verb, and it exists for exactly
|
||||
* one case: chat. "Brannock see you in september" is not a sentence anybody
|
||||
* writes, and putting the colon in the message would put presentation inside the
|
||||
* text a player typed.
|
||||
*/
|
||||
export function describe(row) {
|
||||
const frame = (row && row.frame) || {}
|
||||
const name = frame.name || null
|
||||
|
||||
switch (row && row.kind) {
|
||||
case 'player.death':
|
||||
return death(frame, name)
|
||||
|
||||
case 'player.connected':
|
||||
return { tone: 'join', actor: name, verb: 'connected', subject: null, detail: '' }
|
||||
|
||||
case 'player.disconnected':
|
||||
return {
|
||||
tone: 'leave',
|
||||
actor: name,
|
||||
verb: 'disconnected',
|
||||
subject: null,
|
||||
// Two optional halves, and the session is the interesting one: the plugin
|
||||
// omits `sessionSec` for a player who was already on when it loaded, so an
|
||||
// absent value means "unknown", never zero (§8.4's note, and OnPlayerDisconnected).
|
||||
detail: [frame.reason || null, frame.sessionSec ? `after ${duration(frame.sessionSec)}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
}
|
||||
|
||||
case 'player.respawned':
|
||||
return { tone: 'join', actor: name, verb: 'respawned', subject: null, detail: '' }
|
||||
|
||||
case 'player.chat':
|
||||
return {
|
||||
tone: 'chat',
|
||||
actor: name,
|
||||
join: ': ',
|
||||
// The message is the row, so it goes in `verb` where a component renders
|
||||
// it unemphasised — and it is the one field on this wire a player chooses
|
||||
// the bytes of. React escapes it; nothing here may ever stop doing that.
|
||||
verb: frame.message || '',
|
||||
subject: null,
|
||||
detail: frame.channel && frame.channel !== 'Global' ? frame.channel : '',
|
||||
}
|
||||
|
||||
case 'server.wipe':
|
||||
return {
|
||||
tone: 'server',
|
||||
actor: null,
|
||||
verb: 'The map was wiped',
|
||||
subject: null,
|
||||
detail: frame.wipeId ? `new wipe ${frame.wipeId}` : '',
|
||||
}
|
||||
|
||||
case 'server.initialized':
|
||||
return { tone: 'server', actor: null, verb: 'The server came up', subject: null, detail: '' }
|
||||
|
||||
case 'server.shutdown':
|
||||
return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' }
|
||||
|
||||
// A boss's name is the profile's, as typed (`name`), never a prefab's.
|
||||
case 'npc.boss.spawned':
|
||||
return { tone: 'server', actor: name, verb: 'has appeared', subject: null, detail: 'a boss' }
|
||||
|
||||
case 'npc.boss.died': {
|
||||
const fought = Number(frame.damagers) > 1 ? `${frame.damagers} players fought it` : ''
|
||||
return frame.killerName
|
||||
? { tone: 'kill', actor: frame.killerName, verb: 'killed', subject: name, detail: fought }
|
||||
: { tone: 'death', actor: name, verb: 'was killed', subject: null, detail: fought }
|
||||
}
|
||||
|
||||
case 'world.event':
|
||||
return worldLine(frame)
|
||||
|
||||
case 'pvp.zone.created':
|
||||
case 'pvp.zone.deleted':
|
||||
return pvpLine(row.kind, frame)
|
||||
|
||||
case 'dt.started':
|
||||
case 'dt.won':
|
||||
case 'dt.ended':
|
||||
return treasureLine(row.kind, frame)
|
||||
|
||||
case 'gc.started':
|
||||
case 'gc.ended':
|
||||
case 'gc.failed':
|
||||
return guardedLine(row.kind, frame)
|
||||
|
||||
case 'raid.started':
|
||||
case 'raid.completed':
|
||||
case 'raid.ended':
|
||||
case 'raid.purchased':
|
||||
case 'raid.locked':
|
||||
return raidLine(row.kind, frame)
|
||||
|
||||
default:
|
||||
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A death, which is four different sentences.
|
||||
*
|
||||
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
|
||||
* that a reader does not have to guess from an absent field, and collapsing any
|
||||
* two of them loses something (see `DescribeAttacker` in the bridge plugin). A
|
||||
* killfeed that reported a fall as a kill by nobody is the failure this avoids.
|
||||
*/
|
||||
function death(frame, name) {
|
||||
const where = [
|
||||
frame.weapon ? `with ${weapon(frame)}` : null,
|
||||
frame.distance ? `${Math.round(frame.distance)}m` : null,
|
||||
frame.grid || null,
|
||||
frame.sleeping ? 'while sleeping' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
switch (frame.attackerType) {
|
||||
case 'player':
|
||||
return { tone: 'kill', actor: frame.attackerName || null, verb: 'killed', subject: name, detail: where }
|
||||
|
||||
case 'self':
|
||||
return { tone: 'death', actor: name, verb: 'died by their own hand', subject: null, detail: where }
|
||||
|
||||
case 'npc':
|
||||
return {
|
||||
tone: 'death',
|
||||
// One of RunicNPC's (runicnpc stage 4): its own name, as typed on its profile,
|
||||
// rather than the prefab it is built from. Older frames carry only the prefab.
|
||||
actor: frame.attackerNpc || attacker(frame.attackerName) || 'Something',
|
||||
verb: 'killed',
|
||||
subject: name,
|
||||
detail: where,
|
||||
}
|
||||
|
||||
// `environment` and anything else: falling, drowning, the world. `HitInfo`
|
||||
// is legitimately null on this path, so an absent attacker type is this case
|
||||
// rather than a missing field to complain about.
|
||||
default:
|
||||
return { tone: 'death', actor: name, verb: 'died', subject: null, detail: where }
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a locked crate is, as the end of a sentence. */
|
||||
const CRATE_PLACES = Object.freeze({
|
||||
oilrig_large: 'on Large Oil Rig',
|
||||
oilrig_small: 'on Small Oil Rig',
|
||||
cargo: 'on the cargo ship',
|
||||
ghostship: 'on a ghost ship',
|
||||
chinook: 'dropped by a Chinook',
|
||||
})
|
||||
|
||||
/**
|
||||
* Who a downed helicopter, Chinook or Bradley credits, as a phrase: "Alice",
|
||||
* "Alice and Bob", "Alice, Bob and 2 others". Null when the line names nobody
|
||||
* — below the presence audience the server sends `creditedCount` and no names.
|
||||
*/
|
||||
export function creditedPhrase(frame) {
|
||||
const names = (Array.isArray(frame.credited) ? frame.credited : []).map((c) => c && c.name).filter(Boolean)
|
||||
if (names.length === 0) return null
|
||||
const total = Math.max(names.length, Number(frame.creditedCount) || 0)
|
||||
const shown = names.slice(0, 2)
|
||||
const more = total - shown.length
|
||||
if (more > 0) return `${shown.join(', ')} and ${more} other${more === 1 ? '' : 's'}`
|
||||
return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust's own world events (protocol 14), one sentence per type. The server has
|
||||
* already read the frame for this viewer: it carries `grid`, a `name` only for
|
||||
* the presence audience, and `credited` by the server's rule (D372, D376). A
|
||||
* line therefore never decides who may read a name; it only says what it has.
|
||||
*/
|
||||
function worldLine(frame) {
|
||||
const grid = frame.grid || ''
|
||||
const who = frame.name || null
|
||||
const place = CRATE_PLACES[frame.where] || 'somewhere on the map'
|
||||
const by = creditedPhrase(frame)
|
||||
const downed = (what) => (by
|
||||
? { tone: 'kill', actor: null, verb: `${what} by`, subject: by, detail: grid }
|
||||
: { tone: 'kill', actor: null, verb: what, subject: null, detail: grid })
|
||||
const say = (verb, detail = grid) => ({ tone: 'server', actor: null, verb, subject: null, detail })
|
||||
const harbour = frame.harbourName || 'the harbour'
|
||||
|
||||
switch (frame.event) {
|
||||
case 'heli.arrived':
|
||||
return say('A patrol helicopter has arrived')
|
||||
case 'heli.left':
|
||||
return say('The patrol helicopter has left', '')
|
||||
case 'heli.downed':
|
||||
return downed('Patrol helicopter downed')
|
||||
case 'chinook.downed':
|
||||
return downed('Chinook shot down')
|
||||
case 'bradley.destroyed':
|
||||
return downed(frame.where === 'launch_site' ? "Launch Site's Bradley destroyed" : frame.where === 'road' ? 'Road Bradley destroyed' : 'Bradley destroyed')
|
||||
case 'bradley.spawned': {
|
||||
const n = Number(frame.count) || 1
|
||||
if (frame.where === 'road') return say(n > 1 ? `${n} road Bradleys are out` : 'A road Bradley is out', '')
|
||||
return say('Bradley is patrolling Launch Site', '')
|
||||
}
|
||||
case 'cargo.arrived':
|
||||
return say('The cargo ship has arrived')
|
||||
case 'cargo.docking':
|
||||
return say(`The cargo ship is docking at ${harbour}`)
|
||||
case 'cargo.docked':
|
||||
return say(`The cargo ship has docked at ${harbour}`)
|
||||
case 'cargo.undocked':
|
||||
return say(`The cargo ship has cast off from ${harbour}`)
|
||||
case 'cargo.left':
|
||||
return say('The cargo ship is leaving', '')
|
||||
case 'chinook.arrived':
|
||||
return say('A Chinook is inbound with a locked crate')
|
||||
case 'chinook.dropped':
|
||||
return say(frame.monumentName ? `A Chinook dropped a locked crate at ${frame.monumentName}` : 'A Chinook dropped a locked crate')
|
||||
case 'airdrop.called':
|
||||
return who ? { tone: 'server', actor: who, verb: 'called an airdrop', subject: null, detail: grid } : say('An airdrop is on its way')
|
||||
case 'airdrop.landed':
|
||||
return say('An airdrop has landed', [who ? `called by ${who}` : null, grid].filter(Boolean).join(' · '))
|
||||
case 'airdrop.looted':
|
||||
return who ? { tone: 'server', actor: who, verb: 'looted an airdrop', subject: null, detail: grid } : say('An airdrop was looted')
|
||||
case 'crate.hack.started':
|
||||
return who
|
||||
? { tone: 'server', actor: who, verb: `started hacking the locked crate ${place}`, subject: null, detail: grid }
|
||||
: say(`A locked crate ${place} is being hacked`)
|
||||
case 'crate.unlocked':
|
||||
return say(`The locked crate ${place} is open`)
|
||||
case 'crate.looted':
|
||||
return who
|
||||
? { tone: 'server', actor: who, verb: `looted the locked crate ${place}`, subject: null, detail: grid }
|
||||
: say(`The locked crate ${place} was looted`)
|
||||
case 'vendor.arrived':
|
||||
return say('The Travelling Vendor has arrived')
|
||||
case 'vendor.left':
|
||||
return say('The Travelling Vendor has left', '')
|
||||
case 'satellite.crashed':
|
||||
return say('A satellite has crashed')
|
||||
case 'deepsea.opened':
|
||||
return say('The Deep Sea is open', '')
|
||||
case 'deepsea.closed':
|
||||
return say('The Deep Sea has closed', '')
|
||||
default:
|
||||
// A type this build has no words for, which only staff are sent: its own name.
|
||||
return { tone: 'other', actor: who, verb: String(frame.event || 'world event'), subject: null, detail: grid }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Dynamic PVP zone is around, by its event (Dynamic PVP 5.2.0's own
|
||||
* names). The server sends `event` to the public only for these; a monument's or
|
||||
* an operator's own event reaches staff as written.
|
||||
*/
|
||||
export const PVP_AROUND = Object.freeze({
|
||||
Helicopter: 'the downed patrol helicopter',
|
||||
Bradley: 'the destroyed Bradley',
|
||||
Satellite: 'the crashed satellite',
|
||||
TimedSupply: 'an airdrop',
|
||||
SupplySignal: 'a signalled airdrop',
|
||||
CargoShip: 'the cargo ship',
|
||||
HackableCrate: 'a locked crate',
|
||||
ExcavatorIgnition: 'the Giant Excavator',
|
||||
GhostShip: 'a ghost ship',
|
||||
DeepSeaIsland: 'a Deep Sea island',
|
||||
IslandCannon: 'an island cannon',
|
||||
})
|
||||
|
||||
/** How long a zone was given, as a detail: "for 10 min", or cargo's whole visit. */
|
||||
function pvpLasts(frame) {
|
||||
const sec = Number(frame.durationSec)
|
||||
if (!(sec > 0)) return frame.event === 'CargoShip' ? 'while the ship is here' : ''
|
||||
return sec < 120 ? `for ${Math.round(sec)} s` : `for ${Math.round(sec / 60)} min`
|
||||
}
|
||||
|
||||
/** Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3). The line names nobody. */
|
||||
function pvpLine(kind, frame) {
|
||||
const around = PVP_AROUND[frame.event] ? ` around ${PVP_AROUND[frame.event]}` : frame.event ? ` (${frame.event})` : ''
|
||||
if (kind === 'pvp.zone.deleted') {
|
||||
return { tone: 'server', actor: null, verb: `The PvP zone${around} has closed`, subject: null, detail: '' }
|
||||
}
|
||||
const detail = [frame.grid || null, pvpLasts(frame) || null].filter(Boolean).join(' · ')
|
||||
return { tone: 'kill', actor: null, verb: `A PvP zone opened${around}`, subject: null, detail }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4). The server has read
|
||||
* the frame for this viewer: `grid`, `invaderCount`, and the winner's `name` only
|
||||
* for the presence audience (D376).
|
||||
*/
|
||||
function treasureLine(kind, frame) {
|
||||
const grid = frame.grid || ''
|
||||
if (kind === 'dt.started') {
|
||||
return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has started', subject: null, detail: grid }
|
||||
}
|
||||
if (kind === 'dt.ended') {
|
||||
return { tone: 'server', actor: null, verb: 'A Dangerous Treasures event has ended', subject: null, detail: grid }
|
||||
}
|
||||
const n = Number(frame.invaderCount)
|
||||
const fought = n > 1 ? `${n} players fought for it` : ''
|
||||
const detail = [grid || null, fought || null].filter(Boolean).join(' · ')
|
||||
return frame.name
|
||||
? { tone: 'kill', actor: frame.name, verb: 'won the treasure', subject: null, detail }
|
||||
: { tone: 'kill', actor: null, verb: 'The treasure was won', subject: null, detail }
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5). The server has read the
|
||||
* frame for this viewer: `grid`, `won` (whether a player killed the last
|
||||
* guard), and the clearer's `name` only for the presence audience (D376).
|
||||
*/
|
||||
function guardedLine(kind, frame) {
|
||||
const detail = frame.grid || ''
|
||||
if (kind === 'gc.started') {
|
||||
return { tone: 'server', actor: null, verb: 'A guarded crate landed', subject: null, detail }
|
||||
}
|
||||
if (kind === 'gc.failed') {
|
||||
return { tone: 'server', actor: null, verb: 'A guarded crate timed out with its guards standing', subject: null, detail }
|
||||
}
|
||||
if (frame.won === false) {
|
||||
return { tone: 'server', actor: null, verb: 'A guarded crate ended with nobody clearing it', subject: null, detail }
|
||||
}
|
||||
return frame.name
|
||||
? { tone: 'kill', actor: frame.name, verb: 'cleared the guarded crate', subject: null, detail }
|
||||
: { tone: 'kill', actor: null, verb: 'The guarded crate was cleared', subject: null, detail }
|
||||
}
|
||||
|
||||
/** "Alice", "Alice and Bob", "Alice, Bob and 3 others". */
|
||||
export function raiderNames(names, count) {
|
||||
const shown = (names || []).slice(0, 2)
|
||||
const rest = Math.max(0, (Number(count) || shown.length) - shown.length)
|
||||
if (shown.length === 0) return null
|
||||
if (rest === 0) return shown.length === 2 ? `${shown[0]} and ${shown[1]}` : shown[0]
|
||||
return `${shown.join(', ')} and ${rest} other${rest === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* RaidableBases (protocol 14, PLAN_REDESIGNS §11.4). The server has read the
|
||||
* frame for this viewer: `grid`, the plugin's own `difficulty` name (D386), `pvp`,
|
||||
* and the raiders' `names` or a buyer's `name` only for the presence audience
|
||||
* (D376, D390); `raiderCount` for everyone.
|
||||
*/
|
||||
function raidLine(kind, frame) {
|
||||
const base = frame.difficulty ? `${frame.difficulty} raid base` : 'raid base'
|
||||
const a = /^[aeiou]/i.test(base) ? 'An' : 'A'
|
||||
const detail = [frame.grid || null, frame.pvp === true ? 'PvP' : null].filter(Boolean).join(' · ')
|
||||
|
||||
if (kind === 'raid.started') return { tone: 'server', actor: null, verb: `${a} ${base} appeared`, subject: null, detail }
|
||||
if (kind === 'raid.ended') return { tone: 'server', actor: null, verb: `${a} ${base} despawned`, subject: null, detail }
|
||||
|
||||
if (kind === 'raid.completed') {
|
||||
const who = raiderNames(frame.names, frame.raiderCount)
|
||||
if (who) return { tone: 'kill', actor: who, verb: `raided ${a.toLowerCase()} ${base}`, subject: null, detail }
|
||||
const count = Number(frame.raiderCount) || 0
|
||||
const by = count > 0 ? `by ${count} player${count === 1 ? '' : 's'}` : null
|
||||
return { tone: 'kill', actor: null, verb: `${a} ${base} was raided`, subject: null, detail: [detail, by].filter(Boolean).join(' · ') }
|
||||
}
|
||||
|
||||
if (kind === 'raid.purchased') {
|
||||
return frame.name
|
||||
? { tone: 'server', actor: frame.name, verb: `bought ${a.toLowerCase()} ${base}`, subject: null, detail }
|
||||
: { tone: 'server', actor: null, verb: `${a} ${base} was bought`, subject: null, detail }
|
||||
}
|
||||
|
||||
return frame.name
|
||||
? { tone: 'server', actor: null, verb: `${a} ${base} was locked to ${frame.name}`, subject: null, detail }
|
||||
: { tone: 'server', actor: null, verb: `${a} ${base} was locked to a player`, subject: null, detail }
|
||||
}
|
||||
|
||||
export default { describe, FEED_KINDS, FILTERS, kindsFor }
|
||||
254
client/src/lib/format.js
Normal file
254
client/src/lib/format.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// ── Formatting, with no dependencies and no React ─────────────────────────
|
||||
//
|
||||
// Every function here is pure and takes what the API answered, so the suite next
|
||||
// door can ask all of it without a DOM. That is deliberate: the client half's
|
||||
// real failures are timing and resolution (see `test/build.test.js`), which a
|
||||
// DOM-less runner cannot see — so the way to have any test coverage at all on
|
||||
// this side is to keep the parts that CAN be tested free of React.
|
||||
//
|
||||
// `Intl` does the work. It is in every browser core supports, it knows the
|
||||
// viewer's locale and their clock, and it is one fewer thing in a chunk an
|
||||
// operator ships.
|
||||
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
const UNITS = [
|
||||
['year', 31536000],
|
||||
['month', 2592000],
|
||||
['week', 604800],
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60],
|
||||
['second', 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* "3 minutes ago", from an ISO string or an epoch-millisecond number.
|
||||
*
|
||||
* Both shapes arrive from this module's own API: `updatedAt` is an ISO string
|
||||
* the model produced, and an event's `t` is the millisecond stamp the plugin put
|
||||
* on the frame. Accepting both here is what stops every caller remembering which
|
||||
* is which.
|
||||
*/
|
||||
export function ago(value, now = Date.now()) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return 'never'
|
||||
|
||||
const seconds = Math.round((at - now) / 1000)
|
||||
const magnitude = Math.abs(seconds)
|
||||
|
||||
// Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is
|
||||
// not what anybody means. Say the thing.
|
||||
if (magnitude < 45) return 'just now'
|
||||
|
||||
const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamp on a feed row.
|
||||
*
|
||||
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
||||
* be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM`
|
||||
* reads as this afternoon — which the page walk found the moment it looked at the
|
||||
* previous wipe: three events from August, all apparently a few minutes old.
|
||||
*
|
||||
* `now` is a parameter so the boundary is testable rather than a property of the
|
||||
* machine the test runs on.
|
||||
*/
|
||||
export function clock(value, now = Date.now()) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return ''
|
||||
|
||||
const when = new Date(at)
|
||||
const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const today = new Date(now)
|
||||
const sameDay =
|
||||
when.getFullYear() === today.getFullYear() &&
|
||||
when.getMonth() === today.getMonth() &&
|
||||
when.getDate() === today.getDate()
|
||||
|
||||
if (sameDay) return time
|
||||
return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`
|
||||
}
|
||||
|
||||
/** A date, for a wipe: the thing people actually compare wipes by. */
|
||||
export function day(value) {
|
||||
const at = toMillis(value)
|
||||
if (at === null) return 'unknown'
|
||||
return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* A session or a playtime, as `4h 12m`.
|
||||
*
|
||||
* Seconds are dropped above a minute and kept below it, because a two-hour
|
||||
* session reported to the second is noise and a forty-second one reported as
|
||||
* "0m" is wrong.
|
||||
*/
|
||||
export function duration(seconds) {
|
||||
const total = Number(seconds)
|
||||
if (!Number.isFinite(total) || total <= 0) return '—'
|
||||
if (total < 60) return `${Math.round(total)}s`
|
||||
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.round((total % 3600) / 60)
|
||||
|
||||
if (hours === 0) return `${minutes}m`
|
||||
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
/** Thousands separators, in the viewer's locale. */
|
||||
export function count(value) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
/**
|
||||
* An Economics balance (PLAN_REDESIGNS §11.2.2): up to two places, and a dash
|
||||
* for one the server never reported. Not `count`'s zero — "0 coins" is a claim
|
||||
* about somebody's balance, and an unknown one is not it.
|
||||
*/
|
||||
export function coins(value) {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) : '—'
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||
* `rifle.ak` becomes `rifle ak`.
|
||||
*
|
||||
* Deliberately a light touch rather than a lookup table. A table mapping every
|
||||
* Rust prefab to a pretty name is a second copy of the game's item list that
|
||||
* goes stale every wipe, and the short name is what a Rust player reads on their
|
||||
* own server console anyway.
|
||||
*/
|
||||
export function prefab(name) {
|
||||
if (!name) return ''
|
||||
return String(name).replace(/[_.]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The weapon in a kill. The server sends `weaponName` — the game's own name for
|
||||
* a gun, or the prefab name as words (`server/model/titles/weaponNames.js`) —
|
||||
* and that is what prints. A server too old to send it gets the same fallback
|
||||
* here: the `.entity` / `.weapon` ending dropped, so never "ak47u entity".
|
||||
*/
|
||||
export function weapon(frame) {
|
||||
if (!frame || !frame.weapon) return ''
|
||||
if (frame.weaponName) return String(frame.weaponName)
|
||||
return prefab(String(frame.weapon).trim().replace(/[._](entity|weapon|deployed)$/, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* The NPC families whose prefab reads badly as words, by the prefix the game's
|
||||
* short names share. First match wins, so a longer prefix sits above a shorter
|
||||
* one it starts with.
|
||||
*/
|
||||
const NPC_FAMILIES = [
|
||||
['scientistnpc_heavy', 'Heavy scientist'],
|
||||
['scientistnpc', 'Scientist'],
|
||||
['npc_bandit_guard', 'Bandit guard'],
|
||||
['bandit_guard', 'Bandit guard'],
|
||||
['npc_tunneldweller', 'Tunnel dweller'],
|
||||
['npc_underwaterdweller', 'Underwater dweller'],
|
||||
['bradleyapc', 'Bradley APC'],
|
||||
['patrolhelicopter', 'Patrol helicopter'],
|
||||
['ch47scientists', 'Chinook'],
|
||||
['sentry.scientist', 'Outpost sentry'],
|
||||
['sentry.bandit', 'Bandit Camp sentry'],
|
||||
['autoturret', 'Auto turret'],
|
||||
['flameturret', 'Flame turret'],
|
||||
['guntrap', 'Shotgun trap'],
|
||||
['sam_site', 'SAM site'],
|
||||
['sam_static', 'SAM site'],
|
||||
['polarbear', 'Polar bear'],
|
||||
['simpleshark', 'Shark'],
|
||||
]
|
||||
|
||||
/**
|
||||
* What killed somebody, when it was not a player (PLAN_FIXES F2, D185) — the
|
||||
* killfeed's `npc` attacker, which the plugin sends as a prefab short name.
|
||||
*
|
||||
* The first walk read "killed by wolf2". `prefab` keeps its light touch for a
|
||||
* weapon, where the short name is what a Rust player reads on their own console;
|
||||
* an ATTACKER is a creature or a machine, and a number stuck to its name is the
|
||||
* game's variant, not something a reader needs. So a known family is named
|
||||
* (above), and anything else has its variant digits and deploy suffixes trimmed
|
||||
* and reads as capitalised words — `wolf2` is "Wolf", `boar` is "Boar". Still no
|
||||
* table of every prefab: a new animal reads fine without one.
|
||||
*/
|
||||
export function attacker(name) {
|
||||
if (!name) return ''
|
||||
const text = String(name).toLowerCase()
|
||||
const family = NPC_FAMILIES.find(([prefix]) => text.startsWith(prefix))
|
||||
if (family) return family[1]
|
||||
|
||||
const words = text
|
||||
.replace(/(\.deployed|_deployed|\.entity|\.prefab)$/, '')
|
||||
.replace(/\d+$/, '')
|
||||
.replace(/[_.]+/g, ' ')
|
||||
.trim()
|
||||
return words ? words[0].toUpperCase() + words.slice(1) : ''
|
||||
}
|
||||
|
||||
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||
export function shortId(steamId) {
|
||||
const id = String(steamId || '')
|
||||
return id.length > 10 ? `…${id.slice(-6)}` : id
|
||||
}
|
||||
|
||||
/**
|
||||
* The next wipe (phase 16, D130), as `Thu, Oct 1, 7:00 PM (in 6 days)` in the
|
||||
* VIEWER's locale and clock — or `null` when the server has no schedule.
|
||||
*
|
||||
* The server computes the instant from the operator's rule in the operator's
|
||||
* zone; the page only says it the reader's way. A `once` source is an operator
|
||||
* moving a wipe, and says so, because "the wipe is not when it usually is" is
|
||||
* the thing a regular needs to notice.
|
||||
*/
|
||||
export function nextWipe(value, now = Date.now()) {
|
||||
if (!value || !value.at) return null
|
||||
const at = toMillis(value.at)
|
||||
if (at === null) return null
|
||||
const when = new Date(at).toLocaleString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
return `${when} (${ago(at, now)})${value.source === 'once' ? ' — rescheduled' : ''}`
|
||||
}
|
||||
|
||||
function toMillis(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* The ink that reads on a background of `hex` — black or white, whichever has
|
||||
* the higher WCAG contrast. A chat title's colour is the operator's, chosen for
|
||||
* a dark game chat, and this page is drawn in the reader's theme: yellow text on
|
||||
* a white page is unreadable, so the colour becomes the chip and the text is
|
||||
* picked for it (phase 17, D137). Anything that is not `#rrggbb` gets black on
|
||||
* the caller's fallback.
|
||||
*/
|
||||
export function contrastInk(hex) {
|
||||
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''))
|
||||
if (!m) return '#000000'
|
||||
const linear = (c) => {
|
||||
const v = parseInt(c, 16) / 255
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
const L = 0.2126 * linear(m[1]) + 0.7152 * linear(m[2]) + 0.0722 * linear(m[3])
|
||||
// Contrast against white is 1.05 / (L + 0.05); against black (L + 0.05) / 0.05.
|
||||
return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
export default { ago, clock, day, duration, count, prefab, shortId, nextWipe, contrastInk }
|
||||
28
client/src/lib/leaflet.js
Normal file
28
client/src/lib/leaflet.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// ── Leaflet, and only when the Map tab asks for it ────────────────────────
|
||||
//
|
||||
// D116 put Leaflet on the map; D120 put it HERE, in a chunk of its own that the
|
||||
// Map tab imports with `import()`. Two reasons, and both are about `entry.js`:
|
||||
//
|
||||
// • `entry.js` is loaded on EVERY page of the site, because core injects every
|
||||
// started module's chunk into its shell. Leaflet in it would be ~40 KB gz on
|
||||
// the home page, the forum and the news, for a tab most visitors never open.
|
||||
// • Leaflet touches `document` and `window` the moment it is evaluated. The
|
||||
// chunk is evaluated in Node by `test/registration.test.js`, with a
|
||||
// `window` and nothing else — so Leaflet in the entry chunk fails that test
|
||||
// at import, before a single registration is checked.
|
||||
//
|
||||
// Vite emits this as `dist/map-<hash>.js` beside `entry.js`. Core serves the
|
||||
// directory `client.entry` sits in (MODULE_API.md §3.1), and `release.yml`
|
||||
// copies every `.js` in it — `test/build.test.js` holds both ends of that.
|
||||
//
|
||||
// The ESM build is imported by path: Leaflet 1.9.4's package.json names only its
|
||||
// UMD file, and the UMD one would go through CommonJS interop for nothing.
|
||||
//
|
||||
// The stylesheet comes in as a STRING and is injected once as a `<style>` by the
|
||||
// page (core's CSP allows `style-src 'unsafe-inline'`). The map uses circle and
|
||||
// div markers only, so the images Leaflet's CSS names are never shown.
|
||||
|
||||
import * as L from 'leaflet/dist/leaflet-src.esm.js'
|
||||
import css from 'leaflet/dist/leaflet.css?inline'
|
||||
|
||||
export { L, css }
|
||||
85
client/src/lib/mapEvents.js
Normal file
85
client/src/lib/mapEvents.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// ── What an event marker says (runicnpc stage 8, D302, D303) ──────────────
|
||||
//
|
||||
// An NPC's marker says its name and its event; a boss is drawn bigger, and no
|
||||
// health is shown (D303 — the server does not even send it). A boss placed
|
||||
// outside any event is in the world layer as `kind: 'boss'` (D302).
|
||||
//
|
||||
// The event's title comes from core's public calendar, which publishes each
|
||||
// run's id for this: a map row holds a run id and nothing else. The calendar
|
||||
// lists announced events only, so an unannounced event is "an event" here and
|
||||
// nothing more.
|
||||
//
|
||||
// Pure, and free of React, so it is tested in Node.
|
||||
|
||||
/** `{ [runId]: title }` from core's public calendar answer. */
|
||||
export function titlesFrom(calendar) {
|
||||
const out = {}
|
||||
for (const e of (calendar && calendar.entries) || []) {
|
||||
if (e && e.kind === 'run' && e.runId != null && e.title) out[String(e.runId)] = e.title
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The run ids an answer's events layer names that `titles` does not know yet. */
|
||||
export function unknownRuns(events, titles) {
|
||||
const out = new Set()
|
||||
for (const e of events || []) {
|
||||
if (e && e.runId != null && !Object.prototype.hasOwnProperty.call(titles || {}, String(e.runId))) out.add(String(e.runId))
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
/** The tooltip for one events-layer row that is not a zone. */
|
||||
export function eventTip(row, titles) {
|
||||
const event = (titles && row && row.runId != null && titles[String(row.runId)]) || 'an event'
|
||||
if (!row || row.kind !== 'npc') return `Event crate · ${event}`
|
||||
const who = row.name || 'Event NPC'
|
||||
const role = row.boss ? ' (boss)' : row.add ? " (a boss's add)" : ''
|
||||
return `${who}${role} · ${event}`
|
||||
}
|
||||
|
||||
/** Whether a row is drawn as a boss: bigger (D303). */
|
||||
export function isBoss(row) {
|
||||
return Boolean(row && (row.boss || row.kind === 'boss'))
|
||||
}
|
||||
|
||||
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ──────────────
|
||||
|
||||
/** Dynamic PVP's own events as the map names them; a monument's or an operator's is shown as written. */
|
||||
export const PVP_NAMES = Object.freeze({
|
||||
Helicopter: 'Patrol helicopter',
|
||||
Bradley: 'Bradley APC',
|
||||
Satellite: 'Satellite crash',
|
||||
TimedSupply: 'Airdrop',
|
||||
SupplySignal: 'Signalled airdrop',
|
||||
CargoShip: 'Cargo ship',
|
||||
HackableCrate: 'Locked crate',
|
||||
ExcavatorIgnition: 'Giant Excavator',
|
||||
GhostShip: 'Ghost ship',
|
||||
DeepSeaIsland: 'Deep Sea island',
|
||||
IslandCannon: 'Island cannon',
|
||||
})
|
||||
|
||||
/** A Dynamic PVP ring's tooltip: "PvP zone · Patrol helicopter". */
|
||||
export function pvpTip(z) {
|
||||
const name = PVP_NAMES[z.event] || z.event
|
||||
return name ? `PvP zone · ${name}` : 'PvP zone'
|
||||
}
|
||||
|
||||
// ── RaidableBases' bases (protocol 14, PLAN_REDESIGNS §11.4) ───────────────
|
||||
|
||||
/**
|
||||
* A raid base's tooltip: "Raid base · Hard · raidhard2 · PvP · despawns in 42 min",
|
||||
* and "· this site's event" for one a run spawned. The difficulty and base names are
|
||||
* the plugin's own (D386).
|
||||
*/
|
||||
export function raidTip(r, now = Date.now()) {
|
||||
const parts = ['Raid base']
|
||||
if (r.difficulty) parts.push(r.difficulty)
|
||||
if (r.base) parts.push(r.base)
|
||||
if (r.pvp === true) parts.push('PvP')
|
||||
const left = Number(r.despawnAt) - now
|
||||
if (Number.isFinite(left) && left > 0) parts.push(`despawns in ${Math.max(1, Math.round(left / 60000))} min`)
|
||||
if (r.runId) parts.push('this site’s event')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
101
client/src/lib/mapGeometry.js
Normal file
101
client/src/lib/mapGeometry.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// ── How a world position reaches a pixel ──────────────────────────────────
|
||||
//
|
||||
// PLAN.md §30.3, in the one place the page computes it. Rust's world is centred
|
||||
// on the origin, x east and z north; the picture is the world at a scale, with
|
||||
// an ocean margin around it that is measured in PIXELS and is not scaled (the
|
||||
// rig's 3000 map is 3000 × 0.5 + 2 × 500 = 2500 pixels). So:
|
||||
//
|
||||
// s = (width − 2 × margin) / worldSize
|
||||
// px = (x + worldSize / 2) × s + margin
|
||||
// py = (z + worldSize / 2) × s + margin measured UP from the bottom edge
|
||||
//
|
||||
// Up from the bottom because Leaflet's `CRS.Simple` has y growing north, which
|
||||
// is Rust's z — so the picture's bounds are `[[0, 0], [height, width]]` and a
|
||||
// position is `[py, px]` with no flip anywhere.
|
||||
//
|
||||
// The grid is the GAME's (D119): `gridCells` cells of `gridCellSize` metres per
|
||||
// side, lettered from the west and numbered from the north, `A0` at the
|
||||
// north-west corner. Both numbers come from the plugin, which asks the game's own
|
||||
// `MapHelper`; nothing here assumes a cell size.
|
||||
//
|
||||
// Pure, and free of Leaflet, so it is tested in Node.
|
||||
|
||||
/** Pixels per metre, or 0 for a geometry that cannot place anything. */
|
||||
export function scaleOf(g) {
|
||||
if (!g || !(g.worldSize > 0) || !(g.width > 0)) return 0
|
||||
return (g.width - 2 * (g.oceanMargin || 0)) / g.worldSize
|
||||
}
|
||||
|
||||
/** A world position as `[lat, lng]` in the map's pixel space. */
|
||||
export function toLatLng(g, x, z) {
|
||||
const s = scaleOf(g)
|
||||
const half = g.worldSize / 2
|
||||
const m = g.oceanMargin || 0
|
||||
return [(Number(z) + half) * s + m, (Number(x) + half) * s + m]
|
||||
}
|
||||
|
||||
/** The inverse of `toLatLng`: a point on the picture as world `{ x, z }` in metres (a click on the map). */
|
||||
export function fromLatLng(g, lat, lng) {
|
||||
const s = scaleOf(g)
|
||||
if (!(s > 0)) return null
|
||||
const half = g.worldSize / 2
|
||||
const m = g.oceanMargin || 0
|
||||
return { x: (Number(lng) - m) / s - half, z: (Number(lat) - m) / s - half }
|
||||
}
|
||||
|
||||
/** The picture's bounds in the same space. */
|
||||
export function boundsOf(g) {
|
||||
return [[0, 0], [g.height, g.width]]
|
||||
}
|
||||
|
||||
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
|
||||
export function column(index) {
|
||||
let name = ''
|
||||
let n = index + 1
|
||||
while (n > 0) {
|
||||
const r = (n - 1) % 26
|
||||
name = String.fromCharCode(65 + r) + name
|
||||
n = Math.floor((n - 1) / 26)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** The grid label for a world position, the way the in-game map writes it. */
|
||||
export function gridLabel(g, x, z) {
|
||||
if (!g || !(g.gridCells > 0) || !(g.gridCellSize > 0)) return null
|
||||
const half = g.worldSize / 2
|
||||
const clamp = (v) => Math.max(0, Math.min(g.gridCells - 1, v))
|
||||
const col = clamp(Math.floor((Number(x) + half) / g.gridCellSize))
|
||||
const row = clamp(Math.floor((half - Number(z)) / g.gridCellSize))
|
||||
return `${column(col)}${row}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid as lines and labels in world metres: `lines` are `[[x1, z1], [x2,
|
||||
* z2]]` pairs, `labels` sit at each cell's north-west corner.
|
||||
*/
|
||||
export function grid(g) {
|
||||
if (!g || !(g.gridCells > 0) || !(g.gridCellSize > 0)) return { lines: [], labels: [] }
|
||||
const half = g.worldSize / 2
|
||||
const n = g.gridCells
|
||||
const c = g.gridCellSize
|
||||
const lines = []
|
||||
for (let i = 0; i <= n; i += 1) {
|
||||
const at = -half + i * c
|
||||
lines.push([[at, half], [at, half - n * c]])
|
||||
lines.push([[-half, half - i * c], [-half + n * c, half - i * c]])
|
||||
}
|
||||
const labels = []
|
||||
for (let col = 0; col < n; col += 1) {
|
||||
for (let row = 0; row < n; row += 1) {
|
||||
labels.push({ text: `${column(col)}${row}`, x: -half + col * c, z: half - row * c })
|
||||
}
|
||||
}
|
||||
return { lines, labels }
|
||||
}
|
||||
|
||||
/** Seconds as `m:ss`, for a locked crate's hack. */
|
||||
export function countdown(seconds) {
|
||||
const s = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
91
client/src/lib/mapMarkers.js
Normal file
91
client/src/lib/mapMarkers.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ── Which monument labels a map draws (PLAN_REDESIGNS §4, D196) ───────────
|
||||
//
|
||||
// The admin card's half of the rule `model/map/map.model.js` applies on the
|
||||
// server: a switch per monument LABEL, the minor labels off unless somebody
|
||||
// turns them on, every other label on. A server's switch wins over the fleet's,
|
||||
// the fleet's over the built-in list.
|
||||
//
|
||||
// The form holds only the EXPLICIT switches, as the server does — `{ key: bool }`
|
||||
// — so a label nobody touched keeps following the default above it. Setting the
|
||||
// fleet back to what the built-in list says clears the row rather than storing a
|
||||
// copy of the default: the next change to the list then reaches that label.
|
||||
//
|
||||
// Pure, and free of React, so it is tested in Node.
|
||||
|
||||
/** A label as its switch's key: trimmed, single-spaced, lower case. The server's rule exactly. */
|
||||
export function markerKey(label) {
|
||||
return String(label == null ? '' : label).trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
}
|
||||
|
||||
/** The built-in answer for one key: hidden for the minor labels, drawn for everything else. */
|
||||
export function builtIn(minorLabels, key) {
|
||||
return !(minorLabels || []).some((l) => markerKey(l) === key)
|
||||
}
|
||||
|
||||
const has = (o, k) => Boolean(o) && Object.prototype.hasOwnProperty.call(o, k)
|
||||
|
||||
/** Whether the fleet draws one key: its own switch, or the built-in list. */
|
||||
export function fleetShows(minorLabels, fleetMarkers, key) {
|
||||
return has(fleetMarkers, key) ? fleetMarkers[key] : builtIn(minorLabels, key)
|
||||
}
|
||||
|
||||
/** Whether one server draws one key: its switch, or the fleet's answer. */
|
||||
export function serverShows(minorLabels, fleetMarkers, serverMarkers, key) {
|
||||
return has(serverMarkers, key) ? serverMarkers[key] : fleetShows(minorLabels, fleetMarkers, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every label the fleet list offers, sorted by name: each server's current
|
||||
* labels and the minor list, each once. A minor label no map has yet is still
|
||||
* listed, so it can be decided before a wipe brings it.
|
||||
*/
|
||||
export function fleetLabels(minorLabels, servers) {
|
||||
const byKey = new Map()
|
||||
// A capitalised spelling wins over the game's lower-case one ("jungle swamp").
|
||||
const add = (key, label, count) => {
|
||||
const seen = byKey.get(key)
|
||||
if (!seen) byKey.set(key, { key, label, count })
|
||||
else {
|
||||
seen.count += count
|
||||
if (seen.label === seen.label.toLowerCase() && label !== label.toLowerCase()) seen.label = label
|
||||
}
|
||||
}
|
||||
for (const s of servers || []) {
|
||||
for (const l of s.markerLabels || []) add(l.key, l.label, l.count || 0)
|
||||
}
|
||||
for (const label of minorLabels || []) add(markerKey(label), label, 0)
|
||||
return [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
/** The fleet form after ticking one key: an explicit switch, or none where it matches the built-in list. */
|
||||
export function setFleet(minorLabels, fleetMarkers, key, shown) {
|
||||
const next = { ...(fleetMarkers || {}) }
|
||||
if (shown === builtIn(minorLabels, key)) delete next[key]
|
||||
else next[key] = shown
|
||||
return next
|
||||
}
|
||||
|
||||
/** A server form after choosing one key: `null` follows the fleet, otherwise an explicit switch. */
|
||||
export function setServer(serverMarkers, key, shown) {
|
||||
const next = { ...(serverMarkers || {}) }
|
||||
if (shown === null) delete next[key]
|
||||
else next[key] = shown
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* What changed between two explicit maps, as the PUT takes it: `{ key: bool }`
|
||||
* for a switch set or changed, `{ key: null }` for one cleared. Null when
|
||||
* nothing changed.
|
||||
*/
|
||||
export function markerDiff(before, now) {
|
||||
const out = {}
|
||||
const keys = new Set([...Object.keys(before || {}), ...Object.keys(now || {})])
|
||||
for (const key of keys) {
|
||||
const was = has(before, key) ? before[key] : undefined
|
||||
const is = has(now, key) ? now[key] : undefined
|
||||
if (was === is) continue
|
||||
out[key] = is === undefined ? null : is
|
||||
}
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
163
client/src/routes/admin/ChatStyle.jsx
Normal file
163
client/src/routes/admin/ChatStyle.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ── Admin · Rust · Permissions — a group's chat style (phase 17, D138) ─────
|
||||
//
|
||||
// All twelve of BetterChat's group fields, on a group this site authors. A style
|
||||
// is the whole of a BetterChat group or nothing, so the editor always shows all
|
||||
// twelve, starting from BetterChat's own defaults.
|
||||
//
|
||||
// Three things the section says out loud, because each looks like success from
|
||||
// here:
|
||||
//
|
||||
// • a server where BetterChat is not loaded holds nothing yet — the style
|
||||
// lands when BetterChat does, at the next sync;
|
||||
// • a field somebody changed in game is NOT overwritten — it shows under
|
||||
// "Changed in game", to adopt or put back;
|
||||
// • a field BetterChat refused (`InvalidValue`) is named with its server.
|
||||
//
|
||||
// Removing a style removes the group from BetterChat on the next sync (D139),
|
||||
// which is BetterChat's own `chat group remove` — it has no quieter way.
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
const LABELS = {
|
||||
Priority: 'Priority (lower wins when a player is in several groups)',
|
||||
Title: 'Title',
|
||||
TitleColor: 'Title colour',
|
||||
TitleSize: 'Title size',
|
||||
TitleHidden: 'Hide the title',
|
||||
TitleHiddenIfNotPrimary: 'Hide the title unless this is the player’s main group',
|
||||
UsernameColor: 'Name colour',
|
||||
UsernameSize: 'Name size',
|
||||
MessageColor: 'Message colour',
|
||||
MessageSize: 'Message size',
|
||||
ChatFormat: 'Chat format',
|
||||
ConsoleFormat: 'Console format',
|
||||
}
|
||||
|
||||
/** BetterChat's defaults for this group, from the field list the server sent. */
|
||||
function defaultsFor(group, fields) {
|
||||
const out = {}
|
||||
for (const f of fields) out[f.name] = f.default === null ? '' : f.default
|
||||
out.Title = group === 'default' ? '[Player]' : `[${group}]`
|
||||
return out
|
||||
}
|
||||
|
||||
/** What each server's last sync said about this group's style. */
|
||||
function styleNotes(group, servers) {
|
||||
const notes = []
|
||||
for (const s of servers) {
|
||||
const chat = s.report && s.report.chat
|
||||
if (!chat) continue
|
||||
if (chat.loaded === false) {
|
||||
notes.push(`${s.serverId}: BetterChat is not loaded, so nothing is styled there yet.`)
|
||||
continue
|
||||
}
|
||||
for (const f of chat.failed || []) {
|
||||
if (f.group !== group) continue
|
||||
notes.push(`${s.serverId}: BetterChat refused ${f.field || 'the group'} (${f.reason}).`)
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
export default function ChatStyleSection({ group, fields, servers, busy, onSave }) {
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
if (!fields || !fields.length) return null
|
||||
|
||||
const notes = group.chat ? styleNotes(group.name, servers) : []
|
||||
|
||||
const set = (name, value) => setEditing((e) => ({ ...e, [name]: value }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="field-label" style={{ marginTop: 18 }}>
|
||||
Chat style (BetterChat)
|
||||
</div>
|
||||
{!editing && !group.chat && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||
No chat style. BetterChat, where it is installed, styles this group’s members as it does anybody else.
|
||||
</p>
|
||||
)}
|
||||
{!editing && group.chat && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', margin: '4px 0' }}>
|
||||
<span style={{ color: /^#[0-9a-f]{6}$/i.test(group.chat.TitleColor) ? group.chat.TitleColor : undefined, fontWeight: 600 }}>
|
||||
{group.chat.TitleHidden === 'true' ? '(title hidden)' : group.chat.Title}
|
||||
</span>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
priority {group.chat.Priority} · <code>{group.chat.ChatFormat}</code>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{notes.map((n) => (
|
||||
<p key={n} className="sans" style={{ color: '#d08a2a', fontSize: '0.76rem', margin: '2px 0' }}>{n}</p>
|
||||
))}
|
||||
|
||||
{!editing && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy}
|
||||
onClick={() => setEditing({ ...defaultsFor(group.name, fields), ...(group.chat || {}) })}
|
||||
>
|
||||
{group.chat ? 'Edit the style' : 'Give it a chat style'}
|
||||
</button>
|
||||
{group.chat && (
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => onSave(null)}>
|
||||
Remove the style
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (await onSave(editing)) setEditing(null)
|
||||
}}
|
||||
className="sans"
|
||||
style={{ display: 'grid', gap: 8, marginTop: 8, fontSize: '0.82rem' }}
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<label key={f.name} style={{ display: 'grid', gridTemplateColumns: 'minmax(160px, 1fr) 2fr', gap: 8, alignItems: 'center' }}>
|
||||
<span>{LABELS[f.name] || f.name}</span>
|
||||
{f.type === 'bool' ? (
|
||||
<select className="input" value={editing[f.name]} onChange={(e) => set(f.name, e.target.value)}>
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
) : f.type === 'color' ? (
|
||||
<span style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-f]{6}$/i.test(editing[f.name]) ? editing[f.name] : '#ffffff'}
|
||||
onChange={(e) => set(f.name, e.target.value)}
|
||||
aria-label={LABELS[f.name]}
|
||||
style={{ width: 36, height: 28, padding: 0, border: 'none', background: 'none' }}
|
||||
/>
|
||||
<input className="input" value={editing[f.name]} onChange={(e) => set(f.name, e.target.value)} style={{ flex: 1 }} />
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === 'int' || f.type === 'size' ? 'number' : 'text'}
|
||||
value={editing[f.name]}
|
||||
onChange={(e) => set(f.name, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<p className="dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
||||
A format must hold <code>{'{Message}'}</code> exactly once; <code>{'{Title}'}</code> and <code>{'{Username}'}</code>{' '}
|
||||
are where the title and name go. A hand edit in game is reported rather than overwritten.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn" disabled={busy}>Save the style</button>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
455
client/src/routes/admin/ChatTitles.jsx
Normal file
455
client/src/routes/admin/ChatTitles.jsx
Normal file
@@ -0,0 +1,455 @@
|
||||
// ── Admin · Rust · Servers — chat titles and the voice (phase 17) ─────────
|
||||
//
|
||||
// Two things the servers page gained with BetterChat and PopupNotifications
|
||||
// becoming optional (PLAN.md §33):
|
||||
//
|
||||
// • **A server's chat titles** (D135, D136): rules that rank the current wipe,
|
||||
// in the operator's order, and how many a player shows. Saved with their own
|
||||
// PUT, because they are not part of the server row — an operator retitling
|
||||
// "Top Killer" must not have to re-type a sidecar address.
|
||||
// • **The voice** (D140): the one styled permission group news and event lines
|
||||
// are said in. A fleet setting, so it sits once at the foot of the page.
|
||||
//
|
||||
// Neither needs BetterChat to save. Titles are held by the plugin until BetterChat
|
||||
// arrives, and the voice is said by our own plugin whether BetterChat is there or
|
||||
// not; the "What this server has" line says which is which.
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
import { contrastInk } from '../../lib/format.js'
|
||||
|
||||
const MODES = [
|
||||
{ id: 'first', label: 'The first title they earn', hint: 'The rule highest in the list wins.' },
|
||||
{ id: 'all', label: 'Every title they earn' },
|
||||
{ id: 'upto', label: 'Up to a number of titles', hint: 'In list order.' },
|
||||
]
|
||||
|
||||
/**
|
||||
* What a rule can rank (PLAN_REDESIGNS §5): the server's categories, each with
|
||||
* the title it shows now (D175), then playtime, which has no title of its own.
|
||||
*/
|
||||
export function statOptions(categories) {
|
||||
return [
|
||||
...(categories || []).map((c) => ({ id: c.stat, label: c.label, title: c.current })),
|
||||
{ id: 'playtime', label: 'Playtime', title: null },
|
||||
// RunicNPC (runicnpc stage 4, D250): one NPC profile's kills. The rule names the profile.
|
||||
{ id: 'profilekills', label: 'Kills of an NPC profile', title: null },
|
||||
]
|
||||
}
|
||||
|
||||
const statOf = (categories, id) => statOptions(categories).find((s) => s.id === id) || { id, label: id, title: null }
|
||||
|
||||
/** A rule's title as players see it: its own text, else its category's. */
|
||||
export const ruleTitle = (rule, categories) => rule.text || statOf(categories, rule.stat).title || ''
|
||||
|
||||
/** One line summarising a server's titles, for its row on the list. */
|
||||
export function titlesSummary(titles, push, categories) {
|
||||
const rules = (titles && titles.rules) || []
|
||||
if (!rules.length) return 'No chat titles.'
|
||||
const list = rules.map((r) => `${ruleTitle(r, categories)} (top ${r.topN} ${statOf(categories, r.stat).label.toLowerCase()})`).join(', ')
|
||||
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? ' holds' : 's hold'} one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
|
||||
return `Chat titles: ${list}.${shown}`
|
||||
}
|
||||
|
||||
/** A title as the leaderboard will show it. */
|
||||
function Chip({ text, color }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ padding: '1px 6px', borderRadius: 999, fontSize: '0.72rem', fontWeight: 600, background: color, color: contrastInk(color) }}
|
||||
>
|
||||
{text || '…'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitlesForm({ server, categories, onSaved, onCancel }) {
|
||||
const start = server.titles || { mode: 'first', max: 2, rules: [] }
|
||||
const stats = statOptions(categories)
|
||||
const { data: npc } = useAsync(() => api.servers.npcProfiles(server.id).catch(() => ({ profiles: [] })), [server.id])
|
||||
const profiles = (npc && npc.profiles) || []
|
||||
const [mode, setMode] = useState(start.mode)
|
||||
const [max, setMax] = useState(start.max)
|
||||
const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const setRule = (i, key) => (e) => {
|
||||
const value = e.target.value
|
||||
setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' || key === 'profile' ? Number(value) : value } : r)))
|
||||
}
|
||||
const move = (i, by) => setRules((list) => {
|
||||
const next = [...list]
|
||||
const [r] = next.splice(i, 1)
|
||||
next.splice(i + by, 0, r)
|
||||
return next
|
||||
})
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.saveTitles(server.id, { mode, max: Number(max), rules })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={save} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 12, marginBottom: 18 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>Chat titles on {server.name}</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
|
||||
Each rule gives a title to the top players on this wipe for one stat. A stat of zero earns nothing, so a fresh wipe
|
||||
has no titles until somebody plays. Titles show in game chat when BetterChat is installed, and beside the name on
|
||||
the leaderboard here and in the app. Up to ten rules; a title is at most 24 characters and cannot carry markup.
|
||||
Leave a title blank to use the category’s title, which is set once for every server below the list.
|
||||
</p>
|
||||
|
||||
{rules.map((r, i) => {
|
||||
const fallback = statOf(categories, r.stat).title
|
||||
return (
|
||||
<div key={i} className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', fontSize: '0.84rem' }}>
|
||||
<span className="dim" style={{ width: 18 }}>{i + 1}</span>
|
||||
<span>Top</span>
|
||||
<input type="number" min={1} max={10} value={r.topN} onChange={setRule(i, 'topN')} style={{ ...inputStyle, width: 60 }} aria-label="How many players" />
|
||||
<select value={r.stat} onChange={setRule(i, 'stat')} style={inputStyle} aria-label="Stat">
|
||||
{stats.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||
</select>
|
||||
{r.stat === 'profilekills' && (
|
||||
<select value={r.profile || ''} onChange={setRule(i, 'profile')} style={inputStyle} aria-label="NPC profile" required>
|
||||
<option value="" disabled>{profiles.length ? 'Which NPC?' : 'No NPC profile on this server'}</option>
|
||||
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} ({p.name})</option>)}
|
||||
</select>
|
||||
)}
|
||||
<span>earn</span>
|
||||
{/* The category's title is a PLACEHOLDER, never a value: saved as the rule's own text it would stop a rename reaching this rule (§5.5). */}
|
||||
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required={!fallback} placeholder={fallback || 'A title'} style={{ ...inputStyle, width: 160 }} aria-label="Title" />
|
||||
<input type="color" value={r.color} onChange={setRule(i, 'color')} aria-label="Colour" style={{ width: 36, height: 28, padding: 0, border: 'none', background: 'none' }} />
|
||||
<Chip text={r.text || fallback} color={r.color} />
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
<button type="button" className="btn" disabled={i === 0} onClick={() => move(i, -1)} aria-label="Move up">↑</button>
|
||||
<button type="button" className="btn" disabled={i === rules.length - 1} onClick={() => move(i, 1)} aria-label="Move down">↓</button>
|
||||
<button type="button" className="btn" onClick={() => setRules((list) => list.filter((_, n) => n !== i))}>Remove</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{rules.length < 10 && (
|
||||
<div>
|
||||
<button type="button" className="btn" onClick={() => setRules((list) => [...list, { stat: 'kills', topN: 1, text: '', color: '#ffaa55' }])}>
|
||||
Add a rule
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', fontSize: '0.84rem' }}>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
A player shows
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value)} style={inputStyle}>
|
||||
{MODES.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{mode === 'upto' && (
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
at most
|
||||
<input type="number" min={1} max={5} value={max} onChange={(e) => setMax(e.target.value)} style={{ ...inputStyle, width: 60 }} />
|
||||
</label>
|
||||
)}
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>{(MODES.find((m) => m.id === mode) || {}).hint || ''}</span>
|
||||
</div>
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save titles'}</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The category titles (D175): one title per category for the whole site, which
|
||||
* every rule of that category without its own text shows. Each row saves on its
|
||||
* own, and Reset brings the module's default back.
|
||||
*/
|
||||
export function CategoryTitles({ categories, onSaved }) {
|
||||
const [drafts, setDrafts] = useState({})
|
||||
const [busy, setBusy] = useState('')
|
||||
const [errors, setErrors] = useState({})
|
||||
|
||||
if (!categories || !categories.length) return null
|
||||
|
||||
const save = async (stat, text) => {
|
||||
setBusy(stat)
|
||||
setErrors((e) => ({ ...e, [stat]: '' }))
|
||||
try {
|
||||
await api.admin.saveTitleCategory(stat, text)
|
||||
setDrafts((d) => {
|
||||
const next = { ...d }
|
||||
delete next[stat]
|
||||
return next
|
||||
})
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setErrors((e) => ({ ...e, [stat]: err.message || 'That did not save.' }))
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel sans" style={{ padding: '16px 18px', marginBottom: 18, fontSize: '0.84rem' }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: '0 0 6px', color: 'var(--head)' }}>Category titles</h2>
|
||||
<p className="dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
|
||||
The title each category gives, on every server. A rule that has its own title keeps it; every other rule of the
|
||||
category shows this one from the next push. Reset brings back the default.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(140px, 1fr) minmax(110px, auto) minmax(160px, 1.2fr) auto', gap: '6px 10px', alignItems: 'center' }}>
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>Category</span>
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>Default</span>
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>This site’s title</span>
|
||||
<span />
|
||||
{categories.map((c) => {
|
||||
const draft = drafts[c.stat]
|
||||
const value = draft !== undefined ? draft : c.text || ''
|
||||
const changed = draft !== undefined && draft !== (c.text || '')
|
||||
return (
|
||||
<Row key={c.stat}>
|
||||
<span>{c.label}</span>
|
||||
<span className="dim">{c.default}</span>
|
||||
<span style={{ display: 'grid', gap: 2 }}>
|
||||
<input
|
||||
value={value}
|
||||
placeholder={c.default}
|
||||
maxLength={24}
|
||||
onChange={(e) => setDrafts((d) => ({ ...d, [c.stat]: e.target.value }))}
|
||||
style={inputStyle}
|
||||
aria-label={`${c.label} title`}
|
||||
/>
|
||||
{errors[c.stat] && <span style={{ color: '#d08a2a', fontSize: '0.74rem' }}>{errors[c.stat]}</span>}
|
||||
</span>
|
||||
<span style={{ display: 'flex', gap: 4 }}>
|
||||
<button type="button" className="btn" disabled={!changed || busy === c.stat} onClick={() => save(c.stat, value)}>Save</button>
|
||||
<button type="button" className="btn" disabled={!c.text || busy === c.stat} onClick={() => save(c.stat, '')}>Reset</button>
|
||||
</span>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** A grid row's cells, without a wrapper element the grid would lay out as one cell. */
|
||||
function Row({ children }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
/** What a server says it has loaded, as one sentence (§33.2 `integrations`). */
|
||||
export function integrationsLine(r) {
|
||||
if (!r || !r.integrations) {
|
||||
return r && r.ok === false
|
||||
? `The game could not be asked (${r.status}).`
|
||||
: 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
|
||||
}
|
||||
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
|
||||
const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
|
||||
const withEconomics = r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
|
||||
const withBackpacks = r.integrations.backpacks ? `${withEconomics} ${backpacksLine(r.integrations.backpacks)}` : withEconomics
|
||||
const withTruePve = r.integrations.truePve ? `${withBackpacks} ${truePveLine(r.integrations.truePve)}` : withBackpacks
|
||||
const withDynamicPvp = r.integrations.dynamicPvp ? `${withTruePve} ${dynamicPvpLine(r.integrations.dynamicPvp)}` : withTruePve
|
||||
const withDt = r.integrations.dangerousTreasures ? `${withDynamicPvp} ${dangerousTreasuresLine(r.integrations.dangerousTreasures, r.console)}` : withDynamicPvp
|
||||
const withGc = r.integrations.guardedCrate ? `${withDt} ${guardedCrateLine(r.integrations.guardedCrate, r.console)}` : withDt
|
||||
const withZ = r.integrations.zLevels ? `${withGc} ${zLevelsLine(r.integrations.zLevels)}` : withGc
|
||||
return r.integrations.raidableBases ? `${withZ} ${raidableBasesLine(r.integrations.raidableBases, r.console)}` : withZ
|
||||
}
|
||||
|
||||
/**
|
||||
* RaidableBases (PLAN_REDESIGNS §11.4), from protocol 14: its edition, its own
|
||||
* difficulties (D386), CopyPaste, and the raids helper that makes a step's base the
|
||||
* run's (D204) — and what a step may do without it (D348, D387).
|
||||
*/
|
||||
export function raidableBasesLine(rb, probe) {
|
||||
if (!rb.loaded) {
|
||||
return `RaidableBases is not loaded — no raid bases on the feed or the map, and a step cannot spawn one.${rb.helper && rb.helper.state !== 'missing' ? ' RunicGatewayRaids.cs is installed and waits for it.' : ''}`
|
||||
}
|
||||
const name = `RaidableBases ${rb.version || ''} (${rb.premium ? 'paid' : 'free'} edition)`.replace(' ()', '').trim()
|
||||
const difficulties = Array.isArray(rb.difficulties) && rb.difficulties.length
|
||||
? ` Its difficulties: ${rb.difficulties.map((d) => d.name).join(', ')}.`
|
||||
: ''
|
||||
if (rb.copyPaste && !rb.copyPaste.loaded) {
|
||||
return `${name} is loaded but CopyPaste is missing: it cannot paste a base, and a step cannot spawn one.`
|
||||
}
|
||||
const state = rb.helper ? rb.helper.state : 'missing'
|
||||
if (state === 'patched') {
|
||||
const grid = rb.gridReady === false ? ' It is still building its spawn grid (up to ten minutes after it loads); a step waits for it.' : ''
|
||||
const profiles = Array.isArray(rb.profiles) ? ` ${rb.profiles.length} profile${rb.profiles.length === 1 ? '' : 's'}.` : ''
|
||||
return `${name} is loaded with RunicGatewayRaids.cs — its bases show on the feed and the map, and a step may spawn one that is the event’s: at a spot, with its own settings and lock, despawned when the event ends.${difficulties}${profiles}${grid}`
|
||||
}
|
||||
const helper = state === 'missing'
|
||||
? 'RunicGatewayRaids.cs is not installed'
|
||||
: `RunicGatewayRaids.cs could not patch it${rb.helper && rb.helper.reason ? ` (${rb.helper.reason})` : ''}`
|
||||
if (rb.spawnCall) {
|
||||
return `${name} is loaded — its bases show on the feed and the map. ${helper}, so a step may spawn one only where RaidableBases picks, at a difficulty, and it despawns on its own timer.${difficulties}`
|
||||
}
|
||||
if (probe && probe.plugins === false) {
|
||||
return `${name} is loaded — its bases show on the feed and the map. ${helper}, and this framework cannot run its console command, so a step cannot spawn one (Rust-Plugins#34).`
|
||||
}
|
||||
return `${name} is loaded — its bases show on the feed and the map. ${helper}, so a step may spawn one only where RaidableBases picks, and it despawns on its own timer.${difficulties}`
|
||||
}
|
||||
|
||||
/**
|
||||
* ZLevels Remastered (PLAN_REDESIGNS §14.3.6), from protocol 14. Only whether it is
|
||||
* loaded: its levels never reach the site (D374), and its XP boosts are permissions
|
||||
* an event lends like any other.
|
||||
*/
|
||||
export function zLevelsLine(z) {
|
||||
if (!z.loaded) return 'ZLevels is not loaded — there are no XP boosts for an event to lend.'
|
||||
return `${`ZLevels ${z.version || ''}`.trim()} is loaded — an event may lend its XP boosts (zlevelsremastered.vip1 to vip3) as permissions.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded Crate (PLAN_REDESIGNS §14.3.5), from protocol 14, with NpcSpawn beside it
|
||||
* (D364): its crates on the feed and the map, and whether a step can place one,
|
||||
* which needs NpcSpawn for the guards and the framework to run its console command.
|
||||
*/
|
||||
export function guardedCrateLine(gc, probe) {
|
||||
const npc = gc.npcSpawn && gc.npcSpawn.loaded ? `NpcSpawn ${gc.npcSpawn.version || ''}`.trim() : null
|
||||
if (!gc.loaded) {
|
||||
return `Guarded Crate is not loaded — a step cannot place a guarded crate.${npc ? ` ${npc} is loaded.` : ''}`
|
||||
}
|
||||
const name = `Guarded Crate ${gc.version || ''}`.trim()
|
||||
const events = Array.isArray(gc.events) && gc.events.length ? ` Its events: ${gc.events.join(', ')}.` : ''
|
||||
if (!npc) {
|
||||
return `${name} is loaded but NpcSpawn is missing: its events cannot start, and a step cannot place one.`
|
||||
}
|
||||
if (probe && probe.plugins === false) {
|
||||
return `${name} is loaded with ${npc} — its crates show on the feed and the map, but this framework cannot run its console command, so a step cannot place one (Rust-Plugins#34).${events}`
|
||||
}
|
||||
return `${name} is loaded with ${npc} — its crates show on the feed and the map, and a step may place one.${events}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Dangerous Treasures (PLAN_REDESIGNS §14.3.4), from protocol 14: its events on the
|
||||
* feed and the map, and whether a step can start one, which needs the framework
|
||||
* to run its console command (hello's `console.plugins`, D363).
|
||||
*/
|
||||
export function dangerousTreasuresLine(dt, probe) {
|
||||
if (!dt.loaded) return 'Dangerous Treasures is not loaded — a step cannot start a treasure event.'
|
||||
const name = `Dangerous Treasures ${dt.version || ''}`.trim()
|
||||
if (probe && probe.plugins === false) {
|
||||
return `${name} is loaded — its events show on the feed and the map, but this framework cannot run its console command, so a step cannot start one (Rust-Plugins#34).`
|
||||
}
|
||||
return `${name} is loaded — its events show on the feed and the map, and a step may start one.`
|
||||
}
|
||||
|
||||
/** Dynamic PVP (PLAN_REDESIGNS §14.3.3), from protocol 14: its zones on the feed and the map. */
|
||||
export function dynamicPvpLine(d) {
|
||||
if (!d.loaded) return 'Dynamic PVP is not loaded — no PvP zones of its own on the feed or the map.'
|
||||
return `${`Dynamic PVP ${d.version || ''}`.trim()} is loaded — its PvP zones show on the feed and the map.`
|
||||
}
|
||||
|
||||
/** TruePVE (PLAN_REDESIGNS §14.3.2), from protocol 14: what a zone step may do with PvP. */
|
||||
export function truePveLine(tp) {
|
||||
if (!tp.loaded) return 'TruePVE is not loaded — a zone step cannot change PvP rules.'
|
||||
return `${`TruePVE ${tp.version || ''}`.trim()} is loaded — a zone step may turn off PvP protection or name a ruleset.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Backpacks (PLAN_REDESIGNS §11.3.2), from protocol 14. Its sizes are what a
|
||||
* Bigger backpack step may lease (D337), so they are said.
|
||||
*/
|
||||
export function backpacksLine(b) {
|
||||
if (!b.loaded) return 'Backpacks is not loaded — backpack views and bigger-backpack rewards wait for it.'
|
||||
const name = `Backpacks ${b.version || ''}`.trim()
|
||||
const sizes = Array.isArray(b.sizes) ? b.sizes : []
|
||||
return sizes.length > 1
|
||||
? `${name} is loaded — sizes ${sizes.join(', ')}; an event may lend any above ${sizes[0]}.`
|
||||
: `${name} is loaded, but offers no size above its smallest, so an event has nothing bigger to lend.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Economics (PLAN_REDESIGNS §11.2.2), from protocol 14. Its starting balance
|
||||
* decides who the richest column ranks (D336), so a config the plugin could not
|
||||
* read is said: the site then assumes Economics' default of 1000.
|
||||
*/
|
||||
export function economicsLine(e) {
|
||||
if (!e.loaded) return 'Economics is not loaded — coin rewards, balances and the richest column wait for it.'
|
||||
const name = `Economics ${e.version || ''}`.trim()
|
||||
if (e.startingBalance === undefined || e.startingBalance === null) {
|
||||
return `${name} is loaded, but its config could not be read — the site assumes a starting balance of 1,000.`
|
||||
}
|
||||
const wipes = e.wipesWithMap === true ? 'balances wipe with the map' : e.wipesWithMap === false ? 'balances carry over a wipe' : null
|
||||
return `${name} is loaded — starting balance ${Number(e.startingBalance).toLocaleString()}${wipes ? `, ${wipes}` : ''}.`
|
||||
}
|
||||
|
||||
export function VoiceCard() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.admin.voice(), [reloads])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const choose = async (group) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.saveVoice(group)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const current = data.options.find((o) => o.group === data.voice)
|
||||
|
||||
return (
|
||||
<section className="panel sans" style={{ padding: '16px 18px', marginBottom: 18, fontSize: '0.84rem' }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: '0 0 6px', color: 'var(--head)' }}>Announcement voice</h2>
|
||||
<p className="dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
|
||||
News posts and event announcements said in game chat can wear the title and colours of one permission group that
|
||||
has a chat style. The line has no sender, so no player’s name appears. It works whether or not BetterChat is
|
||||
installed. Popups are plain text.
|
||||
</p>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
Say them as
|
||||
<select value={data.voice} onChange={(e) => choose(e.target.value)} disabled={busy} style={inputStyle}>
|
||||
<option value="">Plain chat</option>
|
||||
{data.options.map((o) => (
|
||||
<option key={o.group} value={o.group}>{o.name} — {o.title} ({o.where})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{data.voice && !current && (
|
||||
<p style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||
The chosen group no longer has a chat style, so lines are said in plain chat until it has one again or another
|
||||
voice is chosen.
|
||||
</p>
|
||||
)}
|
||||
{current && <p className="dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>The line: <code>{current.format}</code></p>}
|
||||
{!data.options.length && (
|
||||
<p className="dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>No group has a chat style yet. Give one a style under Permissions.</p>
|
||||
)}
|
||||
{error && <p style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '5px 8px',
|
||||
fontSize: '0.84rem',
|
||||
}
|
||||
642
client/src/routes/admin/ModConfig.jsx
Normal file
642
client/src/routes/admin/ModConfig.jsx
Normal file
@@ -0,0 +1,642 @@
|
||||
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
|
||||
//
|
||||
// R18. An admin picks a server, a plugin and a file, changes something, and the
|
||||
// plugin reloads. This module's second admin page, and the first that writes to
|
||||
// somebody's filesystem.
|
||||
//
|
||||
// **What is on the screen is decided by what is dangerous about the action.**
|
||||
// Four things are true here that are not true anywhere else in this module, and
|
||||
// each of them is a piece of the page rather than a line in a doc:
|
||||
//
|
||||
// • a save can take a required plugin DOWN. So the reload target is a
|
||||
// deliberate choice with the folder name as a guess, the result is reported
|
||||
// as its own panel, and a rollback shows the server's own log line.
|
||||
// • the form cannot express everything a config holds. A `null`, an empty
|
||||
// array and anything past the depth limit are marked and sent to the raw
|
||||
// tier rather than half-drawn.
|
||||
// • three keys in the bridge's own config would cut the link carrying the
|
||||
// edit, or split the server's history. They render read-only, with the
|
||||
// reason (D38).
|
||||
// • configs hold API keys and Discord webhooks. Those fields render masked
|
||||
// with a reveal, which is about the shoulder rather than the wire: an admin
|
||||
// can already read the file over SSH (D37), and the audit trail never
|
||||
// records the values either way.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
function Card({ title, subtitle, children, actions }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{actions}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Warn({ children, tone = '#d08a2a' }) {
|
||||
return (
|
||||
<p className="sans" style={{ color: tone, fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** A value the form can edit: one row, typed by what the file already holds. */
|
||||
function Field({ field, value, onChange, revealed, onReveal }) {
|
||||
const indent = 12 * Math.max(0, field.depth - 1)
|
||||
const label = (
|
||||
<label
|
||||
className="sans"
|
||||
style={{
|
||||
flex: '0 0 300px',
|
||||
paddingLeft: indent,
|
||||
color: field.locked ? 'var(--ink)' : 'var(--head)',
|
||||
fontSize: '0.84rem',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={field.path}
|
||||
>
|
||||
{field.key}
|
||||
{field.locked && (
|
||||
<span className="dim" style={{ fontSize: '0.72rem' }}>
|
||||
{' '}
|
||||
· read-only
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
|
||||
if (field.type === 'object' || field.type === 'array') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0 2px' }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ paddingLeft: indent, color: 'var(--head)', fontSize: '0.86rem', fontWeight: 500 }}
|
||||
>
|
||||
{field.key || '(the file)'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{field.type === 'array' ? `${field.count} entries` : `${field.count} settings`}
|
||||
{field.advanced && field.reason ? ` · ${field.reason}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.advanced) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
{label}
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{field.reason} — edit it in Raw JSON
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
{label}
|
||||
{field.type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
disabled={field.locked}
|
||||
onChange={(event) => onChange(field, event.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
type={field.secret && !revealed ? 'password' : 'text'}
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
disabled={field.locked}
|
||||
onChange={(event) => onChange(field, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{field.secret && !field.locked && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onReveal(field.path)}>
|
||||
{revealed ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** How often a save that is still reloading asks how it went. */
|
||||
const POLL_MS = 2000
|
||||
|
||||
/**
|
||||
* A settled write row as the report panel reads it.
|
||||
*
|
||||
* The row is this module's audit record, not the plugin's frame, so it carries
|
||||
* no per-file `rewritten` — the file is re-read after either way, which is what
|
||||
* the panel's note about rewriting was for.
|
||||
*/
|
||||
function reportFromWrite(write) {
|
||||
return {
|
||||
pending: write.outcome === 'reloading',
|
||||
lost: write.outcome === 'lost',
|
||||
reload: write.reloadTarget,
|
||||
reloaded: Boolean(write.reloaded),
|
||||
rolledBack: write.outcome === 'rolled-back',
|
||||
restored: write.restored,
|
||||
reason: write.detail,
|
||||
log: write.log,
|
||||
files: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** The sentence at the top of the panel. */
|
||||
function headline(report) {
|
||||
if (report.pending) {
|
||||
return `Saved. Reloading ${report.reload || 'the plugin'}… a first compile after a quiet spell can take a while.`
|
||||
}
|
||||
|
||||
if (report.lost) {
|
||||
return 'The server never said how the reload went — the bridge may have been reloaded, or the link dropped. Re-read the file to see what is on disk.'
|
||||
}
|
||||
|
||||
if (report.rolledBack) {
|
||||
return report.restored === false
|
||||
? 'The plugin did not come back, so the old file was put back — and it did not come back on the old file either. It is down: check the server console.'
|
||||
: 'The plugin did not come back, so the old file was put back automatically.'
|
||||
}
|
||||
|
||||
return report.reloaded ? 'Saved, and the plugin reloaded.' : `Saved. ${report.reason || 'Nothing was reloaded.'}`
|
||||
}
|
||||
|
||||
/** What the game said happened. The rollback case is the one worth reading. */
|
||||
function Report({ report }) {
|
||||
if (!report) return null
|
||||
|
||||
const tone = report.rolledBack || report.lost ? '#e05a5a' : 'var(--ink)'
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
|
||||
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
|
||||
{headline(report)}
|
||||
</p>
|
||||
{report.rolledBack && report.reason && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||||
{report.reason}
|
||||
</p>
|
||||
)}
|
||||
{report.log && (
|
||||
<pre
|
||||
className="sans"
|
||||
style={{
|
||||
background: 'var(--line-soft)',
|
||||
padding: 10,
|
||||
marginTop: 8,
|
||||
fontSize: '0.74rem',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{report.log}
|
||||
</pre>
|
||||
)}
|
||||
{report.files.some((f) => f.rewritten) && (
|
||||
<Warn>
|
||||
The plugin rewrote the file as it loaded — both frameworks add any settings a config is
|
||||
missing and save it back, so what is on disk now is not byte-for-byte what was sent.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ModConfig() {
|
||||
const [serverId, setServerId] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const [tier, setTier] = useState('form')
|
||||
const [edits, setEdits] = useState({})
|
||||
const [raw, setRaw] = useState('')
|
||||
const [reload, setReload] = useState('')
|
||||
const [revealed, setRevealed] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [report, setReport] = useState(null)
|
||||
const [fileNonce, setFileNonce] = useState(0)
|
||||
// The write a save left reloading, polled until it settles (protocol 13, D179).
|
||||
const [watching, setWatching] = useState(null)
|
||||
|
||||
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
|
||||
|
||||
// The tree is asked for per server and never cached across one: what is on a
|
||||
// host's disk has no stale answer worth showing, and a plugin loaded a minute
|
||||
// ago has to be able to appear.
|
||||
const { data: tree, error: treeError } = useAsync(
|
||||
() => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)),
|
||||
[serverId],
|
||||
)
|
||||
|
||||
const { data: file, error: fileError } = useAsync(
|
||||
() => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)),
|
||||
[serverId, path, fileNonce],
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setEdits({})
|
||||
setRevealed({})
|
||||
setError('')
|
||||
}, [])
|
||||
|
||||
// A freshly opened file starts from what the host holds: the raw editor's text
|
||||
// and the reload target's guess both come from the answer rather than from
|
||||
// whatever the previous file left behind.
|
||||
//
|
||||
// **The guess is only taken when the dropdown actually offers it.** A `<select>`
|
||||
// whose value matches no `<option>` displays the first one, so a guess of
|
||||
// `RunicGateway` — which is deliberately not offered, because the bridge cannot
|
||||
// reload itself — put "nothing — just write the file" on the screen while the
|
||||
// request carried `reload: RunicGateway`, and every save of our own config was
|
||||
// refused for a reason the page had just said did not apply.
|
||||
useEffect(() => {
|
||||
if (!file) return
|
||||
setRaw(file.text)
|
||||
|
||||
const offered = (tree ? tree.loaded : []).some(
|
||||
(p) => p.name === file.plugin && p.name !== (tree && tree.self),
|
||||
)
|
||||
|
||||
setReload(offered ? file.plugin : '')
|
||||
reset()
|
||||
}, [file, tree, reset])
|
||||
|
||||
useEffect(() => {
|
||||
setPath('')
|
||||
setReport(null)
|
||||
setWatching(null)
|
||||
}, [serverId])
|
||||
|
||||
// A save that reloads a plugin comes back before the reload has finished.
|
||||
// Ask how it went every couple of seconds until the row leaves `reloading` —
|
||||
// the server calls it `lost` once twice the plugin's ceiling has passed, so
|
||||
// this always ends — then re-read the file, because a reload rewrites it and a
|
||||
// rollback puts the old one back.
|
||||
useEffect(() => {
|
||||
if (!watching) return undefined
|
||||
|
||||
let stopped = false
|
||||
let handle = null
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { write } = await api.adminConfig.write(watching.serverId, watching.id)
|
||||
if (stopped) return
|
||||
|
||||
setReport(reportFromWrite(write))
|
||||
|
||||
if (write.outcome !== 'reloading') {
|
||||
setWatching(null)
|
||||
setFileNonce((n) => n + 1)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// One failed poll is a blip, not an answer; the next one asks again.
|
||||
if (stopped) return
|
||||
}
|
||||
|
||||
handle = setTimeout(poll, POLL_MS)
|
||||
}
|
||||
|
||||
handle = setTimeout(poll, POLL_MS)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}, [watching])
|
||||
|
||||
if (serverError) return <ErrorState error={serverError} />
|
||||
if (!servers) return <Loading />
|
||||
|
||||
const rows = servers.servers || servers || []
|
||||
const change = (field, value) => setEdits((current) => ({ ...current, [field.path]: { field, value } }))
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setReport(null)
|
||||
|
||||
try {
|
||||
const body =
|
||||
tier === 'form'
|
||||
? {
|
||||
path,
|
||||
version: file.version,
|
||||
...(reload ? { reload } : {}),
|
||||
// A number goes up as the TEXT that was typed. `2.50` stays
|
||||
// `2.50` and `1.0` stays `1.0`; turning either into a JavaScript
|
||||
// number here is precisely the bug the server half exists to
|
||||
// avoid, and it would be reintroduced in the browser.
|
||||
edits: Object.values(edits).map(({ field, value }) =>
|
||||
field.type === 'number'
|
||||
? { pointer: field.pointer, raw: String(value) }
|
||||
: { pointer: field.pointer, value },
|
||||
),
|
||||
}
|
||||
: { path, version: file.version, ...(reload ? { reload } : {}), text: raw }
|
||||
|
||||
const answer = await api.adminConfig.save(serverId, body)
|
||||
|
||||
if (answer.pending && answer.write && answer.write.id) {
|
||||
setReport({ ...(answer.report || {}), pending: true, reload: reload || null, files: [] })
|
||||
setWatching({ serverId, id: answer.write.id })
|
||||
} else {
|
||||
setReport(answer.report || null)
|
||||
}
|
||||
|
||||
if (!answer.changed) setError('Nothing changed, so nothing was written.')
|
||||
|
||||
// Re-read either way: a successful reload usually rewrites the file with
|
||||
// the defaults it was missing, and a rollback means what is on disk is no
|
||||
// longer what is on the screen.
|
||||
setFileNonce((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That save did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = Object.keys(edits).length
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 980 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
These are the configuration files on the game host itself, read live through the bridge. A
|
||||
save backs the file up, writes it, reloads the plugin you name, and <strong>puts the old
|
||||
file back automatically</strong> if the plugin does not come back. The game’s data
|
||||
directory — kit cooldowns, zone definitions, the permission store — is not settings and is
|
||||
never listed here.
|
||||
</p>
|
||||
|
||||
<Card title="Server" subtitle={`${rows.length} configured`}>
|
||||
<select className="input" value={serverId} onChange={(event) => setServerId(event.target.value)}>
|
||||
<option value="">Choose a server…</option>
|
||||
{rows.map((row) => (
|
||||
<option key={row.id} value={row.id}>
|
||||
{row.name || row.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tree && tree.root && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
{tree.root}
|
||||
{tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{serverId && treeError && <ErrorState error={treeError} />}
|
||||
|
||||
{serverId && !treeError && !tree && <Loading />}
|
||||
|
||||
{tree && (
|
||||
<Card title="Files" subtitle="grouped by the plugin each one probably belongs to">
|
||||
{tree.plugins.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||||
This server reports no configuration files.
|
||||
</p>
|
||||
)}
|
||||
{tree.plugins.map((group) => (
|
||||
<div key={group.plugin} style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||
<strong className="sans" style={{ fontSize: '0.88rem', fontWeight: 500 }}>
|
||||
{group.title || group.plugin}
|
||||
</strong>
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{group.loaded ? `loaded · ${group.version}` : 'not loaded'}
|
||||
{group.isBridge ? ' · this bridge' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{group.files.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0 4px 12px' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={entry.path === path ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
disabled={!entry.editable}
|
||||
onClick={() => {
|
||||
setPath(entry.path)
|
||||
setReport(null)
|
||||
setTier('form')
|
||||
}}
|
||||
>
|
||||
{/* A RaidableBases profile (D205) travels under a virtual path; show its name. */}
|
||||
{entry.profile ? `Profile · ${entry.path.split('/').pop()}` : entry.path}
|
||||
</button>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{Math.round(entry.bytes / 102.4) / 10} KB
|
||||
{entry.modified ? ` · changed ${ago(entry.modified)}` : ''}
|
||||
{entry.reason ? ` · ${entry.reason}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!group.loaded && (
|
||||
<Warn>
|
||||
Nothing on this server is loaded under that name, so a save here is written and
|
||||
not reloaded. It applies the next time the plugin loads.
|
||||
</Warn>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{path && fileError && <ErrorState error={fileError} />}
|
||||
{path && !fileError && !file && <Loading />}
|
||||
|
||||
{file && (
|
||||
<Card
|
||||
title={file.path}
|
||||
subtitle={tier === 'form' ? `${pending} unsaved` : 'raw JSON'}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={tier === 'form' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
onClick={() => setTier('form')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tier === 'raw' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
onClick={() => setTier('raw')}
|
||||
>
|
||||
Raw JSON
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{file.parseError && (
|
||||
<Warn tone="#e05a5a">
|
||||
This file is not valid JSON on the server ({file.parseError}), so there is nothing to
|
||||
draw a form from. Raw JSON is the tier that can fix it.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{file.isBridge && (
|
||||
<Warn>
|
||||
This is the bridge’s own configuration. Its address, port and server id are read-only
|
||||
here — changing any of them from the website would cut the link carrying the change,
|
||||
or strand every row this site holds for this server. They are editable on the host
|
||||
itself. This plugin also cannot be reloaded from here.
|
||||
</Warn>
|
||||
)}
|
||||
|
||||
{tier === 'form' && file.fields && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{file.fields
|
||||
.filter((field) => field.path !== '')
|
||||
.map((field) => (
|
||||
<Field
|
||||
key={field.path}
|
||||
field={field}
|
||||
value={
|
||||
edits[field.path]
|
||||
? edits[field.path].value
|
||||
: field.type === 'number'
|
||||
? field.raw
|
||||
: field.value
|
||||
}
|
||||
onChange={change}
|
||||
revealed={Boolean(revealed[field.path])}
|
||||
onReveal={(p) => setRevealed((current) => ({ ...current, [p]: !current[p] }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tier === 'raw' && (
|
||||
<textarea
|
||||
className="input"
|
||||
spellCheck={false}
|
||||
value={raw}
|
||||
onChange={(event) => setRaw(event.target.value)}
|
||||
style={{ width: '100%', minHeight: 360, fontFamily: 'monospace', fontSize: '0.8rem' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginTop: 12,
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<label className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
Reload
|
||||
</label>
|
||||
{/* A guess, and it says so. The folder a config sits in is convention
|
||||
rather than contract, so reloading it silently is how the wrong
|
||||
plugin gets reloaded, reports success, and the edited one never
|
||||
re-reads anything. */}
|
||||
<select className="input" value={reload} onChange={(event) => setReload(event.target.value)}>
|
||||
<option value="">nothing — just write the file</option>
|
||||
{(tree ? tree.loaded : [])
|
||||
.filter((p) => p.name !== tree.self)
|
||||
.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || Boolean(watching) || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
|
||||
onClick={save}
|
||||
>
|
||||
{busy ? 'Saving…' : watching ? 'Reloading…' : 'Save and reload'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Beside the button, not at the top of the page. A save is made at the
|
||||
bottom of a long form, and a refusal rendered above the fold is a
|
||||
click that visibly did nothing. */}
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.82rem', margin: '8px 0 0' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Report report={report} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{serverId && <History serverId={serverId} nonce={fileNonce} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who changed what, including the saves that were refused or undone. */
|
||||
function History({ serverId, nonce }) {
|
||||
const { data } = useAsync(() => api.adminConfig.writes(serverId), [serverId, nonce])
|
||||
|
||||
if (!data || !data.writes || data.writes.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card title="Recent changes" subtitle="every save, including the ones that did not land">
|
||||
{data.writes.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="sans"
|
||||
style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)', fontSize: '0.82rem' }}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
|
||||
<strong style={{ fontWeight: 500 }}>{row.path}</strong>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ fontSize: '0.74rem', color: row.outcome === 'applied' ? 'var(--ink)' : '#d08a2a' }}
|
||||
>
|
||||
{row.outcome}
|
||||
{row.reloaded ? ' · reloaded' : ''}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{ago(row.createdAt)}
|
||||
{row.tier === 'raw' ? ' · raw' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{(row.changes || []).map((change, index) => (
|
||||
<div key={`${row.id}-${index}`} className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{change.path}
|
||||
{change.from !== null && change.to !== null ? `: ${change.from} → ${change.to}` : ''}
|
||||
</div>
|
||||
))}
|
||||
{row.detail && (
|
||||
<div className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{row.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
297
client/src/routes/admin/NpcPlacements.jsx
Normal file
297
client/src/routes/admin/NpcPlacements.jsx
Normal file
@@ -0,0 +1,297 @@
|
||||
// ── Admin · Rust · NPC placements (docs/runicnpc/PLAN.md stage 4) ─────────
|
||||
//
|
||||
// One server's RunicNPC placements. They live on the server (D222) — an admin's
|
||||
// `/rnpc place` in game and this page edit the same list — so every read and
|
||||
// write here is a live round trip, and a server whose game is off has nothing
|
||||
// to show.
|
||||
//
|
||||
// A new placement is made by clicking the live map (D245): the server puts the
|
||||
// point on the ground, checks it against the navmesh, and names it as in game
|
||||
// (D246). A roof or a building top is placed in game. Every answer that adds
|
||||
// NPCs carries the cost warning (D227).
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import MapView from '../../components/MapView.jsx'
|
||||
import api from '../../api.js'
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
font: 'inherit',
|
||||
}
|
||||
|
||||
const blank = (profile = '') => ({ profile, count: '1', respawn: '300', respawnMode: 'each', move: '', radius: '', tether: '' })
|
||||
|
||||
function formOf(p) {
|
||||
const v = p.placement || {}
|
||||
return {
|
||||
profile: v.profile || '',
|
||||
count: String(v.count || 1),
|
||||
respawn: String(v.respawn || 300),
|
||||
respawnMode: v.respawnMode || 'each',
|
||||
move: (v.movement && v.movement.mode) || '',
|
||||
radius: v.movement && v.movement.radius !== undefined ? String(v.movement.radius) : '',
|
||||
tether: v.tether || '',
|
||||
}
|
||||
}
|
||||
|
||||
function bodyOf(f) {
|
||||
return {
|
||||
profile: f.profile,
|
||||
count: Number(f.count),
|
||||
respawn: Number(f.respawn),
|
||||
respawnMode: f.respawnMode,
|
||||
...(f.move ? { movement: { mode: f.move, radius: f.radius === '' ? undefined : Number(f.radius) } } : { movement: null }),
|
||||
// Stage 5 (D272): a ZoneManager zone its NPCs never leave; empty for none.
|
||||
tether: f.tether.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
export default function NpcPlacements() {
|
||||
const { data: meta, error: metaError } = useAsync(() => api.adminNpcs.read(), [])
|
||||
const ready = useMemo(() => ((meta && meta.servers) || []).filter((s) => s.ready), [meta])
|
||||
const [serverId, setServerId] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId && ready.length) setServerId(ready[0].id)
|
||||
}, [ready, serverId])
|
||||
|
||||
if (metaError) return <ErrorState error={metaError} />
|
||||
if (!meta) return <Loading />
|
||||
|
||||
const server = ready.find((s) => s.id === serverId)
|
||||
const profiles = (meta.profiles || []).filter((p) => !p.replaced && (p.allServers || p.servers.includes(serverId)))
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1100 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
Where RunicNPC’s NPCs stand, and come back after they die. These live on the server: <code>/rnpc place</code> in
|
||||
game changes the same list. Click the map to place NPCs there; the server puts them on the ground and names the
|
||||
placement. A roof or the top of a building is placed in game. Profiles are on the{' '}
|
||||
<Link to="/admin/rust/npcs">NPC profiles page</Link>.
|
||||
</p>
|
||||
|
||||
{ready.length === 0 ? (
|
||||
<section className="panel sans" style={{ padding: '14px 18px', fontSize: '0.84rem' }}>
|
||||
No server can take placements from the site yet: each needs RunicNPC with API 3.{' '}
|
||||
{(meta.servers || []).map((s) => `${s.name || s.id}: ${s.absence}`).join(' · ')}
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: '0.86rem', marginBottom: 12 }}>
|
||||
<span style={{ color: 'var(--head)' }}>Server</span>
|
||||
<select value={serverId} onChange={(e) => setServerId(e.target.value)} style={inputStyle}>
|
||||
{ready.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{server && <ServerPlacements key={server.id} server={server} profiles={profiles} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerPlacements({ server, profiles }) {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.adminNpcs.placements(server.id), [server.id, reloads])
|
||||
const [picked, setPicked] = useState(null)
|
||||
const [form, setForm] = useState(blank(profiles[0] ? profiles[0].name : ''))
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [renaming, setRenaming] = useState(null)
|
||||
const [confirming, setConfirming] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
const act = async (fn, done) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const out = await fn()
|
||||
setNotice(done(out))
|
||||
setReloads((n) => n + 1)
|
||||
return true
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
return false
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const placements = (data && data.placements) || []
|
||||
const pins = useMemo(() => {
|
||||
const out = placements
|
||||
.filter((p) => p.placement && p.placement.position)
|
||||
.map((p) => ({
|
||||
x: p.placement.position.x,
|
||||
z: p.placement.position.z,
|
||||
label: `${p.id} · ${p.placement.count} × ${p.placement.profile}${p.waiting ? ` · waits: ${p.waiting}` : ''}`,
|
||||
colour: p.waiting ? '#9e9e9e' : '#ffd54f',
|
||||
}))
|
||||
if (picked) out.push({ x: picked.x, z: picked.z, label: 'New placement', colour: '#00e5ff', ring: true })
|
||||
return out
|
||||
}, [placements, picked])
|
||||
|
||||
const place = async (e) => {
|
||||
e.preventDefault()
|
||||
const ok = await act(
|
||||
() => api.adminNpcs.place(server.id, { ...bodyOf(form), position: { x: picked.x, z: picked.z } }),
|
||||
(r) => `Placed ${r.id}${r.position ? ` at ${Math.round(r.position.x)}, ${Math.round(r.position.z)} (${Math.round(r.position.y)} m up)` : ''}.${r.built ? ' It is on something players built: if that is destroyed, its NPCs stand on the nearest navmesh.' : ''} ${r.cost || ''}`,
|
||||
)
|
||||
if (ok) setPicked(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: 'var(--head)', margin: 0 }}>{notice}</p>}
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem', margin: 0 }}>{error}</p>}
|
||||
|
||||
<MapView serverId={server.id} online={server.online !== false} onPick={(at) => { setError(''); setPicked(at) }} pins={pins} />
|
||||
|
||||
{picked && (
|
||||
<form onSubmit={place} className="panel sans" style={{ padding: '14px 18px', display: 'grid', gap: 10, fontSize: '0.84rem' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>
|
||||
New placement at {picked.x}, {picked.z}{picked.grid ? ` (${picked.grid})` : ''}
|
||||
</strong>
|
||||
{profiles.length === 0 ? (
|
||||
<span className="dim">No NPC profile is on this server yet.</span>
|
||||
) : (
|
||||
<PlacementFields form={form} setForm={setForm} profiles={profiles} routes={(data && data.routes) || []} />
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn" disabled={busy || profiles.length === 0}>{busy ? 'Placing…' : 'Place'}</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => setPicked(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<section className="panel sans" style={{ padding: '14px 18px', fontSize: '0.84rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: 0, color: 'var(--head)' }}>Placements</h2>
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} disabled={busy} onClick={() => setReloads((n) => n + 1)}>Refresh</button>
|
||||
</div>
|
||||
{loadError && <p style={{ color: 'var(--danger, #d98b84)' }}>{loadError.message}</p>}
|
||||
{!data && !loadError && <Loading />}
|
||||
{data && data.cost && <p className="dim" style={{ fontSize: '0.78rem' }}>{data.cost}</p>}
|
||||
{data && placements.length === 0 && <p className="dim">None on this server.</p>}
|
||||
{placements.map((p) => {
|
||||
const v = p.placement || {}
|
||||
const move = v.movement ? `${v.movement.mode}${v.movement.mode === 'wander' ? ` ${v.movement.radius} m` : ''}` : 'the profile’s movement'
|
||||
return (
|
||||
<div key={p.id} style={{ borderTop: '1px solid var(--line-soft)', padding: '8px 0' }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{p.id}</strong>
|
||||
<span className="dim">
|
||||
{v.count} × {v.profile} · {p.alive} alive · respawn {v.respawn} s, {v.respawnMode} · {move}
|
||||
{v.tether ? ` · inside zone ${v.tether}` : ''}
|
||||
{v.position ? ` · ${Math.round(v.position.x)}, ${Math.round(v.position.z)}` : ''}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => { setEditing(editing === p.id ? null : p.id); setForm(formOf(p)) }}>Edit</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => setRenaming(renaming && renaming.id === p.id ? null : { id: p.id, to: p.id })}>Rename</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminNpcs.respawnPlacement(server.id, p.id), (r) => `Respawning ${r.respawned} NPC(s) of ${p.id}.`)}>Respawn</button>
|
||||
{confirming === p.id ? (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => { setConfirming(null); act(() => api.adminNpcs.removePlacement(server.id, p.id), () => `Removed ${p.id} and its NPCs.`) }}>
|
||||
Confirm remove
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => setConfirming(p.id)}>Remove</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{p.waiting && <div style={{ color: 'var(--danger, #d98b84)' }}>Waits: {p.waiting}</div>}
|
||||
{p.note && <div className="dim">{p.note}</div>}
|
||||
{p.lastError && <div className="dim">Last spawn failed: {p.lastError}</div>}
|
||||
{renaming && renaming.id === p.id && (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); act(() => api.adminNpcs.renamePlacement(server.id, p.id, renaming.to), () => `Renamed ${p.id} to ${renaming.to}.`).then((ok) => ok && setRenaming(null)) }}
|
||||
style={{ display: 'flex', gap: 8, marginTop: 6 }}
|
||||
>
|
||||
<input value={renaming.to} onChange={(e) => setRenaming({ ...renaming, to: e.target.value })} pattern="[a-z0-9_\-]{1,40}" style={{ ...inputStyle, maxWidth: 240 }} />
|
||||
<button type="submit" className="btn" disabled={busy}>Rename</button>
|
||||
</form>
|
||||
)}
|
||||
{editing === p.id && (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); act(() => api.adminNpcs.setPlacement(server.id, p.id, bodyOf(form)), (r) => `Changed ${p.id}. ${r.cost || ''}`).then((ok) => ok && setEditing(null)) }}
|
||||
style={{ display: 'grid', gap: 8, marginTop: 6 }}
|
||||
>
|
||||
<PlacementFields form={form} setForm={setForm} profiles={profiles} routes={(data && data.routes) || []} />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn" disabled={busy}>Save</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** `/rnpc place`'s options (D242, D246): profile, count, respawn, each or group, movement and radius. */
|
||||
function PlacementFields({ form, setForm, profiles, routes }) {
|
||||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
const profile = profiles.find((p) => p.name === form.profile)
|
||||
const sentry = profile && profile.body.role === 'sentry'
|
||||
const label = { display: 'grid', gap: 4 }
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'end' }}>
|
||||
<label style={label}>
|
||||
<span style={{ color: 'var(--head)' }}>Profile</span>
|
||||
<select value={form.profile} onChange={set('profile')} style={inputStyle} required>
|
||||
{!profile && <option value={form.profile}>{form.profile || 'Pick one'}</option>}
|
||||
{profiles.map((p) => <option key={p.id} value={p.name}>{p.label} ({p.name})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={label}>
|
||||
<span style={{ color: 'var(--head)' }}>How many</span>
|
||||
<input value={form.count} onChange={set('count')} inputMode="numeric" style={{ ...inputStyle, maxWidth: 70 }} />
|
||||
</label>
|
||||
<label style={label}>
|
||||
<span style={{ color: 'var(--head)' }}>Respawn (s)</span>
|
||||
<input value={form.respawn} onChange={set('respawn')} inputMode="numeric" style={{ ...inputStyle, maxWidth: 90 }} />
|
||||
</label>
|
||||
<label style={label}>
|
||||
<span style={{ color: 'var(--head)' }}>Come back</span>
|
||||
<select value={form.respawnMode} onChange={set('respawnMode')} style={inputStyle}>
|
||||
<option value="each">each, after its own death</option>
|
||||
<option value="group">together, once all are dead</option>
|
||||
</select>
|
||||
</label>
|
||||
{!sentry && (
|
||||
<>
|
||||
<label style={label}>
|
||||
<span style={{ color: 'var(--head)' }}>Movement</span>
|
||||
<select value={form.move} onChange={set('move')} style={inputStyle}>
|
||||
<option value="">the profile’s own</option>
|
||||
<option value="wander">wander</option>
|
||||
<option value="monument">monument</option>
|
||||
{routes.map((r) => <option key={r} value={`route:${r}`}>route: {r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{form.move === 'wander' && (
|
||||
<label style={label}>
|
||||
<span style={{ color: 'var(--head)' }}>Radius (m)</span>
|
||||
<input value={form.radius} onChange={set('radius')} inputMode="decimal" style={{ ...inputStyle, maxWidth: 80 }} />
|
||||
</label>
|
||||
)}
|
||||
<label style={label} title="A ZoneManager zone on this server its NPCs never leave. The server refuses a zone it does not have, or one that does not hold the spot.">
|
||||
<span style={{ color: 'var(--head)' }}>Keep inside zone</span>
|
||||
<input value={form.tether} onChange={set('tether')} placeholder="none" maxLength={64} style={{ ...inputStyle, maxWidth: 160 }} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
845
client/src/routes/admin/NpcProfiles.jsx
Normal file
845
client/src/routes/admin/NpcProfiles.jsx
Normal file
@@ -0,0 +1,845 @@
|
||||
// ── Admin · Rust · NPC profiles (docs/runicnpc/PLAN.md stage 4) ──────────
|
||||
//
|
||||
// Where RunicNPC's profiles are authored (D216: no in-game editor). A profile is
|
||||
// for one server, several, or every server, like a zone preset, and the site
|
||||
// pushes each server its set, which RunicNPC then holds as managed (D221).
|
||||
//
|
||||
// The first push to a server adopts that server's own profiles as profiles for
|
||||
// it alone (D244); one whose name a site profile already had there is kept
|
||||
// aside as "replaced" (D251), listed below with a Restore. Each profile says how
|
||||
// its kills are counted on the leaderboard and in titles (D247).
|
||||
//
|
||||
// Stage 5: a profile's side (its faction, its own exceptions, its alert radius),
|
||||
// turrets, the two PVE checkboxes and the kit's extras; and the site's one
|
||||
// faction table, pushed to every server with the profiles (D254, D268).
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
font: 'inherit',
|
||||
}
|
||||
|
||||
const SCOPE_WORDS = {
|
||||
server: 'Kills of this name on the server a leaderboard is for (the default)',
|
||||
name: 'Kills of this name on every server',
|
||||
profile: 'Kills of this profile only, on whichever servers it is pushed to',
|
||||
}
|
||||
|
||||
const TURRET_WORDS = {
|
||||
default: 'As Rust’s scientists: player turrets shoot it, Outpost and Bandit Camp sentries do not',
|
||||
ignore: 'No turret shoots it',
|
||||
always: 'Every turret shoots it, Outpost’s and Bandit Camp’s sentries too',
|
||||
}
|
||||
|
||||
const KIT_WORDS = {
|
||||
heal: 'Heals with syringes, medkits or bandages in a lull',
|
||||
grenades: 'Throws grenades at 5 to 20 m',
|
||||
melee: 'Switches to a melee weapon up close',
|
||||
rockets: 'Fires rockets at 15 to 80 m',
|
||||
flamethrower: 'Switches to a flamethrower within 7 m',
|
||||
}
|
||||
|
||||
/** A profile's own exceptions as lines of `faction: relation`, and back. */
|
||||
const relationsText = (r) => Object.entries(r || {}).map(([k, v]) => `${k}: ${v}`).join('\n')
|
||||
function relationsFrom(text) {
|
||||
const out = {}
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const at = line.lastIndexOf(':')
|
||||
if (at <= 0) continue
|
||||
const key = line.slice(0, at).trim()
|
||||
const value = line.slice(at + 1).trim()
|
||||
if (key && value) out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const list = (text) => String(text || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean)
|
||||
const lines = (text) => String(text || '').split('\n').map((s) => s.trim()).filter(Boolean)
|
||||
const str = (v) => (v === undefined || v === null ? '' : String(v))
|
||||
/** An optional number as typed: blank is "not set". */
|
||||
const opt = (v) => (String(v).trim() === '' ? undefined : v)
|
||||
|
||||
/** A boss phase as the form edits it: `at` in per cent, every other part optional (D275). */
|
||||
function phaseForm(p = {}) {
|
||||
const r = p.ranges || {}
|
||||
return {
|
||||
at: p.at === undefined ? '' : str(Math.round(p.at * 100)),
|
||||
damageDealt: str(p.damageDealt),
|
||||
aimCone: str(p.aimCone),
|
||||
speed: str(p.speed),
|
||||
kit: str(p.kit),
|
||||
addsProfile: str(p.adds && p.adds.profile),
|
||||
addsCount: str(p.adds && p.adds.count),
|
||||
line: str(p.line),
|
||||
sense: str(r.sense),
|
||||
loseTarget: str(r.loseTarget),
|
||||
chase: str(r.chase),
|
||||
attack: str(r.attack),
|
||||
}
|
||||
}
|
||||
|
||||
function phaseBody(p) {
|
||||
const out = { at: Number(p.at) / 100 }
|
||||
if (opt(p.damageDealt) !== undefined) out.damageDealt = p.damageDealt
|
||||
if (opt(p.aimCone) !== undefined) out.aimCone = p.aimCone
|
||||
if (opt(p.speed) !== undefined) out.speed = p.speed
|
||||
if (p.kit.trim()) out.kit = p.kit.trim()
|
||||
if (p.addsProfile.trim()) out.adds = { profile: p.addsProfile.trim(), count: p.addsCount }
|
||||
if (p.line.trim()) out.line = p.line.trim()
|
||||
if ([p.sense, p.loseTarget, p.chase, p.attack].some((v) => String(v).trim() !== '')) {
|
||||
out.ranges = { sense: p.sense, loseTarget: p.loseTarget, chase: p.chase, attack: p.attack }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function formFrom(p, defaults) {
|
||||
const b = { ...defaults, ...(p ? p.body : {}) }
|
||||
return {
|
||||
id: p ? p.id : null,
|
||||
name: p ? p.name : '',
|
||||
allServers: p ? p.allServers : false,
|
||||
servers: p ? [...p.servers] : [],
|
||||
killsScope: p ? p.killsScope : 'server',
|
||||
names: (b.names || []).join('\n'),
|
||||
kits: (b.kits || []).join(', '),
|
||||
prefab: b.prefab,
|
||||
role: b.role,
|
||||
moveMode: (b.movement && b.movement.mode) || 'wander',
|
||||
moveRadius: str(b.movement && b.movement.radius),
|
||||
health: str(b.health),
|
||||
damageDealt: str(b.damageDealt),
|
||||
head: str(b.damageTaken && b.damageTaken.head),
|
||||
body: str(b.damageTaken && b.damageTaken.body),
|
||||
legs: str(b.damageTaken && b.damageTaken.legs),
|
||||
aimCone: str(b.aimCone),
|
||||
sense: str(b.ranges && b.ranges.sense),
|
||||
loseTarget: str(b.ranges && b.ranges.loseTarget),
|
||||
chase: str(b.ranges && b.ranges.chase),
|
||||
attack: str(b.ranges && b.ranges.attack),
|
||||
visionCone: str(b.visionCone),
|
||||
sleepDistance: str(b.sleepDistance),
|
||||
thresholds: (b.healthThresholds || []).map((t) => Math.round(t * 100)).join(', '),
|
||||
faction: str(b.faction),
|
||||
relations: relationsText(b.relations),
|
||||
alertRadius: str(b.alertRadius),
|
||||
turrets: b.turrets || 'default',
|
||||
hurtByPlayers: b.hurtByPlayers !== false,
|
||||
hurtsPlayers: b.hurtsPlayers !== false,
|
||||
kitUse: { ...(defaults.kitUse || {}), ...(b.kitUse || {}) },
|
||||
// Stage 6 (D273–D278).
|
||||
bossOn: Boolean(b.boss),
|
||||
barDistance: str(b.boss ? b.boss.barDistance : 100),
|
||||
announce: (b.boss && b.boss.announce) || 'chat',
|
||||
spawnLine: str(b.boss && b.boss.spawnLine),
|
||||
deathLine: str(b.boss && b.boss.deathLine),
|
||||
phases: ((b.boss && b.boss.phases) || []).map(phaseForm),
|
||||
// Stage 7 (D290–D301).
|
||||
lootOn: Boolean(b.loot),
|
||||
lootStart: (b.loot && b.loot.start) || 'rust',
|
||||
lootAlways: ((b.loot && b.loot.always) || []).map((r) => ({ item: str(r.item), min: str(r.min), max: str(r.max), chance: str(r.chance === undefined ? '' : Math.round(r.chance * 100)) })),
|
||||
lootPick: str(b.loot && b.loot.pool ? b.loot.pool.pick : 0),
|
||||
lootNothing: str(b.loot && b.loot.pool ? b.loot.pool.nothing : 0),
|
||||
lootPool: ((b.loot && b.loot.pool && b.loot.pool.rows) || []).map((r) => ({ item: str(r.item), min: str(r.min), max: str(r.max), weight: str(r.weight) })),
|
||||
lootCrate: (b.loot && b.loot.crate) || '',
|
||||
lootHackMinutes: str(b.loot ? Math.round((b.loot.hackSeconds || 900) / 6) / 10 : 15),
|
||||
lootCorpse: !b.loot || b.loot.corpseSeconds === null || b.loot.corpseSeconds === undefined ? 'rust' : b.loot.corpseSeconds === 0 ? 'none' : 'seconds',
|
||||
lootCorpseSeconds: str(b.loot && b.loot.corpseSeconds ? b.loot.corpseSeconds : 120),
|
||||
useMode: (b.use && b.use.mode) || 'chat',
|
||||
useLines: ((b.use && b.use.lines) || []).join('\n'),
|
||||
useTitle: str(b.use && b.use.title),
|
||||
useText: str(b.use && b.use.text),
|
||||
}
|
||||
}
|
||||
|
||||
/** What the page sends: numbers as typed (the server checks them, as RunicNPC would). */
|
||||
function bodyFrom(f) {
|
||||
return {
|
||||
name: f.name.trim(),
|
||||
allServers: f.allServers,
|
||||
servers: f.allServers ? [] : f.servers,
|
||||
killsScope: f.killsScope,
|
||||
body: {
|
||||
names: list(f.names),
|
||||
kits: list(f.kits),
|
||||
prefab: f.prefab,
|
||||
role: f.role,
|
||||
movement: { mode: f.moveMode.trim(), radius: f.moveRadius },
|
||||
health: f.health,
|
||||
damageDealt: f.damageDealt,
|
||||
damageTaken: { head: f.head, body: f.body, legs: f.legs },
|
||||
aimCone: f.aimCone,
|
||||
ranges: { sense: f.sense, loseTarget: f.loseTarget, chase: f.chase, attack: f.attack },
|
||||
visionCone: f.visionCone,
|
||||
sleepDistance: f.sleepDistance,
|
||||
healthThresholds: list(f.thresholds).map((t) => Number(t) / 100),
|
||||
faction: f.faction.trim() || null,
|
||||
relations: relationsFrom(f.relations),
|
||||
alertRadius: f.alertRadius,
|
||||
turrets: f.turrets,
|
||||
hurtByPlayers: f.hurtByPlayers,
|
||||
hurtsPlayers: f.hurtsPlayers,
|
||||
kitUse: f.kitUse,
|
||||
boss:
|
||||
f.bossOn && f.role !== 'passive'
|
||||
? { barDistance: f.barDistance, announce: f.announce, spawnLine: f.spawnLine, deathLine: f.deathLine, phases: f.phases.map(phaseBody) }
|
||||
: null,
|
||||
use: f.role === 'passive' ? { mode: f.useMode, lines: lines(f.useLines), title: f.useTitle, text: f.useText } : null,
|
||||
loot: f.lootOn
|
||||
? {
|
||||
start: f.lootStart,
|
||||
always: f.lootAlways.filter((r) => r.item.trim()).map((r) => ({ item: r.item.trim(), min: r.min, max: r.max || r.min, chance: r.chance === '' ? 1 : Number(r.chance) / 100 })),
|
||||
pool: { pick: f.lootPick, nothing: f.lootNothing, rows: f.lootPool.filter((r) => r.item.trim()).map((r) => ({ item: r.item.trim(), min: r.min, max: r.max || r.min, weight: r.weight })) },
|
||||
crate: f.lootCrate || null,
|
||||
hackSeconds: Math.round(Number(f.lootHackMinutes) * 60),
|
||||
corpseSeconds: f.lootCorpse === 'rust' ? null : f.lootCorpse === 'none' ? 0 : f.lootCorpseSeconds,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A role change: a passive NPC starts unhurtable and standing (D279, D288); leaving passive, it walks again. */
|
||||
function withRole(f, role) {
|
||||
if (role === 'passive' && f.role !== 'passive') return { ...f, role, hurtByPlayers: false, moveMode: 'stand' }
|
||||
if (role !== 'passive' && f.role === 'passive') return { ...f, role, hurtByPlayers: true, moveMode: f.moveMode === 'stand' ? 'wander' : f.moveMode }
|
||||
return { ...f, role }
|
||||
}
|
||||
|
||||
/** One line on a server's RunicNPC and the last push to it. */
|
||||
function serverLine(s) {
|
||||
if (!s.ready) return s.absence || 'no RunicNPC'
|
||||
const parts = [`RunicNPC ${s.runicNpc.version || ''} · API ${s.runicNpc.api}`.replace(' ', ' ')]
|
||||
const sync = s.sync
|
||||
if (!sync || !sync.adoptedAt) parts.push('not pushed yet: its own profiles are adopted first')
|
||||
else if (sync.state === 'ok') parts.push(`pushed ${new Date(sync.syncedAt).toLocaleString()}`)
|
||||
else if (sync.state === 'failed') parts.push(`the last push failed: ${sync.error || 'no reason given'}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export default function NpcProfiles() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.adminNpcs.read(), [reloads])
|
||||
const [form, setForm] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const servers = data.servers || []
|
||||
const nameOf = new Map(servers.map((s) => [s.id, s.name || s.id]))
|
||||
const inUse = (data.profiles || []).filter((p) => !p.replaced)
|
||||
const replaced = (data.profiles || []).filter((p) => p.replaced)
|
||||
|
||||
const act = async (fn, done) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const out = await fn()
|
||||
if (done) setNotice(done(out))
|
||||
setForm(null)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = (e) => {
|
||||
e.preventDefault()
|
||||
act(() => (form.id ? api.adminNpcs.update(form.id, bodyFrom(form)) : api.adminNpcs.create(bodyFrom(form))), () => 'Saved. It reaches each server on the next push, within half a minute.')
|
||||
}
|
||||
|
||||
const push = (s) =>
|
||||
act(
|
||||
() => api.adminNpcs.push(s.id),
|
||||
(r) => {
|
||||
if (r.outcome === 'pushed') {
|
||||
const refused = Object.entries(r.refused || {})
|
||||
const adopted = (r.adopted || []).map((a) => `${a.name} (${a.outcome})`)
|
||||
return `${nameOf.get(s.id)}: pushed ${r.profiles.length} profile(s)${adopted.length ? `; adopted ${adopted.join(', ')}` : ''}${refused.length ? `; RunicNPC refused ${refused.map(([n, why]) => `${n}: ${why}`).join('; ')}` : ''}.`
|
||||
}
|
||||
if (r.outcome === 'current') return `${nameOf.get(s.id)} already has the current set.`
|
||||
return `${nameOf.get(s.id)}: ${r.outcome}${r.reason ? `, ${r.reason}` : ''}${r.error ? `, ${r.error}` : ''}.`
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
RunicNPC’s NPC profiles: what an NPC wears (its Kits kits), how hard it is and how it moves. Each profile is for
|
||||
one server, several, or every server, and each server is pushed its set. The first push to a server adopts the
|
||||
profiles it already had, so nothing on it changes. Where NPCs stand is set on the{' '}
|
||||
<Link to="/admin/rust/npcs/placements">placements page</Link>, in game with <code>/rnpc place</code>, or by an
|
||||
event’s <em>Place NPCs</em> step.
|
||||
</p>
|
||||
|
||||
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 8px', color: 'var(--head)' }}>Servers</h2>
|
||||
{servers.length === 0 && <p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>}
|
||||
{servers.map((s) => {
|
||||
const refused = Object.entries((s.sync && s.sync.refused) || {})
|
||||
return (
|
||||
<div key={s.id} className="sans" style={{ fontSize: '0.8rem', padding: '5px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{s.name || s.id}</strong>
|
||||
<span className="dim">{serverLine(s)}</span>
|
||||
{s.ready && (
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} disabled={busy} onClick={() => push(s)}>
|
||||
Push now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{refused.length > 0 && (
|
||||
<div style={{ color: 'var(--danger, #d98b84)', marginTop: 2 }}>
|
||||
RunicNPC refused: {refused.map(([n, why]) => `${n} (${why})`).join('; ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: 'var(--head)' }}>{notice}</p>}
|
||||
{error && !form && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem' }}>{error}</p>}
|
||||
|
||||
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: 0, color: 'var(--head)' }}>Profiles</h2>
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(formFrom(null, data.defaults)) }}>
|
||||
New profile
|
||||
</button>
|
||||
</div>
|
||||
{inUse.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>None yet. A server’s own profiles appear here after its first push.</p>
|
||||
)}
|
||||
{inUse.map((p) => (
|
||||
<div key={p.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '8px 0', fontSize: '0.84rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{p.label}</strong>
|
||||
<code className="dim">{p.name}</code>
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{p.allServers ? 'every server' : p.servers.map((id) => nameOf.get(id) || id).join(', ')}
|
||||
{p.adoptedFrom ? ` · adopted from ${nameOf.get(p.adoptedFrom) || p.adoptedFrom}` : ''}
|
||||
</span>
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(formFrom(p, data.defaults)) }}>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{p.body.role}{p.body.boss ? ' · boss' : ''}{p.body.loot ? ' · own loot' : ''} · {p.body.health} health · kits {(p.body.kits || []).join(', ')} · {p.body.role === 'sentry' ? 'stands still' : p.body.movement && p.body.movement.mode}
|
||||
{' · '}kills counted: {p.killsScope === 'server' ? 'per server' : p.killsScope === 'name' ? 'every server with this name' : 'this profile only'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<FactionTable data={data} busy={busy} act={act} />
|
||||
|
||||
{replaced.length > 0 && (
|
||||
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 6px', color: 'var(--head)' }}>Replaced</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 6px' }}>
|
||||
A server’s own profiles whose name a site profile already had there when the site first pushed to it. The site’s
|
||||
is in use; these are kept in case you want them back.
|
||||
</p>
|
||||
{replaced.map((p) => (
|
||||
<div key={p.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'baseline', borderTop: '1px solid var(--line-soft)', padding: '6px 0', fontSize: '0.84rem' }}>
|
||||
<code>{p.name}</code>
|
||||
<span className="dim">from {nameOf.get(p.adoptedFrom) || p.adoptedFrom}</span>
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} disabled={busy} onClick={() => act(() => api.adminNpcs.restore(p.id), () => `${p.name} is back in use on ${nameOf.get(p.adoptedFrom) || p.adoptedFrom}.`)}>
|
||||
Restore
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminNpcs.remove(p.id), () => `${p.name} deleted.`)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<ProfileForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
data={data}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onSave={save}
|
||||
onCancel={() => setForm(null)}
|
||||
onDelete={form.id ? () => act(() => api.adminNpcs.remove(form.id), () => `${form.name} deleted. Its placements wait until a profile of that name returns.`) : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{label}</span>
|
||||
{children}
|
||||
{hint && <span className="dim" style={{ fontSize: '0.74rem' }}>{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Num({ value, onChange, width = 110 }) {
|
||||
return <input value={value} onChange={(e) => onChange(e.target.value)} inputMode="decimal" style={{ ...inputStyle, maxWidth: width }} />
|
||||
}
|
||||
|
||||
const CRATE_WORDS = {
|
||||
wooden: 'A wooden box (6 slots)',
|
||||
military: 'A military crate (6 slots)',
|
||||
elite: 'An elite crate (12 slots)',
|
||||
locked: 'A locked crate players hack (24 slots)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Loot (D290–D301): what the corpse starts with, a table of rows that always
|
||||
* roll and a pool capped at "pick", where the table goes, and how long the
|
||||
* corpse stays. A server with no site edits the same block in RunicNPC's file.
|
||||
*/
|
||||
function LootSection({ form, setForm, data, fieldset, legend, row }) {
|
||||
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }))
|
||||
const setRow = (list, i, key, value) => setForm((f) => ({ ...f, [list]: f[list].map((r, j) => (j === i ? { ...r, [key]: value } : r)) }))
|
||||
const addRow = (list, blank) => setForm((f) => ({ ...f, [list]: [...f[list], blank] }))
|
||||
const dropRow = (list, i) => setForm((f) => ({ ...f, [list]: f[list].filter((_, j) => j !== i) }))
|
||||
const cell = (list, i, key, width = 90) => (
|
||||
<input value={form[list][i][key]} onChange={(e) => setRow(list, i, key, e.target.value)} style={{ ...inputStyle, width, maxWidth: '100%' }} />
|
||||
)
|
||||
const head = { color: 'var(--head)', fontSize: '0.78rem' }
|
||||
|
||||
return (
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Loot</legend>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.lootOn} onChange={(e) => set('lootOn')(e.target.checked)} />
|
||||
Its own loot <span className="dim">(off: Rust’s scientist loot, as any scientist drops)</span>
|
||||
</label>
|
||||
{form.lootOn && (
|
||||
<>
|
||||
<div style={row}>
|
||||
<Field label="The corpse starts with">
|
||||
<select value={form.lootStart} onChange={(e) => set('lootStart')(e.target.value)} style={inputStyle}>
|
||||
<option value="rust">Rust’s scientist loot, and the clothes it wore</option>
|
||||
<option value="kit">What it carried: its kit, and the clothes it wore</option>
|
||||
<option value="none">Nothing at all, not even its clothes</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="The corpse stays">
|
||||
<select value={form.lootCorpse} onChange={(e) => set('lootCorpse')(e.target.value)} style={inputStyle}>
|
||||
<option value="rust">As long as Rust’s own (10 minutes)</option>
|
||||
<option value="seconds">For a number of seconds</option>
|
||||
<option value="none">Not at all: no corpse</option>
|
||||
</select>
|
||||
</Field>
|
||||
{form.lootCorpse === 'seconds' && <Field label="Seconds"><Num value={form.lootCorpseSeconds} onChange={set('lootCorpseSeconds')} /></Field>}
|
||||
</div>
|
||||
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>Always rolled</span>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>Each row rolls on its own, with its chance. Item names are Rust’s short names: scrap, rifle.ak, explosive.timed.</p>
|
||||
{form.lootAlways.map((r, i) => (
|
||||
<div key={i} style={{ ...row, alignItems: 'end' }}>
|
||||
<Field label={i === 0 ? 'Item' : ''}>{cell('lootAlways', i, 'item', 200)}</Field>
|
||||
<Field label={i === 0 ? 'From' : ''}>{cell('lootAlways', i, 'min', 70)}</Field>
|
||||
<Field label={i === 0 ? 'To' : ''}>{cell('lootAlways', i, 'max', 70)}</Field>
|
||||
<Field label={i === 0 ? 'Chance (%)' : ''}>{cell('lootAlways', i, 'chance', 80)}</Field>
|
||||
<button type="button" className="btn" onClick={() => dropRow('lootAlways', i)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<div><button type="button" className="btn" onClick={() => addRow('lootAlways', { item: '', min: '1', max: '1', chance: '100' })}>Add a row</button></div>
|
||||
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>The pool</span>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
At most “picks” rows are drawn, by weight, and never the same row twice. “Nothing” is the weight of drawing none,
|
||||
so a kill can never drop more than the picks however lucky: the fair cap on rare items.
|
||||
</p>
|
||||
<div style={row}>
|
||||
<Field label="Picks"><Num value={form.lootPick} onChange={set('lootPick')} width={70} /></Field>
|
||||
<Field label="Nothing (weight)"><Num value={form.lootNothing} onChange={set('lootNothing')} width={90} /></Field>
|
||||
</div>
|
||||
{form.lootPool.map((r, i) => (
|
||||
<div key={i} style={{ ...row, alignItems: 'end' }}>
|
||||
<Field label={i === 0 ? 'Item' : ''}>{cell('lootPool', i, 'item', 200)}</Field>
|
||||
<Field label={i === 0 ? 'From' : ''}>{cell('lootPool', i, 'min', 70)}</Field>
|
||||
<Field label={i === 0 ? 'To' : ''}>{cell('lootPool', i, 'max', 70)}</Field>
|
||||
<Field label={i === 0 ? 'Weight' : ''}>{cell('lootPool', i, 'weight', 80)}</Field>
|
||||
<button type="button" className="btn" onClick={() => dropRow('lootPool', i)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<div><button type="button" className="btn" onClick={() => addRow('lootPool', { item: '', min: '1', max: '1', weight: '10' })}>Add a pool row</button></div>
|
||||
|
||||
<div style={row}>
|
||||
<Field label="The table goes into" hint="A crate stays until it is emptied, as Rust’s own do.">
|
||||
<select value={form.lootCrate} onChange={(e) => set('lootCrate')(e.target.value)} style={inputStyle}>
|
||||
<option value="">The corpse</option>
|
||||
{(data.lootCrates || Object.keys(CRATE_WORDS)).map((c) => <option key={c} value={c}>{CRATE_WORDS[c] || c}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{form.lootCrate === 'locked' && (
|
||||
<Field label="Hack time (minutes)" hint="Rust’s own is 15. It counts once a player starts the hack.">
|
||||
<Num value={form.lootHackMinutes} onChange={set('lootHackMinutes')} width={90} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A boss's phases (D275): each at a health percentage, each part optional. The
|
||||
* adds' profile is offered from the site's profiles that are not bosses.
|
||||
*/
|
||||
function PhaseList({ form, setForm, profiles }) {
|
||||
const setPhase = (i, key, value) =>
|
||||
setForm((f) => ({ ...f, phases: f.phases.map((p, j) => (j === i ? { ...p, [key]: value } : p)) }))
|
||||
const remove = (i) => setForm((f) => ({ ...f, phases: f.phases.filter((_, j) => j !== i) }))
|
||||
const add = () => setForm((f) => ({ ...f, phases: [...f.phases, phaseForm()] }))
|
||||
const addable = [...new Set(profiles.filter((p) => !p.replaced && !(p.body && p.body.boss) && p.name !== form.name).map((p) => p.name))].sort()
|
||||
const row = { display: 'flex', gap: 12, flexWrap: 'wrap' }
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>Phases</span>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
When its health falls to a phase’s percentage, the phase’s changes apply from then on. Leave a part blank to keep it as it was. A
|
||||
phase’s line is said only to the players near it and those who have hurt it.
|
||||
</p>
|
||||
{form.phases.map((p, i) => (
|
||||
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 6, padding: '8px 10px', display: 'grid', gap: 8 }}>
|
||||
<div style={row}>
|
||||
<Field label="At health (%)"><Num value={p.at} onChange={(v) => setPhase(i, 'at', v)} width={80} /></Field>
|
||||
<Field label="Damage dealt ×"><Num value={p.damageDealt} onChange={(v) => setPhase(i, 'damageDealt', v)} width={80} /></Field>
|
||||
<Field label="Aim spread"><Num value={p.aimCone} onChange={(v) => setPhase(i, 'aimCone', v)} width={80} /></Field>
|
||||
<Field label="Speed ×" hint="1.3: a third faster."><Num value={p.speed} onChange={(v) => setPhase(i, 'speed', v)} width={80} /></Field>
|
||||
<Field label="Swaps to kit"><input value={p.kit} onChange={(e) => setPhase(i, 'kit', e.target.value)} style={{ ...inputStyle, maxWidth: 160 }} /></Field>
|
||||
</div>
|
||||
<div style={row}>
|
||||
<Field label="Summons (profile)">
|
||||
<select value={p.addsProfile} onChange={(e) => setPhase(i, 'addsProfile', e.target.value)} style={inputStyle}>
|
||||
<option value="">nobody</option>
|
||||
{[...new Set([p.addsProfile, ...addable].filter(Boolean))].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{p.addsProfile && <Field label="How many" hint="1 to 20."><Num value={p.addsCount} onChange={(v) => setPhase(i, 'addsCount', v)} width={60} /></Field>}
|
||||
<Field label="Notices at (m)"><Num value={p.sense} onChange={(v) => setPhase(i, 'sense', v)} width={70} /></Field>
|
||||
<Field label="Forgets at"><Num value={p.loseTarget} onChange={(v) => setPhase(i, 'loseTarget', v)} width={70} /></Field>
|
||||
<Field label="Chases to"><Num value={p.chase} onChange={(v) => setPhase(i, 'chase', v)} width={70} /></Field>
|
||||
<Field label="Shoots from"><Num value={p.attack} onChange={(v) => setPhase(i, 'attack', v)} width={70} /></Field>
|
||||
</div>
|
||||
<div style={{ ...row, alignItems: 'end' }}>
|
||||
<Field label="Line">
|
||||
<input value={p.line} onChange={(e) => setPhase(i, 'line', e.target.value)} maxLength={256} style={{ ...inputStyle, width: 420, maxWidth: '100%' }} />
|
||||
</Field>
|
||||
<button type="button" className="btn" onClick={() => remove(i)}>Remove phase</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<button type="button" className="btn" onClick={add}>Add a phase</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDelete }) {
|
||||
const servers = data.servers || []
|
||||
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }))
|
||||
const toggleServer = (id) => setForm((f) => ({ ...f, servers: f.servers.includes(id) ? f.servers.filter((x) => x !== id) : [...f.servers, id] }))
|
||||
const fieldset = { border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 10 }
|
||||
const legend = { color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }
|
||||
const row = { display: 'flex', gap: 14, flexWrap: 'wrap' }
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>{form.id ? `Edit ${form.name}` : 'A new profile'}</h2>
|
||||
|
||||
<Field label="Name" hint="RunicNPC’s name for it: 1 to 40 of a-z, 0-9, _ and -. It is what /rnpc place and an event step name.">
|
||||
<input value={form.name} onChange={(e) => set('name')(e.target.value)} required maxLength={40} pattern="[a-z0-9_\-]{1,40}" style={{ ...inputStyle, maxWidth: 320 }} />
|
||||
</Field>
|
||||
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>For</legend>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.allServers} onChange={(e) => set('allServers')(e.target.checked)} />
|
||||
Every server, including servers added later
|
||||
</label>
|
||||
{!form.allServers &&
|
||||
servers.map((s) => (
|
||||
<label key={s.id} className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.servers.includes(s.id)} onChange={() => toggleServer(s.id)} />
|
||||
{s.name || s.id}
|
||||
{!s.ready && <span className="dim">({s.absence})</span>}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Looks</legend>
|
||||
<Field label="NPC names" hint="One per line. Each NPC is given one of these; the first is the profile’s name on the leaderboard.">
|
||||
<textarea value={form.names} onChange={(e) => set('names')(e.target.value)} rows={3} style={{ ...inputStyle, maxWidth: 360 }} />
|
||||
</Field>
|
||||
<Field label="Kits" hint="Kits kits, separated by commas. One is picked for each NPC. Every server the profile is for must have each one.">
|
||||
<input value={form.kits} onChange={(e) => set('kits')(e.target.value)} style={{ ...inputStyle, maxWidth: 480 }} />
|
||||
</Field>
|
||||
<div style={row}>
|
||||
<Field label="Prefab">
|
||||
<select value={form.prefab} onChange={(e) => set('prefab')(e.target.value)} style={inputStyle}>
|
||||
{[...new Set([form.prefab, ...(data.prefabs || [])])].map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
<select value={form.role} onChange={(e) => setForm((f) => withRole(f, e.target.value))} style={inputStyle}>
|
||||
<option value="roamer">Roamer: moves, chases and fights</option>
|
||||
<option value="sentry">Sentry: stands still and shoots</option>
|
||||
<option value="guard">Guard: holds its spot, chases within its leash, walks back</option>
|
||||
<option value="passive">Passive: never fights; players press E to talk to it</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{form.role !== 'sentry' && (
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Moving</legend>
|
||||
<div style={row}>
|
||||
{(form.role === 'roamer' || form.role === 'passive') && (
|
||||
<>
|
||||
<Field
|
||||
label="How it moves"
|
||||
hint={`wander, monument (Rust’s own paths there), or route:<name> for a route recorded in game${form.role === 'passive' ? ', or stand: it stays where it is placed and turns to whoever talks to it' : ''}. A placement may change it.`}
|
||||
>
|
||||
<input value={form.moveMode} onChange={(e) => set('moveMode')(e.target.value)} list="rnpc-modes" style={{ ...inputStyle, maxWidth: 220 }} />
|
||||
<datalist id="rnpc-modes">
|
||||
{form.role === 'passive' && <option value="stand" />}
|
||||
<option value="wander" />
|
||||
<option value="monument" />
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field label="Wander radius (m)"><Num value={form.moveRadius} onChange={set('moveRadius')} /></Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="Sleeps with nobody within (m)" hint="0: never sleeps."><Num value={form.sleepDistance} onChange={set('sleepDistance')} /></Field>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Fighting</legend>
|
||||
<div style={row}>
|
||||
<Field label="Health"><Num value={form.health} onChange={set('health')} /></Field>
|
||||
<Field label="Damage dealt ×" hint="1 is the weapon’s own."><Num value={form.damageDealt} onChange={set('damageDealt')} /></Field>
|
||||
<Field label="Aim spread" hint="Rust’s scientists: 2."><Num value={form.aimCone} onChange={set('aimCone')} /></Field>
|
||||
<Field label="Vision cone" hint="−1 to 1."><Num value={form.visionCone} onChange={set('visionCone')} /></Field>
|
||||
</div>
|
||||
<div style={row}>
|
||||
<Field label="Damage taken × head"><Num value={form.head} onChange={set('head')} /></Field>
|
||||
<Field label="× body"><Num value={form.body} onChange={set('body')} /></Field>
|
||||
<Field label="× legs"><Num value={form.legs} onChange={set('legs')} /></Field>
|
||||
</div>
|
||||
<div style={row}>
|
||||
<Field label="Notices players at (m)"><Num value={form.sense} onChange={set('sense')} /></Field>
|
||||
<Field label="Forgets them at (m)"><Num value={form.loseTarget} onChange={set('loseTarget')} /></Field>
|
||||
<Field label="Chases up to (m from home)" hint="0: no limit."><Num value={form.chase} onChange={set('chase')} /></Field>
|
||||
<Field label="Shoots from (m)"><Num value={form.attack} onChange={set('attack')} /></Field>
|
||||
</div>
|
||||
<Field label="Health thresholds (%)" hint="An event phase can wait for these: 50 raises “below 50%” once. Separated by commas.">
|
||||
<input value={form.thresholds} onChange={(e) => set('thresholds')(e.target.value)} style={{ ...inputStyle, maxWidth: 220 }} />
|
||||
</Field>
|
||||
</fieldset>
|
||||
|
||||
{form.role === 'passive' && (
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Press E</legend>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
What a player gets for pressing E while looking at it, from up to 3 m. It never fights, whatever happens to it.{' '}
|
||||
<code>{'{player}'}</code> is the player’s name.
|
||||
</p>
|
||||
<Field label="Answer">
|
||||
<select value={form.useMode} onChange={(e) => set('useMode')(e.target.value)} style={{ ...inputStyle, maxWidth: 420 }}>
|
||||
<option value="chat">A line in that player’s chat, picked at random from the lines below</option>
|
||||
<option value="window">A window with a title, the text and a Close button</option>
|
||||
</select>
|
||||
</Field>
|
||||
{form.useMode === 'chat' ? (
|
||||
<Field label="Lines" hint="One per line, up to 256 characters each.">
|
||||
<textarea value={form.useLines} onChange={(e) => set('useLines')(e.target.value)} rows={4} style={{ ...inputStyle, maxWidth: 560 }} />
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Title" hint="Blank: the NPC’s name.">
|
||||
<input value={form.useTitle} onChange={(e) => set('useTitle')(e.target.value)} maxLength={80} style={{ ...inputStyle, maxWidth: 420 }} />
|
||||
</Field>
|
||||
<Field label="Text" hint="Up to 2,000 characters.">
|
||||
<textarea value={form.useText} onChange={(e) => set('useText')(e.target.value)} rows={6} maxLength={2000} style={{ ...inputStyle, maxWidth: 560 }} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{form.role !== 'passive' && (
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Boss</legend>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.bossOn} onChange={(e) => set('bossOn')(e.target.checked)} />
|
||||
It is a boss <span className="dim">(a health bar on screen, phases, and lines said in game; it still moves and fights as its role does)</span>
|
||||
</label>
|
||||
{form.bossOn && (
|
||||
<>
|
||||
<div style={row}>
|
||||
<Field label="Bar shown within (m)" hint="And to everyone who has hurt it, wherever they are.">
|
||||
<Num value={form.barDistance} onChange={set('barDistance')} />
|
||||
</Field>
|
||||
<Field label="Lines said in" hint="A popup needs PopupNotifications on the server; chat is used where it is missing.">
|
||||
<select value={form.announce} onChange={(e) => set('announce')(e.target.value)} style={inputStyle}>
|
||||
{(data.announces || ['chat', 'popup']).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="When it spawns, to everyone" hint="Blank says nothing. {name} is its name.">
|
||||
<input value={form.spawnLine} onChange={(e) => set('spawnLine')(e.target.value)} maxLength={256} style={{ ...inputStyle, maxWidth: 560 }} />
|
||||
</Field>
|
||||
<Field label="When it dies, to everyone" hint="{killer} is the player who landed the killing blow.">
|
||||
<input value={form.deathLine} onChange={(e) => set('deathLine')(e.target.value)} maxLength={256} style={{ ...inputStyle, maxWidth: 560 }} />
|
||||
</Field>
|
||||
<PhaseList form={form} setForm={setForm} profiles={data.profiles || []} />
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<LootSection form={form} setForm={setForm} data={data} fieldset={fieldset} legend={legend} row={row} />
|
||||
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Sides</legend>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
With none of this set, it fights players only. Fighting Rust’s scientists, animals or other profiles is opted
|
||||
into here or in the faction table, and costs about what fighting players does. It never walks into a monument
|
||||
to look for scientists: it fights only what comes within its chase range of its spot.
|
||||
</p>
|
||||
<div style={row}>
|
||||
<Field label="Faction" hint="Profiles of one faction are allies. Empty: its allies are its own profile’s NPCs.">
|
||||
<input value={form.faction} onChange={(e) => set('faction')(e.target.value)} list="rnpc-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 220 }} />
|
||||
<datalist id="rnpc-factions">{(data.factionNames || []).map((n) => <option key={n} value={n} />)}</datalist>
|
||||
</Field>
|
||||
<Field label="Alert radius (m)" hint="Allies this close join a fight one of them is in, seen or not. 0: off.">
|
||||
<Num value={form.alertRadius} onChange={set('alertRadius')} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Its own exceptions" hint="One per line, winning over the faction table: scientists: hostile, animals: hostile, bandits: allied, profile:warden: hostile.">
|
||||
<textarea value={form.relations} onChange={(e) => set('relations')(e.target.value)} rows={3} style={{ ...inputStyle, maxWidth: 420 }} />
|
||||
</Field>
|
||||
<Field label="Turrets">
|
||||
<select value={form.turrets} onChange={(e) => set('turrets')(e.target.value)} style={{ ...inputStyle, maxWidth: 560 }}>
|
||||
{(data.turrets || ['default', 'ignore', 'always']).map((t) => <option key={t} value={t}>{TURRET_WORDS[t] || t}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.hurtByPlayers} onChange={(e) => set('hurtByPlayers')(e.target.checked)} />
|
||||
Players can hurt it <span className="dim">(off: it cannot be killed by players, on any server)</span>
|
||||
</label>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.hurtsPlayers} onChange={(e) => set('hurtsPlayers')(e.target.checked)} />
|
||||
It can hurt players <span className="dim">(off: it still fights NPCs and turrets)</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>The kit’s extras</legend>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
The kit’s main weapon is always used, and never runs out. Each extra is used only if ticked, and comes out of
|
||||
the NPC’s inventory, so its loot is what is left.
|
||||
</p>
|
||||
{(data.kitUses || Object.keys(KIT_WORDS)).map((k) => (
|
||||
<label key={k} className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={Boolean(form.kitUse[k])} onChange={(e) => setForm((f) => ({ ...f, kitUse: { ...f.kitUse, [k]: e.target.checked } }))} />
|
||||
{KIT_WORDS[k] || k}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<Field label="Kills count" hint="How the leaderboard and chat titles count this profile’s kills.">
|
||||
<select value={form.killsScope} onChange={(e) => set('killsScope')(e.target.value)} style={{ ...inputStyle, maxWidth: 520 }}>
|
||||
{(data.killsScopes || ['server', 'name', 'profile']).map((s) => <option key={s} value={s}>{SCOPE_WORDS[s] || s}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem', margin: 0 }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
{onDelete && (
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={onDelete} disabled={busy}>Delete profile</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The site's faction table (stage 5, D254): one row per pair, both ways (D268).
|
||||
* A pair not listed is neutral; Rust's scientists and animals are the two
|
||||
* built-in factions. Pushed to every server with its profiles.
|
||||
*/
|
||||
function FactionTable({ data, busy, act }) {
|
||||
const [rows, setRows] = useState(null)
|
||||
const shown = rows || (data.factions || []).map((f) => ({ ...f }))
|
||||
const names = [...new Set([...(data.factionNames || []), ...(data.builtInFactions || ['scientists', 'animals'])])]
|
||||
const change = (i, key, value) => setRows(shown.map((r, j) => (j === i ? { ...r, [key]: value } : r)))
|
||||
const save = () =>
|
||||
act(
|
||||
() => api.adminNpcs.setFactions(shown.filter((r) => r.a && r.b)),
|
||||
(out) => {
|
||||
setRows(null)
|
||||
return `Faction table saved: ${out.factions.length} pair(s). It reaches each server on the next push.`
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 6px', color: 'var(--head)' }}>Faction table</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
How factions treat each other. One row is both ways: <em>bandits ↔ guards: hostile</em> makes each hunt the other.
|
||||
A pair that is not here is neutral, and a faction is always allied to itself. <em>scientists</em> and{' '}
|
||||
<em>animals</em> are Rust’s own; a profile hostile to scientists fights them, and they only take cover.
|
||||
</p>
|
||||
<datalist id="rnpc-table-factions">{names.map((n) => <option key={n} value={n} />)}</datalist>
|
||||
{shown.length === 0 && <p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>Empty: every pair is neutral.</p>}
|
||||
{shown.map((r, i) => (
|
||||
<div key={i} className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0', fontSize: '0.84rem' }}>
|
||||
<input value={r.a} onChange={(e) => change(i, 'a', e.target.value)} list="rnpc-table-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 180 }} />
|
||||
<span className="dim">↔</span>
|
||||
<input value={r.b} onChange={(e) => change(i, 'b', e.target.value)} list="rnpc-table-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 180 }} />
|
||||
<select value={r.relation} onChange={(e) => change(i, 'relation', e.target.value)} style={inputStyle}>
|
||||
<option value="hostile">hostile</option>
|
||||
<option value="neutral">neutral</option>
|
||||
<option value="allied">allied</option>
|
||||
</select>
|
||||
<button type="button" className="btn" onClick={() => setRows(shown.filter((_, j) => j !== i))} disabled={busy}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn" onClick={() => setRows([...shown, { a: '', b: '', relation: 'hostile' }])} disabled={busy}>Add a pair</button>
|
||||
<button type="button" className="btn" onClick={save} disabled={busy || rows === null}>Save the table</button>
|
||||
{rows !== null && <button type="button" className="btn" onClick={() => setRows(null)} disabled={busy}>Discard changes</button>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
806
client/src/routes/admin/Permissions.jsx
Normal file
806
client/src/routes/admin/Permissions.jsx
Normal file
@@ -0,0 +1,806 @@
|
||||
// ── Admin · Rust · Permissions ────────────────────────────────────────────
|
||||
//
|
||||
// The permission manager (PLAN_REDESIGNS §1). The site owns every permission and
|
||||
// group on every server (D160), and this screen follows uMod PermissionsManager's
|
||||
// flow (D162): a server, then players ⇄ groups, then a subject, then one plugin's
|
||||
// permissions with Granted / Revoked, Grant all and Revoke all.
|
||||
//
|
||||
// Three rules shape it:
|
||||
//
|
||||
// • **Every toggle says what is true on THIS server** (U-1): granted,
|
||||
// waiting for a sync, through a group, waiting for a first connection, not
|
||||
// registered here, or did not land. A state in a server-level sentence is a
|
||||
// state nobody reads.
|
||||
// • **Plugins are who REGISTERED a permission, never its prefix.**
|
||||
// `zonemanager.ignoreflag.nokits` is ZoneManager's.
|
||||
// • **Anything that reaches further than this server says so and asks.** A
|
||||
// fleet-wide grant is revoked everywhere or only here; a shared group is
|
||||
// changed everywhere or split for this server (D190's two answers, offered
|
||||
// to a person).
|
||||
//
|
||||
// Every subject is named by linked account and in-game name, or by Steam id
|
||||
// when there is neither (D163). The screen never writes to a game: every button
|
||||
// writes to the site, and the sync loop settles it within seconds — except
|
||||
// *Sync now*.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import Tabs from '../../components/Tabs.jsx'
|
||||
import ChatStyleSection from './ChatStyle.jsx'
|
||||
|
||||
const POLICY_TEXT = {
|
||||
'auto-adopt': 'A change made in the game becomes the site’s own, for that server.',
|
||||
adopt: 'A change made in the game waits here for a person to adopt or undo it.',
|
||||
revoke: 'A change made in the game is undone at the next sync.',
|
||||
}
|
||||
|
||||
// ── Furniture ────────────────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, subtitle, children, actions }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>{title}</h2>
|
||||
{subtitle && <span className="sans dim" style={{ fontSize: '0.76rem' }}>{subtitle}</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{actions}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ children, onClick, selected = false }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? (e) => (e.key === 'Enter' ? onClick() : null) : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '7px 8px',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--head)',
|
||||
cursor: onClick ? 'pointer' : undefined,
|
||||
background: selected ? 'var(--blue)' : undefined,
|
||||
borderRadius: selected ? 6 : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TONES = {
|
||||
good: { color: '#5bb85b', border: '#3d7a3d' },
|
||||
warn: { color: '#d08a2a', border: '#8a5c1c' },
|
||||
bad: { color: '#e05a5a', border: '#8a3434' },
|
||||
info: { color: 'var(--muted)', border: 'var(--line)' },
|
||||
}
|
||||
|
||||
function Chip({ tone = 'info', children, title }) {
|
||||
const t = TONES[tone]
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className="sans"
|
||||
style={{ fontSize: '0.7rem', padding: '1px 8px', borderRadius: 999, border: `1px solid ${t.border}`, color: t.color, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Warn({ children }) {
|
||||
return <p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>{children}</p>
|
||||
}
|
||||
|
||||
/** Granted / Revoked, as two buttons. */
|
||||
function Toggle({ on, disabled, onChange }) {
|
||||
const style = (active) => ({
|
||||
padding: '2px 10px',
|
||||
fontSize: '0.74rem',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: active ? 'var(--blue)' : 'transparent',
|
||||
color: active ? 'var(--accent-bright)' : 'var(--muted)',
|
||||
})
|
||||
return (
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<button type="button" disabled={disabled} onClick={() => !on && onChange(true)} style={{ ...style(on), borderRadius: '999px 0 0 999px' }}>Granted</button>
|
||||
<button type="button" disabled={disabled} onClick={() => on && onChange(false)} style={{ ...style(!on), borderRadius: '0 999px 999px 0' }}>Revoked</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** A subject's name, as D163 has it: account and in-game name, or the Steam id. */
|
||||
function SubjectName({ player }) {
|
||||
const primary = player.name || player.steamId
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', flexDirection: 'column' }}>
|
||||
<span>{primary}</span>
|
||||
<span className="dim" style={{ fontSize: '0.72rem' }}>
|
||||
{player.account ? `site account ${player.account.username}` : 'not linked'}
|
||||
{player.name ? ` · ${player.steamId}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The plugin buttons: who registered each permission (§0.1). */
|
||||
function PluginPicker({ plugins, active, onSelect, countFor }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, margin: '6px 0 12px' }}>
|
||||
{plugins.map((p) => {
|
||||
const n = countFor ? countFor(p) : 0
|
||||
const selected = p.key === active
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => onSelect(p.key)}
|
||||
className="sans"
|
||||
title={p.registered ? `Registered by ${p.label}` : `Not registered by a plugin — grouped by the prefix “${p.label}”`}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.78rem',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: selected ? 'var(--blue)' : 'transparent',
|
||||
color: selected ? 'var(--accent-bright)' : p.registered ? 'var(--head)' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{p.label}{p.registered ? '' : ' *'}{n ? ` · ${n}` : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The facts behind every toggle (U-1) ──────────────────────────────────
|
||||
|
||||
function useFacts(view) {
|
||||
return useMemo(() => {
|
||||
const landed = new Set(view.landed || [])
|
||||
const report = view.report || { unresolved: [], pending: [], notLanded: [] }
|
||||
const unresolved = new Set(report.unresolved.map((p) => String(p).toLowerCase()))
|
||||
const pending = new Set(report.pending)
|
||||
const notLanded = new Set(report.notLanded.map((p) => String(p).toLowerCase()))
|
||||
const groupsByName = new Map(view.groups.map((g) => [g.name, g]))
|
||||
const registered = new Set(view.plugins.flatMap((p) => p.permissions))
|
||||
return { landed, unresolved, pending, notLanded, groupsByName, registered }
|
||||
}, [view])
|
||||
}
|
||||
|
||||
/** One player's state for one permission on this server. */
|
||||
function playerState(facts, view, player, permission) {
|
||||
const direct = player.grants.find((g) => g.permission === permission)
|
||||
const viaGroups = player.groups.filter((name) => (facts.groupsByName.get(name) || { permissions: [] }).permissions.includes(permission))
|
||||
const excepted = (view.excepted || []).find((e) => e.steamId === player.steamId && e.permission === permission)
|
||||
|
||||
if (direct) {
|
||||
const wider = direct.sources.some((s) => (s.type === 'userGrant' || s.type === 'steamGrant') && s.scope !== view.server.id)
|
||||
const fromEvent = direct.sources.every((s) => s.type === 'runGrant')
|
||||
// Given for good by an event (PLAN_REDESIGNS.md §12.5): a standing grant like
|
||||
// a staff member's, toggled off like one, and marked with its note.
|
||||
const given = direct.sources.find((s) => s.type === 'userGrant' && s.origin === 'event')
|
||||
let chip
|
||||
if (!facts.registered.has(permission)) chip = <Chip tone="bad" title="No loaded plugin registers it here">not registered here</Chip>
|
||||
else if (facts.notLanded.has(`${player.steamId}:${permission}`)) chip = <Chip tone="bad">did not land</Chip>
|
||||
else if (facts.landed.has(`grant ${player.steamId} ${permission}`)) chip = <Chip tone="good">granted</Chip>
|
||||
else chip = <Chip tone="warn">waiting for a sync</Chip>
|
||||
return { on: true, chip, wider, fromEvent, given: given ? given.note || 'an event' : null, excepted: null, viaGroups }
|
||||
}
|
||||
|
||||
if (viaGroups.length) {
|
||||
return { on: true, readOnly: true, chip: <Chip tone="info">through {viaGroups.join(', ')}</Chip>, viaGroups }
|
||||
}
|
||||
|
||||
if (excepted) {
|
||||
return { on: false, excepted, chip: <Chip tone="warn" title="Granted on every other server">revoked here only</Chip> }
|
||||
}
|
||||
|
||||
return { on: false, chip: null }
|
||||
}
|
||||
|
||||
// ── A player (D162: a subject, its plugins, its groups) ──────────────────
|
||||
|
||||
function PlayerPanel({ view, player, act, busy }) {
|
||||
const facts = useFacts(view)
|
||||
const [tab, setTab] = useState('permissions')
|
||||
const [plugin, setPlugin] = useState(view.plugins[0] ? view.plugins[0].key : null)
|
||||
const [everywhere, setEverywhere] = useState(false)
|
||||
const [asking, setAsking] = useState(null)
|
||||
const [addGroup, setAddGroup] = useState('')
|
||||
|
||||
const serverId = view.server.id
|
||||
const subject = { steamId: player.steamId }
|
||||
const chosen = view.plugins.find((p) => p.key === plugin)
|
||||
const held = (p) => p.permissions.filter((perm) => playerState(facts, view, player, perm).on).length
|
||||
|
||||
const toggle = (permission, on) => {
|
||||
const state = playerState(facts, view, player, permission)
|
||||
if (on) {
|
||||
if (state.excepted) return act(() => api.adminPermissions.removeException(state.excepted.id))
|
||||
return act(() => api.adminPermissions.grant(serverId, subject, [permission], everywhere))
|
||||
}
|
||||
// A grant that reaches further than this server: ask (D190's two answers).
|
||||
if (state.wider) return setAsking({ permissions: [permission] })
|
||||
return act(() => api.adminPermissions.revoke(serverId, subject, [permission]))
|
||||
}
|
||||
|
||||
const bulk = (on) => {
|
||||
if (!chosen) return
|
||||
const perms = chosen.permissions.filter((perm) => {
|
||||
const s = playerState(facts, view, player, perm)
|
||||
return on ? !s.on : s.on && !s.readOnly
|
||||
})
|
||||
if (!perms.length) return
|
||||
if (on) return act(() => api.adminPermissions.grant(serverId, subject, perms, everywhere))
|
||||
if (perms.some((perm) => playerState(facts, view, player, perm).wider)) return setAsking({ permissions: perms })
|
||||
return act(() => api.adminPermissions.revoke(serverId, subject, perms))
|
||||
}
|
||||
|
||||
const groupIds = new Map(view.groups.map((g) => [g.name, g.id]))
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<SubjectName player={player} />}
|
||||
subtitle={`${player.grants.length} direct · ${player.groups.length} group${player.groups.length === 1 ? '' : 's'}`}
|
||||
>
|
||||
<Tabs
|
||||
tabs={[{ id: 'permissions', label: 'Permissions' }, { id: 'groups', label: 'Groups' }]}
|
||||
active={tab}
|
||||
onSelect={setTab}
|
||||
label="Player"
|
||||
/>
|
||||
|
||||
{tab === 'permissions' && (
|
||||
<>
|
||||
<label className="sans dim" style={{ fontSize: '0.78rem', display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={everywhere} onChange={(e) => setEverywhere(e.target.checked)} />
|
||||
Grant on every server, not just {view.server.name}
|
||||
</label>
|
||||
<PluginPicker plugins={view.plugins} active={plugin} onSelect={setPlugin} countFor={held} />
|
||||
|
||||
{asking && (
|
||||
<div className="panel sans" style={{ padding: '10px 12px', marginBottom: 10, fontSize: '0.82rem' }}>
|
||||
{asking.permissions.length === 1 ? <code>{asking.permissions[0]}</code> : `${asking.permissions.length} of these`} reach
|
||||
{' '}further than {view.server.name}. Revoke:
|
||||
<span style={{ display: 'inline-flex', gap: 6, marginLeft: 8 }}>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => { const p = asking.permissions; setAsking(null); act(() => api.adminPermissions.revoke(serverId, subject, p, true)) }}>Everywhere</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => { const p = asking.permissions; setAsking(null); act(() => api.adminPermissions.revoke(serverId, subject, p, false)) }}>Only on {view.server.name}</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setAsking(null)}>Cancel</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chosen && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk(true)}>Grant all</button>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk(false)}>Revoke all</button>
|
||||
</div>
|
||||
{chosen.permissions.map((perm) => {
|
||||
const s = playerState(facts, view, player, perm)
|
||||
return (
|
||||
<Row key={perm}>
|
||||
<code style={{ flex: 1, fontSize: '0.8rem' }}>{perm}</code>
|
||||
{s.fromEvent && <Chip tone="info" title="An event gave it; the event’s revert takes it back">from an event</Chip>}
|
||||
{s.given && <Chip tone="info" title={`Given for good by ${s.given}; turn it off to take it back`}>given by {s.given}</Chip>}
|
||||
{s.chip}
|
||||
<Toggle on={s.on} disabled={busy || s.readOnly || s.fromEvent} onChange={(on) => toggle(perm, on)} />
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{!view.plugins.length && <p className="sans dim">This server has not reported its plugins yet. Sync it once.</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'groups' && (
|
||||
<>
|
||||
{player.groups.length === 0 && <p className="sans dim" style={{ fontSize: '0.82rem' }}>In no group on {view.server.name} (every player is in <code>default</code>).</p>}
|
||||
{player.groups.map((name) => {
|
||||
const waiting = facts.pending.has(`${player.steamId}:${name}`)
|
||||
const landed = facts.landed.has(`member ${player.steamId} ${name}`)
|
||||
return (
|
||||
<Row key={name}>
|
||||
<span style={{ flex: 1 }}>{name}</span>
|
||||
{waiting ? <Chip tone="warn" title="The server has never seen this player">waiting for their first connection</Chip>
|
||||
: landed ? <Chip tone="good">in the group</Chip> : <Chip tone="warn">waiting for a sync</Chip>}
|
||||
<button type="button" className="btn btn-ghost" disabled={busy || !groupIds.has(name)} onClick={() => act(() => api.adminPermissions.removeMember(groupIds.get(name), subject))}>Remove</button>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<select className="input" value={addGroup} onChange={(e) => setAddGroup(e.target.value)}>
|
||||
<option value="">Add to a group…</option>
|
||||
{view.groups.filter((g) => g.name !== 'default' && !player.groups.includes(g.name)).map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}{g.title && g.title !== g.name ? ` — ${g.title}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn" disabled={busy || !addGroup} onClick={() => { const id = addGroup; setAddGroup(''); act(() => api.adminPermissions.addMember(Number(id), subject)) }}>Add</button>
|
||||
{player.groups.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(async () => {
|
||||
for (const name of player.groups) {
|
||||
if (groupIds.has(name)) await api.adminPermissions.removeMember(groupIds.get(name), subject)
|
||||
}
|
||||
})}
|
||||
>
|
||||
Remove from all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── A group (D189, D190) ─────────────────────────────────────────────────
|
||||
|
||||
function GroupPanel({ view, group, act, busy, chatFields }) {
|
||||
const facts = useFacts(view)
|
||||
const [tab, setTab] = useState('permissions')
|
||||
const [plugin, setPlugin] = useState(view.plugins[0] ? view.plugins[0].key : null)
|
||||
const [scope, setScope] = useState('everywhere')
|
||||
const [attrs, setAttrs] = useState({ title: group.title, rank: group.rank, parent: group.parent })
|
||||
const [member, setMember] = useState('')
|
||||
const [share, setShare] = useState({ allServers: group.allServers, servers: group.servers })
|
||||
const [conflict, setConflict] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setAttrs({ title: group.title, rank: group.rank, parent: group.parent })
|
||||
setShare({ allServers: group.allServers, servers: group.servers })
|
||||
setConflict(null)
|
||||
}, [group])
|
||||
|
||||
const serverId = view.server.id
|
||||
const here = group.shared && scope === 'here' ? { onlyHere: true, serverId } : null
|
||||
const chosen = view.plugins.find((p) => p.key === plugin)
|
||||
const carried = new Set(group.permissions)
|
||||
|
||||
const setPermissions = (next) => act(() => api.adminPermissions.setGroupPermissions(group.id, [...next].sort(), here))
|
||||
const toggle = (perm, on) => {
|
||||
const next = new Set(carried)
|
||||
if (on) next.add(perm)
|
||||
else next.delete(perm)
|
||||
return setPermissions(next)
|
||||
}
|
||||
const bulk = (on) => {
|
||||
if (!chosen) return
|
||||
const next = new Set(carried)
|
||||
for (const perm of chosen.permissions) on ? next.add(perm) : next.delete(perm)
|
||||
return setPermissions(next)
|
||||
}
|
||||
|
||||
const saveShare = async (replace = []) => {
|
||||
const body = share.allServers ? { allServers: true, replace } : { allServers: false, servers: share.servers, replace }
|
||||
try {
|
||||
await act(() => api.adminPermissions.setGroupServers(group.id, body), { rethrow: true })
|
||||
setConflict(null)
|
||||
} catch (err) {
|
||||
if (err && err.status === 409 && err.body && err.body.conflicts) setConflict(err.body)
|
||||
}
|
||||
}
|
||||
|
||||
const permState = (perm) => {
|
||||
if (!facts.registered.has(perm)) return <Chip tone="bad">not registered here</Chip>
|
||||
if (!carried.has(perm)) return null
|
||||
if (facts.landed.has(`group-permission ${group.name} ${perm}`)) return <Chip tone="good">carried</Chip>
|
||||
return <Chip tone="warn">waiting for a sync</Chip>
|
||||
}
|
||||
|
||||
const members = [
|
||||
...group.members.map((m) => ({ key: `u${m.userId}`, label: m.username, detail: m.steamIds.length ? m.steamIds.join(', ') : 'no Steam account linked — reaches nobody yet', subject: { userId: m.userId } })),
|
||||
...group.steamMembers.map((steamId) => {
|
||||
const p = view.players.find((x) => x.steamId === steamId)
|
||||
return { key: `s${steamId}`, label: (p && p.name) || steamId, detail: p && p.account ? `site account ${p.account.username}` : steamId, subject: { steamId } }
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={group.title || group.name}
|
||||
subtitle={<>{group.name}{group.builtin ? ' · built in' : ''} · {group.allServers ? 'every server' : group.servers.join(', ')}{group.source && group.source !== 'admin' ? ` · ${group.source}` : ''}</>}
|
||||
actions={!group.builtin && (
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.deleteGroup(group.id))}>Delete</button>
|
||||
)}
|
||||
>
|
||||
{group.shared && (
|
||||
<div className="sans" style={{ fontSize: '0.8rem', marginBottom: 10, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Chip tone="warn">shared</Chip>
|
||||
<span className="dim">A change here applies</span>
|
||||
<label><input type="radio" checked={scope === 'everywhere'} onChange={() => setScope('everywhere')} /> everywhere it is</label>
|
||||
<label><input type="radio" checked={scope === 'here'} onChange={() => setScope('here')} /> only on {view.server.name} (gives it its own copy)</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'permissions', label: 'Permissions' },
|
||||
{ id: 'members', label: `Players (${members.length})` },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
{ id: 'servers', label: 'Servers' },
|
||||
]}
|
||||
active={tab}
|
||||
onSelect={setTab}
|
||||
label="Group"
|
||||
/>
|
||||
|
||||
{tab === 'permissions' && (
|
||||
<>
|
||||
<PluginPicker plugins={view.plugins} active={plugin} onSelect={setPlugin} countFor={(p) => p.permissions.filter((x) => carried.has(x)).length} />
|
||||
{chosen && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk(true)}>Grant all</button>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk(false)}>Revoke all</button>
|
||||
</div>
|
||||
{chosen.permissions.map((perm) => (
|
||||
<Row key={perm}>
|
||||
<code style={{ flex: 1, fontSize: '0.8rem' }}>{perm}</code>
|
||||
{permState(perm)}
|
||||
<Toggle on={carried.has(perm)} disabled={busy} onChange={(on) => toggle(perm, on)} />
|
||||
</Row>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{group.permissions.filter((p) => !facts.registered.has(p)).length > 0 && (
|
||||
<Warn>
|
||||
It also carries {group.permissions.filter((p) => !facts.registered.has(p)).join(', ')}, which no plugin on this server registers now.
|
||||
They land when that plugin loads.
|
||||
</Warn>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'members' && (
|
||||
<>
|
||||
{group.name === 'default' && <p className="sans dim" style={{ fontSize: '0.82rem' }}>Every player who connects is in <code>default</code>; it is not listed.</p>}
|
||||
{members.map((m) => (
|
||||
<Row key={m.key}>
|
||||
<span style={{ flex: 1, display: 'inline-flex', flexDirection: 'column' }}>
|
||||
<span>{m.label}</span>
|
||||
<span className="dim" style={{ fontSize: '0.72rem' }}>{m.detail}</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.removeMember(group.id, m.subject, here))}>Remove</button>
|
||||
</Row>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<input className="input" placeholder="Steam id or site account name" value={member} onChange={(e) => setMember(e.target.value)} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !member.trim()}
|
||||
onClick={() => {
|
||||
const v = member.trim()
|
||||
setMember('')
|
||||
act(() => api.adminPermissions.addMember(group.id, /^\d{5,20}$/.test(v) ? { steamId: v } : { username: v }, here))
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
{members.length > 0 && (
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.clearMembers(group.id, here))}>Remove all</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'settings' && (
|
||||
<>
|
||||
<form
|
||||
className="sans"
|
||||
onSubmit={(e) => { e.preventDefault(); act(() => api.adminPermissions.updateGroup(group.id, attrs, here)) }}
|
||||
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8, fontSize: '0.82rem' }}
|
||||
>
|
||||
<label>Title<input className="input" value={attrs.title} onChange={(e) => setAttrs({ ...attrs, title: e.target.value })} /></label>
|
||||
<label>Rank<input className="input" type="number" value={attrs.rank} onChange={(e) => setAttrs({ ...attrs, rank: Number(e.target.value) })} /></label>
|
||||
<label>
|
||||
Parent
|
||||
<select className="input" value={attrs.parent} onChange={(e) => setAttrs({ ...attrs, parent: e.target.value })}>
|
||||
<option value="">None</option>
|
||||
{view.groups.filter((g) => g.name !== group.name).map((g) => <option key={g.id} value={g.name}>{g.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span style={{ alignSelf: 'end' }}><button type="submit" className="btn" disabled={busy}>Save</button></span>
|
||||
</form>
|
||||
<ChatStyleSection
|
||||
group={group}
|
||||
fields={chatFields}
|
||||
servers={view.sync ? [{ serverId: view.server.id, report: view.sync.report }] : []}
|
||||
busy={busy}
|
||||
onSave={async (chat) => {
|
||||
try {
|
||||
await act(() => api.adminPermissions.updateGroup(group.id, { chat }, here), { rethrow: true })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'servers' && (
|
||||
<div className="sans" style={{ fontSize: '0.82rem' }}>
|
||||
<p className="dim" style={{ margin: '0 0 8px' }}>
|
||||
A group belongs to one server unless it is shared. A shared group carries the same permissions and players on every server
|
||||
it is on; a change made in one game gives that server its own copy.
|
||||
</p>
|
||||
<label style={{ display: 'block', marginBottom: 6 }}>
|
||||
<input type="checkbox" checked={share.allServers} onChange={(e) => setShare({ ...share, allServers: e.target.checked })} /> On every server, including servers added later
|
||||
</label>
|
||||
{!share.allServers && view.servers.map((s) => (
|
||||
<label key={s.id} style={{ display: 'block' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={share.servers.includes(s.id)}
|
||||
onChange={(e) => setShare({ ...share, servers: e.target.checked ? [...share.servers, s.id] : share.servers.filter((x) => x !== s.id) })}
|
||||
/> {s.name}
|
||||
</label>
|
||||
))}
|
||||
<button type="button" className="btn" style={{ marginTop: 8 }} disabled={busy} onClick={() => saveShare()}>Save</button>
|
||||
{conflict && (
|
||||
<div className="panel" style={{ padding: '10px 12px', marginTop: 10 }}>
|
||||
<p style={{ margin: '0 0 6px' }}>{conflict.message}</p>
|
||||
<p className="dim" style={{ margin: '0 0 6px' }}>This group carries: {(conflict.group.permissions || []).join(', ') || 'nothing'}</p>
|
||||
{conflict.conflicts.map((c) => (
|
||||
<p key={c.id} style={{ margin: '0 0 4px' }}>
|
||||
The group on {c.servers.join(', ')} carries: {c.permissions.join(', ') || 'nothing'}
|
||||
</p>
|
||||
))}
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => saveShare(conflict.conflicts.map((c) => c.id))}>
|
||||
Replace them with this group
|
||||
</button>{' '}
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setConflict(null)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── What waits for a person (D161) ───────────────────────────────────────
|
||||
|
||||
const DIRECTION_TEXT = {
|
||||
added: 'added in the game',
|
||||
removed: 'removed in the game',
|
||||
changed: 'changed in the game',
|
||||
split: 'split off',
|
||||
}
|
||||
|
||||
function DriftList({ rows, act, busy }) {
|
||||
if (!rows.length) return null
|
||||
|
||||
return (
|
||||
<Card title="Waiting for a person" subtitle={`${rows.length}`}>
|
||||
{rows.map((d) => {
|
||||
const who = d.username ? `${d.playerName || d.subject} (${d.username})` : d.playerName || d.subject
|
||||
const what = d.kind === 'grant' ? <>{who} holds <code>{d.object}</code></>
|
||||
: d.kind === 'member' ? <>{who} is in <code>{d.object}</code></>
|
||||
: d.kind === 'group-permission' ? <>group <code>{d.subject}</code> carries <code>{d.object}</code></>
|
||||
: d.kind === 'chat-field' ? <>group <code>{d.subject}</code>’s {d.object} is <code>{d.detail}</code></>
|
||||
: <>group <code>{d.subject}</code></>
|
||||
return (
|
||||
<Row key={d.id}>
|
||||
<span style={{ flex: 1 }}>
|
||||
{what} — <span className="dim">{DIRECTION_TEXT[d.direction] || d.direction}{d.detail === 'event' ? ' (an event gave it, so it was put back)' : ''}</span>
|
||||
{d.direction === 'split' && d.detail && <span className="dim" style={{ display: 'block', fontSize: '0.74rem' }}>{d.detail}</span>}
|
||||
</span>
|
||||
{(d.direction === 'added' || d.direction === 'changed' || d.kind === 'chat-field') && d.detail !== 'event' && (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminPermissions.adoptDrift(d.id))}>Adopt</button>
|
||||
)}
|
||||
{(d.direction === 'added' || d.kind === 'chat-field') && (
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.revokeDrift(d.id))}>Undo</button>
|
||||
)}
|
||||
{d.direction === 'removed' && d.detail !== 'event' && (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminPermissions.acceptDrift(d.id))}>Accept</button>
|
||||
)}
|
||||
{(d.direction === 'removed' || d.direction === 'changed') && d.kind !== 'chat-field' && d.detail !== 'event' && (
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.restoreDrift(d.id))}>Put back</button>
|
||||
)}
|
||||
{(d.direction === 'split' || d.detail === 'event') && (
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.dismissDrift(d.id))}>Dismiss</button>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One server ───────────────────────────────────────────────────────────
|
||||
|
||||
function ServerPanel({ serverId, chatFields, reloads, act, busy }) {
|
||||
const { data: view, error } = useAsync(() => api.adminPermissions.server(serverId), [serverId, reloads])
|
||||
const [tab, setTab] = useState('players')
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [found, setFound] = useState([])
|
||||
const [newGroup, setNewGroup] = useState('')
|
||||
|
||||
useEffect(() => { setSelected(null); setFilter(''); setFound([]) }, [serverId])
|
||||
|
||||
if (error) return <ErrorState error={error} />
|
||||
if (!view) return <Loading />
|
||||
|
||||
const f = filter.trim().toLowerCase()
|
||||
const players = view.players.filter((p) => !f || [p.name, p.steamId, p.account && p.account.username].some((v) => v && String(v).toLowerCase().includes(f)))
|
||||
|
||||
// A player found by search who holds nothing yet: shown with empty holdings.
|
||||
const player = selected && selected.type === 'player'
|
||||
? view.players.find((p) => p.steamId === selected.steamId) || { ...selected.found, grants: [], groups: [] }
|
||||
: null
|
||||
const group = selected && selected.type === 'group' ? view.groups.find((g) => g.id === selected.id) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs tabs={[{ id: 'players', label: `Players (${view.players.length})` }, { id: 'groups', label: `Groups (${view.groups.length})` }]} active={tab} onSelect={(t) => { setTab(t); setSelected(null) }} label="Permissions" />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 18, alignItems: 'start' }}>
|
||||
<div className="panel" style={{ padding: '10px 10px' }}>
|
||||
{tab === 'players' && (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Find a player…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key !== 'Enter' || !filter.trim()) return
|
||||
const res = await api.adminPermissions.players(serverId, filter.trim())
|
||||
setFound(res.players || [])
|
||||
}}
|
||||
style={{ width: '100%', marginBottom: 6 }}
|
||||
/>
|
||||
<p className="sans dim" style={{ fontSize: '0.7rem', margin: '0 0 6px' }}>Enter searches every player this server has seen.</p>
|
||||
{players.map((p) => (
|
||||
<Row key={p.steamId} selected={player && player.steamId === p.steamId} onClick={() => setSelected({ type: 'player', steamId: p.steamId })}>
|
||||
<SubjectName player={p} />
|
||||
</Row>
|
||||
))}
|
||||
{found.filter((p) => !view.players.some((x) => x.steamId === p.steamId)).map((p) => (
|
||||
<Row key={`f${p.steamId}`} onClick={() => setSelected({ type: 'player', steamId: p.steamId, found: p })}>
|
||||
<SubjectName player={p} /> <Chip>holds nothing</Chip>
|
||||
</Row>
|
||||
))}
|
||||
{!players.length && !found.length && <p className="sans dim" style={{ fontSize: '0.8rem' }}>Nobody holds anything here yet.</p>}
|
||||
</>
|
||||
)}
|
||||
{tab === 'groups' && (
|
||||
<>
|
||||
{view.groups.map((g) => (
|
||||
<Row key={g.id} selected={group && group.id === g.id} onClick={() => setSelected({ type: 'group', id: g.id })}>
|
||||
<span style={{ flex: 1 }}>{g.name}</span>
|
||||
{g.shared && <Chip tone="warn">shared</Chip>}
|
||||
<span className="dim" style={{ fontSize: '0.72rem' }}>{g.permissions.length}</span>
|
||||
</Row>
|
||||
))}
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); const name = newGroup.trim(); if (!name) return; setNewGroup(''); act(() => api.adminPermissions.createGroup(serverId, { name, title: name })) }}
|
||||
style={{ display: 'flex', gap: 6, marginTop: 10 }}
|
||||
>
|
||||
<input className="input" placeholder="New group name" value={newGroup} onChange={(e) => setNewGroup(e.target.value)} style={{ flex: 1 }} />
|
||||
<button type="submit" className="btn" disabled={busy || !newGroup.trim()}>Create</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{player && <PlayerPanel view={view} player={player} act={act} busy={busy} />}
|
||||
{group && <GroupPanel view={view} group={group} act={act} busy={busy} chatFields={chatFields} />}
|
||||
{!player && !group && (
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem' }}>
|
||||
Choose a {tab === 'players' ? 'player' : 'group'} on the left.
|
||||
</p>
|
||||
)}
|
||||
<DriftList rows={view.drift} act={act} busy={busy} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Permissions() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [serverId, setServerId] = useState(null)
|
||||
|
||||
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
const act = async (fn, { rethrow = false } = {}) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
reload()
|
||||
} catch (err) {
|
||||
if (!(rethrow && err && err.status === 409)) setError(err.message || 'That did not work.')
|
||||
if (rethrow) throw err
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId && data && data.servers.length) setServerId(data.servers[0].id)
|
||||
}, [data, serverId])
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const server = data.servers.find((s) => s.id === serverId)
|
||||
const sync = server && server.sync
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1100 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
This site holds every permission and group on each server — what was there before it, what is made here, and
|
||||
what is changed in the game. It reads each server’s store and pushes the site’s set back, so a wipe loses nothing.
|
||||
</p>
|
||||
|
||||
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>{error}</p>}
|
||||
|
||||
<div className="sans" style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
<label style={{ fontSize: '0.84rem' }}>
|
||||
Server{' '}
|
||||
<select className="input" value={serverId || ''} onChange={(e) => setServerId(e.target.value)}>
|
||||
{data.servers.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{server && (
|
||||
<>
|
||||
<label style={{ fontSize: '0.84rem' }}>
|
||||
In-game changes{' '}
|
||||
<select className="input" value={server.policy} disabled={busy} onChange={(e) => act(() => api.adminPermissions.setPolicy(server.id, e.target.value))}>
|
||||
{data.policies.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{sync && (
|
||||
sync.inSync && sync.waitingToLand > 0
|
||||
? (
|
||||
<Chip tone="warn" title="A grant to a Steam id this server has never seen lands when that player first joins: every sync pushes it again">
|
||||
in sync · {sync.waitingToLand} {sync.waitingToLand === 1 ? 'waits' : 'wait'} for their player to join
|
||||
</Chip>
|
||||
)
|
||||
: sync.inSync ? <Chip tone="good">in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''}</Chip>
|
||||
: sync.state === 'failed' ? <Chip tone="bad" title={sync.error || ''}>last sync failed</Chip>
|
||||
: <Chip tone="warn">waiting for a sync</Chip>
|
||||
)}
|
||||
{sync && !sync.importedAt && <Chip tone="warn" title="The first sync imports everything on the server">not imported yet</Chip>}
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync(server.id))}>Sync now</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{server && <p className="sans dim" style={{ fontSize: '0.78rem', margin: '-6px 0 14px' }}>{POLICY_TEXT[server.policy]}</p>}
|
||||
{sync && sync.state === 'failed' && sync.error && <Warn>{sync.error}</Warn>}
|
||||
|
||||
{serverId && <ServerPanel serverId={serverId} chatFields={data.chatFields} reloads={reloads} act={act} busy={busy} />}
|
||||
{!data.servers.length && <p className="sans dim">No Rust servers are configured yet.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
454
client/src/routes/admin/ServerSettings.jsx
Normal file
454
client/src/routes/admin/ServerSettings.jsx
Normal file
@@ -0,0 +1,454 @@
|
||||
// ── Admin · Rust · Servers (phase 16, D133) ───────────────────────────────
|
||||
//
|
||||
// The page the module did not have. Until phase 16 a server row was written only
|
||||
// through `PUT /admin/rust/servers/:id` — D106 recorded it, and the README said
|
||||
// "in Admin → Rust, add a server" about a page that did not exist. D130's wipe
|
||||
// schedule needed somewhere to be typed, and the org lead chose to build the
|
||||
// missing page rather than hang the schedule on the visibility page (D133).
|
||||
//
|
||||
// One form for adding and editing, because they are one `PUT`. Three rules it
|
||||
// keeps from the API:
|
||||
//
|
||||
// 1. **The token is write-only.** The field is blank on every edit, a blank
|
||||
// save leaves the stored one alone, and the row says whether one is stored.
|
||||
// 2. **The protocol a row was configured against is kept.** The `PUT` defaults
|
||||
// an omitted protocol to this build's, so an edit sends the stored value back
|
||||
// rather than silently re-stamping the row.
|
||||
// 3. **The wipe schedule is the operator's words, not the forecast.** The form
|
||||
// shows what was stored; the row shows what it computes to, from the same
|
||||
// answer the public pages read.
|
||||
//
|
||||
// Test and Delete act at once rather than on Save — they are questions put to a
|
||||
// sidecar and a removal, not settings.
|
||||
//
|
||||
// Phase 17 added a server's chat titles and the announcement voice, in
|
||||
// `ChatTitles.jsx`, each saved on its own.
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
import { ago, nextWipe } from '../../lib/format.js'
|
||||
import { CategoryTitles, TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx'
|
||||
|
||||
const RULES = [
|
||||
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
|
||||
{ id: 'forced', label: 'Forced wipe only', hint: 'The first Thursday of each month, 19:00 UK time — Facepunch forces it on every server.' },
|
||||
{ id: 'weekly', label: 'Weekly', hint: 'Every week on the day and time below, and the forced wipe.' },
|
||||
{ id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' },
|
||||
]
|
||||
|
||||
// Who a downed patrol helicopter, Chinook or Bradley credits (protocol 14, D372).
|
||||
// The same rule writes the feed line and the Rotorbane / Tankbreaker counts; the
|
||||
// server's own list, mirrored here so the form needs no round trip.
|
||||
const CREDIT_RULES = [
|
||||
{ id: 'blow', label: 'Killing blow', hint: 'The player whose hit destroyed it — for the patrol helicopter, the crippling hit, else the last hit. The default.' },
|
||||
{ id: 'last', label: 'Last hit', hint: 'The last player to hurt it.' },
|
||||
{ id: 'most', label: 'Most damage', hint: 'The player who dealt it the most damage.' },
|
||||
{ id: 'all', label: 'Everyone who hurt it', hint: 'Every player who damaged it, one each.' },
|
||||
]
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' }
|
||||
|
||||
/** The browser's own zone — the likeliest answer for an operator typing a time. */
|
||||
const localZone = () => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
/** Every zone the browser knows, for the datalist; an older browser gets none and a free field. */
|
||||
const ZONES = (() => {
|
||||
try {
|
||||
return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
|
||||
/**
|
||||
* Why the server's RunicNPC will not do (runicnpc stage 9, D310), or null when it
|
||||
* does. RunicNPC is required, so a server without it is shown as incomplete.
|
||||
*/
|
||||
export function runicNpcNote(npc) {
|
||||
if (!npc || npc.ready) return null
|
||||
return `Incomplete: ${npc.absence || 'RunicNPC is not loaded on it'}. RunicNPC is required — without it every Place NPCs step is refused, Rust’s own scientists included. Runic Gateway’s installer and egg put it in place.`
|
||||
}
|
||||
|
||||
/**
|
||||
* What the missing ZoneManager helper costs this server (PLAN_FIXES F12, D182), or
|
||||
* null when there is nothing to say — the helper patched, or a plugin that
|
||||
* reported no ZoneManager state at all. Scoring is never what is lost: the bridge
|
||||
* falls back to the zone's shape. ZoneManager's own flags are.
|
||||
*/
|
||||
export function zoneHelperNote(helper) {
|
||||
if (!helper || helper.state === 'patched' || helper.state === 'no-zonemanager') return null
|
||||
const cost =
|
||||
'Event zones still score everybody inside, but ZoneManager’s own flags and messages miss a player who was already standing in a zone when it opened or came back after a restart.'
|
||||
if (helper.state === 'missing') {
|
||||
return `The ZoneManager helper (RunicGatewayZones.cs) is not installed. ${cost} Reinstall or update the plugin to put it back.`
|
||||
}
|
||||
return `The ZoneManager helper could not patch this ZoneManager${helper.reason ? ` (${helper.reason})` : ''}. ${cost}`
|
||||
}
|
||||
|
||||
function toLocalInput(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function blankForm() {
|
||||
return {
|
||||
isNew: true,
|
||||
id: '',
|
||||
name: '',
|
||||
sidecarBaseUrl: '',
|
||||
sidecarToken: '',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
protocol: undefined,
|
||||
wipeRule: 'none',
|
||||
wipeDay: 4,
|
||||
wipeTime: '19:00',
|
||||
wipeTz: localZone(),
|
||||
wipeAnchor: '',
|
||||
wipeOnce: '',
|
||||
creditRule: 'blow',
|
||||
}
|
||||
}
|
||||
|
||||
function formFrom(server) {
|
||||
const s = server.schedule || { rule: 'none' }
|
||||
return {
|
||||
isNew: false,
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
sidecarBaseUrl: server.sidecarBaseUrl || '',
|
||||
sidecarToken: '',
|
||||
enabled: server.enabled,
|
||||
sortOrder: server.sortOrder,
|
||||
protocol: server.protocol,
|
||||
wipeRule: s.rule || 'none',
|
||||
wipeDay: s.day == null ? 4 : s.day,
|
||||
wipeTime: s.time || '19:00',
|
||||
wipeTz: s.tz || localZone(),
|
||||
wipeAnchor: s.anchor || '',
|
||||
wipeOnce: toLocalInput(s.onceAt),
|
||||
storedOnceAt: s.onceAt || null,
|
||||
creditRule: server.creditRule || 'blow',
|
||||
}
|
||||
}
|
||||
|
||||
/** The PUT body. The token only when one was typed; the schedule always. */
|
||||
function bodyFrom(form) {
|
||||
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
|
||||
const onceUnchanged = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt)
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
sidecarBaseUrl: form.sidecarBaseUrl.trim(),
|
||||
enabled: form.enabled,
|
||||
sortOrder: Number(form.sortOrder) || 0,
|
||||
wipeRule: form.wipeRule,
|
||||
wipeDay: weekly ? Number(form.wipeDay) : null,
|
||||
wipeTime: weekly ? form.wipeTime : null,
|
||||
wipeTz: weekly ? form.wipeTz.trim() : null,
|
||||
wipeAnchor: form.wipeRule === 'biweekly' ? form.wipeAnchor : null,
|
||||
// A past one-off date that was not touched is dropped rather than sent back:
|
||||
// the server refuses a past date on save (it says nothing about the next
|
||||
// wipe), and an operator editing the name must not be stopped by it.
|
||||
wipeOnceAt: !form.wipeOnce || (onceUnchanged && Date.parse(form.storedOnceAt) <= Date.now())
|
||||
? null
|
||||
: new Date(form.wipeOnce).toISOString(),
|
||||
}
|
||||
if (form.creditRule) body.creditRule = form.creditRule
|
||||
if (form.sidecarToken) body.sidecarToken = form.sidecarToken
|
||||
if (form.protocol !== undefined) body.protocol = form.protocol
|
||||
return body
|
||||
}
|
||||
|
||||
export default function ServerSettings() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.admin.listServers(), [reloads])
|
||||
const [form, setForm] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notes, setNotes] = useState({})
|
||||
const [confirming, setConfirming] = useState(null)
|
||||
const [titling, setTitling] = useState(null)
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
const servers = data.servers || []
|
||||
const categories = data.titleCategories || []
|
||||
|
||||
const set = (key) => (e) => {
|
||||
const value = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.saveServer(form.id.trim(), bodyFrom(form))
|
||||
setForm(null)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const test = async (server) => {
|
||||
setNotes((n) => ({ ...n, [server.id]: 'Asking the sidecar…' }))
|
||||
try {
|
||||
const r = await api.admin.testServer(server.id)
|
||||
const sidecar = r.sidecar || {}
|
||||
const text = r.ok
|
||||
? `The sidecar answered${sidecar.protocol ? ` (protocol ${sidecar.protocol})` : ''}${sidecar.plugin_connected === false ? ', but the game’s plugin is not connected to it' : ''}.`
|
||||
: `The sidecar did not answer: ${r.status}.`
|
||||
setNotes((n) => ({ ...n, [server.id]: text }))
|
||||
} catch (err) {
|
||||
setNotes((n) => ({ ...n, [server.id]: err.message || 'The test did not run.' }))
|
||||
}
|
||||
}
|
||||
|
||||
const mods = async (server) => {
|
||||
setNotes((n) => ({ ...n, [server.id]: 'Asking the game…' }))
|
||||
try {
|
||||
const line = integrationsLine(await api.admin.integrations(server.id))
|
||||
setNotes((n) => ({ ...n, [server.id]: line }))
|
||||
} catch (err) {
|
||||
setNotes((n) => ({ ...n, [server.id]: err.message || 'The game could not be asked.' }))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (server) => {
|
||||
setConfirming(null)
|
||||
try {
|
||||
await api.admin.deleteServer(server.id)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setNotes((n) => ({ ...n, [server.id]: err.message || 'That did not delete.' }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
Each Rust server this site follows, the sidecar it answers through, and when it wipes. A server’s sidecar is
|
||||
installed on its own game host; this page tells the site where to find it.
|
||||
</p>
|
||||
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
{servers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
)}
|
||||
{servers.map((s) => (
|
||||
<div key={s.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '10px 0', fontSize: '0.84rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 10 }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{s.name}</strong>
|
||||
<code className="dim" style={{ fontSize: '0.74rem' }}>{s.id}</code>
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}>disabled</span>}
|
||||
<span style={{ marginLeft: 'auto', color: s.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
|
||||
{s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
|
||||
{s.sidecarBaseUrl || 'sidecar address hidden'} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
|
||||
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
|
||||
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{s.nextWipe
|
||||
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
|
||||
: 'No wipe schedule set.'}
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush, categories)}</div>
|
||||
{runicNpcNote(s.runicNpc) && (
|
||||
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{runicNpcNote(s.runicNpc)}</div>
|
||||
)}
|
||||
{zoneHelperNote(s.zoneHelper) && (
|
||||
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn" onClick={() => { setError(''); setTitling(null); setForm(formFrom(s)) }}>Edit</button>
|
||||
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
|
||||
<button type="button" className="btn" onClick={() => { setForm(null); setTitling(s) }}>Chat titles</button>
|
||||
<button type="button" className="btn" onClick={() => mods(s)}>Optional mods</button>
|
||||
{confirming === s.id ? (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={() => remove(s)}>Delete {s.name} and everything recorded about it</button>
|
||||
<button type="button" className="btn" onClick={() => setConfirming(null)}>Keep it</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="btn" onClick={() => setConfirming(s.id)}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
{notes[s.id] && <p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>{notes[s.id]}</p>}
|
||||
</div>
|
||||
))}
|
||||
{!form && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button type="button" className="btn" onClick={() => { setError(''); setForm(blankForm()) }}>Add a server</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{form && (
|
||||
<ServerForm form={form} set={set} busy={busy} error={error} onSave={save} onCancel={() => setForm(null)} />
|
||||
)}
|
||||
|
||||
{titling && (
|
||||
<TitlesForm
|
||||
key={titling.id}
|
||||
server={titling}
|
||||
categories={categories}
|
||||
onSaved={() => { setTitling(null); setReloads((n) => n + 1) }}
|
||||
onCancel={() => setTitling(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CategoryTitles categories={categories} onSaved={() => setReloads((n) => n + 1)} />
|
||||
|
||||
<VoiceCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{label}</span>
|
||||
{children}
|
||||
{hint && <span className="dim" style={{ fontSize: '0.74rem' }}>{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerForm({ form, set, busy, error, onSave, onCancel }) {
|
||||
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
|
||||
const rule = RULES.find((r) => r.id === form.wipeRule) || RULES[0]
|
||||
const oncePast = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt) && Date.parse(form.storedOnceAt) <= Date.now()
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{form.isNew ? 'Add a server' : `Edit ${form.name}`}
|
||||
</h2>
|
||||
|
||||
{form.isNew && (
|
||||
<Field label="Id" hint="Lowercase letters, digits and hyphens. It is in every link to this server and cannot be changed later.">
|
||||
<input value={form.id} onChange={set('id')} required pattern="[a-z0-9][a-z0-9-]{0,63}" style={inputStyle} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Name">
|
||||
<input value={form.name} onChange={set('name')} required maxLength={120} style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Sidecar address" hint="The base URL of the sidecar on the game host, e.g. http://10.0.0.5:8090.">
|
||||
<input value={form.sidecarBaseUrl} onChange={set('sidecarBaseUrl')} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Sidecar token"
|
||||
hint={form.isNew ? 'From the sidecar’s own config. Needed to add a server.' : 'Leave blank to keep the stored token. It is never shown again once saved.'}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.sidecarToken}
|
||||
onChange={set('sidecarToken')}
|
||||
required={form.isNew}
|
||||
maxLength={512}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.enabled} onChange={set('enabled')} /> Enabled
|
||||
</label>
|
||||
<Field label="Order">
|
||||
<input type="number" value={form.sortOrder} onChange={set('sortOrder')} min={-1000} max={1000} style={{ ...inputStyle, width: 90 }} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Wipe schedule</legend>
|
||||
<Field label="Rule" hint={rule.hint}>
|
||||
<select value={form.wipeRule} onChange={set('wipeRule')} style={inputStyle}>
|
||||
{RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{weekly && (
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Field label="Day">
|
||||
<select value={form.wipeDay} onChange={set('wipeDay')} style={inputStyle}>
|
||||
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Time">
|
||||
<input type="time" value={form.wipeTime} onChange={set('wipeTime')} required style={inputStyle} />
|
||||
</Field>
|
||||
<Field label="Time zone" hint="The zone the time is in. It follows that zone’s summer time.">
|
||||
<input value={form.wipeTz} onChange={set('wipeTz')} list="rust-zones" required style={inputStyle} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{form.wipeRule === 'biweekly' && (
|
||||
<Field label="One wipe on this schedule" hint={`A ${DAYS[form.wipeDay]} the server wiped or will wipe on. It says which weeks are wipe weeks.`}>
|
||||
<input type="date" value={form.wipeAnchor} onChange={set('wipeAnchor')} required style={inputStyle} />
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label="One-off wipe (optional)"
|
||||
hint="A delayed or extra wipe, in your own clock. While it is in the future it is the next wipe, and anything scheduled before it is skipped."
|
||||
>
|
||||
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input type="datetime-local" value={form.wipeOnce} onChange={set('wipeOnce')} style={inputStyle} />
|
||||
{form.wipeOnce && <button type="button" className="btn" onClick={() => set('wipeOnce')('')}>Clear</button>}
|
||||
</span>
|
||||
</Field>
|
||||
{oncePast && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
That date has passed, so it no longer changes anything. Saving will clear it.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>World events</legend>
|
||||
<Field
|
||||
label="Who downs a helicopter or a Bradley"
|
||||
hint={`${(CREDIT_RULES.find((r) => r.id === form.creditRule) || CREDIT_RULES[0]).hint} It names them in the feed and counts toward Rotorbane and Tankbreaker, from now on.`}
|
||||
>
|
||||
<select value={form.creditRule} onChange={set('creditRule')} style={inputStyle}>
|
||||
{CREDIT_RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</fieldset>
|
||||
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '5px 8px',
|
||||
fontSize: '0.84rem',
|
||||
}
|
||||
436
client/src/routes/admin/UserRustSections.jsx
Normal file
436
client/src/routes/admin/UserRustSections.jsx
Normal file
@@ -0,0 +1,436 @@
|
||||
// ── This module's fill for `admin.users.detail` ───────────────────────────
|
||||
//
|
||||
// R13's first slot, and the phase criterion as an operator meets it: the Steam
|
||||
// id inside core's own user page, under core's own security panel.
|
||||
//
|
||||
// **The slot hands over `userId` and nothing else** — not a client. So this file
|
||||
// builds its own bindings for the routes the server half registered
|
||||
// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of
|
||||
// a call belong to the same module even when the URL between them is core's.
|
||||
//
|
||||
// **Most users have no Rust account, so most of the time this renders nothing.**
|
||||
// A panel that announced "no linked Steam accounts" on every user page in a
|
||||
// community that also runs a UO shard would be noise on the overwhelming
|
||||
// majority of them. Silence is the honest answer to "what does the Rust module
|
||||
// know about this person" when it is nothing.
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ago, coins, count, duration } from '../../lib/format.js'
|
||||
import { useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
import Backpack from '../../components/Backpack.jsx'
|
||||
|
||||
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One server's all-time totals for this player. */
|
||||
function ServerRow({ server }) {
|
||||
return (
|
||||
<li
|
||||
className="sans"
|
||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{server.serverName}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||
{count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)}
|
||||
{server.wipes > 1 ? ` · ${server.wipes} wipes` : ''}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** One linked Steam account: who it is, when it was linked, and the way out. */
|
||||
function LinkPanel({ userId, link, onRemoved }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function unlink() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.adminUserLinks.remove(userId, link.steamId)
|
||||
await onRemoved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink that account.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{link.name || link.steamId}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{link.steamId} · linked {ago(link.linkedAt)}
|
||||
{link.serverId ? ` on ${link.serverId}` : ''}
|
||||
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
|
||||
</div>
|
||||
{/* Worth showing only when they differ: the name on the link is what
|
||||
they were called when they linked, the other is what the game last
|
||||
saw. A rename is the ordinary reason, and an operator reading a
|
||||
support ticket wants both names. */}
|
||||
{link.linkedName && link.name && link.linkedName !== link.name && (
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
Linked as “{link.linkedName}”.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>
|
||||
)}
|
||||
|
||||
{link.servers.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: '12px 0 0',
|
||||
padding: '12px 0 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{link.servers.map((server) => (
|
||||
<ServerRow key={server.serverId} server={server} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7's half of the panel: what this person may do in game.
|
||||
*
|
||||
* It renders whenever they hold anything, INCLUDING when they have linked no
|
||||
* Steam account — which is the one case worth going out of the way for. A grant
|
||||
* against an unlinked person is authored, stored, pushed nowhere, and identical
|
||||
* to a working one everywhere except here.
|
||||
*/
|
||||
function PermissionsPanel({ userId, data, onChanged }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [permission, setPermission] = useState('')
|
||||
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const nothing = data.groups.length === 0 && data.grants.length === 0
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Permissions
|
||||
</div>
|
||||
|
||||
{nothing && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
Nothing granted.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.groups.map((group) => (
|
||||
<div key={group.name} className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{group.title || group.name}</span>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
group · {group.scope === '*' ? 'every server' : group.scope}
|
||||
{group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.grants.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem', padding: '4px 0' }}
|
||||
>
|
||||
<span style={{ flex: 1, color: 'var(--head)' }}>
|
||||
{row.permission}{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{row.scope === '*' ? 'every server' : row.scope}
|
||||
{row.source !== 'admin' ? ` · ${row.source}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
|
||||
style={{ flex: 'none' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!nothing && data.reaches.length === 0 && (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||
This account has linked no Steam id, so none of it reaches a game yet. It will apply by
|
||||
itself when they link.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!permission.trim()) return
|
||||
act(() =>
|
||||
api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }),
|
||||
)
|
||||
setPermission('')
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="kits.vip"
|
||||
value={permission}
|
||||
onChange={(event) => setPermission(event.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Grant
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One submission's key, so a resent form moves nothing (§11.2.2). */
|
||||
function submissionKey() {
|
||||
const c = globalThis.crypto
|
||||
return c && typeof c.randomUUID === 'function' ? c.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/** One server's coins for this person, and the form that changes them. */
|
||||
function EconomicsServer({ userId, server, onChanged }) {
|
||||
const [steamId, setSteamId] = useState(server.accounts.length ? server.accounts[0].steamId : '')
|
||||
const [op, setOp] = useState('deposit')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [done, setDone] = useState('')
|
||||
|
||||
// D199: said, not hidden — staff asking why the form is missing are owed it.
|
||||
if (!server.economics) {
|
||||
return (
|
||||
<div className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>{server.reason}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (busy || !steamId || amount === '') return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setDone('')
|
||||
try {
|
||||
const result = await api.adminUserEconomics.adjust(userId, { serverId: server.serverId, steamId, op, amount: Number(amount), key: submissionKey() })
|
||||
setDone(`${coins(result.before)} → ${coins(result.after)}`)
|
||||
setAmount('')
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
// "They have only 340 coins on Main" is the server's sentence, and the useful one.
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '6px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
|
||||
{server.accounts.map((a, i) => (
|
||||
<span key={a.steamId}>
|
||||
{i > 0 && <span className="dim"> · </span>}
|
||||
{server.accounts.length > 1 && <span className="dim">{a.name || a.steamId} </span>}
|
||||
<strong>{coins(a.balance)}</strong>
|
||||
{a.source === 'last-known' && <span className="dim"> (last known)</span>}
|
||||
</span>
|
||||
))}
|
||||
{!server.startingKnown && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{' '}· its Economics config could not be read, so a starting balance of {coins(server.startingBalance)} is assumed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{server.accounts.length > 1 && (
|
||||
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
|
||||
{server.accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || a.steamId}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select className="input" value={op} onChange={(e) => setOp(e.target.value)} aria-label="What to do">
|
||||
<option value="deposit">Deposit</option>
|
||||
<option value="withdraw">Withdraw</option>
|
||||
<option value="set">Set to</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={op === 'set' ? 0 : 0.01}
|
||||
max={1000000}
|
||||
step="0.01"
|
||||
placeholder="500"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-label="Coins"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy || amount === ''}>
|
||||
{busy ? 'Working…' : 'Apply'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{done && <p className="sans dim" style={{ fontSize: '0.78rem', margin: '6px 0 0' }}>Done: {done}. Recorded in the activity log.</p>}
|
||||
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Economics (PLAN_REDESIGNS §11.2.2): this person's coins on every server, and
|
||||
* staff's hand on them — the only way coins come back after an event paid them
|
||||
* (D335). Its own reload, and it keeps showing the last answer while it
|
||||
* re-reads, so a deposit's "1,000 → 1,500" is not blanked by the read it caused.
|
||||
*/
|
||||
function EconomicsPanel({ userId }) {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data } = useAsync(() => api.adminUserEconomics.list(userId), [userId, reloads])
|
||||
const last = useRef(null)
|
||||
if (data) last.current = data
|
||||
const shown = data || last.current
|
||||
const onChanged = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
const list = (shown && shown.servers) || []
|
||||
if (!list.length) return null
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Coins</div>
|
||||
{list.map((server) => (
|
||||
<EconomicsServer key={server.serverId} userId={userId} server={server} onChanged={onChanged} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Backpacks (PLAN_REDESIGNS §11.3.2): what this person carries, per server and
|
||||
* linked account, read live when staff open it. Read only (R2): staff see the
|
||||
* overflow too, and only the owner's own open moves it (D338).
|
||||
*/
|
||||
function BackpackPanel({ userId, links }) {
|
||||
const { data } = useAsync(() => api.servers.list(), [])
|
||||
const list = (data && data.servers) || []
|
||||
if (!list.length) return null
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Backpack</div>
|
||||
<Backpack
|
||||
servers={list.map((s) => ({ id: s.id, name: s.name }))}
|
||||
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
|
||||
read={(serverId, steamId) => api.adminUserBackpacks.read(userId, serverId, steamId)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserRustSections({ userId }) {
|
||||
// Core's `useAsync` has no refresh, so a counter in the deps is how this
|
||||
// re-reads after its own write (the same shape the player page uses).
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
|
||||
const { data: permissions } = useAsync(
|
||||
() => api.adminUserPermissions.list(userId),
|
||||
[userId, reloads],
|
||||
)
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
|
||||
// somebody else's page: a spinner on every user page for a module most users
|
||||
// have nothing to do with is worse than a section that appears when it has
|
||||
// something, and a failure here must not replace core's own user detail with an
|
||||
// error card.
|
||||
// **Both reads decide whether this section exists**, and the second one is the
|
||||
// reason. A browser walk found it: a person can hold permissions and have
|
||||
// linked no Steam account — which is exactly the state an operator most needs
|
||||
// to see, because it is the one that reaches nobody — and a section gated on
|
||||
// links alone hides it completely.
|
||||
const holdsSomething =
|
||||
permissions && (permissions.groups.length > 0 || permissions.grants.length > 0)
|
||||
|
||||
if (!data || (data.links.length === 0 && !holdsSomething)) return null
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Rust</SectionTitle>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{data.links.map((link) => (
|
||||
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
|
||||
))}
|
||||
|
||||
{data.links.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
||||
A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
|
||||
recorded in the activity log — it is the way back for a player who linked the wrong
|
||||
account and cannot reach it in game.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Inside the same section rather than beside it: "who is this in game"
|
||||
and "what may they do there" are one question asked twice, and an
|
||||
operator reading a support ticket has both in front of them. The note
|
||||
above belongs to the links, so it sits with them rather than under
|
||||
the panel it would otherwise appear to describe. */}
|
||||
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
|
||||
|
||||
{/* Coins belong to a Steam account, so only with one linked. */}
|
||||
{data.links.length > 0 && <EconomicsPanel userId={userId} />}
|
||||
|
||||
{/* A backpack belongs to a Steam account too. */}
|
||||
{data.links.length > 0 && <BackpackPanel userId={userId} links={data.links} />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
671
client/src/routes/admin/Visibility.jsx
Normal file
671
client/src/routes/admin/Visibility.jsx
Normal file
@@ -0,0 +1,671 @@
|
||||
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
|
||||
//
|
||||
// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
|
||||
// is online by default — the narrowest audience, staff, unless an operator
|
||||
// deliberately widens it here. A count of players is public at every setting.
|
||||
//
|
||||
// One fleet default and an optional override per server, because a creative or
|
||||
// PvE server may reasonably publish a roll call a PvP server must not — and a
|
||||
// server that has not chosen follows the fleet, so narrowing the fleet narrows
|
||||
// every server that never said otherwise.
|
||||
//
|
||||
// The page says what "who is online" covers, because it is wider than the tab
|
||||
// of the same name: the killfeed, chat and joins in the feed, and the
|
||||
// leaderboard's "last seen" all name a player who was on at a given moment.
|
||||
//
|
||||
// Phase 9 adds a second setting beside it: who may see a CLAN ROSTER (D48). It
|
||||
// defaults to the clan's own members and staff, and widening it widens online
|
||||
// status too, because a roster row carries it — the page says so. The same card
|
||||
// lists each server's clan board: a server whose clans cannot be read, one at
|
||||
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
|
||||
// whose clans are a separate system and never Teams (D47).
|
||||
//
|
||||
// Phase 13b adds a third (D104, D106): whether a published news post is also
|
||||
// said in each server's in-game chat. Off by default, because core sends every
|
||||
// post to every registered leg — without a switch, the day this module updated,
|
||||
// every post would start appearing in every server's chat. It lives here because
|
||||
// this is the one page that lists every server with a setting of its own.
|
||||
//
|
||||
// Phase 14 adds the live map's switches (D114): four layers, each with a fleet
|
||||
// audience and an optional per-server override, and the own-and-mates switch
|
||||
// (D118). Beside each server is what its map picture is, and the two buttons
|
||||
// that act at once rather than on Save: Fetch again, and Render now — shown only
|
||||
// where there is no picture, with how long it will stall that server (D109).
|
||||
//
|
||||
// PLAN_REDESIGNS §4 adds which monuments the map draws (D196): a switch per
|
||||
// monument label, because that is what staff read and it groups the variants
|
||||
// (31 substations of four prefabs are one "Substation"). The minor labels start
|
||||
// hidden and every other label drawn, so a monument Facepunch adds appears. One
|
||||
// fleet list, and each server's own labels with an override each.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
import { fleetLabels, fleetShows, markerDiff, serverShows, setFleet as setFleetMarker, setServer as setServerMarker } from '../../lib/mapMarkers.js'
|
||||
|
||||
const INHERIT = ''
|
||||
|
||||
const LABEL = {
|
||||
staff: 'Staff only',
|
||||
signed_in: 'Signed-in members',
|
||||
public: 'Everyone',
|
||||
}
|
||||
|
||||
const CLAN_LABEL = {
|
||||
members: 'The clan’s members and staff',
|
||||
signed_in: 'Signed-in members',
|
||||
public: 'Everyone',
|
||||
}
|
||||
|
||||
const CLAN_DESCRIBE = {
|
||||
members: 'Players whose linked Rust account is in the clan, plus admins and moderators. The default.',
|
||||
signed_in: 'Anybody with an account on this site.',
|
||||
public: 'Anybody at all, signed in or not.',
|
||||
}
|
||||
|
||||
const DESCRIBE = {
|
||||
staff: 'Admins and moderators. The default.',
|
||||
signed_in: 'Anybody with an account on this site.',
|
||||
public: 'Anybody at all, signed in or not.',
|
||||
}
|
||||
|
||||
function Card({ title, subtitle, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle} aria-label={label}>
|
||||
{inherit && <option value={INHERIT}>{inherit}</option>}
|
||||
{audiences.map((a) => (
|
||||
<option key={a} value={a}>{LABEL[a] || a}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Visibility() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
|
||||
|
||||
const [fleet, setFleet] = useState('staff')
|
||||
const [clanRoster, setClanRoster] = useState('members')
|
||||
const [servers, setServers] = useState({})
|
||||
const [news, setNews] = useState({})
|
||||
const [delivery, setDelivery] = useState({})
|
||||
const [mapFleet, setMapFleet] = useState({})
|
||||
const [mapServers, setMapServers] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
// The form starts from what the server said and is reset from it after every
|
||||
// save — the answer to a PUT is the new state, so what is on screen is always
|
||||
// the site's word rather than what this page sent.
|
||||
const load = useCallback((state) => {
|
||||
setFleet(state.presence.fleet)
|
||||
setClanRoster((state.clans && state.clans.roster) || 'members')
|
||||
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
|
||||
setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
|
||||
setDelivery(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, s.delivery || 'chat'])))
|
||||
if (state.map) {
|
||||
setMapFleet({ ...state.map.fleet, markers: { ...(state.map.fleet.markers || {}) } })
|
||||
setMapServers(Object.fromEntries(state.map.servers.map((s) => [s.id, mapOverridesToForm(s.overrides)])))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (data) load(data)
|
||||
}, [data, load])
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const audiences = data.audiences
|
||||
const rows = data.presence.servers
|
||||
|
||||
const dirtyFleet = fleet !== data.presence.fleet
|
||||
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
|
||||
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
|
||||
const dirtyClans = clanRoster !== clans.roster
|
||||
const newsRows = (data.news && data.news.servers) || []
|
||||
const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
|
||||
const dirtyDelivery = newsRows.filter((s) => (delivery[s.id] || 'chat') !== (s.delivery || 'chat'))
|
||||
const mapCard = data.map || null
|
||||
const mapChanges = mapCard ? mapDiff(mapCard, mapFleet, mapServers) : null
|
||||
const dirtyMap = Boolean(mapChanges)
|
||||
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0 || dirtyDelivery.length > 0 || dirtyMap
|
||||
|
||||
const effective = (id) => servers[id] || fleet
|
||||
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setSaved(false)
|
||||
try {
|
||||
const body = {}
|
||||
if (dirtyFleet) body.fleet = fleet
|
||||
if (dirtyClans) body.clanRoster = clanRoster
|
||||
if (dirtyServers.length) {
|
||||
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
|
||||
}
|
||||
if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
|
||||
if (dirtyDelivery.length) body.newsDelivery = Object.fromEntries(dirtyDelivery.map((s) => [s.id, delivery[s.id] || 'chat']))
|
||||
if (dirtyMap) body.map = mapChanges
|
||||
load(await api.adminVisibility.save(body))
|
||||
setSaved(true)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={save} style={{ maxWidth: 900 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
Nothing on this site names who is online unless you choose to show it. That covers more than
|
||||
the Online tab: the joins, deaths and chat in each server’s feed, and the leaderboard’s “last
|
||||
seen”, all say that a named player was on at a given moment. How many players are online is
|
||||
always shown.
|
||||
</p>
|
||||
|
||||
<Card title="Who is online" subtitle="the default for every server">
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
|
||||
<AudienceSelect value={fleet} onChange={setFleet} audiences={audiences} label="Fleet default" />
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{DESCRIBE[fleet]}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Per server" subtitle="an override, or the default above">
|
||||
{rows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
)}
|
||||
{rows.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 180, color: 'var(--head)' }}>
|
||||
{s.name}
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||||
</span>
|
||||
<AudienceSelect
|
||||
value={servers[s.id] ?? INHERIT}
|
||||
onChange={(v) => setServers((prev) => ({ ...prev, [s.id]: v }))}
|
||||
audiences={audiences}
|
||||
inherit={`Default (${LABEL[fleet] || fleet})`}
|
||||
label={`Who is online on ${s.name}`}
|
||||
/>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||||
{servers[s.id] ? 'its own setting' : 'follows the default'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{widened && (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem' }}>
|
||||
Wider than staff: on a PvP server, knowing who is on tells a raiding party whose base is
|
||||
undefended.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Card title="Clan rosters" subtitle="who is in each clan, on every server">
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
|
||||
<select
|
||||
value={clanRoster}
|
||||
onChange={(e) => setClanRoster(e.target.value)}
|
||||
style={selectStyle}
|
||||
aria-label="Who may see a clan roster"
|
||||
>
|
||||
{clans.audiences.map((a) => (
|
||||
<option key={a} value={a}>{CLAN_LABEL[a] || a}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{CLAN_DESCRIBE[clanRoster]}</span>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
|
||||
Each clan’s name, colour, score and member count are always public.
|
||||
</p>
|
||||
{clanRoster !== 'members' && (
|
||||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
||||
A roster also shows which members are online right now, so this shows who is on to{' '}
|
||||
{clanRoster === 'public' ? 'everyone' : 'every signed-in member'} as well.
|
||||
</p>
|
||||
)}
|
||||
<ClanBoards servers={clans.servers || []} />
|
||||
</Card>
|
||||
|
||||
<Card title="News in game chat" subtitle="a published news post, said in each server’s chat">
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
When a news post is published, its title is said in the chat of every server switched on
|
||||
here. A server that is down when a post is published is skipped rather than told late. A popup
|
||||
needs PopupNotifications on that server; one without it refuses the post and says why.
|
||||
</p>
|
||||
{newsRows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
)}
|
||||
{newsRows.map((s) => (
|
||||
<label
|
||||
key={s.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.86rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(news[s.id])}
|
||||
onChange={(e) => setNews((prev) => ({ ...prev, [s.id]: e.target.checked }))}
|
||||
aria-label={`Say news in ${s.name}’s chat`}
|
||||
/>
|
||||
<span style={{ minWidth: 180, color: 'var(--head)' }}>
|
||||
{s.name}
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||||
</span>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{news[s.id] ? 'says news' : 'off'}</span>
|
||||
{/* D142: where the post goes on this server when it is switched on. */}
|
||||
<select
|
||||
value={delivery[s.id] || 'chat'}
|
||||
onChange={(e) => setDelivery((prev) => ({ ...prev, [s.id]: e.target.value }))}
|
||||
disabled={!news[s.id]}
|
||||
aria-label={`Where news goes on ${s.name}`}
|
||||
style={{ marginLeft: 'auto', background: 'var(--panel-flat, transparent)', color: 'var(--text)', border: '1px solid var(--line)', borderRadius: 'var(--radius-input, 6px)', padding: '3px 6px', fontSize: '0.8rem' }}
|
||||
>
|
||||
<option value="chat">in chat</option>
|
||||
<option value="popup">as a popup</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{mapCard && (
|
||||
<MapCard
|
||||
card={mapCard}
|
||||
audiences={audiences}
|
||||
presenceOf={(id) => effective(id)}
|
||||
fleet={mapFleet}
|
||||
setFleet={setMapFleet}
|
||||
servers={mapServers}
|
||||
setServers={setMapServers}
|
||||
onActed={() => setReloads((n) => n + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="submit" className="btn" disabled={busy || !dirty}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{saved && !dirty && <span className="dim" style={{ fontSize: '0.8rem' }}>Saved.</span>}
|
||||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What each server's clan board says about itself. Only the servers with
|
||||
* something to report are listed: a board that is current, complete and read
|
||||
* normally is the case that needs no sentence.
|
||||
*/
|
||||
function ClanBoards({ servers }) {
|
||||
const notes = []
|
||||
for (const s of servers) {
|
||||
if (s.umodClans) {
|
||||
notes.push([s, 'is running the uMod Clans plugin. Its clans are a separate system from the game’s own, and only the game’s clans appear on this site.'])
|
||||
}
|
||||
if (!s.supported) {
|
||||
notes.push([s, s.reason ? `cannot report its clans: ${s.reason}.` : 'has not reported its clans yet.'])
|
||||
} else if (s.truncated) {
|
||||
notes.push([s, 'is at the game’s limit of 100 listed clans, so clans beyond the top 100 by score are not shown, and a disbanded clan is not removed until it drops below.'])
|
||||
} else if (!s.fresh) {
|
||||
notes.push([s, 'has not reported its clans recently, so they are shown as last reported.'])
|
||||
}
|
||||
}
|
||||
if (!notes.length) return null
|
||||
return (
|
||||
<ul className="sans" style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: '0.8rem' }}>
|
||||
{notes.map(([s, text], i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li key={`${s.id}-${i}`} style={{ margin: '4px 0' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{s.name}</strong> {text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const selectStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.84rem',
|
||||
}
|
||||
|
||||
// ── The live map (phase 14) ───────────────────────────────────────────────
|
||||
|
||||
const MAP_LAYERS = [
|
||||
{ id: 'world', label: 'Monuments & world events', hint: 'Cargo, helicopters, Bradley, supply drops, locked crates, and RunicNPC bosses placed outside an event.' },
|
||||
{ id: 'events', label: 'Site events', hint: 'The zones, crates and NPCs this site’s events placed, while they run.' },
|
||||
{ id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' },
|
||||
{ id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' },
|
||||
{ id: 'pvp', label: 'PvP zones', hint: 'Dynamic PVP’s own PvP zones around a downed helicopter, a Bradley, cargo and the rest. Needs Dynamic PVP on the server.' },
|
||||
{ id: 'raids', label: 'Raid bases', hint: 'RaidableBases’ live bases, their difficulty and when they despawn, and which an event of this site spawned. Needs RaidableBases on the server.' },
|
||||
]
|
||||
|
||||
const RANK = { public: 0, signed_in: 1, staff: 2 }
|
||||
|
||||
/** A server's overrides as form values: '' follows the fleet, mates is 'on' / 'off'. */
|
||||
function mapOverridesToForm(overrides) {
|
||||
const form = {}
|
||||
for (const l of MAP_LAYERS) form[l.id] = overrides[l.id] || INHERIT
|
||||
form.mates = overrides.mates === null || overrides.mates === undefined ? INHERIT : overrides.mates ? 'on' : 'off'
|
||||
form.markers = { ...(overrides.markers || {}) }
|
||||
return form
|
||||
}
|
||||
|
||||
/** What the form changed, in the shape the PUT takes, or null when nothing did. */
|
||||
function mapDiff(card, fleet, servers) {
|
||||
const out = {}
|
||||
const fleetChanges = {}
|
||||
for (const key of [...MAP_LAYERS.map((l) => l.id), 'mates']) {
|
||||
if (key in fleet && fleet[key] !== card.fleet[key]) fleetChanges[key] = fleet[key]
|
||||
}
|
||||
const fleetMarkers = markerDiff(card.fleet.markers, fleet.markers)
|
||||
if (fleetMarkers) fleetChanges.markers = fleetMarkers
|
||||
if (Object.keys(fleetChanges).length) out.fleet = fleetChanges
|
||||
|
||||
const serverChanges = {}
|
||||
for (const s of card.servers) {
|
||||
const before = mapOverridesToForm(s.overrides)
|
||||
const now = servers[s.id] || before
|
||||
const changed = {}
|
||||
const markers = markerDiff(before.markers, now.markers)
|
||||
if (markers) changed.markers = markers
|
||||
for (const key of Object.keys(before)) {
|
||||
if (key === 'markers' || now[key] === before[key]) continue
|
||||
if (key === 'mates') changed.mates = now.mates === INHERIT ? null : now.mates === 'on'
|
||||
else changed[key] = now[key] === INHERIT ? null : now[key]
|
||||
}
|
||||
if (Object.keys(changed).length) serverChanges[s.id] = changed
|
||||
}
|
||||
if (Object.keys(serverChanges).length) out.servers = serverChanges
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
function MapCard({ card, audiences, presenceOf, fleet, setFleet, servers, setServers, onActed }) {
|
||||
const [acting, setActing] = useState(null)
|
||||
const [notes, setNotes] = useState({})
|
||||
|
||||
// Fetch again and Render now act at once rather than on Save: they are
|
||||
// questions put to a game server, not settings.
|
||||
const act = async (server, kind) => {
|
||||
setActing(`${server.id}:${kind}`)
|
||||
setNotes((n) => ({ ...n, [server.id]: '' }))
|
||||
try {
|
||||
if (kind === 'fetch') {
|
||||
const r = await api.admin.fetchMap(server.id)
|
||||
setNotes((n) => ({ ...n, [server.id]: r.message || `Fetched: ${r.outcome}.` }))
|
||||
} else {
|
||||
const r = await api.admin.renderMap(server.id)
|
||||
setNotes((n) => ({
|
||||
...n,
|
||||
[server.id]: `The server is drawing its map now — about ${r.stallSeconds} seconds of stall. The picture appears here when it is done.`,
|
||||
}))
|
||||
}
|
||||
onActed()
|
||||
} catch (err) {
|
||||
setNotes((n) => ({ ...n, [server.id]: err.message || 'That did not work.' }))
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="The live map" subtitle="what each server’s map shows, and to whom">
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 10px' }}>
|
||||
The picture of the map is always public: it is drawn from the map seed and says nothing about who plays.
|
||||
Everything that moves on it is a layer, and each layer has its own audience. The players layer can never
|
||||
show more than who is online does, whatever it is set to here.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'grid', gridTemplateColumns: 'minmax(180px, 1fr) auto', gap: '6px 12px', alignItems: 'center', fontSize: '0.86rem' }}
|
||||
>
|
||||
{MAP_LAYERS.map((l) => (
|
||||
<FleetRow key={l.id} label={l.label} hint={l.hint}>
|
||||
<AudienceSelect
|
||||
value={fleet[l.id] || 'staff'}
|
||||
onChange={(v) => setFleet((f) => ({ ...f, [l.id]: v }))}
|
||||
audiences={audiences}
|
||||
label={`${l.label}: fleet default`}
|
||||
/>
|
||||
</FleetRow>
|
||||
))}
|
||||
<FleetRow label="You and your clan" hint="A linked player sees themselves and their online clan mates, whatever the players layer says.">
|
||||
<select
|
||||
value={fleet.mates ? 'on' : 'off'}
|
||||
onChange={(e) => setFleet((f) => ({ ...f, mates: e.target.value === 'on' }))}
|
||||
style={selectStyle}
|
||||
aria-label="You and your clan: fleet default"
|
||||
>
|
||||
<option value="on">On</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
</FleetRow>
|
||||
</div>
|
||||
|
||||
<FleetMarkers card={card} fleet={fleet} setFleet={setFleet} />
|
||||
|
||||
{card.servers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '12px 0 0' }}>No servers are configured yet.</p>
|
||||
)}
|
||||
|
||||
{card.servers.map((s) => {
|
||||
const form = servers[s.id] || mapOverridesToForm(s.overrides)
|
||||
const set = (key, value) => setServers((prev) => ({ ...prev, [s.id]: { ...form, [key]: value } }))
|
||||
const players = form.players || fleet.players
|
||||
const presence = presenceOf(s.id)
|
||||
const capped = RANK[players] < RANK[presence]
|
||||
const noPicture = !s.picture || !s.picture.hasPicture
|
||||
|
||||
return (
|
||||
<div key={s.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '10px 0', fontSize: '0.84rem' }}>
|
||||
<div style={{ color: 'var(--head)', marginBottom: 6 }}>
|
||||
{s.name}
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{MAP_LAYERS.map((l) => (
|
||||
<label key={l.id} className="dim" style={{ display: 'inline-flex', flexDirection: 'column', gap: 2, fontSize: '0.74rem' }}>
|
||||
{l.label}
|
||||
<AudienceSelect
|
||||
value={form[l.id]}
|
||||
onChange={(v) => set(l.id, v)}
|
||||
audiences={audiences}
|
||||
inherit={`Default (${LABEL[fleet[l.id]] || fleet[l.id]})`}
|
||||
label={`${l.label} on ${s.name}`}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="dim" style={{ display: 'inline-flex', flexDirection: 'column', gap: 2, fontSize: '0.74rem' }}>
|
||||
You and your clan
|
||||
<select value={form.mates} onChange={(e) => set('mates', e.target.value)} style={selectStyle} aria-label={`You and your clan on ${s.name}`}>
|
||||
<option value={INHERIT}>{`Default (${fleet.mates ? 'On' : 'Off'})`}</option>
|
||||
<option value="on">On</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<ServerMarkers
|
||||
server={s}
|
||||
minorLabels={card.minorLabels || []}
|
||||
fleetMarkers={fleet.markers}
|
||||
markers={form.markers}
|
||||
onChange={(next) => set('markers', next)}
|
||||
/>
|
||||
{capped && (
|
||||
<p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>
|
||||
Players will be shown to {(LABEL[presence] || presence).toLowerCase()} on this server, because that is who may see who is online.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10, marginTop: 8, fontSize: '0.78rem' }}>
|
||||
<span className="dim">{describePicture(s.picture)}</span>
|
||||
<button type="button" className="btn" disabled={Boolean(acting) || s.fetching} onClick={() => act(s, 'fetch')}>
|
||||
{acting === `${s.id}:fetch` || s.fetching ? 'Fetching…' : 'Fetch again'}
|
||||
</button>
|
||||
{noPicture && (
|
||||
<button type="button" className="btn" disabled={Boolean(acting) || s.rendering} onClick={() => act(s, 'render')}>
|
||||
{s.rendering ? 'Drawing…' : 'Render now'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{noPicture && (
|
||||
<p style={{ color: '#d08a2a', fontSize: '0.76rem', margin: '6px 0 0' }}>
|
||||
Render now makes the game draw its own map, which stops that server for about {s.renderStallSeconds} seconds —
|
||||
nobody on it can move while it draws. A server with Rust+ switched on (<code>app.port</code>) never needs it.
|
||||
</p>
|
||||
)}
|
||||
{(notes[s.id] || s.lastError) && (
|
||||
<p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>{notes[s.id] || s.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fleet's monument labels (D196): every label on any server's current map,
|
||||
* and the minor list, each a checkbox. Ticked is drawn.
|
||||
*/
|
||||
function FleetMarkers({ card, fleet, setFleet }) {
|
||||
const minor = card.minorLabels || []
|
||||
const labels = fleetLabels(minor, card.servers)
|
||||
const hidden = labels.filter((l) => !fleetShows(minor, fleet.markers, l.key)).length
|
||||
|
||||
return (
|
||||
<details className="sans" style={{ marginTop: 12, fontSize: '0.84rem' }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--head)' }}>
|
||||
Monuments on the map <span className="dim" style={{ fontSize: '0.76rem' }}>· {labels.length} labels, {hidden} hidden by default</span>
|
||||
</summary>
|
||||
<p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 8px' }}>
|
||||
Ticked labels are drawn on every server that has not chosen for itself. The minor ones — substations, caves, train
|
||||
tunnels, wells and the like — start hidden. Any label not listed here, such as a monument a game update adds, is drawn.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: '4px 12px' }}>
|
||||
{labels.map((l) => (
|
||||
<label key={l.key} style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fleetShows(minor, fleet.markers, l.key)}
|
||||
onChange={(e) => setFleet((f) => ({ ...f, markers: setFleetMarker(minor, f.markers, l.key, e.target.checked) }))}
|
||||
aria-label={`Draw ${l.label} by default`}
|
||||
/>
|
||||
<span>{l.label}</span>
|
||||
{l.count > 0 && <span className="dim" style={{ fontSize: '0.72rem' }}>{l.count}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One server's monument labels (D196): the labels on its current map, each
|
||||
* following the fleet or drawn or hidden on this server alone.
|
||||
*/
|
||||
function ServerMarkers({ server, minorLabels, fleetMarkers, markers, onChange }) {
|
||||
const labels = server.markerLabels || []
|
||||
if (!labels.length) return null
|
||||
const hidden = labels.filter((l) => !serverShows(minorLabels, fleetMarkers, markers, l.key)).length
|
||||
const own = Object.keys(markers || {}).length
|
||||
|
||||
return (
|
||||
<details style={{ marginTop: 8, fontSize: '0.8rem' }}>
|
||||
<summary style={{ cursor: 'pointer' }} className="dim">
|
||||
Monuments: {labels.length} labels on this map, {hidden} hidden{own ? `, ${own} set for this server` : ''}
|
||||
</summary>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '4px 12px', marginTop: 6 }}>
|
||||
{labels.map((l) => {
|
||||
const mine = markers && Object.prototype.hasOwnProperty.call(markers, l.key) ? (markers[l.key] ? 'on' : 'off') : INHERIT
|
||||
const fleetOn = fleetShows(minorLabels, fleetMarkers, l.key)
|
||||
return (
|
||||
<label key={l.key} style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'space-between' }}>
|
||||
<span>
|
||||
{l.label} <span className="dim" style={{ fontSize: '0.72rem' }}>{l.count}</span>
|
||||
</span>
|
||||
<select
|
||||
value={mine}
|
||||
onChange={(e) => onChange(setServerMarker(markers, l.key, e.target.value === INHERIT ? null : e.target.value === 'on'))}
|
||||
style={{ ...selectStyle, fontSize: '0.78rem', padding: '2px 6px' }}
|
||||
aria-label={`${l.label} on ${server.name}`}
|
||||
>
|
||||
<option value={INHERIT}>{`Default (${fleetOn ? 'drawn' : 'hidden'})`}</option>
|
||||
<option value="on">Drawn</option>
|
||||
<option value="off">Hidden</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function FleetRow({ label, hint, children }) {
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
<span style={{ color: 'var(--head)' }}>{label}</span>
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.74rem' }}>{hint}</span>
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function describePicture(pic) {
|
||||
if (!pic) return 'The server has not described its map yet.'
|
||||
if (!pic.hasPicture) return `Map ${pic.mapKey}: the game has no picture of it.`
|
||||
const from = pic.source === 'companion' ? 'from Rust+' : 'drawn on request'
|
||||
const kb = Math.round((pic.bytes || 0) / 1024)
|
||||
return `Map ${pic.mapKey}: picture ${from}, ${pic.width} × ${pic.height}, ${kb} KB${pic.fetchedAt ? `, fetched ${new Date(pic.fetchedAt).toLocaleString()}` : ''}.`
|
||||
}
|
||||
294
client/src/routes/admin/ZonePresets.jsx
Normal file
294
client/src/routes/admin/ZonePresets.jsx
Normal file
@@ -0,0 +1,294 @@
|
||||
// ── Admin · Rust · Zone presets (PLAN_REDESIGNS §3.1, D195, D210) ──────────
|
||||
//
|
||||
// Where a zone's ZoneManager flags and settings are chosen. Core's step editor
|
||||
// has single-value fields only, so the org lead put the checkboxes here (D210):
|
||||
// an admin saves a named set for one server, several, or every server, and a
|
||||
// zone step's Options field offers the presets. Picking one COPIES its line into
|
||||
// the step, so editing a preset here changes no published event.
|
||||
//
|
||||
// The flags are whatever each server's ZoneManager declared in its last hello
|
||||
// (D211), grouped by `server/model/zones/zoneOptions.js`. A flag that file does
|
||||
// not know is under Other, never hidden. A flag not on every server a preset
|
||||
// covers is refused on save, with the server named.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
font: 'inherit',
|
||||
}
|
||||
|
||||
function blankForm() {
|
||||
return { id: null, name: '', flags: [], settings: {}, allServers: false, servers: [] }
|
||||
}
|
||||
|
||||
function formFrom(p) {
|
||||
const settings = {}
|
||||
for (const [k, v] of Object.entries(p.settings || {})) settings[k] = String(v)
|
||||
return { id: p.id, name: p.name, flags: [...p.flags], settings, allServers: p.allServers, servers: [...p.servers] }
|
||||
}
|
||||
|
||||
/** What the page sends: blank settings left out, the rest as typed (the server checks them). */
|
||||
function bodyFrom(form) {
|
||||
const settings = {}
|
||||
for (const [k, v] of Object.entries(form.settings)) {
|
||||
if (v !== '' && v !== undefined && v !== null) settings[k] = v
|
||||
}
|
||||
return { name: form.name.trim(), flags: form.flags, settings, allServers: form.allServers, servers: form.allServers ? [] : form.servers }
|
||||
}
|
||||
|
||||
/** One line on what a server said about its zone mods. */
|
||||
export function zoneModsLine(s) {
|
||||
const zm = s.zoneManager
|
||||
const zd = s.zoneDomes
|
||||
const parts = [zm ? `ZoneManager ${zm.version || ''} · ${zm.flagCount} flags`.replace(' ', ' ') : 'no ZoneManager reported']
|
||||
if (!zd || !zd.loaded) parts.push('no ZoneDomes, so no domes')
|
||||
else if (!zd.helper || zd.helper.state === 'missing') parts.push('ZoneDomes without RunicGatewayDomes.cs, so no domes')
|
||||
else if (zd.helper.state !== 'patched') parts.push(`the domes helper could not patch ZoneDomes${zd.helper.reason ? ` (${zd.helper.reason})` : ''}`)
|
||||
else parts.push('domes available')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export default function ZonePresets() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.adminZones.read(), [reloads])
|
||||
const [form, setForm] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const servers = data.servers || []
|
||||
const nameOf = new Map(servers.map((s) => [s.id, s.name || s.id]))
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (form.id) await api.adminZones.update(form.id, bodyFrom(form))
|
||||
else await api.adminZones.create(bodyFrom(form))
|
||||
setForm(null)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (preset) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.adminZones.remove(preset.id)
|
||||
setForm(null)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not delete.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
Named sets of ZoneManager flags and settings. An event’s <em>Open a zone</em> step offers these in its Options
|
||||
field; picking one copies it into the step, so changing a preset later does not change an event already
|
||||
published. Who is exempt from a flag is a <code>zonemanager.ignoreflag.*</code> permission, set on the{' '}
|
||||
<Link to="/admin/rust">permissions page</Link>.
|
||||
</p>
|
||||
|
||||
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 8px', color: 'var(--head)' }}>Servers</h2>
|
||||
{servers.length === 0 && <p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>}
|
||||
{servers.map((s) => (
|
||||
<div key={s.id} className="sans" style={{ fontSize: '0.8rem', padding: '4px 0' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{s.name || s.id}</strong>{' '}
|
||||
<span className="dim">{zoneModsLine(s)}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: 0, color: 'var(--head)' }}>Presets</h2>
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(blankForm()) }}>
|
||||
New preset
|
||||
</button>
|
||||
</div>
|
||||
{(data.presets || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>
|
||||
None yet. None ship with the module: what an arena is differs by server.
|
||||
</p>
|
||||
)}
|
||||
{(data.presets || []).map((p) => (
|
||||
<div key={p.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '8px 0', fontSize: '0.84rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{p.name}</strong>
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{p.allServers ? 'every server' : p.servers.map((id) => nameOf.get(id) || id).join(', ')}
|
||||
</span>
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(formFrom(p)) }}>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<code className="dim" style={{ fontSize: '0.74rem', wordBreak: 'break-word' }}>{p.line || 'no flags or settings'}</code>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{form && (
|
||||
<PresetForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
data={data}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onSave={save}
|
||||
onCancel={() => setForm(null)}
|
||||
onDelete={form.id ? () => remove(form) : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetForm({ form, setForm, data, busy, error, onSave, onCancel, onDelete }) {
|
||||
const servers = data.servers || []
|
||||
|
||||
// The flags on EVERY covered server that reported a list: a flag missing from
|
||||
// one of them is shown but marked, and the save refuses it with the server named.
|
||||
const covered = form.allServers ? servers : servers.filter((s) => form.servers.includes(s.id))
|
||||
const missingOn = useMemo(() => {
|
||||
const out = new Map()
|
||||
for (const s of covered) {
|
||||
const flags = s.zoneManager && s.zoneManager.flags
|
||||
if (!flags || flags.length === 0) continue
|
||||
const have = new Set(flags.map((f) => f.toLowerCase()))
|
||||
for (const g of data.groups || []) {
|
||||
for (const f of g.flags) {
|
||||
if (!have.has(f.name.toLowerCase())) out.set(f.name, [...(out.get(f.name) || []), s.name || s.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [covered, data.groups])
|
||||
|
||||
const toggleFlag = (name) =>
|
||||
setForm((f) => ({ ...f, flags: f.flags.includes(name) ? f.flags.filter((x) => x !== name) : [...f.flags, name] }))
|
||||
const toggleServer = (id) =>
|
||||
setForm((f) => ({ ...f, servers: f.servers.includes(id) ? f.servers.filter((x) => x !== id) : [...f.servers, id] }))
|
||||
const setSetting = (key, value) => setForm((f) => ({ ...f, settings: { ...f.settings, [key]: value } }))
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{form.id ? `Edit ${form.name}` : 'A new preset'}
|
||||
</h2>
|
||||
|
||||
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>Name</span>
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required maxLength={64} style={inputStyle} />
|
||||
</label>
|
||||
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 6 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>For</legend>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.allServers} onChange={(e) => setForm((f) => ({ ...f, allServers: e.target.checked }))} />
|
||||
Every server, including servers added later
|
||||
</label>
|
||||
{!form.allServers &&
|
||||
servers.map((s) => (
|
||||
<label key={s.id} className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.servers.includes(s.id)} onChange={() => toggleServer(s.id)} />
|
||||
{s.name || s.id}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 12 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Flags</legend>
|
||||
{(data.groups || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
|
||||
No server has reported its ZoneManager flags yet. They arrive when a server with ZoneManager connects.
|
||||
</p>
|
||||
)}
|
||||
{(data.groups || []).map((g) => (
|
||||
<div key={g.group}>
|
||||
<div className="sans" style={{ fontSize: '0.8rem', color: 'var(--head)', marginBottom: 4 }}>{g.group}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: '4px 12px' }}>
|
||||
{g.flags.map((f) => {
|
||||
const missing = missingOn.get(f.name)
|
||||
return (
|
||||
<label
|
||||
key={f.name}
|
||||
className="sans"
|
||||
title={[f.note, missing ? `Not on ${missing.join(', ')}` : null].filter(Boolean).join(' ')}
|
||||
style={{ display: 'flex', gap: 6, fontSize: '0.8rem', opacity: missing ? 0.55 : 1 }}
|
||||
>
|
||||
<input type="checkbox" checked={form.flags.includes(f.name)} onChange={() => toggleFlag(f.name)} />
|
||||
{f.name}
|
||||
{missing && <span className="dim">*</span>}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{missingOn.size > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
||||
* not on every server this preset is for. Hover for which.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 10 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Settings</legend>
|
||||
{(data.settings || []).map((s) => (
|
||||
<label key={s.key} className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{s.label}</span>
|
||||
{s.type === 'boolean' ? (
|
||||
<select value={form.settings[s.key] ?? ''} onChange={(e) => setSetting(s.key, e.target.value)} style={inputStyle}>
|
||||
<option value="">Not set</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.settings[s.key] ?? ''}
|
||||
onChange={(e) => setSetting(s.key, e.target.value)}
|
||||
inputMode={s.type === 'float' ? 'decimal' : undefined}
|
||||
placeholder={s.type === 'float' ? `${s.min} to ${s.max}` : ''}
|
||||
style={{ ...inputStyle, maxWidth: 260 }}
|
||||
/>
|
||||
)}
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>{s.description}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem', margin: 0 }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
{onDelete && (
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={onDelete} disabled={busy}>
|
||||
Delete preset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
434
client/src/routes/player/Account.jsx
Normal file
434
client/src/routes/player/Account.jsx
Normal file
@@ -0,0 +1,434 @@
|
||||
// ── The player's own Rust identity ────────────────────────────────────────
|
||||
//
|
||||
// `/player/rust` — where a signed-in player links the Steam account they play
|
||||
// on. It is the one page in this module a player is asked to *do* something on,
|
||||
// and the thing they are doing matters more than it looks: from phase 7 the link
|
||||
// is what in-game permissions are granted against, and from phase 13 it is what
|
||||
// rewards are handed to.
|
||||
//
|
||||
// **A player route renders no layout of its own.** Core wraps `/player/*` in its
|
||||
// own portal chrome, so this page starts at a heading — unlike the public pages
|
||||
// in this module, which render `PublicLayout` themselves.
|
||||
//
|
||||
// The three-step instruction at the top is not decoration. Nothing else on the
|
||||
// site tells a player that the code comes from the game, and a code field with no
|
||||
// explanation is a code field nobody can use.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago, coins, shortId } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import Backpack from '../../components/Backpack.jsx'
|
||||
|
||||
/** The code field, and the four answers it can produce. */
|
||||
function LinkForm({ onLinked }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (!code.trim() || busy) return
|
||||
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await api.playerLinks.confirm(code.trim())
|
||||
setMessage(
|
||||
result.already
|
||||
? 'That account was already linked to you.'
|
||||
: `Linked ${result.link.name || shortId(result.link.steamId)}.`,
|
||||
)
|
||||
setCode('')
|
||||
await onLinked()
|
||||
} catch (err) {
|
||||
// Every refusal the server sends is already a sentence aimed at a player —
|
||||
// "run /link again", "run /unlink in game", "try again in a minute" — so
|
||||
// this renders it rather than replacing it with one of its own. The three
|
||||
// are not interchangeable, and a page that flattened them into "could not
|
||||
// link that code" would send a player back to the server that is down.
|
||||
setError(err.message || 'Could not link that code.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: 18 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label" style={{ display: 'block', marginBottom: 6 }}>Link code</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="K7M2PQ"
|
||||
// The plugin's alphabet has no O, 0, I or 1, so a player reading a
|
||||
// code off their screen cannot produce one — but they can type a
|
||||
// lowercase one, and the code is matched case-insensitively at the
|
||||
// other end. Upper-casing here makes what they typed look like what
|
||||
// they were shown.
|
||||
maxLength={12}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="input"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', width: 160 }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy || !code.trim()}>
|
||||
{busy ? 'Checking…' : 'Link account'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className="sans" style={{ color: '#7fd0a4', fontSize: '0.86rem', margin: '10px 0 0' }}>{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.86rem', margin: '10px 0 0' }}>{error}</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** One linked account, and the control that releases it. */
|
||||
function LinkRow({ link, onRemoved }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function remove() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.playerLinks.remove(link.steamId)
|
||||
await onRemoved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink that account.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{link.name || shortId(link.steamId)}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{link.steamId} · linked {ago(link.linkedAt)}
|
||||
{link.serverId ? ` on ${link.serverId}` : ''}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an entitlement has actually landed.
|
||||
*
|
||||
* The server resolves the scope and marks each server, so this renders an answer
|
||||
* rather than working one out — `*` means nothing to a player, and a second
|
||||
* implementation of the scope arithmetic on the client is a second thing to keep
|
||||
* true (see `forPlayer` in the permission model).
|
||||
*/
|
||||
function Reach({ reach }) {
|
||||
if (!reach.length) {
|
||||
return (
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
No servers are configured yet
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, fontSize: '0.76rem' }}>
|
||||
{reach.map((server) => (
|
||||
<span
|
||||
key={server.id}
|
||||
style={{
|
||||
border: '1px solid var(--line, rgba(255,255,255,0.14))',
|
||||
borderRadius: 999,
|
||||
padding: '2px 10px',
|
||||
color: server.live ? 'var(--head)' : undefined,
|
||||
opacity: server.live ? 1 : 0.65,
|
||||
}}
|
||||
>
|
||||
{/* The word, not only the dot. A filled circle beside a hollow one is
|
||||
the whole difference between "you have this in game" and "you do
|
||||
not yet", which is more than a shape should have to carry — and a
|
||||
reader who cannot tell the two apart gets no answer at all. */}
|
||||
{server.live ? '● ' : '○ '}
|
||||
{server.name} · {server.live ? 'has it' : 'waiting'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One group or one direct grant, drawn the same way because they read the same. */
|
||||
function HeldRow({ title, subtitle, permissions, reach }) {
|
||||
return (
|
||||
<li className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{title}</div>
|
||||
|
||||
{subtitle && (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{subtitle}</div>
|
||||
)}
|
||||
|
||||
{permissions && permissions.length > 0 && (
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
{permissions.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Reach reach={reach} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the site has given this player in game.
|
||||
*
|
||||
* Its own read, not part of the links read: an entitlement exists whether or not
|
||||
* a Steam account is linked, and a player who has just been given something and
|
||||
* has not linked yet is exactly the person who needs to see both halves at once.
|
||||
*/
|
||||
function Held({ accounts }) {
|
||||
const { data, loading, error } = useAsync(() => api.playerPermissions.list(), [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
const groups = data.groups || []
|
||||
const grants = data.grants || []
|
||||
|
||||
if (!groups.length && !grants.length) {
|
||||
return (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0, maxWidth: '60ch' }}>
|
||||
Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on
|
||||
the servers they cover.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const waiting = [...groups, ...grants].some((entry) => entry.reach.some((server) => !server.live))
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{groups.map((group) => (
|
||||
<HeldRow
|
||||
key={`group:${group.name}`}
|
||||
title={group.title}
|
||||
subtitle={`Rank · joined ${ago(group.since)}`}
|
||||
permissions={group.permissions}
|
||||
reach={group.reach}
|
||||
/>
|
||||
))}
|
||||
|
||||
{grants.map((grant) => (
|
||||
<HeldRow
|
||||
key={`grant:${grant.permission}:${grant.scope}`}
|
||||
title={grant.permission}
|
||||
subtitle={grant.note || `Granted ${ago(grant.since)}`}
|
||||
reach={grant.reach}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{accounts === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
|
||||
None of this reaches the game yet — link a Steam account above and the site pushes it
|
||||
across on its next sync.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{accounts > 0 && waiting && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
|
||||
A server marked <em>waiting</em> has not confirmed it yet. One that is offline catches up
|
||||
when it comes back.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RunicNPC (runicnpc stage 4, D252): your kills of each NPC profile, this wipe,
|
||||
* by server, across every Steam account you have linked. Nothing without a link.
|
||||
*/
|
||||
function NpcKills() {
|
||||
const { data, loading, error } = useAsync(() => api.playerNpcKills.list(), [])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
const rows = (data && data.kills) || []
|
||||
if (rows.length === 0) {
|
||||
return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>None this wipe.</p>
|
||||
}
|
||||
const byServer = new Map()
|
||||
for (const r of rows) byServer.set(r.server || r.serverId, [...(byServer.get(r.server || r.serverId) || []), r])
|
||||
return (
|
||||
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
|
||||
{[...byServer.entries()].map(([serverId, list]) => (
|
||||
<li key={serverId}>
|
||||
<span className="dim">{serverId}: </span>
|
||||
{list.map((k, i) => (
|
||||
<span key={k.profile}>
|
||||
{i > 0 && <span className="dim"> · </span>}
|
||||
{k.label} <strong>{k.kills}</strong>
|
||||
</span>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Economics (PLAN_REDESIGNS §11.2.2): your coins on each server, per linked
|
||||
* account. Live where the server answers; otherwise the last balance it
|
||||
* reported, marked so. A server without Economics says so (D199) rather than
|
||||
* vanishing — a player asking "where are my coins" is owed the reason.
|
||||
*/
|
||||
function Coins() {
|
||||
const { data, loading, error } = useAsync(() => api.playerEconomics.list(), [])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
const list = (data && data.servers) || []
|
||||
if (list.length === 0) return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No servers yet.</p>
|
||||
return (
|
||||
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
|
||||
{list.map((server) => (
|
||||
<li key={server.serverId}>
|
||||
<span className="dim">{server.server}: </span>
|
||||
{!server.economics ? (
|
||||
<span className="dim">{server.reason}</span>
|
||||
) : (
|
||||
server.accounts.map((a, i) => (
|
||||
<span key={a.steamId}>
|
||||
{i > 0 && <span className="dim"> · </span>}
|
||||
{server.accounts.length > 1 && <span className="dim">{a.name || shortId(a.steamId)} </span>}
|
||||
<strong>{coins(a.balance)}</strong>
|
||||
{a.source === 'last-known' && <span className="dim"> (last known — the server did not answer)</span>}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Backpacks (PLAN_REDESIGNS §11.3.2): your own backpack, from one of your
|
||||
* linked accounts, read live when you open it. A server without Backpacks
|
||||
* says so when asked (D199).
|
||||
*/
|
||||
function OwnBackpack({ links }) {
|
||||
const { data } = useAsync(() => api.playerServers.list(), [])
|
||||
const list = (data && data.servers) || []
|
||||
if (!list.length) return null
|
||||
return (
|
||||
<Backpack
|
||||
servers={list.map((s) => ({ id: s.id, name: s.name }))}
|
||||
accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))}
|
||||
read={(serverId, steamId) => api.playerBackpacks.read(serverId, steamId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
|
||||
// the person looking at it changes it, and a page that re-asked every twenty
|
||||
// seconds would be asking a question nobody is waiting on.
|
||||
//
|
||||
// **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and
|
||||
// that is the whole of its interface — so a counter in the deps is how a page
|
||||
// re-reads after its own write. It blanks while it re-reads, which is right
|
||||
// here and is exactly what made it wrong for a poll (see `hooks/usePolled.js`).
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads])
|
||||
const links = data ? data.links : []
|
||||
|
||||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>Steam accounts</div>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', maxWidth: '60ch' }}>
|
||||
Linking tells this site which Steam account is yours, so your play on our servers appears
|
||||
under your name here — and so rewards and permissions the site hands out can reach you in
|
||||
game.
|
||||
</p>
|
||||
|
||||
<ol className="sans dim" style={{ fontSize: '0.86rem', marginTop: 14, paddingLeft: 20, maxWidth: '60ch' }}>
|
||||
<li>Join any of our Rust servers and type <code>/link</code> in chat.</li>
|
||||
<li>The server replies with a six-character code, only you can see it, and it lasts five minutes.</li>
|
||||
<li>Type it below. It works once.</li>
|
||||
</ol>
|
||||
|
||||
{/* D392: core's "Link Steam" is a second way to make this link. Unlinking
|
||||
Steam there removes only the sign-in (D393), so this page says where a
|
||||
Rust link is removed. */}
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.86rem', maxWidth: '60ch' }}>
|
||||
If this site offers Steam sign-in, linking Steam on your <a href="/account" className="link-accent">account
|
||||
page</a> links that Steam account here too. Unlinking it there only stops you signing in with it; remove a
|
||||
Rust link here.
|
||||
</p>
|
||||
|
||||
<LinkForm onLinked={reload} />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState error={error} />}
|
||||
|
||||
{data && links.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: '22px 0 0', padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{links.map((link) => (
|
||||
<LinkRow key={link.steamId} link={link} onRemoved={reload} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data && links.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 14, maxWidth: '60ch' }}>
|
||||
A link covers every server this community runs — a Steam account is one person wherever
|
||||
they play, while stats are kept per server and per wipe. You can also type
|
||||
{' '}<code>/unlink</code> in game to release one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && links.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 18 }}>
|
||||
No Steam account is linked to this profile yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Phase 8. Rendered whether or not anything is linked: an entitlement is
|
||||
authored against the website account, so it exists before a Steam id
|
||||
does — and hiding it until one appears is the mistake the admin user
|
||||
page shipped in phase 7 (PLAN.md §20.5). */}
|
||||
<div className="field-label" style={{ margin: '30px 0 12px' }}>What you can do in game</div>
|
||||
|
||||
{data && <Held accounts={links.length} />}
|
||||
|
||||
<div className="field-label" style={{ margin: '30px 0 12px' }}>NPCs you have killed</div>
|
||||
{data && links.length > 0 ? <NpcKills /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see them.</p>}
|
||||
|
||||
<div className="field-label" style={{ margin: '30px 0 12px' }}>Coins</div>
|
||||
{data && links.length > 0 ? <Coins /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your balance.</p>}
|
||||
|
||||
<div className="field-label" style={{ margin: '30px 0 12px' }}>Backpack</div>
|
||||
{data && links.length > 0 ? <OwnBackpack links={links} /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your backpack.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
client/src/routes/public/Clan.jsx
Normal file
161
client/src/routes/public/Clan.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
// ── One clan ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A first-party Rust clan is a Team (R5), and this is its page. Core owns the
|
||||
// Team — the reconciler, the access rules, the activity feed, the forum — but
|
||||
// not the word "clan", so it publishes no Team page of its own (MODULE_API.md
|
||||
// §3.7a). The page is this module's, and the three parts only core can render
|
||||
// are contributed into places this page names:
|
||||
//
|
||||
// rust.clan.header ← core's `team.notify` (above the roster: an action ON the page)
|
||||
// rust.clan.detail ← core's `team.activity` (the members-only feed, D49)
|
||||
// rust.clan.forum ← core's `team.forum`
|
||||
//
|
||||
// One slot per PLACE, as module-uo does, so core never decides the layout of a
|
||||
// page it does not own. **Every slot may be empty** — a core without Teams, a
|
||||
// deployment with the forum switched off, a clan whose Team core has not created
|
||||
// yet — and the page has to read correctly anyway. That is the phase criterion,
|
||||
// and it is why nothing here says "see below" about something core may not put
|
||||
// below.
|
||||
//
|
||||
// The roster comes from this module's own board, through the same function core
|
||||
// asks when it projects a roster (D48), so the two cannot disagree about who may
|
||||
// look. Below the audience the clan is still described — its name, score and
|
||||
// count are public (D58) — and the roster says who may see it instead.
|
||||
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
|
||||
import Empty from '../../components/Empty.jsx'
|
||||
import { Swatch } from '../../components/Clans.jsx'
|
||||
import { count, day } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const ID = 'rust'
|
||||
|
||||
export default function Clan() {
|
||||
const { externalId } = useParams()
|
||||
const { data, loading, error } = useAsync(() => api.clans.get(externalId), [externalId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<Loading />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// A mistyped or out-of-date address is not an outage, and must not read as
|
||||
// one — the same rule the server page learned in phase 4.
|
||||
if (error || !data || !data.clan) {
|
||||
const missing = !error || error.status === 404
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<PageHeader
|
||||
title={missing ? 'No such clan' : 'That clan could not be loaded'}
|
||||
lead={
|
||||
missing
|
||||
? 'This address does not name a clan this site knows about.'
|
||||
: 'The site could not read this clan just now. It is worth trying again.'
|
||||
}
|
||||
/>
|
||||
{!missing && <ErrorState error={error} />}
|
||||
<p className="sans" style={{ marginTop: 20 }}>
|
||||
<Link to="/rust">Back to the server list</Link>
|
||||
</p>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const { clan, roster } = data
|
||||
const serverLink = `/rust/servers/${encodeURIComponent(clan.serverId)}?tab=clans`
|
||||
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<p className="sans" style={{ margin: '0 0 12px' }}>
|
||||
<Link to={serverLink}>← Clans on {clan.serverName || clan.serverId}</Link>
|
||||
</p>
|
||||
|
||||
<PageHeader eyebrow="Rust clan" title={clan.name} lead={describe(clan)} />
|
||||
|
||||
{clan.gone && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', marginTop: 0 }}>
|
||||
This clan has been disbanded, or has left its server’s clan list. What is shown is the last the site heard.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Slot name="rust.clan.header" externalId={clan.externalId} moduleId={ID} />
|
||||
|
||||
<h2 className="sans" style={{ fontSize: '1rem', margin: '24px 0 8px' }}>Members</h2>
|
||||
<Roster roster={roster} memberCount={clan.memberCount} gone={clan.gone} />
|
||||
|
||||
<Slot name="rust.clan.detail" externalId={clan.externalId} moduleId={ID} />
|
||||
|
||||
<Slot name="rust.clan.forum" externalId={clan.externalId} moduleId={ID} />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function describe(clan) {
|
||||
const parts = [
|
||||
<Swatch key="c" color={clan.color} />,
|
||||
` ${count(clan.memberCount)} ${clan.memberCount === 1 ? 'member' : 'members'}`,
|
||||
clan.maxMembers ? ` of ${count(clan.maxMembers)}` : '',
|
||||
` · ${count(clan.score)} points`,
|
||||
clan.founded ? ` · founded ${day(clan.founded)}` : '',
|
||||
]
|
||||
return <span>{parts}</span>
|
||||
}
|
||||
|
||||
function Roster({ roster, memberCount, gone }) {
|
||||
if (!roster || !roster.visible) {
|
||||
return <Empty title={`${count(memberCount)} ${memberCount === 1 ? 'member' : 'members'}`} message={withheld(roster && roster.audience)} />
|
||||
}
|
||||
|
||||
if (roster.members.length === 0) {
|
||||
return gone
|
||||
? <Empty title="No roster" message="A clan that has left its server’s list has no members to show." />
|
||||
: <Empty title="No roster yet" message="The server has not sent this clan’s members yet." />
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{roster.members.map((m, i) => (
|
||||
<li
|
||||
// The roster carries no identifier on purpose (a Steam id and a site
|
||||
// account are withheld from every public roster), so the row's place is
|
||||
// its key. The list is re-rendered whole, never reordered in place.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--line-soft, var(--line))',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--ink)', flex: 1, minWidth: 0 }}>
|
||||
{m.name || 'Unknown player'}
|
||||
{m.leader && (
|
||||
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
|
||||
)}
|
||||
</strong>
|
||||
{m.role && !m.leader && (
|
||||
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>{m.role}</span>
|
||||
)}
|
||||
{/* Inside the roster audience by construction (D48): a viewer who may
|
||||
not see the roster sees no row to hang this on. */}
|
||||
<span className="sans" style={{ color: m.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
||||
{m.online ? 'online' : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** Why the roster was withheld, in words a visitor can act on. */
|
||||
function withheld(audience) {
|
||||
if (audience === 'signed_in') return 'Sign in to see who is in this clan.'
|
||||
if (audience === 'public') return 'This site is not showing clan rosters right now.'
|
||||
return 'Only this clan’s own members, with a linked Rust account, and this site’s staff can see who is in it.'
|
||||
}
|
||||
195
client/src/routes/public/ServerDetail.jsx
Normal file
195
client/src/routes/public/ServerDetail.jsx
Normal file
@@ -0,0 +1,195 @@
|
||||
// ── One server ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// R8's page beneath the landing page, and the phase-4 criterion lives here: it
|
||||
// renders the last thing this server said while every server is off. Nothing on
|
||||
// it is a live call to a game host — every panel reads this module's own tables,
|
||||
// filled by the ingest cursor — so a shard that has been down for a week renders
|
||||
// a week-old killfeed and a leaderboard that is still correct, rather than an
|
||||
// error page.
|
||||
//
|
||||
// ── Everything selectable is in the URL ───────────────────────────────────
|
||||
//
|
||||
// Tab, feed filter, wipe and leaderboard sort all live in search parameters.
|
||||
// That costs a little ceremony here and buys the thing a community site is for:
|
||||
// "look at last wipe's leaderboard on Main" is a LINK. State held in `useState`
|
||||
// would make every one of those sentences unlinkable, lose the reader's place on
|
||||
// a refresh, and make the browser's back button leave the page instead of
|
||||
// undoing what they just clicked.
|
||||
//
|
||||
// `useSearchParams` comes from CORE's router (the shim in `src/shim/`), so it is
|
||||
// the same live navigation context core's own pages use. A module with its own
|
||||
// copy of react-router would get a `useParams` that returns nothing on a page
|
||||
// that otherwise renders perfectly — see `core.js`'s identity check.
|
||||
|
||||
import { useSearchParams, useParams, Link } from 'react-router-dom'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import Clans from '../../components/Clans.jsx'
|
||||
import Feed from '../../components/Feed.jsx'
|
||||
import Leaderboard from '../../components/Leaderboard.jsx'
|
||||
import MapView from '../../components/MapView.jsx'
|
||||
import Online from '../../components/Online.jsx'
|
||||
import Tabs from '../../components/Tabs.jsx'
|
||||
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
|
||||
import Wipes from '../../components/Wipes.jsx'
|
||||
import { ago, count, day, nextWipe } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'feed', label: 'Feed' },
|
||||
{ id: 'leaderboard', label: 'Leaderboard' },
|
||||
{ id: 'online', label: 'Online' },
|
||||
// Phase 14. The picture is public; what moves on it has an audience per layer.
|
||||
{ id: 'map', label: 'Map' },
|
||||
{ id: 'wipes', label: 'Wipes' },
|
||||
// Phase 9. The list is public (D58); each clan's roster is on its own page.
|
||||
{ id: 'clans', label: 'Clans' },
|
||||
]
|
||||
|
||||
export default function ServerDetail() {
|
||||
const { id } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const { data, loading, error } = useAsync(() => api.servers.get(id), [id])
|
||||
const server = data ? data.server : null
|
||||
|
||||
const tab = TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'feed'
|
||||
const filter = params.get('show') || 'all'
|
||||
const sort = params.get('sort') || 'kills'
|
||||
|
||||
// `wipe` absent means all time; `wipe=current` means whatever wipe the server
|
||||
// is on now, which is a moving target and therefore a word rather than an id —
|
||||
// a link somebody shares stays about "now" rather than about the map that was
|
||||
// current when they sent it.
|
||||
const wipeParam = params.get('wipe')
|
||||
const wipeId = !wipeParam || wipeParam === ALL_TIME ? null : wipeParam === 'current' ? (server && server.wipeId) || null : wipeParam
|
||||
|
||||
const set = (key, value) => {
|
||||
const next = new URLSearchParams(params)
|
||||
if (!value || value === 'all' || (key === 'tab' && value === 'feed')) next.delete(key)
|
||||
else next.set(key, value)
|
||||
// `replace` so that flipping between tabs does not fill the reader's history
|
||||
// with one entry per click — back should leave the page they arrived on.
|
||||
setParams(next, { replace: true })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<Loading />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// A 404 from the detail route is the one answer the other four cannot give:
|
||||
// an unknown id has no events, no leaderboard and nobody online, and each of
|
||||
// those empty lists is a perfectly good answer to its own question. So this is
|
||||
// where "there is no such server" is said.
|
||||
//
|
||||
// **A mistyped address is not a fault, and must not be dressed as one.** The
|
||||
// first version of this page rendered core's `ErrorState` under the heading and
|
||||
// the result read "No such server / Something went wrong" — which sends a
|
||||
// reader who fat-fingered a URL looking for an outage. `ErrorState` is kept for
|
||||
// the case it is for: a request that failed for a reason nobody can see.
|
||||
if (error || !server) {
|
||||
const missing = !error || error.status === 404
|
||||
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<PageHeader
|
||||
title={missing ? 'No such server' : 'That server could not be loaded'}
|
||||
lead={
|
||||
missing
|
||||
? 'This address does not name a server this site follows.'
|
||||
: 'The site could not read this server just now. It is worth trying again.'
|
||||
}
|
||||
/>
|
||||
{!missing && <ErrorState error={error} />}
|
||||
<p className="sans" style={{ marginTop: 20 }}>
|
||||
<Link to="/rust">Back to the server list</Link>
|
||||
</p>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout shell="mid">
|
||||
<PageHeader
|
||||
eyebrow="Rust"
|
||||
title={server.name}
|
||||
lead={describeWorld(server)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'baseline', marginBottom: 24 }}
|
||||
>
|
||||
<span style={{ color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
|
||||
{server.online
|
||||
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||
: 'Offline'}
|
||||
</span>
|
||||
{/* `lastSeenAt` is when a frame arrived; `updatedAt` is when this site
|
||||
last wrote the row, which a FAILED poll does too. Reading the second
|
||||
as the first is what made an offline server claim it had reported just
|
||||
now, every thirty seconds, for as long as it stayed down. */}
|
||||
<span style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>
|
||||
{server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'}
|
||||
{server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<WipeSelect
|
||||
serverId={server.id}
|
||||
value={wipeParam}
|
||||
currentWipeId={server.wipeId}
|
||||
onChange={(value) => set('wipe', value === ALL_TIME ? null : value)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs tabs={TABS} active={tab} onSelect={(next) => set('tab', next)} label={`${server.name} sections`} />
|
||||
|
||||
{tab === 'feed' && (
|
||||
<Feed serverId={server.id} wipeId={wipeId} filter={filter} onFilter={(value) => set('show', value)} />
|
||||
)}
|
||||
|
||||
{tab === 'leaderboard' && (
|
||||
<Leaderboard serverId={server.id} wipeId={wipeId} sort={sort} onSort={(value) => set('sort', value)} />
|
||||
)}
|
||||
|
||||
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
|
||||
|
||||
{tab === 'map' && <MapView serverId={server.id} online={server.online} />}
|
||||
|
||||
{tab === 'clans' && <Clans serverId={server.id} />}
|
||||
|
||||
{tab === 'wipes' && (
|
||||
<Wipes
|
||||
serverId={server.id}
|
||||
currentWipeId={server.wipeId}
|
||||
selected={wipeId}
|
||||
// Picking a wipe here is a navigation as much as a filter: it is the
|
||||
// question "what happened during that map", and the answer is the feed.
|
||||
onSelect={(value) => {
|
||||
const next = new URLSearchParams(params)
|
||||
next.set('wipe', value)
|
||||
next.delete('tab')
|
||||
setParams(next, { replace: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** The world line under the heading — the things a Rust player asks first. */
|
||||
function describeWorld(server) {
|
||||
const parts = [
|
||||
server.level || null,
|
||||
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||
server.seed ? `seed ${server.seed}` : null,
|
||||
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||
nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.'
|
||||
}
|
||||
@@ -1,43 +1,46 @@
|
||||
// ── The server list ───────────────────────────────────────────────────────
|
||||
// ── The server list, and the module's landing page ────────────────────────
|
||||
//
|
||||
// R8: the list is what `/rust` renders, and `/rust/servers/:id` hangs beneath
|
||||
// it. The route is registered with an empty path in `entry.jsx` — core turns
|
||||
// that into the module's own namespace root — so this page's address is the one
|
||||
// an operator links to when they mean "our Rust servers".
|
||||
//
|
||||
// An ordinary React component. Nothing about being inside a module changes how
|
||||
// you write one — the only differences are where React comes from (core, via the
|
||||
// you write one; the only differences are where React comes from (core, via the
|
||||
// aliases in `vite.config.js`, so the import below looks completely normal and is
|
||||
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
|
||||
//
|
||||
// **Render `PublicLayout` yourself.** Core wraps public routes in its maintenance
|
||||
// gate and nothing else, so a page that omits the layout renders bare — no
|
||||
// header, no footer, no site chrome — which looks like a bug and is the contract
|
||||
// (§3.3). Admin and player routes are the other way round: core wraps those.
|
||||
// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public
|
||||
// routes in its maintenance gate and nothing else, so a page that omits the
|
||||
// layout renders bare; without a `shell` it renders full-bleed with the footer
|
||||
// riding up underneath it. Name a width, never a class — the classes are core's
|
||||
// (MODULE_API.md §3.3).
|
||||
//
|
||||
// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the
|
||||
// centred column, the vertical padding, and the thing that holds the footer at
|
||||
// the bottom of the viewport. Widths are 'narrow', 'mid' and 'wide'; name a
|
||||
// width, never a class, because the classes belong to core's stylesheet.
|
||||
//
|
||||
// This is the phase-1 version of the landing page R8 calls for. It lists servers
|
||||
// and links nowhere yet — `/rust/servers/:id` is the next phase's work — so it is
|
||||
// deliberately a table and not a design.
|
||||
// **This page never calls a game server.** Every field it renders comes from
|
||||
// this module's own tables, written by the ingest cursor, which is what lets it
|
||||
// render "offline, last seen an hour ago" instead of an error page when a shard
|
||||
// is down. The site's availability does not depend on the game's.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import Empty from '../../components/Empty.jsx'
|
||||
import { ago, count, day, nextWipe } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
||||
// is in every browser core supports, and one fewer dependency in the chunk is
|
||||
// one fewer thing an operator ships.
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
|
||||
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
/** The "last reported" line, which has three cases and not one. */
|
||||
function reported(server) {
|
||||
if (!server.lastSeenAt) return 'This server has never reported.'
|
||||
if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.`
|
||||
return `Last reported ${ago(server.lastSeenAt)}.`
|
||||
}
|
||||
|
||||
export default function Servers() {
|
||||
// `useAsync` is core's fetch/loading/error hook, and the components below are
|
||||
// its states. Using them rather than rolling your own is what makes a module
|
||||
// page indistinguishable from a core one while it loads and while it fails.
|
||||
//
|
||||
// It loads once, deliberately. The DETAIL page polls, because that is where
|
||||
// somebody watching a server sits; a list is a place people pass through.
|
||||
const { data, loading, error } = useAsync(() => api.servers.list(), [])
|
||||
const servers = data ? data.servers : []
|
||||
|
||||
@@ -59,44 +62,64 @@ export default function Servers() {
|
||||
empty game — it is an install that is not finished. Saying so beats a
|
||||
blank page that looks like a failure. */}
|
||||
{data && servers.length === 0 && (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="No servers yet"
|
||||
message="An administrator adds a Rust server, and its sidecar, from the admin panel."
|
||||
/>
|
||||
)}
|
||||
|
||||
{servers.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
// The whole row is the link. A server's name being the only clickable
|
||||
// part is the thing people miss on a list of cards, and `a.card`
|
||||
// already carries core's own hover treatment.
|
||||
<Link
|
||||
key={server.id}
|
||||
to={`/rust/servers/${encodeURIComponent(server.id)}`}
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: '1px solid rgba(128,128,128,0.25)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
{server.level ? <span style={{ opacity: 0.7 }}> · {server.level}</span> : null}
|
||||
<div style={{ opacity: 0.7, fontSize: '0.9em' }}>
|
||||
{/* `stale` is a first-class part of the answer rather than
|
||||
something the page infers from a timestamp. The server
|
||||
decides what counts as stale, because the server is what
|
||||
knows how often a sidecar is supposed to check in. */}
|
||||
Last reported {ago(server.updatedAt)}
|
||||
{server.stale ? ' — out of date, so it is shown as offline.' : '.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
<span>
|
||||
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
{[
|
||||
server.level || null,
|
||||
server.worldSize ? `size ${count(server.worldSize)}` : null,
|
||||
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
|
||||
// Phase 16 (D131): absent from an older module, and null for a
|
||||
// server with no schedule — either way the part is left out.
|
||||
nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
|
||||
{/* `lastSeenAt`, never `updatedAt`. The second is when THIS
|
||||
site last wrote the row — which a failed poll does too — so
|
||||
a page reading it told a reader that a server down for three
|
||||
days had reported just now. And `stale` is a first-class
|
||||
part of the answer rather than something inferred from a
|
||||
timestamp: the server decides what counts as stale, because
|
||||
the server knows how often a sidecar is supposed to check in. */}
|
||||
{reported(server)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{ whiteSpace: 'nowrap', color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}
|
||||
>
|
||||
{server.online
|
||||
? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online`
|
||||
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
|
||||
: 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
62
client/test/backpack.test.js
Normal file
62
client/test/backpack.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// ── A backpack as a grid (PLAN_REDESIGNS §11.3.2) ─────────────────────────
|
||||
//
|
||||
// The grid is drawn from these, so each test is a backpack that could be drawn
|
||||
// wrong: a two-page one, one with overflow past its capacity, one that never
|
||||
// existed, and the labels a slot can carry (D340, D341).
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { backpackPages, overflowCount, stackLabel } from '../src/lib/backpack.js'
|
||||
|
||||
test('a backpack that does not exist is no pages', () => {
|
||||
assert.deepEqual(backpackPages(null), [])
|
||||
assert.deepEqual(backpackPages({ exists: false }), [])
|
||||
})
|
||||
|
||||
test('slots run across pages of the page size', () => {
|
||||
const pages = backpackPages({
|
||||
exists: true,
|
||||
capacity: 96,
|
||||
pageSize: 48,
|
||||
items: [{ slot: 0, shortname: 'wood', amount: 1000 }, { slot: 50, shortname: 'note', amount: 1 }],
|
||||
})
|
||||
assert.equal(pages.length, 2)
|
||||
assert.equal(pages[0].cells.length, 48)
|
||||
assert.equal(pages[1].cells.length, 48)
|
||||
assert.equal(pages[0].cells[0].stack.shortname, 'wood')
|
||||
assert.equal(pages[1].cells[2].slot, 50)
|
||||
assert.equal(pages[1].cells[2].stack.shortname, 'note')
|
||||
assert.ok(pages.every((p) => p.cells.every((c) => !c.overflow)))
|
||||
})
|
||||
|
||||
test('stacks past the capacity are drawn after it, marked overflow', () => {
|
||||
// A size revoked at the end of a run: capacity 6, five notes still on what was page two.
|
||||
const backpack = {
|
||||
exists: true,
|
||||
capacity: 6,
|
||||
pageSize: 48,
|
||||
items: [{ slot: 0, shortname: 'wood', amount: 1 }, ...[48, 49, 50, 51, 52].map((slot) => ({ slot, shortname: 'note', amount: 1, overflow: true }))],
|
||||
}
|
||||
const pages = backpackPages(backpack)
|
||||
assert.equal(pages.length, 2)
|
||||
assert.equal(pages[0].cells.slice(0, 6).filter((c) => c.overflow).length, 0)
|
||||
assert.ok(pages[0].cells.slice(6).every((c) => c.overflow))
|
||||
assert.equal(pages[1].cells.length, 5)
|
||||
assert.equal(overflowCount(backpack), 5)
|
||||
})
|
||||
|
||||
test('an empty backpack still draws its capacity', () => {
|
||||
const pages = backpackPages({ exists: true, capacity: 12, pageSize: 48, items: [] })
|
||||
assert.equal(pages.length, 1)
|
||||
assert.equal(pages[0].cells.length, 12)
|
||||
assert.ok(pages[0].cells.every((c) => c.stack === null))
|
||||
})
|
||||
|
||||
test('a slot is named by the game, a blueprint by what it makes', () => {
|
||||
assert.equal(stackLabel({ shortname: 'rifle.ak', display: 'Assault Rifle' }), 'Assault Rifle')
|
||||
assert.equal(stackLabel({ shortname: 'blueprintbase', display: 'Assault Rifle', blueprint: 'rifle.ak' }), 'Assault Rifle blueprint')
|
||||
assert.equal(stackLabel({ shortname: 'wood' }), 'wood')
|
||||
assert.equal(stackLabel({ shortname: null, itemId: 999 }), 'item 999')
|
||||
assert.equal(stackLabel(null), '')
|
||||
})
|
||||
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CLIENT = path.resolve(HERE, '..')
|
||||
|
||||
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
|
||||
const { bareImports, problemsWith, relativeImports } = await import('../scripts/checkExternals.js')
|
||||
const configModule = await import('../vite.config.js')
|
||||
const config = configModule.default
|
||||
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
|
||||
@@ -152,3 +152,38 @@ test('an import inside a string is not an import — the check reads code, not t
|
||||
// string must not end it early and leave the tail looking like code.
|
||||
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
|
||||
})
|
||||
|
||||
// ── D120: Leaflet's split chunk ───────────────────────────────────────────
|
||||
|
||||
test('a split chunk is found by what entry.js imports, and a string is still not an import', () => {
|
||||
assert.deepStrictEqual(relativeImports('const m=await import("./leaflet-Ab12.js")'), ['leaflet-Ab12.js'])
|
||||
assert.deepStrictEqual(relativeImports('import"./other.js";import"react"'), ['other.js'])
|
||||
assert.deepStrictEqual(relativeImports('const s="import(\\"./fake.js\\")"'), [])
|
||||
})
|
||||
|
||||
test('every chunk entry.js imports is in dist/, and none of them bundles a shared dependency', () => {
|
||||
const dist = path.join(CLIENT, 'dist')
|
||||
const entry = path.join(dist, 'entry.js')
|
||||
if (!fs.existsSync(entry)) return
|
||||
const split = relativeImports(fs.readFileSync(entry, 'utf8'))
|
||||
assert.ok(split.length >= 1, 'the Map tab imports Leaflet from a chunk of its own (D120)')
|
||||
for (const name of split) {
|
||||
assert.ok(fs.existsSync(path.join(dist, name)), `entry.js imports ./${name}, which the build did not emit`)
|
||||
assert.deepStrictEqual(problemsWith(fs.readFileSync(path.join(dist, name), 'utf8')), [], name)
|
||||
}
|
||||
})
|
||||
|
||||
test('Leaflet is not in the entry chunk — every page of the site loads that one', () => {
|
||||
const entry = path.join(CLIENT, 'dist', 'entry.js')
|
||||
if (!fs.existsSync(entry)) return
|
||||
assert.doesNotMatch(fs.readFileSync(entry, 'utf8'), /Leaflet 1\.9/, 'Leaflet was bundled into entry.js')
|
||||
})
|
||||
|
||||
test('the release ships every chunk, not entry.js by name', () => {
|
||||
// The other half of D120. A release that copied `entry.js` alone would load on
|
||||
// every page and spin for ever on the Map tab, with every check above green.
|
||||
const release = fs.readFileSync(path.join(CLIENT, '..', '.gitea', 'workflows', 'release.yml'), 'utf8')
|
||||
assert.match(release, /cp client\/dist\/\*\.js "\$OUT\/client\/dist\/"/)
|
||||
assert.doesNotMatch(release, /cp client\/dist\/entry\.js /)
|
||||
})
|
||||
|
||||
|
||||
355
client/test/feed.test.js
Normal file
355
client/test/feed.test.js
Normal file
@@ -0,0 +1,355 @@
|
||||
// ── The feed's sentences ──────────────────────────────────────────────────
|
||||
//
|
||||
// `lib/feed.js` is the one part of the client half with real branching in it, and
|
||||
// it is pure on purpose so that a DOM-less runner can ask all of it. Everything
|
||||
// here is a claim about what a reader sees for a given frame — which is exactly
|
||||
// the kind of thing that rots silently, because a wrong killfeed line is still a
|
||||
// killfeed line.
|
||||
//
|
||||
// The fixtures are the frames the bridge plugin actually emits (its
|
||||
// `DescribeAttacker`, and PROTOCOL.md §8.4), not invented shapes.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, FEED_KINDS, FILTERS, kindsFor } from '../src/lib/feed.js'
|
||||
|
||||
const row = (kind, frame = {}) => ({ id: 1, kind, t: Date.now(), wipeId: 'w1', steamId: '7656', frame })
|
||||
|
||||
test('a player kill names the killer and the victim, in that order', () => {
|
||||
const line = describe(row('player.death', {
|
||||
name: 'Bob',
|
||||
attackerType: 'player',
|
||||
attackerName: 'Alice',
|
||||
weapon: 'rifle.ak',
|
||||
distance: 42.4,
|
||||
grid: 'H7',
|
||||
}))
|
||||
|
||||
assert.equal(line.tone, 'kill')
|
||||
assert.equal(line.actor, 'Alice')
|
||||
assert.equal(line.verb, 'killed')
|
||||
assert.equal(line.subject, 'Bob')
|
||||
assert.match(line.detail, /rifle ak/)
|
||||
assert.match(line.detail, /42m/)
|
||||
assert.match(line.detail, /H7/)
|
||||
})
|
||||
|
||||
test('the four attacker types are four different sentences', () => {
|
||||
// The plugin distinguishes them precisely so a reader does not have to guess
|
||||
// from an absent field, and collapsing any two loses something: a fall reported
|
||||
// as a kill by nobody is the failure this prevents.
|
||||
const victim = { name: 'Bob' }
|
||||
|
||||
const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' }))
|
||||
// A family is named, not spelled out as a prefab (PLAN_FIXES F2, D185).
|
||||
assert.equal(npc.actor, 'Scientist')
|
||||
assert.equal(npc.subject, 'Bob')
|
||||
|
||||
const self = describe(row('player.death', { ...victim, attackerType: 'self' }))
|
||||
assert.equal(self.actor, 'Bob')
|
||||
assert.equal(self.subject, null)
|
||||
assert.match(self.verb, /own hand/)
|
||||
|
||||
const environment = describe(row('player.death', { ...victim, attackerType: 'environment' }))
|
||||
assert.equal(environment.actor, 'Bob')
|
||||
assert.equal(environment.verb, 'died')
|
||||
assert.equal(environment.subject, null)
|
||||
|
||||
// `HitInfo` is legitimately null on the environment path, so a death frame with
|
||||
// NO attacker type at all is that case — not a missing field to render around.
|
||||
const bare = describe(row('player.death', victim))
|
||||
assert.equal(bare.verb, 'died')
|
||||
assert.equal(bare.subject, null)
|
||||
})
|
||||
|
||||
test('a sleeping victim is said to have been sleeping', () => {
|
||||
const line = describe(row('player.death', { name: 'Bob', attackerType: 'player', attackerName: 'Alice', sleeping: true }))
|
||||
assert.match(line.detail, /while sleeping/)
|
||||
})
|
||||
|
||||
test('a disconnect with no session length says nothing about one', () => {
|
||||
// The plugin OMITS `sessionSec` for a player who was already on when it loaded:
|
||||
// an unknown session is not a session of no length. A line reading "after 0s"
|
||||
// would be a lie this module invented.
|
||||
const unknown = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit' }))
|
||||
assert.equal(unknown.detail, 'Quit')
|
||||
|
||||
const known = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit', sessionSec: 3720 }))
|
||||
assert.equal(known.detail, 'Quit · after 1h 2m')
|
||||
})
|
||||
|
||||
test('a chat line carries the message as text, never as markup', () => {
|
||||
// The message is the one field on this wire whose bytes a player chooses. It
|
||||
// comes back as a STRING and is rendered as a React child, which escapes it;
|
||||
// this test is here so that a later "render the message with formatting" idea
|
||||
// has to delete an explicit assertion rather than quietly change behaviour.
|
||||
const line = describe(row('player.chat', { name: 'Bob', message: '<img src=x onerror=alert(1)>', channel: 'Global' }))
|
||||
assert.equal(line.verb, '<img src=x onerror=alert(1)>')
|
||||
assert.equal(typeof line.verb, 'string')
|
||||
// Global is the default channel and saying so on every line is noise; Team is
|
||||
// information.
|
||||
assert.equal(line.detail, '')
|
||||
assert.equal(describe(row('player.chat', { name: 'B', message: 'hi', channel: 'Team' })).detail, 'Team')
|
||||
|
||||
// A chat row is the one line where the actor is a speaker rather than a
|
||||
// subject, and "Brannock see you in september" is not a sentence anybody
|
||||
// writes. The colon is presentation, so it lives here and not inside the text
|
||||
// the player typed.
|
||||
assert.equal(line.join, ': ')
|
||||
assert.equal(describe(row('player.connected', { name: 'B' })).join, undefined)
|
||||
})
|
||||
|
||||
test('an unknown kind renders as itself rather than vanishing', () => {
|
||||
// A later protocol adds kinds, and a module may be older than the game host it
|
||||
// is reading. The server's allowlist has already decided the row may be seen;
|
||||
// dropping it here would make the page quietly say less than the truth.
|
||||
const line = describe(row('player.teleported', { name: 'Bob' }))
|
||||
assert.equal(line.verb, 'player.teleported')
|
||||
assert.equal(line.tone, 'other')
|
||||
})
|
||||
|
||||
test('the feed never asks for the aggregate kind', () => {
|
||||
// `player.tally` is public and is flushed once a minute per active player
|
||||
// (§8.6). A feed that included it would be mostly wood counts; it is the
|
||||
// leaderboard's input, and that is where it shows up.
|
||||
assert.ok(!FEED_KINDS.includes('player.tally'))
|
||||
for (const filter of FILTERS) {
|
||||
for (const kind of filter.kinds) {
|
||||
assert.ok(FEED_KINDS.includes(kind), `filter "${filter.id}" asks for ${kind}, which the feed does not carry`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every kind the feed asks for is one the public route will serve', () => {
|
||||
// Held against the module's own allowlist rather than against a copy of it: a
|
||||
// kind this file asked for and `server/catalogue.js` refuses is a filter that
|
||||
// silently returns nothing, which reads as a quiet server.
|
||||
//
|
||||
// A CommonJS file from the server half, read by an ESM test through
|
||||
// `createRequire`. Crossing the two halves is fine HERE and nowhere else:
|
||||
// `test/` is not shipped, and `scripts/checkImports.js` governs what is.
|
||||
const catalogue = createRequire(import.meta.url)('../../server/catalogue.js')
|
||||
for (const kind of FEED_KINDS) {
|
||||
assert.ok(catalogue.PUBLIC_KINDS.includes(kind), `the feed asks for ${kind}, which is not public`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a boss appearing and falling read as sentences, with its name as typed (runicnpc stage 6)', () => {
|
||||
const up = describe(row('npc.boss.spawned', { profile: 'juggernaut', name: 'The Juggernaut' }))
|
||||
assert.deepEqual({ actor: up.actor, verb: up.verb, detail: up.detail }, { actor: 'The Juggernaut', verb: 'has appeared', detail: 'a boss' })
|
||||
|
||||
const killed = describe(row('npc.boss.died', { profile: 'juggernaut', name: 'The Juggernaut', killerName: 'Marisol', damagers: 5 }))
|
||||
assert.deepEqual({ tone: killed.tone, actor: killed.actor, verb: killed.verb, subject: killed.subject, detail: killed.detail },
|
||||
{ tone: 'kill', actor: 'Marisol', verb: 'killed', subject: 'The Juggernaut', detail: '5 players fought it' })
|
||||
|
||||
// No player landed the blow (a turret, or another NPC): it still fell.
|
||||
const fell = describe(row('npc.boss.died', { profile: 'juggernaut', name: 'The Juggernaut', damagers: 1 }))
|
||||
assert.deepEqual({ actor: fell.actor, verb: fell.verb, detail: fell.detail }, { actor: 'The Juggernaut', verb: 'was killed', detail: '' })
|
||||
|
||||
assert.deepEqual(kindsFor('bosses'), ['npc.boss.spawned', 'npc.boss.died'])
|
||||
})
|
||||
|
||||
test('an unknown filter falls back to everything rather than to nothing', () => {
|
||||
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
|
||||
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
|
||||
})
|
||||
|
||||
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ─────────
|
||||
//
|
||||
// The frames here are what the SERVER sends a viewer (`worldEvents.shapeFor`),
|
||||
// not what the plugin emitted: a grid instead of a position, names only for the
|
||||
// presence audience, `credited` by the server's rule.
|
||||
|
||||
const world = (frame) => row('world.event', { kind: 'world.event', ...frame })
|
||||
|
||||
test('a downed helicopter names whom the rule credits, and only them', () => {
|
||||
const one = describe(world({ event: 'heli.downed', grid: 'G7', creditRule: 'blow', creditedCount: 1, credited: [{ name: 'Alice' }] }))
|
||||
assert.deepEqual([one.tone, one.verb, one.subject, one.detail], ['kill', 'Patrol helicopter downed by', 'Alice', 'G7'])
|
||||
|
||||
const many = describe(world({ event: 'heli.downed', grid: 'G7', creditRule: 'all', creditedCount: 4, credited: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Cleo' }, { name: 'Dov' }] }))
|
||||
assert.equal(many.subject, 'Alice, Bob and 2 others')
|
||||
})
|
||||
|
||||
test('below the presence audience the same line names nobody (D376)', () => {
|
||||
const line = describe(world({ event: 'heli.downed', grid: 'G8', creditRule: 'blow', creditedCount: 1 }))
|
||||
assert.deepEqual([line.verb, line.subject, line.detail], ['Patrol helicopter downed', null, 'G8'])
|
||||
|
||||
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', grid: 'C3' }))
|
||||
assert.equal(hack.actor, null)
|
||||
assert.equal(hack.verb, 'A locked crate on Large Oil Rig is being hacked')
|
||||
})
|
||||
|
||||
test('a named line reads as the plan writes it', () => {
|
||||
const hack = describe(world({ event: 'crate.hack.started', where: 'oilrig_large', name: 'Bob' }))
|
||||
assert.equal(`${hack.actor} ${hack.verb}`, 'Bob started hacking the locked crate on Large Oil Rig')
|
||||
|
||||
const road = describe(world({ event: 'bradley.spawned', where: 'road', count: 12 }))
|
||||
assert.equal(road.verb, '12 road Bradleys are out')
|
||||
|
||||
assert.equal(describe(world({ event: 'deepsea.opened' })).verb, 'The Deep Sea is open')
|
||||
assert.equal(describe(world({ event: 'cargo.docked', harbourName: 'Harbor', grid: 'Q9' })).verb, 'The cargo ship has docked at Harbor')
|
||||
assert.equal(describe(world({ event: 'chinook.dropped', monumentName: 'Water Treatment Plant' })).verb, 'A Chinook dropped a locked crate at Water Treatment Plant')
|
||||
})
|
||||
|
||||
test('every type the server knows has its own words, and an unknown one says its name', () => {
|
||||
const { TYPE_IDS } = createRequire(import.meta.url)('../../server/worldEvents.js')
|
||||
for (const event of TYPE_IDS) {
|
||||
const line = describe(world({ event }))
|
||||
assert.notEqual(line.tone, 'other', `${event} has no words of its own`)
|
||||
assert.ok(line.verb && line.verb !== event, event)
|
||||
}
|
||||
assert.equal(describe(world({ event: 'meteor.landed' })).verb, 'meteor.landed')
|
||||
})
|
||||
|
||||
test('the world filter asks for world events, Dynamic PVP zones opening, Dangerous Treasures, Guarded Crate and RaidableBases, which the server serves publicly', () => {
|
||||
assert.deepEqual(kindsFor('world'), ['world.event', 'pvp.zone.created', 'dt.started', 'dt.won', 'gc.started', 'gc.ended', 'raid.started', 'raid.completed', 'raid.purchased', 'raid.locked'])
|
||||
assert.ok(!FEED_KINDS.includes('raid.ended'), 'a raid base despawning is a staff line')
|
||||
assert.ok(!FEED_KINDS.includes('dt.ended'), 'a treasure event ending is a staff line')
|
||||
assert.ok(!FEED_KINDS.includes('gc.failed'), 'a guarded crate timing out is a staff line')
|
||||
assert.ok(FEED_KINDS.includes('pvp.zone.created'))
|
||||
assert.ok(!FEED_KINDS.includes('pvp.zone.deleted'), 'a zone closing is a staff line')
|
||||
})
|
||||
|
||||
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ─────────────
|
||||
|
||||
const pvp = (kind, frame) => row(kind, { kind, ...frame })
|
||||
|
||||
test('a zone opening says what it is around, where, and for how long, and names nobody', () => {
|
||||
const heli = describe(pvp('pvp.zone.created', { event: 'Helicopter', grid: 'G7', durationSec: 600 }))
|
||||
assert.equal(heli.verb, 'A PvP zone opened around the downed patrol helicopter')
|
||||
assert.equal(heli.detail, 'G7 · for 10 min')
|
||||
assert.equal(heli.actor, null)
|
||||
assert.equal(heli.subject, null)
|
||||
|
||||
const cargo = describe(pvp('pvp.zone.created', { event: 'CargoShip', grid: 'Q9', durationSec: -1 }))
|
||||
assert.equal(cargo.verb, 'A PvP zone opened around the cargo ship')
|
||||
assert.equal(cargo.detail, 'Q9 · while the ship is here')
|
||||
|
||||
assert.equal(describe(pvp('pvp.zone.created', { event: 'Bradley', durationSec: 90 })).detail, 'for 90 s')
|
||||
})
|
||||
|
||||
test('a zone the public are not told the event of is just a zone; staff read a monument’s name as written', () => {
|
||||
assert.equal(describe(pvp('pvp.zone.created', { grid: 'C3' })).verb, 'A PvP zone opened')
|
||||
assert.equal(describe(pvp('pvp.zone.created', { event: 'Airfield' })).verb, 'A PvP zone opened (Airfield)')
|
||||
})
|
||||
|
||||
test('a zone closing, for a staff reader, says which one closed', () => {
|
||||
assert.equal(describe(pvp('pvp.zone.deleted', { event: 'Helicopter' })).verb, 'The PvP zone around the downed patrol helicopter has closed')
|
||||
assert.equal(describe(pvp('pvp.zone.deleted', {})).verb, 'The PvP zone has closed')
|
||||
})
|
||||
|
||||
test('every one of Dynamic PVP’s own events has words, and the server and client agree on which they are', () => {
|
||||
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
|
||||
for (const event of GENERAL_EVENTS) {
|
||||
assert.ok(!describe(pvp('pvp.zone.created', { event })).verb.includes(`(${event})`), `${event} has no words of its own`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4) ─────────────
|
||||
|
||||
const treasure = (kind, frame) => row(kind, { kind, ...frame })
|
||||
|
||||
test('a treasure event starting says where, and names nobody', () => {
|
||||
const line = describe(treasure('dt.started', { grid: 'K12' }))
|
||||
assert.equal(line.verb, 'A Dangerous Treasures event has started')
|
||||
assert.equal(line.detail, 'K12')
|
||||
assert.equal(line.actor, null)
|
||||
})
|
||||
|
||||
test('a win names its winner only when the server sent the name, and says how many fought', () => {
|
||||
const named = describe(treasure('dt.won', { grid: 'K12', name: 'Alice', invaderCount: 3 }))
|
||||
assert.equal(named.actor, 'Alice')
|
||||
assert.equal(named.verb, 'won the treasure')
|
||||
assert.equal(named.detail, 'K12 · 3 players fought for it')
|
||||
|
||||
const unnamed = describe(treasure('dt.won', { grid: 'K12', invaderCount: 1 }))
|
||||
assert.equal(unnamed.actor, null)
|
||||
assert.equal(unnamed.verb, 'The treasure was won')
|
||||
assert.equal(unnamed.detail, 'K12')
|
||||
})
|
||||
|
||||
test('a treasure event ending, for a staff reader, is its own line', () => {
|
||||
assert.equal(describe(treasure('dt.ended', { grid: 'K12' })).verb, 'A Dangerous Treasures event has ended')
|
||||
})
|
||||
|
||||
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5) ───────────────────
|
||||
|
||||
const guarded = (kind, frame) => row(kind, { kind, ...frame })
|
||||
|
||||
test('a guarded crate landing says where, and names nobody', () => {
|
||||
const line = describe(guarded('gc.started', { grid: 'M4' }))
|
||||
assert.equal(line.verb, 'A guarded crate landed')
|
||||
assert.equal(line.detail, 'M4')
|
||||
assert.equal(line.actor, null)
|
||||
})
|
||||
|
||||
test('a clearing names its clearer only when the server sent the name', () => {
|
||||
const named = describe(guarded('gc.ended', { grid: 'M4', won: true, name: 'Alice' }))
|
||||
assert.equal(named.actor, 'Alice')
|
||||
assert.equal(named.verb, 'cleared the guarded crate')
|
||||
assert.equal(named.detail, 'M4')
|
||||
|
||||
const unnamed = describe(guarded('gc.ended', { grid: 'M4', won: true }))
|
||||
assert.equal(unnamed.actor, null)
|
||||
assert.equal(unnamed.verb, 'The guarded crate was cleared')
|
||||
})
|
||||
|
||||
test('an ending with nobody clearing it, and a timeout for a staff reader, are their own lines', () => {
|
||||
assert.equal(describe(guarded('gc.ended', { grid: 'M4', won: false })).verb, 'A guarded crate ended with nobody clearing it')
|
||||
assert.equal(describe(guarded('gc.failed', { grid: 'M4' })).verb, 'A guarded crate timed out with its guards standing')
|
||||
})
|
||||
|
||||
// ── RaidableBases (protocol 14, PLAN_REDESIGNS §11.4) ─────────────────────
|
||||
//
|
||||
// Every difficulty here is invented (D386): the line says the plugin's own name.
|
||||
|
||||
const raid = (kind, frame) => row(kind, { kind, ...frame })
|
||||
|
||||
test('a raid base appearing says its difficulty as the plugin named it, where, and whether it is PvP', () => {
|
||||
const line = describe(raid('raid.started', { difficulty: 'Wobbly', grid: 'G7', pvp: true }))
|
||||
assert.equal(line.verb, 'A Wobbly raid base appeared')
|
||||
assert.equal(line.detail, 'G7 · PvP')
|
||||
assert.equal(line.actor, null)
|
||||
assert.equal(describe(raid('raid.started', { difficulty: 'Eerie', grid: 'G7' })).verb, 'An Eerie raid base appeared')
|
||||
assert.equal(describe(raid('raid.started', { grid: 'G7' })).verb, 'A raid base appeared')
|
||||
})
|
||||
|
||||
test('a raid names its raiders only when the server sent the names, and otherwise counts them', () => {
|
||||
const named = describe(raid('raid.completed', { difficulty: 'Wobbly', grid: 'G7', names: ['Alice', 'Bob', 'Cy'], raiderCount: 4 }))
|
||||
assert.equal(named.actor, 'Alice, Bob and 2 others')
|
||||
assert.equal(named.verb, 'raided a Wobbly raid base')
|
||||
|
||||
const two = describe(raid('raid.completed', { difficulty: 'Wobbly', names: ['Alice', 'Bob'], raiderCount: 2 }))
|
||||
assert.equal(two.actor, 'Alice and Bob')
|
||||
|
||||
const unnamed = describe(raid('raid.completed', { difficulty: 'Wobbly', grid: 'G7', raiderCount: 3 }))
|
||||
assert.equal(unnamed.actor, null)
|
||||
assert.equal(unnamed.verb, 'A Wobbly raid base was raided')
|
||||
assert.equal(unnamed.detail, 'G7 · by 3 players')
|
||||
})
|
||||
|
||||
test('a purchase and an admin lock name the player only when the server sent the name (D390), and a despawn is its own line', () => {
|
||||
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly', name: 'Alice' })).actor, 'Alice')
|
||||
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly', name: 'Alice' })).verb, 'bought a Wobbly raid base')
|
||||
assert.equal(describe(raid('raid.purchased', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was bought')
|
||||
assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly', name: 'Alice' })).verb, 'A Wobbly raid base was locked to Alice')
|
||||
assert.equal(describe(raid('raid.locked', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base was locked to a player')
|
||||
assert.equal(describe(raid('raid.ended', { difficulty: 'Wobbly' })).verb, 'A Wobbly raid base despawned')
|
||||
})
|
||||
|
||||
test('a kill line says the weapon by name', () => {
|
||||
const named = describe(row('player.death', {
|
||||
name: 'Bob', attackerType: 'player', attackerName: 'Alice',
|
||||
weapon: 'ak47u.entity', weaponName: 'Assault Rifle', distance: 22,
|
||||
}))
|
||||
assert.match(named.detail, /with Assault Rifle/)
|
||||
assert.doesNotMatch(named.detail, /entity/)
|
||||
|
||||
const older = describe(row('player.death', {
|
||||
name: 'Bob', attackerType: 'player', attackerName: 'Alice', weapon: 'bolt_rifle.entity',
|
||||
}))
|
||||
assert.match(older.detail, /with bolt rifle/)
|
||||
assert.doesNotMatch(older.detail, /entity/)
|
||||
})
|
||||
161
client/test/format.test.js
Normal file
161
client/test/format.test.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// ── Formatting ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Small functions, and the tests are small too — but three of them guard claims
|
||||
// that would otherwise be made by a page that looks fine: an unknown duration
|
||||
// rendered as zero, a timestamp in the wrong unit, and "in 0 seconds".
|
||||
//
|
||||
// Locale-dependent output is asserted loosely on purpose. `Intl` formats to the
|
||||
// RUNNER's locale, and a test pinned to "3 minutes ago" would be a test that
|
||||
// fails on a machine set to French while the page it describes is correct.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId, weapon } from '../src/lib/format.js'
|
||||
|
||||
const NOW = Date.parse('2026-09-16T12:00:00Z')
|
||||
|
||||
test('a relative time picks the unit that fits', () => {
|
||||
assert.match(ago(NOW - 3 * 60_000, NOW), /3/)
|
||||
assert.match(ago(NOW - 5 * 3600_000, NOW), /5/)
|
||||
assert.match(ago(NOW - 3 * 86400_000, NOW), /3/)
|
||||
})
|
||||
|
||||
test('"just now" rather than "in 0 seconds"', () => {
|
||||
// What `numeric: 'auto'` produces under a minute is not what anybody means,
|
||||
// and a feed row a few seconds old is the commonest row on the page.
|
||||
assert.equal(ago(NOW, NOW), 'just now')
|
||||
assert.equal(ago(NOW - 10_000, NOW), 'just now')
|
||||
})
|
||||
|
||||
test('both time shapes this module serves are accepted', () => {
|
||||
// `updatedAt` is an ISO string the model produced; an event's `t` is the
|
||||
// millisecond stamp the plugin put on the frame. A helper that took only one
|
||||
// would be a helper every caller has to remember the type for.
|
||||
assert.equal(ago('2026-09-16T11:57:00.000Z', NOW), ago(NOW - 3 * 60_000, NOW))
|
||||
})
|
||||
|
||||
test('a missing time is "never", not the epoch', () => {
|
||||
assert.equal(ago(null), 'never')
|
||||
assert.equal(ago(undefined), 'never')
|
||||
assert.equal(ago(''), 'never')
|
||||
assert.equal(day(null), 'unknown')
|
||||
})
|
||||
|
||||
test('an unknown duration is a dash, and a short one keeps its seconds', () => {
|
||||
// The distinction the plugin makes and this must not lose: `sessionSec` is
|
||||
// ABSENT for a player who was already on when it loaded, so zero and unknown
|
||||
// arrive at the same function and must not render the same way.
|
||||
assert.equal(duration(null), '—')
|
||||
assert.equal(duration(0), '—')
|
||||
assert.equal(duration(40), '40s')
|
||||
assert.equal(duration(90), '2m')
|
||||
assert.equal(duration(3720), '1h 2m')
|
||||
assert.equal(duration(7200), '2h')
|
||||
})
|
||||
|
||||
test('a prefab reads as words, without a lookup table', () => {
|
||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
||||
assert.equal(prefab('scientistnpc_full_any'), 'scientistnpc full any')
|
||||
assert.equal(prefab(null), '')
|
||||
})
|
||||
|
||||
test('a steam id is shortened without pretending to be a name', () => {
|
||||
assert.equal(shortId('76561198000000001'), '…000001')
|
||||
assert.equal(shortId(''), '')
|
||||
})
|
||||
|
||||
test('a count that is not a number is zero, never NaN on the page', () => {
|
||||
assert.equal(count(undefined), '0')
|
||||
assert.equal(count(null), '0')
|
||||
})
|
||||
|
||||
test('a balance never reported is a dash, not zero coins (PLAN_REDESIGNS §11.2.2)', () => {
|
||||
assert.equal(coins(null), '—')
|
||||
assert.equal(coins(undefined), '—')
|
||||
assert.equal(coins(0), (0).toLocaleString())
|
||||
assert.equal(coins(1500.456), (1500.46).toLocaleString(undefined, { maximumFractionDigits: 2 }))
|
||||
})
|
||||
|
||||
test("a feed row from another day carries its date, not just a time", () => {
|
||||
// Found by the page walk: with the feed filtered to the previous wipe, three
|
||||
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.
|
||||
// Today's rows stay bare, because a killfeed of today's fights does not want
|
||||
// the date on every line.
|
||||
// Asserted against `Intl` rather than against a literal: a 12-hour locale puts
|
||||
// letters in a bare time ("05:30 AM"), so "has letters in it" is not the test —
|
||||
// "is exactly the time, and nothing else" is.
|
||||
const time = (at) => new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const todayAt = NOW - 90 * 60_000
|
||||
assert.equal(clock(todayAt, NOW), time(todayAt))
|
||||
|
||||
const olderAt = NOW - 46 * 86400_000
|
||||
assert.ok(clock(olderAt, NOW).endsWith(time(olderAt)))
|
||||
assert.ok(clock(olderAt, NOW).length > time(olderAt).length, 'an older row carries no date')
|
||||
|
||||
// Yesterday counts as another day even when it is only a few hours back — the
|
||||
// boundary is the calendar, not a duration, because that is what a reader
|
||||
// means by "what time was that".
|
||||
const lateLastNight = Date.parse('2026-09-15T23:50:00')
|
||||
const earlyToday = Date.parse('2026-09-16T00:20:00')
|
||||
assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length)
|
||||
})
|
||||
|
||||
test('the next wipe: the reader’s own clock, how far away, and whether it moved', () => {
|
||||
const now = Date.parse('2026-09-25T12:00:00Z')
|
||||
const forced = nextWipe({ at: '2026-10-01T18:00:00.000Z', source: 'forced' }, now)
|
||||
assert.match(forced, /\(in 6 days\)$/)
|
||||
assert.ok(!forced.includes('rescheduled'))
|
||||
|
||||
assert.match(nextWipe({ at: '2026-09-27T17:00:00.000Z', source: 'once' }, now), /\(in 2 days\) — rescheduled$/)
|
||||
|
||||
// No schedule is no line, not "unknown": the page leaves the part out.
|
||||
assert.equal(nextWipe(null, now), null)
|
||||
assert.equal(nextWipe({ at: 'soon', source: 'rule' }, now), null)
|
||||
})
|
||||
|
||||
test('a title chip’s ink is whichever of black and white reads on its colour', () => {
|
||||
assert.equal(contrastInk('#ffff00'), '#000000')
|
||||
assert.equal(contrastInk('#FFAA55'), '#000000')
|
||||
assert.equal(contrastInk('#1a1a8c'), '#ffffff')
|
||||
assert.equal(contrastInk('#ff0000'), '#000000')
|
||||
assert.equal(contrastInk('red'), '#000000', 'not a hex colour: black, on the caller’s fallback')
|
||||
})
|
||||
|
||||
test('an NPC attacker reads as a name: a family, or the prefab without its variant (F2, D185)', () => {
|
||||
// The first walk's "killed by wolf2".
|
||||
assert.equal(attacker('wolf2'), 'Wolf')
|
||||
assert.equal(attacker('wolf'), 'Wolf')
|
||||
assert.equal(attacker('boar'), 'Boar')
|
||||
assert.equal(attacker('polarbear'), 'Polar bear')
|
||||
assert.equal(attacker('scientistnpc_full_any'), 'Scientist')
|
||||
assert.equal(attacker('scientistnpc_roam'), 'Scientist')
|
||||
assert.equal(attacker('scientistnpc_heavy'), 'Heavy scientist')
|
||||
assert.equal(attacker('npc_bandit_guard'), 'Bandit guard')
|
||||
assert.equal(attacker('bradleyapc'), 'Bradley APC')
|
||||
assert.equal(attacker('patrolhelicopter'), 'Patrol helicopter')
|
||||
assert.equal(attacker('autoturret_deployed'), 'Auto turret')
|
||||
assert.equal(attacker('sam_site_turret_deployed'), 'SAM site')
|
||||
// Anything unknown still reads as words, capitalised, with no suffix or variant.
|
||||
assert.equal(attacker('some_new_beast3'), 'Some new beast')
|
||||
assert.equal(attacker('spikes.floor.deployed'), 'Spikes floor')
|
||||
assert.equal(attacker(null), '')
|
||||
assert.equal(attacker(''), '')
|
||||
})
|
||||
|
||||
test('a weapon keeps the light touch — attacker() is for what did the killing', () => {
|
||||
assert.equal(prefab('rifle.ak'), 'rifle ak')
|
||||
})
|
||||
|
||||
test('a kill prints the weapon the server named, and never an entity ending', () => {
|
||||
// weaponName is the server's (model/titles/weaponNames.js): the game's own name for a gun.
|
||||
assert.equal(weapon({ weapon: 'ak47u.entity', weaponName: 'Assault Rifle' }), 'Assault Rifle')
|
||||
// A server too old to send it: the ending dropped, the rest as words.
|
||||
assert.equal(weapon({ weapon: 'ak47u.entity' }), 'ak47u')
|
||||
assert.equal(weapon({ weapon: 'bolt_rifle.entity' }), 'bolt rifle')
|
||||
assert.equal(weapon({ weapon: 'machete.weapon' }), 'machete')
|
||||
assert.equal(weapon({ weapon: 'rifle.ak' }), 'rifle ak')
|
||||
assert.equal(weapon({}), '')
|
||||
assert.equal(weapon(null), '')
|
||||
})
|
||||
68
client/test/mapEvents.test.js
Normal file
68
client/test/mapEvents.test.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// ── What an event marker says (runicnpc stage 8, D302, D303) ──────────────
|
||||
//
|
||||
// The decisions: a name and an event title, never health; a boss marked and
|
||||
// drawn bigger; an unannounced event named by nothing but "an event".
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { eventTip, isBoss, pvpTip, PVP_NAMES, raidTip, titlesFrom, unknownRuns } from '../src/lib/mapEvents.js'
|
||||
|
||||
const CALENDAR = {
|
||||
entries: [
|
||||
{ kind: 'run', runId: 41, title: 'Harbour Raid' },
|
||||
{ kind: 'projected', title: 'Next week' },
|
||||
{ kind: 'run', runId: 42, title: '' },
|
||||
],
|
||||
}
|
||||
|
||||
test("the calendar's runs become titles by run id; a projection has none", () => {
|
||||
assert.deepEqual(titlesFrom(CALENDAR), { 41: 'Harbour Raid' })
|
||||
assert.deepEqual(titlesFrom(null), {})
|
||||
})
|
||||
|
||||
test('an NPC says its name and its event; a boss and an add say which they are', () => {
|
||||
const titles = titlesFrom(CALENDAR)
|
||||
assert.equal(eventTip({ kind: 'npc', runId: '41', name: 'Bandit' }, titles), 'Bandit · Harbour Raid')
|
||||
assert.equal(eventTip({ kind: 'npc', runId: '41', name: 'The Juggernaut', boss: true }, titles), 'The Juggernaut (boss) · Harbour Raid')
|
||||
assert.equal(eventTip({ kind: 'npc', runId: '41', name: 'Bandit', add: true }, titles), "Bandit (a boss's add) · Harbour Raid")
|
||||
})
|
||||
|
||||
test('an unannounced event, a Rust scientist and a crate', () => {
|
||||
assert.equal(eventTip({ kind: 'npc', runId: '7', name: 'Bandit' }, {}), 'Bandit · an event')
|
||||
assert.equal(eventTip({ kind: 'npc', runId: '41' }, { 41: 'Harbour Raid' }), 'Event NPC · Harbour Raid')
|
||||
assert.equal(eventTip({ kind: 'crate', runId: '41' }, { 41: 'Harbour Raid' }), 'Event crate · Harbour Raid')
|
||||
})
|
||||
|
||||
test('health never reaches a tooltip, even if a row carried it', () => {
|
||||
assert.equal(eventTip({ kind: 'npc', runId: '41', name: 'J', boss: true, health: 0.5 }, {}).includes('50'), false)
|
||||
})
|
||||
|
||||
test('a boss is an event boss or a world one', () => {
|
||||
assert.equal(isBoss({ kind: 'npc', boss: true }), true)
|
||||
assert.equal(isBoss({ kind: 'boss' }), true)
|
||||
assert.equal(isBoss({ kind: 'npc' }), false)
|
||||
})
|
||||
|
||||
test('the run ids still to look up', () => {
|
||||
assert.deepEqual(unknownRuns([{ runId: '41' }, { runId: '7' }, { runId: '7' }, {}], { 41: 'x' }), ['7'])
|
||||
})
|
||||
|
||||
// Dynamic PVP's rings (PLAN_REDESIGNS §14.3.3): its own events by a plain name,
|
||||
// a monument's or an operator's as written, and nothing at all as just a zone.
|
||||
test('a Dynamic PVP ring says what it is around', () => {
|
||||
assert.equal(pvpTip({ event: 'Helicopter' }), 'PvP zone · Patrol helicopter')
|
||||
assert.equal(pvpTip({ event: 'Airfield' }), 'PvP zone · Airfield')
|
||||
assert.equal(pvpTip({}), 'PvP zone')
|
||||
const { GENERAL_EVENTS } = createRequire(import.meta.url)('../../server/dynamicPvp.js')
|
||||
assert.deepEqual(Object.keys(PVP_NAMES).sort(), [...GENERAL_EVENTS].sort())
|
||||
})
|
||||
|
||||
test('a raid base’s tooltip says its own difficulty and base, PvP, when it despawns, and whether an event of this site spawned it', () => {
|
||||
const now = 1_000_000
|
||||
assert.equal(raidTip({ difficulty: 'Wobbly', base: 'wobble3', pvp: true, despawnAt: now + 42 * 60000 }, now), 'Raid base · Wobbly · wobble3 · PvP · despawns in 42 min')
|
||||
assert.equal(raidTip({ difficulty: 'Wobbly', runId: '7' }, now), 'Raid base · Wobbly · this site’s event')
|
||||
assert.equal(raidTip({ despawnAt: now - 1 }, now), 'Raid base')
|
||||
})
|
||||
92
client/test/mapGeometry.test.js
Normal file
92
client/test/mapGeometry.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// ── How a world position reaches a pixel (PLAN.md §30.3) ─────────────────
|
||||
//
|
||||
// The grid cases are the GAME's answers, not this file's: on 2026-09-25 a probe
|
||||
// on the Oxide rig (a 3000 map, seed 1234) asked `MapHelper.PositionToString`
|
||||
// for these positions and wrote down what it said. A label the page draws that
|
||||
// disagrees with the in-game map is a player walking to the wrong square — and
|
||||
// phase 3's plugin did exactly that for three weeks with a 146.3 m cell (D119).
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { boundsOf, column, countdown, grid, gridLabel, scaleOf, toLatLng } from '../src/lib/mapGeometry.js'
|
||||
|
||||
// The rig's map as `GET /map` describes it.
|
||||
const RIG = { worldSize: 3000, oceanMargin: 500, width: 2500, height: 2500, gridCells: 20, gridCellSize: 150 }
|
||||
|
||||
test('the grid label is the game’s, at every probed position', () => {
|
||||
const probed = [
|
||||
['ue_jungle_swamp_a', 764.7, 167.4, 'P8'],
|
||||
['ue_jungle_swamp_a', 733.7, -556.0, 'O13'],
|
||||
['harbor_2', 1122.7, 204.6, 'R8'],
|
||||
['harbor_1', 678.1, 1005.7, 'O3'],
|
||||
['ferry_terminal_1', 645.4, -1004.1, 'O16'],
|
||||
['fishing_village_a', -787.8, 224.1, 'E8'],
|
||||
['fishing_village_c', -203.0, -911.1, 'I16'],
|
||||
['fishing_village_b', 1142.1, -566.5, 'R13'],
|
||||
['desert_military_base_c', 94.0, -729.3, 'K14'],
|
||||
['arctic_research_base_a', -556.9, 840.0, 'G4'],
|
||||
['powerplant_1', -608.6, -346.4, 'F12'],
|
||||
['water_treatment_plant_1', 497.8, 84.9, 'N9'],
|
||||
['nw-corner', -1499, 1499, 'A0'],
|
||||
['se-corner', 1499, -1499, 'T19'],
|
||||
['origin', 0, 0, 'K10'],
|
||||
]
|
||||
for (const [name, x, z, game] of probed) assert.equal(gridLabel(RIG, x, z), game, name)
|
||||
})
|
||||
|
||||
test('a 146.3 m cell — phase 3’s constant — would have disagreed with the game', () => {
|
||||
// Kept as a test so the constant cannot come back in a refactor that "fixes"
|
||||
// the cell size to the number community tools quote.
|
||||
assert.notEqual(gridLabel({ ...RIG, gridCells: 21, gridCellSize: 146.3 }, 645.4, -1004.1), 'O16')
|
||||
})
|
||||
|
||||
test('columns past Z are spelled the way Rust spells them', () => {
|
||||
assert.equal(column(0), 'A')
|
||||
assert.equal(column(25), 'Z')
|
||||
assert.equal(column(26), 'AA')
|
||||
assert.equal(column(27), 'AB')
|
||||
})
|
||||
|
||||
test('the ocean margin is in pixels and is not scaled', () => {
|
||||
assert.equal(scaleOf(RIG), 0.5)
|
||||
// The world's corners sit exactly one margin inside the picture's.
|
||||
assert.deepEqual(toLatLng(RIG, -1500, -1500), [500, 500])
|
||||
assert.deepEqual(toLatLng(RIG, 1500, 1500), [2000, 2000])
|
||||
assert.deepEqual(toLatLng(RIG, 0, 0), [1250, 1250])
|
||||
})
|
||||
|
||||
test('north is up: a larger z is a larger latitude, and x is longitude', () => {
|
||||
const [lat1, lng1] = toLatLng(RIG, 100, 100)
|
||||
const [lat2, lng2] = toLatLng(RIG, 100, 400)
|
||||
assert.ok(lat2 > lat1)
|
||||
assert.equal(lng1, lng2)
|
||||
assert.deepEqual(boundsOf(RIG), [[0, 0], [2500, 2500]])
|
||||
})
|
||||
|
||||
test('something off the edge of the world is still placed, outside the picture', () => {
|
||||
// The rig's cargo ship, as the probe found it: past the world AND the margin.
|
||||
const [lat, lng] = toLatLng(RIG, 2691.6, -1453.1)
|
||||
assert.ok(lng > RIG.width)
|
||||
assert.ok(lat > 0 && lat < RIG.height)
|
||||
})
|
||||
|
||||
test('the grid has a line per edge and a label per cell, A0 at the north-west corner', () => {
|
||||
const g = grid(RIG)
|
||||
assert.equal(g.lines.length, 2 * (RIG.gridCells + 1))
|
||||
assert.equal(g.labels.length, RIG.gridCells * RIG.gridCells)
|
||||
const a0 = g.labels.find((l) => l.text === 'A0')
|
||||
assert.deepEqual([a0.x, a0.z], [-1500, 1500])
|
||||
assert.deepEqual(grid(null), { lines: [], labels: [] })
|
||||
})
|
||||
|
||||
test('a geometry that cannot place anything places nothing rather than NaN everywhere', () => {
|
||||
assert.equal(scaleOf({ worldSize: 0, width: 2500 }), 0)
|
||||
assert.equal(gridLabel({ worldSize: 3000 }, 0, 0), null)
|
||||
})
|
||||
|
||||
test('a hack timer reads as minutes and seconds', () => {
|
||||
assert.equal(countdown(540), '9:00')
|
||||
assert.equal(countdown(61.4), '1:01')
|
||||
assert.equal(countdown(-3), '0:00')
|
||||
})
|
||||
70
client/test/mapMarkers.test.js
Normal file
70
client/test/mapMarkers.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// ── Which monument labels a map draws (PLAN_REDESIGNS §4, D196) ───────────
|
||||
//
|
||||
// The admin card must agree with the server about what a switch means, or the
|
||||
// page says a label is drawn while the map leaves it out. These are the ways it
|
||||
// could disagree: case, the built-in list, the fleet under a server, and a
|
||||
// switch set back to its default.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { builtIn, fleetLabels, fleetShows, markerDiff, markerKey, serverShows, setFleet, setServer } from '../src/lib/mapMarkers.js'
|
||||
|
||||
const MINOR = ['Substation', 'Jungle Swamp', 'Ranch']
|
||||
|
||||
test('a label becomes the same key the server stores', () => {
|
||||
assert.equal(markerKey(' jungle Swamp '), 'jungle swamp')
|
||||
assert.equal(markerKey(null), '')
|
||||
})
|
||||
|
||||
test('the minor labels start hidden and every other label drawn', () => {
|
||||
assert.equal(builtIn(MINOR, 'substation'), false)
|
||||
assert.equal(builtIn(MINOR, 'something facepunch added'), true)
|
||||
})
|
||||
|
||||
test('a server switch wins over the fleet, and the fleet over the built-in list', () => {
|
||||
assert.equal(fleetShows(MINOR, {}, 'substation'), false)
|
||||
assert.equal(fleetShows(MINOR, { substation: true }, 'substation'), true)
|
||||
assert.equal(serverShows(MINOR, { substation: true }, {}, 'substation'), true)
|
||||
assert.equal(serverShows(MINOR, { substation: true }, { substation: false }, 'substation'), false)
|
||||
assert.equal(serverShows(MINOR, {}, { 'power plant': false }, 'power plant'), false)
|
||||
})
|
||||
|
||||
test('the fleet list is every server’s labels and the minor list, each once', () => {
|
||||
const labels = fleetLabels(MINOR, [
|
||||
{ markerLabels: [{ key: 'substation', label: 'Substation', count: 31 }, { key: 'harbor', label: 'Harbor', count: 2 }] },
|
||||
{ markerLabels: [{ key: 'substation', label: 'Substation', count: 12 }] },
|
||||
{},
|
||||
])
|
||||
assert.deepEqual(
|
||||
labels.map((l) => [l.key, l.count]),
|
||||
[
|
||||
['harbor', 2],
|
||||
['jungle swamp', 0],
|
||||
['ranch', 0],
|
||||
['substation', 43],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('the fleet list shows a capitalised spelling where the game only wrote lower case', () => {
|
||||
const labels = fleetLabels(MINOR, [{ markerLabels: [{ key: 'jungle swamp', label: 'jungle swamp', count: 3 }] }])
|
||||
assert.deepEqual(labels.find((l) => l.key === 'jungle swamp'), { key: 'jungle swamp', label: 'Jungle Swamp', count: 3 })
|
||||
})
|
||||
|
||||
test('setting the fleet back to the built-in answer clears the row instead of copying it', () => {
|
||||
assert.deepEqual(setFleet(MINOR, {}, 'substation', true), { substation: true })
|
||||
assert.deepEqual(setFleet(MINOR, { substation: true }, 'substation', false), {})
|
||||
assert.deepEqual(setFleet(MINOR, {}, 'harbor', false), { harbor: false })
|
||||
})
|
||||
|
||||
test('a server set to follow the fleet loses its own switch', () => {
|
||||
assert.deepEqual(setServer({ substation: true }, 'substation', null), {})
|
||||
assert.deepEqual(setServer({}, 'substation', false), { substation: false })
|
||||
})
|
||||
|
||||
test('the diff sends a changed switch as its value and a removed one as null', () => {
|
||||
assert.equal(markerDiff({ a: true }, { a: true }), null)
|
||||
assert.deepEqual(markerDiff({ a: true, b: false }, { a: false, c: true }), { a: false, b: null, c: true })
|
||||
assert.equal(markerDiff(undefined, {}), null)
|
||||
})
|
||||
@@ -66,9 +66,22 @@ function fakeRg() {
|
||||
),
|
||||
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||
registry: {
|
||||
// Core's own prefixing, character for character (client/src/modules/registry.js):
|
||||
// the leading separators of the module's path are stripped and so are the
|
||||
// TRAILING ones, which is what lets a module register `path: ''` and own its
|
||||
// namespace root — `/rust` rather than `/rust/`.
|
||||
//
|
||||
// This fake did the obvious `${id}/${path}` until phase 4, and the day a
|
||||
// module registered an index route it produced `rust/` while a real core
|
||||
// produced `rust`. The suite then failed the nav check for a link that works
|
||||
// perfectly in a browser. A fake that is nearly core is worse than one that
|
||||
// is obviously not: it fails on the truth.
|
||||
registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
|
||||
for (const r of list || []) {
|
||||
const path = `${id}/${String(r.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||
routes[area].push({ ...r, path, moduleId: id })
|
||||
}
|
||||
}
|
||||
},
|
||||
registerNav(id, { area, items }) {
|
||||
@@ -120,7 +133,12 @@ it('registers at least one route, namespaced under the module id', () => {
|
||||
assert.ok(all.length > 0, 'the chunk registered no routes at all')
|
||||
for (const [area, list] of Object.entries(registered.routes)) {
|
||||
for (const r of list) {
|
||||
assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`)
|
||||
// Either the namespace root itself (a module's index route, `rust`) or
|
||||
// something under it (`rust/servers/:id`). `startsWith('rust/')` alone
|
||||
// would reject the root — and `startsWith('rust')` alone would accept a
|
||||
// hypothetical `rustling`, which is why this is spelled out.
|
||||
const under = r.path === manifest.id || r.path.startsWith(`${manifest.id}/`)
|
||||
assert.ok(under, `${area} route "${r.path}" is not under the namespace`)
|
||||
assert.ok(r.element, `${area} route "${r.path}" has no element`)
|
||||
}
|
||||
}
|
||||
@@ -176,6 +194,19 @@ it('a nav row that gates on a feature has a provider to resolve it', () => {
|
||||
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
|
||||
})
|
||||
|
||||
it('the footer slot core declares is filled, and by a component', () => {
|
||||
// R13's first slot, and the half that lives in the CHUNK: `site.footer.status`
|
||||
// is a CLIENT slot, so it cannot be named in `module.json`'s `extensions` —
|
||||
// that array is validated against the SERVER registry and naming a client slot
|
||||
// there fails the load outright. Nothing else holds this registration, and an
|
||||
// extension that stopped being registered is invisible: an unfilled slot
|
||||
// renders nothing, exactly as an uninstalled module does.
|
||||
const footer = registered.extensions.get('site.footer.status')
|
||||
assert.ok(footer, 'nothing fills site.footer.status')
|
||||
assert.equal(footer.id, manifest.id)
|
||||
assert.equal(typeof footer.Component, 'function')
|
||||
})
|
||||
|
||||
it('every slot module.json declares is one the chunk fills', () => {
|
||||
// `module.json` declares SERVER slots, and the loader validates those before
|
||||
// the chunk is ever served. Client slots cannot be declared there — the server
|
||||
@@ -226,6 +257,23 @@ it('every declared slot names a core contribution core actually offers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('the clan page gets all three of core’s Team contributions, one per place (phase 9, D56)', () => {
|
||||
// Core contributes three things to a Team page it does not own. Each has its
|
||||
// own place on the clan page, so no contribution is decided by another's
|
||||
// position — and a slot missing here is a clan page with no feed, no forum or
|
||||
// no notification switch, with nothing logged anywhere.
|
||||
const byName = Object.fromEntries(registered.declaredSlots.map((s) => [s.name, s.wants]))
|
||||
assert.deepEqual(byName, {
|
||||
'rust.clan.header': 'team.notify',
|
||||
'rust.clan.detail': 'team.activity',
|
||||
'rust.clan.forum': 'team.forum',
|
||||
})
|
||||
|
||||
// And the page is at the address the Team provider hands core.
|
||||
const paths = registered.routes.public.map((r) => r.path)
|
||||
assert.ok(paths.includes('rust/clans/:externalId'), paths.join(', '))
|
||||
})
|
||||
|
||||
it('registers under exactly one module id, matching the manifest', () => {
|
||||
const owners = new Set([
|
||||
...Object.values(registered.routes).flat().map((r) => r.moduleId),
|
||||
|
||||
49
client/test/uiKitProps.test.js
Normal file
49
client/test/uiKitProps.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// ── The UI kit's props, as core actually reads them ───────────────────────
|
||||
//
|
||||
// React drops an unknown prop without a word, so a UI-kit component called with
|
||||
// the wrong one renders — just not what was written. Two of these have shipped
|
||||
// from this org already: `PageHeader subtitle` (Teams phase 11, the kit's
|
||||
// template) and `EmptyState title/message` (this module, phases 4 to 8 — every
|
||||
// empty panel was a blank box until the presence fix's browser walk).
|
||||
//
|
||||
// A DOM-less runner cannot see a blank box, so this reads the source instead:
|
||||
// it names the props core's components do NOT take and fails on any use of them.
|
||||
// It is a claim about core that must be re-read when core's kit changes —
|
||||
// written down rather than imported, because no core is in this process.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
||||
|
||||
/** Every .jsx/.js under src/. */
|
||||
function sources(dir = SRC) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) return sources(full)
|
||||
return /\.(jsx?|mjs)$/.test(entry.name) ? [full] : []
|
||||
})
|
||||
}
|
||||
|
||||
// Core's `components/PageState.jsx` and `PageHeader.jsx`, read 2026-09-23 at the
|
||||
// pinned core (ci/core-ref.json).
|
||||
const REFUSED = {
|
||||
// `EmptyState({ children })` — children only.
|
||||
EmptyState: /<EmptyState\b[^>]*\b(title|message|description|text)\s*=/,
|
||||
// `PageHeader({ eyebrow, title, lead, center })` — there is no `subtitle`.
|
||||
PageHeader: /<PageHeader\b[^>]*\bsubtitle\s*=/,
|
||||
}
|
||||
|
||||
test('no UI-kit component is handed a prop core does not read', () => {
|
||||
const offences = []
|
||||
for (const file of sources()) {
|
||||
const text = fs.readFileSync(file, 'utf8')
|
||||
for (const [component, pattern] of Object.entries(REFUSED)) {
|
||||
if (pattern.test(text)) offences.push(`${path.relative(SRC, file)}: ${component}`)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offences, [], 'use components/Empty.jsx for a titled empty state')
|
||||
})
|
||||
3607
engagement-triggers.json
Normal file
3607
engagement-triggers.json
Normal file
File diff suppressed because it is too large
Load Diff
10
module.json
10
module.json
@@ -2,7 +2,7 @@
|
||||
"id": "rust",
|
||||
"name": "Rust",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.10.0",
|
||||
"coreApi": "^1.19.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
@@ -12,5 +12,11 @@
|
||||
"admin": ["/rust"],
|
||||
"player": ["/rust"]
|
||||
},
|
||||
"capabilities": ["servers"]
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"],
|
||||
"demoLocks": [
|
||||
{ "method": "PUT", "path": "/admin/rust/servers/:id" },
|
||||
{ "method": "DELETE", "path": "/admin/rust/servers/:id" },
|
||||
{ "method": "DELETE", "path": "/player/rust/links/:steamId" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,35 +1,450 @@
|
||||
{
|
||||
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
|
||||
"routes": [
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles/:pid",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/exceptions/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/zones/presets/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/rust/links/:steamId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions/grants/:grantId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/rust/links/:steamId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/files",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/writes",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/writes/:writeId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/npcs",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions/catalogue",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions/servers/:serverId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions/servers/:serverId/players",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/servers/:id/integrations",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/title-categories",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/visibility",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/voice",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/zones",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/backpacks",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/economics",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/links",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/backpacks",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/economics",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/links",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/npc-kills",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/clans/:externalId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/clans",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/events",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/leaderboard",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/map",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/map/image",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/map/live",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/npc-leaderboard",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/npc-profiles",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/online",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/players/:steamId/npc-kills",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/rust/servers/:id/wipes",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles/:pid/restore",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement/rename",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement/respawn",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/push",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/accept",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/dismiss",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/restore",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/drift/:id/revoke",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id/members",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id/members/clear",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id/members/remove",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id/split",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/servers/:serverId/grant",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/servers/:serverId/groups",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/servers/:serverId/revoke",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/permissions/sync",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/servers/:id/map/fetch",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/servers/:id/map/render",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/servers/:id/test",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/zones/presets",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/rust/economics",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions/grants",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/rust/link",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/npcs/factions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles/:pid",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id/permissions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/permissions/groups/:id/servers",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/permissions/servers/:serverId/policy",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/servers/:id/titles",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/title-categories/:stat",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/visibility",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/voice",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/zones/presets/:id",
|
||||
"tier": "public"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
225
server/boot.js
225
server/boot.js
@@ -21,25 +21,62 @@
|
||||
// letting that fail the boot would make installing the module before installing
|
||||
// the bridge impossible.
|
||||
//
|
||||
// ── Polling, in phase 1 ───────────────────────────────────────────────────
|
||||
// ── Four timers, and they answer four different questions ─────────────────
|
||||
//
|
||||
// This is a poll, and the live feed it will become is a later phase's work. The
|
||||
// poll is not a placeholder for it: a sidecar's store-backed reads are exactly
|
||||
// what answers while a game server is off, and the module will keep reading them
|
||||
// on an interval to notice a server that went away without saying anything.
|
||||
// What the feed adds is latency, not coverage.
|
||||
// refresh (30s) what is each server, and who is on it — the BOARDS
|
||||
// ingest (5s) what has happened since we last looked — the CURSOR
|
||||
// sweep (1m) which login attempts were never let in (PLAN.md §25)
|
||||
// prune (1h) forgetting the detail we promised not to keep for ever
|
||||
//
|
||||
// The boards poll and the ingest are deliberately separate rather than one loop
|
||||
// reading both. They fail differently and they matter differently: a board that
|
||||
// is 30 seconds stale shows a player count slightly behind, and an ingest that
|
||||
// is 30 seconds behind shows a killfeed that feels broken. Splitting them lets
|
||||
// the cheap one run often and the expensive one run rarely, and it means a
|
||||
// sidecar that answers one and not the other degrades in exactly one place.
|
||||
//
|
||||
// The poll was never a placeholder for a socket: a sidecar's store-backed reads
|
||||
// are what answer while a game server is off, which is most of what this module
|
||||
// renders. See `ingest.js` for why the live feed is a cursor and not a
|
||||
// WebSocket.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const db = require('./model/servers/servers.db')
|
||||
const engagement = require('./engagement/emit')
|
||||
const eventsDb = require('./model/events/events.db')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const ingest = require('./ingest')
|
||||
const mapImages = require('./mapImages')
|
||||
const npcSync = require('./npcSync')
|
||||
const permSync = require('./permSync')
|
||||
const permissionsDb = require('./model/permissions/permissions.db')
|
||||
const titleSync = require('./titleSync')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('boot')
|
||||
|
||||
let refreshTimer = null
|
||||
let ingestTimer = null
|
||||
let pruneTimer = null
|
||||
let sweepTimer = null
|
||||
|
||||
const REFRESH_MS = 30 * 1000
|
||||
const INGEST_MS = 5 * 1000
|
||||
const PRUNE_MS = 60 * 60 * 1000
|
||||
const SWEEP_MS = 60 * 1000
|
||||
|
||||
/**
|
||||
* How long this module keeps raw events.
|
||||
*
|
||||
* Longer than the sidecar's 14 days, because this is the richer store and the
|
||||
* one a page reads — and because the sidecar lives on somebody's game host while
|
||||
* this lives on the website's own database. What is NOT bounded by it is the
|
||||
* record: `rust_player_wipe_stats` and `rust_gather_totals` are permanent, which
|
||||
* is the whole of R12's "a wipe does not erase a player's history".
|
||||
*/
|
||||
const EVENT_RETENTION_DAYS = 30
|
||||
|
||||
/**
|
||||
* Ask every configured sidecar how its server is doing, and store what it said.
|
||||
@@ -63,7 +100,18 @@ async function refresh() {
|
||||
|
||||
async function refreshOne(server) {
|
||||
try {
|
||||
const board = await sidecar.serverBoard(server)
|
||||
// One call for both boards. `/server` would answer the same question about
|
||||
// the server itself, but presence would then be a second round trip to the
|
||||
// same process for a fact it already had in hand.
|
||||
//
|
||||
// **And one for `/health`, because the boards cannot say whether the game is
|
||||
// there NOW** (D68, PLAN.md §25.1). The sidecar keeps its last `server.hello`
|
||||
// after the plugin disconnects — that is what lets a page render a server
|
||||
// that is off — so a game that hung, or whose bridge was unloaded, while the
|
||||
// sidecar stayed up read as online here from phase 4 until phase 10. Only
|
||||
// `/health`'s `plugin_connected` answers the question, and the two are asked
|
||||
// together so they describe the same moment.
|
||||
const [board, health] = await Promise.all([sidecar.boards(server), sidecar.health(server)])
|
||||
|
||||
// Three outcomes, and collapsing any two of them loses something an operator
|
||||
// needs:
|
||||
@@ -76,25 +124,53 @@ async function refreshOne(server) {
|
||||
// whose plugin is not loaded yet, and reporting it as unreachable sends the
|
||||
// operator to look at the network instead of at the game server.
|
||||
if (!board.ok) {
|
||||
await db.putState({ serverId: server.id, reachable: false, online: false })
|
||||
// `markUnreachable`, not `putState`: nothing answered, so the only new fact
|
||||
// is that nothing answered. Writing the whole row from that one fact would
|
||||
// blank the hostname, the map, the seed and the wipe — the last thing this
|
||||
// server said, which is exactly what the pages exist to render while it is
|
||||
// off.
|
||||
await db.markUnreachable(server.id, false)
|
||||
engagement.serverObserved(server, false)
|
||||
return
|
||||
}
|
||||
|
||||
const frame = board.data
|
||||
const boards = (board.data && board.data.boards) || {}
|
||||
const frame = boards['server.hello']
|
||||
|
||||
if (!frame) {
|
||||
await db.putState({ serverId: server.id, reachable: true, online: false })
|
||||
// The sidecar is up and has never heard from the game. Presence is emptied
|
||||
// rather than left alone: a stale list of players on a server nobody can
|
||||
// reach is worse than an empty one, because it looks current.
|
||||
await db.markUnreachable(server.id, true)
|
||||
await ingest.applyBoards(server.id, {})
|
||||
engagement.serverObserved(server, false)
|
||||
return
|
||||
}
|
||||
|
||||
// Unknown is not connected. A `/health` that did not answer while `/boards`
|
||||
// did is odd enough to be worth a line, and reporting the server up on the
|
||||
// strength of a board the game may have left behind hours ago is the defect
|
||||
// this call exists to remove.
|
||||
const connected = Boolean(health.ok && health.data && health.data.plugin_connected === true)
|
||||
if (!health.ok) log.warn('the sidecar answered /boards but not /health', { server: server.id })
|
||||
|
||||
// The presence board is the plugin's last word too. While the game is not
|
||||
// connected it names people as online who may have left hours ago, which is
|
||||
// both wrong and — under §23's rule — a claim about named people nobody made.
|
||||
await ingest.applyBoards(
|
||||
server.id,
|
||||
connected ? boards : { ...boards, 'players.online': { players: [] } },
|
||||
)
|
||||
|
||||
await db.putState({
|
||||
serverId: server.id,
|
||||
reachable: true,
|
||||
// A stored `server.hello` means the game connected; whether it is connected
|
||||
// NOW is a different question, and `/health` is what answers it. The board
|
||||
// alone cannot say, which is why `online` is not simply `true` here — it is
|
||||
// decided by freshness in the model, from `updated_at`.
|
||||
online: true,
|
||||
players: Number(frame.players) || 0,
|
||||
// The plugin is connected NOW (D68). A stored `server.hello` only says it
|
||||
// connected once; the model still applies its own freshness on top.
|
||||
online: connected,
|
||||
// A board the game left behind is a description, not a sighting.
|
||||
seen: connected,
|
||||
players: connected ? Number(frame.players) || 0 : 0,
|
||||
maxPlayers: Number(frame.maxPlayers) || 0,
|
||||
hostname: frame.hostname || null,
|
||||
level: frame.level || null,
|
||||
@@ -102,9 +178,26 @@ async function refreshOne(server) {
|
||||
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
|
||||
bootId: frame.bootId || null,
|
||||
saveCreatedAt: frame.saveCreatedAt || null,
|
||||
wipeId: frame.wipeId || null,
|
||||
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
|
||||
raw: frame,
|
||||
})
|
||||
|
||||
// After the write, so a transition announced is one a page already shows.
|
||||
engagement.serverObserved(server, connected)
|
||||
|
||||
// A restart or a wipe under a running event is the moment core must be told
|
||||
// to ask what the world still holds (§11.1). Only a CONNECTED plugin's hello
|
||||
// counts: a board the game left behind says nothing about now.
|
||||
if (connected) {
|
||||
eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId, worldReady: frame.worldReady })
|
||||
|
||||
// A new boot, wipe or map is a reason to ask what the map is now (D110).
|
||||
// Started, never awaited: a picture is a megabyte over the game link, and
|
||||
// the board poll does not wait on one. `mapImages` keeps one fetch per
|
||||
// server and backs off on its own.
|
||||
mapImages.observe(server, frame)
|
||||
}
|
||||
} catch (err) {
|
||||
// A failure here is one server's, and it must not reach `Promise.allSettled`
|
||||
// as a rejection that hides which one. Log with the id and carry on.
|
||||
@@ -119,14 +212,85 @@ async function refreshOne(server) {
|
||||
* built to look like it — so a module that only needs core at boot time can skip
|
||||
* `core.init` entirely and use this argument.
|
||||
*/
|
||||
/** Runs the cursor for every configured server, independently. */
|
||||
async function ingestAll() {
|
||||
let rows
|
||||
|
||||
try {
|
||||
rows = await servers.listForPolling()
|
||||
} catch (err) {
|
||||
log.warn('could not read the server list', { error: err.message })
|
||||
return
|
||||
}
|
||||
|
||||
// `allSettled`, for the same reason the board poll uses it: six servers behind
|
||||
// one unreachable host must not stop the other five being ingested.
|
||||
await Promise.allSettled(rows.map((server) => ingest.ingestServer(server)))
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
try {
|
||||
const gone = await eventsDb.pruneEvents(EVENT_RETENTION_DAYS)
|
||||
if (gone > 0) log.info('pruned old events', { events: gone, days: EVENT_RETENTION_DAYS })
|
||||
} catch (err) {
|
||||
log.warn('could not prune events', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login attempts that were never approved (D64, PLAN.md §25).
|
||||
*
|
||||
* On its own minute timer rather than the prune's hour: an attempt waits a
|
||||
* minute for its approval, and a staff alert an hour late is not an alert. A
|
||||
* query over stored rows, so it needs nothing kept in memory and a restart loses
|
||||
* nothing; the dedupe key makes a second pass over the same attempt a no-op.
|
||||
*/
|
||||
async function sweep() {
|
||||
let rows
|
||||
try {
|
||||
rows = await servers.listForPolling()
|
||||
} catch (err) {
|
||||
log.warn('could not read the server list', { error: err.message })
|
||||
return
|
||||
}
|
||||
const sent = await engagement.sweepLoginDenied(rows)
|
||||
if (sent > 0) log.info('unapproved logins reported', { attempts: sent })
|
||||
}
|
||||
|
||||
async function onBoot() {
|
||||
await refresh()
|
||||
// The permission manager's rebuild (PLAN_REDESIGNS §1) keeps groups in new
|
||||
// tables. Copied once, before the loop can push anything, so no sync ever
|
||||
// sees a site with its groups missing. A failure is logged, not fatal: the
|
||||
// copy runs again next boot, and until then the site simply has no groups.
|
||||
try {
|
||||
const copied = await permissionsDb.migrateGroups()
|
||||
if (copied) log.info('permission groups copied into the rebuilt tables', { groups: copied })
|
||||
} catch (err) {
|
||||
log.error('could not copy the permission groups', { error: err.message })
|
||||
}
|
||||
// The permission mirror owns its own loop and its own cadence (see
|
||||
// `permSync.js`). It is started rather than run here: a first pass would write
|
||||
// to every configured game server before the website had finished booting, and
|
||||
// nothing about R2 is urgent enough to delay a listener for.
|
||||
permSync.start()
|
||||
// The chat titles have a loop of their own for the same reason (phase 17).
|
||||
titleSync.start()
|
||||
// So do the NPC profiles, adopted and pushed to each server's RunicNPC
|
||||
// (runicnpc stage 4, D244): a first push reads a server before it writes it.
|
||||
npcSync.start()
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS)
|
||||
ingestTimer = setInterval(ingestAll, INGEST_MS)
|
||||
pruneTimer = setInterval(prune, PRUNE_MS)
|
||||
sweepTimer = setInterval(sweep, SWEEP_MS)
|
||||
// Node keeps the process alive for a pending timer. Core's own intervals are
|
||||
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
|
||||
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
||||
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
|
||||
log.info('booted', { refreshMs: REFRESH_MS })
|
||||
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
|
||||
if (timer && typeof timer.unref === 'function') timer.unref()
|
||||
}
|
||||
|
||||
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,9 +302,30 @@ async function onBoot() {
|
||||
* rather than cancelled, since nothing can stop a promise that is still running.
|
||||
*/
|
||||
async function onShutdown() {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
permSync.stop()
|
||||
titleSync.stop()
|
||||
npcSync.stop()
|
||||
|
||||
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
|
||||
if (timer) clearInterval(timer)
|
||||
}
|
||||
|
||||
refreshTimer = null
|
||||
ingestTimer = null
|
||||
pruneTimer = null
|
||||
sweepTimer = null
|
||||
|
||||
log.info('shut down')
|
||||
}
|
||||
|
||||
module.exports = { onBoot, onShutdown, refresh, refreshOne, REFRESH_MS }
|
||||
module.exports = {
|
||||
onBoot,
|
||||
onShutdown,
|
||||
refresh,
|
||||
refreshOne,
|
||||
ingestAll,
|
||||
prune,
|
||||
REFRESH_MS,
|
||||
INGEST_MS,
|
||||
EVENT_RETENTION_DAYS,
|
||||
}
|
||||
|
||||
258
server/catalogue.js
Normal file
258
server/catalogue.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// ── What the bridge can say, and who may hear it ──────────────────────────
|
||||
//
|
||||
// One file, because these two questions have to be answered together or the
|
||||
// second one rots: which frame kinds exist, and which of them a member of the
|
||||
// public may see.
|
||||
//
|
||||
// ── The boundary ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Protocol 2's catalogue includes frames carrying **IP addresses** (a login
|
||||
// attempt, an approval, a ban) and **one player's complaint about another** (a
|
||||
// report), and one — a destroyed structure — that names where somebody lives.
|
||||
// They are stored, because an operator chasing ban evasion needs them and
|
||||
// because the sidecar persists what it is told. They must never reach a public
|
||||
// page.
|
||||
//
|
||||
// **The boundary is enforced HERE, on the side that serves, and not on the wire.**
|
||||
// The plugin could have stamped a `class` on every frame and saved this file the
|
||||
// trouble; it deliberately does not (PROTOCOL.md §8.5). A boundary declared by
|
||||
// the sender is a boundary a compromised — or merely out-of-date — game host can
|
||||
// widen. Core's own shard fan-out works the same way: a public stream with an
|
||||
// allowlist of kinds, and an admin stream that adds the rest.
|
||||
//
|
||||
// ── Default deny, and why it is not paranoia ──────────────────────────────
|
||||
//
|
||||
// `isPublic` answers `false` for a kind it has never heard of. That matters
|
||||
// because of the shape of the mistake it prevents: the next protocol version
|
||||
// adds a kind, this module ingests it happily (`rust_events` stores what it is
|
||||
// given), and a page that filtered by a DENY list would publish it the day it
|
||||
// first arrived — before anybody had decided whether it should be public. With
|
||||
// an allowlist the new kind is invisible until somebody adds it here, which is
|
||||
// the same moment they think about it.
|
||||
//
|
||||
// The test holds this list against `docs/rust-link/PROTOCOL.md` §8.4's table, so
|
||||
// adding a kind to the spec without classifying it fails a build rather than
|
||||
// shipping an address to a public page.
|
||||
|
||||
/**
|
||||
* Kinds a public, signed-out visitor may see.
|
||||
*
|
||||
* Each entry is a decision. `player.chat` is here because a shard's chat is
|
||||
* public by the same logic that makes a killfeed public — it happened in front
|
||||
* of everyone who was on the server — and an operator who disagrees turns the
|
||||
* feature off rather than relying on this list being wrong.
|
||||
*/
|
||||
const PUBLIC_KINDS = Object.freeze([
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.death',
|
||||
'player.chat',
|
||||
'player.tally',
|
||||
'server.wipe',
|
||||
'server.initialized',
|
||||
'server.shutdown',
|
||||
// RunicNPC bosses (runicnpc stage 6, D277, D286). The game says a boss's spawn
|
||||
// and death to everyone on the server, so the feed may too: the death names the
|
||||
// killer by in-game name and counts the players who fought it, and names no
|
||||
// Steam id but the killer's, as the killfeed does. Its phases are told only to
|
||||
// the players near it (D286), so `npc.boss.phase` is staff's, below.
|
||||
'npc.boss.spawned',
|
||||
'npc.boss.died',
|
||||
// Protocol 14: Rust's own world events (PLAN_REDESIGNS §14.3.1, D353, D376).
|
||||
// Public as a KIND, and filtered per `event` inside it by `worldEvents.js`:
|
||||
// an event type this build does not know is staff's alone, a player's name
|
||||
// shows only to the presence audience, a Steam id only to staff, and a
|
||||
// position to nobody. So it is not a presence kind — the event names nobody
|
||||
// once `worldEvents.shapeFor` has read it for a viewer below that audience.
|
||||
'world.event',
|
||||
// Protocol 14: Dynamic PVP opened a PvP zone (PLAN_REDESIGNS §14.3.3). It
|
||||
// names nobody, and `dynamicPvp.shapeFor` keeps its position for staff and an
|
||||
// operator's own event names off public pages.
|
||||
'pvp.zone.created',
|
||||
// Protocol 14: a Dangerous Treasures event opened, and was won
|
||||
// (PLAN_REDESIGNS §14.3.4). Public as kinds, read per viewer by
|
||||
// `dangerousTreasures.shapeFor` as a world event is: the winner's name only
|
||||
// for the presence audience, Steam ids and the position only for staff.
|
||||
'dt.started',
|
||||
'dt.won',
|
||||
// Protocol 14: a guarded crate landed, and was cleared (PLAN_REDESIGNS
|
||||
// §14.3.5). Public as kinds, read per viewer by `guardedCrate.shapeFor` as a
|
||||
// treasure is: the clearer's name only for the presence audience, the Steam id
|
||||
// and the position only for staff.
|
||||
'gc.started',
|
||||
'gc.ended',
|
||||
// Protocol 14: a RaidableBases base appeared, was raided, was bought, or was
|
||||
// locked to a player by an admin (PLAN_REDESIGNS §11.4, D390). Public as
|
||||
// kinds, read per viewer by `raidableBases.shapeFor`: the raiders' and a
|
||||
// buyer's names only for the presence audience, Steam ids, the position and a
|
||||
// run's key only for staff.
|
||||
'raid.started',
|
||||
'raid.completed',
|
||||
'raid.purchased',
|
||||
'raid.locked',
|
||||
])
|
||||
|
||||
/**
|
||||
* Kinds an admin may see and nobody else.
|
||||
*
|
||||
* Listed rather than implied by absence, so that "we know about this kind and it
|
||||
* is restricted" is distinguishable from "nobody has classified this kind" — the
|
||||
* second is a finding, and a bare allowlist cannot tell you which you are
|
||||
* looking at.
|
||||
*/
|
||||
const STAFF_KINDS = Object.freeze([
|
||||
'entity.destroyed',
|
||||
'player.reported',
|
||||
'player.banned',
|
||||
'player.unbanned',
|
||||
'player.login.attempt',
|
||||
'player.approved',
|
||||
// Protocol 3's two account frames. Neither carries a code — the code travels
|
||||
// through the player, which is what makes typing it proof — but both name a
|
||||
// Steam id ALONGSIDE a website account's activity, which is exactly the join a
|
||||
// public page must not be able to make: "this player is that person" is a fact
|
||||
// about somebody's identity, not about what happened on the server.
|
||||
'account.link.requested',
|
||||
'account.unlinked',
|
||||
// Protocol 4. Who holds which privilege in game, and the fact that somebody
|
||||
// changed it by hand — a question about a person's standing and about an
|
||||
// operator's own console, neither of which is a public page's business.
|
||||
'perm.drift',
|
||||
// Protocol 13. How a configuration save's reload ended — and, when it failed,
|
||||
// the tail of the game server's own log, which is an operator's console and
|
||||
// can hold anything a plugin chose to print.
|
||||
'config.outcome',
|
||||
// Protocol 13 (F8, D184). Which plugins a server runs, and which permissions
|
||||
// each one registers: an operator's inventory, and the map of what can be
|
||||
// granted there — not a public page's business.
|
||||
'plugin.loaded',
|
||||
'plugin.unloaded',
|
||||
// The world an event borrowed or made, ending on its own deadline:
|
||||
// `lease.expired` since protocol 8 and `world.expired` since protocol 9 — the
|
||||
// latter recognisable only from protocol 13 (F13). Neither was ever classified
|
||||
// (default deny kept both off public pages); both are an event's machinery,
|
||||
// and the public learns what an event did from core's own announcements.
|
||||
'lease.expired',
|
||||
'world.expired',
|
||||
// Protocol 6. Clan membership, which the org lead made members-only (D49):
|
||||
// who joined which clan, and who threw whom out, is the clan's business. It
|
||||
// reaches a clan's own members through core's Team feed, where core resolves
|
||||
// who is a member, and it reaches the server's public feed not at all.
|
||||
'clan.created',
|
||||
'clan.disbanded',
|
||||
'clan.member.added',
|
||||
'clan.member.left',
|
||||
'clan.member.kicked',
|
||||
// RunicNPC (runicnpc stage 4). `npc.died` names the player who killed it and
|
||||
// everyone who hurt it, so it is a roll call like `player.tally`: staff until
|
||||
// an operator says otherwise. What the public sees of a kill is the tally's
|
||||
// per-profile count on the leaderboard (D250).
|
||||
'npc.died',
|
||||
'npc.health',
|
||||
'npc.placement.changed',
|
||||
// Runicnpc stage 6: a boss's phase is said in game only to the players near it (D286).
|
||||
'npc.boss.phase',
|
||||
// Protocol 14 (§14.3.3): a Dynamic PVP zone closed. The plan keeps its close
|
||||
// a staff line: the public learns a zone opened, and the map shows it going.
|
||||
'pvp.zone.deleted',
|
||||
// Protocol 14 (§14.3.4): a Dangerous Treasures event ended, won or not. The
|
||||
// plan keeps it staff's; the map shows the chest go.
|
||||
'dt.ended',
|
||||
// Protocol 14 (§14.3.5): a guarded crate timed out with guards still standing.
|
||||
// The plan keeps it staff's; the map shows the crate go.
|
||||
'gc.failed',
|
||||
// Protocol 14 (§11.4): a RaidableBases base despawned. Staff's; the map shows
|
||||
// the base go.
|
||||
'raid.ended',
|
||||
])
|
||||
|
||||
/**
|
||||
* The public kinds that say a NAMED player was on the server at a given moment.
|
||||
*
|
||||
* A subset of `PUBLIC_KINDS`, not a third list: these are public-page material
|
||||
* whose audience an operator chooses (`model/visibility`), where the rest of
|
||||
* `PUBLIC_KINDS` is public by construction. The org lead's rule, settled
|
||||
* 2026-09-22: **nothing tells who is online by default** — the narrowest
|
||||
* audience (staff) unless an operator widens it, and a count is never a name.
|
||||
*
|
||||
* `player.death` and `player.chat` are here, and that was decided rather than
|
||||
* overlooked. They are the killfeed and the chat — the content a feed exists
|
||||
* for — and each one says "this person was on at 12:03" as plainly as a connect
|
||||
* frame does. `player.tally` is a per-minute flush that is only ever sent for a
|
||||
* player who is playing, which makes it a roll call with extra steps.
|
||||
*
|
||||
* What is left in the public set once these are removed is the server's own
|
||||
* story — a wipe, a start, a shutdown — which names nobody.
|
||||
*/
|
||||
const PRESENCE_KINDS = Object.freeze([
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.death',
|
||||
'player.chat',
|
||||
'player.tally',
|
||||
// Runicnpc stage 6: a boss's death names its killer, which says that player was on.
|
||||
'npc.boss.died',
|
||||
])
|
||||
|
||||
/** Every event kind the protocol defines, through protocol 14. */
|
||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||
|
||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||
const STAFF = new Set(STAFF_KINDS)
|
||||
const PRESENCE = new Set(PRESENCE_KINDS)
|
||||
|
||||
/**
|
||||
* May a signed-out visitor see this kind?
|
||||
*
|
||||
* Default deny: an unknown kind is not public. Callers pass whatever arrived on
|
||||
* the wire, including a kind from a newer protocol this build has never seen.
|
||||
*/
|
||||
function isPublic(kind) {
|
||||
return PUBLIC.has(kind)
|
||||
}
|
||||
|
||||
/** Is this a kind this build knows about at all? */
|
||||
function isKnown(kind) {
|
||||
return PUBLIC.has(kind) || STAFF.has(kind)
|
||||
}
|
||||
|
||||
/** Does this kind name a player who was on the server at the time? */
|
||||
function isPresence(kind) {
|
||||
return PRESENCE.has(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a list of requested kinds to the ones a viewer may have.
|
||||
*
|
||||
* Returning the allowlist itself when nothing was requested is what makes the
|
||||
* public route safe by construction rather than by remembering to filter: there
|
||||
* is no code path where "no filter" means "everything".
|
||||
*
|
||||
* `presence` defaults to `false` for the same reason `admin` does: a caller that
|
||||
* forgets to say what the viewer may see gets the narrowest answer. The route
|
||||
* resolves it from the operator's setting (`model/visibility`); nothing else
|
||||
* should be passing `true`.
|
||||
*/
|
||||
function kindsFor({ admin = false, presence = false, requested = null } = {}) {
|
||||
const permitted = admin
|
||||
? ALL_KINDS
|
||||
: PUBLIC_KINDS.filter((k) => presence || !PRESENCE.has(k))
|
||||
|
||||
if (!requested || requested.length === 0) return [...permitted]
|
||||
|
||||
const allowed = new Set(permitted)
|
||||
return requested.filter((k) => allowed.has(k))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PUBLIC_KINDS,
|
||||
STAFF_KINDS,
|
||||
PRESENCE_KINDS,
|
||||
ALL_KINDS,
|
||||
isPublic,
|
||||
isKnown,
|
||||
isPresence,
|
||||
kindsFor,
|
||||
}
|
||||
111
server/commands/clan.command.js
Normal file
111
server/commands/clan.command.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// ── `/clan name [server]` — one clan, and its roster where the caller may see it
|
||||
//
|
||||
// D50 deferred it here. A clan's name, colour, score and member count are public
|
||||
// (D58); its ROSTER — who is in it and which of them is on — sits behind the
|
||||
// roster audience (D48), which defaults to the clan's own members plus staff.
|
||||
//
|
||||
// The roster decision is `clans.getForViewer`'s, the same function the web page
|
||||
// and core's `projectRoster` use, so the three cannot disagree about who may
|
||||
// look. What this file adds is D127: **a roster shown to anything short of a
|
||||
// `public` audience goes to the caller alone**, never to the channel.
|
||||
|
||||
const c = require('./common')
|
||||
const clans = require('../model/clans/clans.model')
|
||||
|
||||
/** The candidates an ambiguous name lists. */
|
||||
const CANDIDATES = 10
|
||||
|
||||
/**
|
||||
* Every clan on the servers searched, with the server each came from, and the
|
||||
* servers whose clan board cannot be trusted right now.
|
||||
*/
|
||||
async function gather(servers) {
|
||||
const boards = await Promise.all(servers.map(async (s) => ({ server: s, ...(await clans.listForServer(s.id)) })))
|
||||
return {
|
||||
rows: boards.flatMap((b) => b.clans.map((clan) => ({ clan, server: b.server }))),
|
||||
unreadable: boards.filter((b) => !b.board.supported || !b.board.fresh).map((b) => b.server),
|
||||
}
|
||||
}
|
||||
|
||||
/** An exact name (case-insensitive), then a unique prefix. */
|
||||
function find(rows, wanted) {
|
||||
const needle = wanted.trim().toLowerCase()
|
||||
const exact = rows.filter((r) => r.clan.name.toLowerCase() === needle)
|
||||
if (exact.length === 1) return { hit: exact[0] }
|
||||
if (exact.length > 1) return { ambiguous: exact }
|
||||
const prefix = rows.filter((r) => r.clan.name.toLowerCase().startsWith(needle))
|
||||
if (prefix.length === 1) return { hit: prefix[0] }
|
||||
if (prefix.length > 1) return { ambiguous: prefix }
|
||||
return {}
|
||||
}
|
||||
|
||||
function memberLine(m) {
|
||||
const tags = [m.leader ? 'leader' : null, m.online ? 'online' : null].filter(Boolean)
|
||||
return `${m.name || 'Unknown player'}${tags.length ? ` (${tags.join(', ')})` : ''}`
|
||||
}
|
||||
|
||||
async function handler({ options, actor }) {
|
||||
const wanted = options && typeof options.name === 'string' ? options.name.trim() : ''
|
||||
if (!wanted) return c.refuse('Name a clan.')
|
||||
|
||||
const picked = c.pickServer(await c.listServers(), options.server)
|
||||
const refusal = c.pickRefusal(picked)
|
||||
if (refusal) return refusal
|
||||
const searched = picked.server ? [picked.server] : picked.all
|
||||
|
||||
const { rows, unreadable } = await gather(searched)
|
||||
const { hit, ambiguous } = find(rows, wanted)
|
||||
|
||||
if (ambiguous) {
|
||||
const list = ambiguous.slice(0, CANDIDATES).map((r) => (searched.length > 1 ? `${r.clan.name} on ${r.server.name}` : r.clan.name))
|
||||
return c.refuse(`Several clans match “${wanted}”: ${list.join(', ')}${ambiguous.length > CANDIDATES ? ', …' : ''}`)
|
||||
}
|
||||
if (!hit) {
|
||||
// A board that is missing, stale or unsupported proves nothing about a clan
|
||||
// it does not list. Saying "no such clan" from it would be a guess presented
|
||||
// as a fact.
|
||||
if (unreadable.length) {
|
||||
return c.refuse(
|
||||
`No clan called “${wanted}” is on the boards that can be read right now. ` +
|
||||
`The clan list for ${unreadable.map((s) => s.name).join(', ')} is not available, so it may be there.`,
|
||||
)
|
||||
}
|
||||
return c.refuse(`No clan called “${wanted}”.`)
|
||||
}
|
||||
|
||||
const answer = await clans.getForViewer(hit.clan.externalId, c.viewerOf(actor))
|
||||
if (!answer) return c.refuse(`No clan called “${wanted}”.`)
|
||||
const { clan, roster } = answer
|
||||
|
||||
const fields = [
|
||||
{ name: 'Score', value: String(clan.score), inline: true },
|
||||
{ name: 'Members', value: clan.maxMembers ? `${clan.memberCount}/${clan.maxMembers}` : String(clan.memberCount), inline: true },
|
||||
{ name: 'Server', value: clan.serverName || hit.server.name, inline: true },
|
||||
]
|
||||
if (clan.color) fields.push({ name: 'Colour', value: clan.color, inline: true })
|
||||
|
||||
const base = { title: clan.name, url: c.pageUrl(`/rust/clans/${encodeURIComponent(clan.externalId)}`), fields }
|
||||
if (!roster.visible || !roster.members.length) return base
|
||||
|
||||
// Leaders first, then whoever is on, then everyone else — the order a person
|
||||
// looking for "who can I talk to" wants.
|
||||
const members = [...roster.members].sort(
|
||||
(a, b) => Number(b.leader) - Number(a.leader) || Number(b.online) - Number(a.online) || String(a.name).localeCompare(String(b.name)),
|
||||
)
|
||||
fields.push({ name: 'Roster', value: c.fitLines(members.map(memberLine)), inline: false })
|
||||
|
||||
// D127 and D48 together: a roster the whole site may see may be posted; any
|
||||
// narrower one is for the caller.
|
||||
return { ...base, ephemeral: roster.audience !== 'public' }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'clan',
|
||||
description: 'A clan on a Rust server — score and members, and its roster where you may see it',
|
||||
options: [
|
||||
{ name: 'name', type: 'string', description: 'Clan name, or the start of it', required: true },
|
||||
{ name: 'server', type: 'string', description: 'Server name or id (every server when left out)', required: false },
|
||||
],
|
||||
access: 'everyone',
|
||||
handler,
|
||||
}
|
||||
187
server/commands/common.js
Normal file
187
server/commands/common.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// ── What the five commands share (phase 16, R11) ───────────────────────────
|
||||
//
|
||||
// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0). The handlers
|
||||
// run in the WEBSITE process: core pulls the definitions over its internal API to
|
||||
// the bot and dispatches each call back here. Nothing in these files knows what
|
||||
// Discord is — a handler is handed an `actor` and returns an envelope.
|
||||
//
|
||||
// ── The two privacy rules (D127) ──────────────────────────────────────────
|
||||
//
|
||||
// 1. **A refusal is private.** Core already delivers an `ephemeral` answer as a
|
||||
// private follow-up; `refuse()` below is the one way these files say no, and
|
||||
// it always sets the flag.
|
||||
// 2. **An answer narrower than `public` is private too.** Core has no reverse
|
||||
// case: an answer WITHOUT the flag is posted to the channel the command was
|
||||
// run in, where everybody reads it. So a moderator running `/online` in a
|
||||
// public channel must get the names privately, or the names they may see are
|
||||
// published to everyone who may not. That is the leak module-uo's `/guild`
|
||||
// has (D132, Module-uo#46), and every answer here decides it explicitly.
|
||||
//
|
||||
// ── Three seconds (`HANDLER_TIMEOUT_MS`) ──────────────────────────────────
|
||||
//
|
||||
// Every answer reads this module's own tables. **No command asks a sidecar**: a
|
||||
// game that is slow to answer would cost the reply, and the tables already hold
|
||||
// the last thing each server said.
|
||||
|
||||
const core = require('../core')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const visibility = require('../model/visibility/visibility.model')
|
||||
|
||||
/**
|
||||
* Where the caller sits on the presence ladder.
|
||||
*
|
||||
* An UNLINKED caller is `public`, answered directly. Handing `visibility.viewer`
|
||||
* a synthetic request with no user would make it fall through to
|
||||
* `getUserFromRequest`, which expects real cookies (module-uo's phase 3 bug). A
|
||||
* linked caller is handed over as `{ user }`, which `viewer` reads first and then
|
||||
* re-reads from the account row — so a banned or demoted account is judged by
|
||||
* what it is now, not by what core resolved.
|
||||
*/
|
||||
async function levelFor(actor) {
|
||||
if (!actor || actor.userId == null) return 'public'
|
||||
return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
|
||||
}
|
||||
|
||||
/** The viewer shape `model/clans` takes: `{ userId, role }` or null. */
|
||||
const viewerOf = (actor) => (actor && actor.userId != null ? { userId: actor.userId, role: actor.role || null } : null)
|
||||
|
||||
/** Every answer that says no. Private, always (D127 rule 1). */
|
||||
const refuse = (text) => ({ text, ephemeral: true })
|
||||
|
||||
/** An absolute link to a page on the site. */
|
||||
const pageUrl = (path) => `${core.baseUrl}${path}`
|
||||
|
||||
const serverUrl = (server) => pageUrl(`/rust/servers/${encodeURIComponent(server.id)}`)
|
||||
|
||||
/**
|
||||
* Which server a `server` option names, among `list`.
|
||||
*
|
||||
* An exact id, then an exact name (case-insensitive), then a unique prefix of
|
||||
* either. Servers are added at runtime and `choices` are fixed at load, so this
|
||||
* is free text matched here. A wrong-server answer is worse than "say which one",
|
||||
* so two prefix matches are ambiguous rather than a guess.
|
||||
*
|
||||
* Answers `{ server }`, `{ all }` (no option and more than one server),
|
||||
* `{ none }` (no servers at all), `{ ambiguous }` or `{ missing }`.
|
||||
*/
|
||||
function pickServer(list, option) {
|
||||
if (!list.length) return { none: true }
|
||||
const wanted = typeof option === 'string' ? option.trim().toLowerCase() : ''
|
||||
if (!wanted) return list.length === 1 ? { server: list[0] } : { all: list }
|
||||
|
||||
const byId = list.find((s) => s.id.toLowerCase() === wanted)
|
||||
if (byId) return { server: byId }
|
||||
const byName = list.filter((s) => s.name.toLowerCase() === wanted)
|
||||
if (byName.length === 1) return { server: byName[0] }
|
||||
|
||||
const prefix = list.filter((s) => s.id.toLowerCase().startsWith(wanted) || s.name.toLowerCase().startsWith(wanted))
|
||||
if (prefix.length === 1) return { server: prefix[0] }
|
||||
if (prefix.length > 1) return { ambiguous: prefix }
|
||||
return { missing: option.trim() }
|
||||
}
|
||||
|
||||
/** The refusal for each way `pickServer` can fail to name exactly one server. */
|
||||
function pickRefusal(picked, { needOne = false } = {}) {
|
||||
const names = (list) => list.map((s) => `${s.name} (\`${s.id}\`)`).join(', ')
|
||||
if (picked.none) return refuse('No Rust servers are set up on this site yet.')
|
||||
if (picked.missing) return refuse(`No server matches “${picked.missing}”.`)
|
||||
if (picked.ambiguous) return refuse(`Several servers match: ${names(picked.ambiguous)}. Name one.`)
|
||||
if (needOne && picked.all) return refuse(`Which server? This site follows ${names(picked.all)}.`)
|
||||
return null
|
||||
}
|
||||
|
||||
/** Every enabled server, as the public shape. */
|
||||
const listServers = (now = Date.now()) => servers.listPublic(now)
|
||||
|
||||
// ── Time, in words ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Plain text, not a platform's timestamp markup: the envelope is platform-
|
||||
// agnostic (§7.1), and a second platform would print `<t:…>` literally. So an
|
||||
// instant is written in UTC, with how far away it is beside it — the relative
|
||||
// part is the one every reader can use without converting.
|
||||
|
||||
const UTC_FORMAT = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'UTC',
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
|
||||
function relative(ms, now = Date.now()) {
|
||||
const diff = ms - now
|
||||
const abs = Math.abs(diff)
|
||||
const unit = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
||||
let span
|
||||
if (abs < 60_000) span = 'less than a minute'
|
||||
else if (abs < 3_600_000) span = unit(Math.round(abs / 60_000), 'minute')
|
||||
else if (abs < 172_800_000) span = unit(Math.round(abs / 3_600_000), 'hour')
|
||||
else span = unit(Math.round(abs / 86_400_000), 'day')
|
||||
return diff >= 0 ? `in ${span}` : `${span} ago`
|
||||
}
|
||||
|
||||
/** `Thu 1 Oct, 18:00 UTC (in 6 days)`, or null for no instant. */
|
||||
function when(value, now = Date.now()) {
|
||||
if (!value) return null
|
||||
const ms = value instanceof Date ? value.getTime() : Date.parse(value)
|
||||
if (Number.isNaN(ms)) return null
|
||||
return `${UTC_FORMAT.format(ms).replace(/,? (\d\d:\d\d)$/, ', $1')} UTC (${relative(ms, now)})`
|
||||
}
|
||||
|
||||
/** Just the distance: `3 days ago`. */
|
||||
function ago(value, now = Date.now()) {
|
||||
if (!value) return null
|
||||
const ms = value instanceof Date ? value.getTime() : Date.parse(value)
|
||||
return Number.isNaN(ms) ? null : relative(ms, now)
|
||||
}
|
||||
|
||||
const SOURCES = {
|
||||
forced: 'the monthly forced wipe',
|
||||
rule: 'the server’s own schedule',
|
||||
once: 'rescheduled by the operator',
|
||||
}
|
||||
|
||||
/** The next wipe in words, with what decided it — or `null` for no schedule. */
|
||||
function nextWipeText(server, now = Date.now()) {
|
||||
const next = server.nextWipe
|
||||
if (!next) return null
|
||||
return `${when(next.at, now)} — ${SOURCES[next.source] || next.source}`
|
||||
}
|
||||
|
||||
/** Players, as a number out of the maximum, for a server that is up. */
|
||||
const playerCount = (server) => (server.maxPlayers ? `${server.players}/${server.maxPlayers}` : String(server.players))
|
||||
|
||||
/**
|
||||
* Cut a list of lines to fit one embed field (1 024 characters) and a count,
|
||||
* ending in "and N more" when it had to cut.
|
||||
*/
|
||||
function fitLines(lines, { max = lines.length, limit = 1000 } = {}) {
|
||||
const kept = []
|
||||
let length = 0
|
||||
for (const line of lines.slice(0, max)) {
|
||||
if (length + line.length + 1 > limit - 20) break
|
||||
kept.push(line)
|
||||
length += line.length + 1
|
||||
}
|
||||
const rest = lines.length - kept.length
|
||||
return rest > 0 ? `${kept.join('\n')}\nand ${rest} more` : kept.join('\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
levelFor,
|
||||
viewerOf,
|
||||
refuse,
|
||||
pageUrl,
|
||||
serverUrl,
|
||||
pickServer,
|
||||
pickRefusal,
|
||||
listServers,
|
||||
relative,
|
||||
when,
|
||||
ago,
|
||||
nextWipeText,
|
||||
playerCount,
|
||||
fitLines,
|
||||
}
|
||||
16
server/commands/index.js
Normal file
16
server/commands/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// ── The slash commands (phase 16, R11, D126) ──────────────────────────────
|
||||
//
|
||||
// Five read-only questions, answered from this module's own tables. No write
|
||||
// verbs, and no `/link`: the account link stays on R1's two surfaces.
|
||||
//
|
||||
// Registered as ONE batch in `index.js`. Names are bare words (§32.4 reading 1):
|
||||
// Discord scopes commands to the bot that owns them, and one module per site
|
||||
// (website#204) means no other module competes for them. None collides with the
|
||||
// bot's own built-ins, which core cannot see and the bot resolves against us.
|
||||
module.exports = [
|
||||
require('./status.command'),
|
||||
require('./wipe.command'),
|
||||
require('./top.command'),
|
||||
require('./online.command'),
|
||||
require('./clan.command'),
|
||||
]
|
||||
93
server/commands/online.command.js
Normal file
93
server/commands/online.command.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// ── `/online [server]` — how many, and who, where the caller may see it ─────
|
||||
//
|
||||
// The command with the gate. The org lead's rule (2026-09-22) is that nothing
|
||||
// names who is online by default: the COUNT is public and the NAMES reach the
|
||||
// server's presence audience (D42, per-server override D45), staff unless an
|
||||
// operator widened it.
|
||||
//
|
||||
// And D127 on top of it: **a caller inside a narrower-than-public audience gets
|
||||
// the names PRIVATELY.** The answer is posted where the command was run, and a
|
||||
// moderator's `/online` in a public channel would otherwise hand the roll call to
|
||||
// everyone reading the channel. Only a server whose audience is `public` posts
|
||||
// its names in the open.
|
||||
|
||||
const c = require('./common')
|
||||
const events = require('../model/events/events.model')
|
||||
const visibility = require('../model/visibility/visibility.model')
|
||||
|
||||
/** Fifty names, then "and N more" (§32.4 reading 2). */
|
||||
const LIMIT = 50
|
||||
|
||||
// The link nudge, and only when it is TRUE — module-uo's rule. Linking a Discord
|
||||
// account to a site account earns `signed_in` and nothing above it; `staff` is a
|
||||
// role an operator grants. So a server that shows names to staff is not a reason
|
||||
// to tell anybody to link.
|
||||
function linkNudge(actor, required) {
|
||||
if (actor && actor.isLinked) return null
|
||||
if (required !== 'signed_in') return null
|
||||
return 'Link your Discord account on the site to see who is on — this server shows names to signed-in members.'
|
||||
}
|
||||
|
||||
async function forServer(server, actor, level) {
|
||||
const required = await visibility.presenceFor(server.id)
|
||||
const visible = visibility.meets(level, required)
|
||||
const count = server.online ? server.players : 0
|
||||
|
||||
// Offline, or nobody on: there are no names to decide about, and saying who
|
||||
// was on when the server went down would be the presence board's last word
|
||||
// presented as now.
|
||||
if (!server.online) return { text: `${server.name} is offline.`, url: c.serverUrl(server) }
|
||||
if (!visible || count === 0) {
|
||||
return {
|
||||
title: `${server.name} — ${count} online`,
|
||||
url: c.serverUrl(server),
|
||||
...(visible ? {} : { notice: linkNudge(actor, required) }),
|
||||
}
|
||||
}
|
||||
|
||||
const players = await events.online(server.id)
|
||||
const names = players.map((p) => (p.sleeping ? `${p.name || 'Unknown player'} (sleeping)` : p.name || 'Unknown player'))
|
||||
return {
|
||||
title: `${server.name} — ${players.length} online`,
|
||||
url: c.serverUrl(server),
|
||||
text: c.fitLines(names, { max: LIMIT, limit: 1900 }),
|
||||
// D127. Anything short of `public` is for the caller alone.
|
||||
ephemeral: required !== 'public',
|
||||
}
|
||||
}
|
||||
|
||||
async function handler({ options, actor }) {
|
||||
const now = Date.now()
|
||||
const picked = c.pickServer(await c.listServers(now), options && options.server)
|
||||
const refusal = c.pickRefusal(picked)
|
||||
if (refusal) return refusal
|
||||
|
||||
const level = await c.levelFor(actor)
|
||||
if (picked.server) return forServer(picked.server, actor, level)
|
||||
|
||||
// The fleet: counts only, which are public. Names are one server's question,
|
||||
// and the closing line offers it only when naming a server would show some.
|
||||
const visibleSomewhere = (
|
||||
await Promise.all(picked.all.map(async (s) => visibility.meets(level, await visibility.presenceFor(s.id))))
|
||||
).some(Boolean)
|
||||
return {
|
||||
title: 'Online now',
|
||||
url: c.pageUrl('/rust'),
|
||||
fields: picked.all.slice(0, 25).map((s) => ({
|
||||
name: s.name,
|
||||
value: s.online ? `${s.players} online` : 'Offline',
|
||||
inline: true,
|
||||
})),
|
||||
...(visibleSomewhere ? { text: 'Name a server to see who is on.' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'online',
|
||||
description: 'How many are on a Rust server, and who, where this site shows names',
|
||||
options: [{ name: 'server', type: 'string', description: 'Server name or id (counts for all when left out)', required: false }],
|
||||
// Everyone, deliberately: `linked` would hide the command from the unlinked
|
||||
// members the nudge exists to invite. The gate is inside the handler.
|
||||
access: 'everyone',
|
||||
handler,
|
||||
}
|
||||
60
server/commands/status.command.js
Normal file
60
server/commands/status.command.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// ── `/status [server]` — is it up, how full, when did it wipe ───────────────
|
||||
//
|
||||
// Public at every setting: everything here is on the public server list already.
|
||||
// The count is a number and names nobody; the names are `/online`'s business.
|
||||
|
||||
const c = require('./common')
|
||||
|
||||
/** One line per server, for the fleet view. */
|
||||
function summary(server, now) {
|
||||
const parts = [server.online ? `Online · ${c.playerCount(server)}` : 'Offline']
|
||||
if (server.wipedAt) parts.push(`wiped ${c.ago(server.wipedAt, now)}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function detail(server, now) {
|
||||
const fields = [
|
||||
{ name: 'Status', value: server.online ? 'Online' : 'Offline', inline: true },
|
||||
{ name: 'Players', value: server.online ? c.playerCount(server) : '—', inline: true },
|
||||
]
|
||||
if (server.worldSize) {
|
||||
fields.push({ name: 'Map', value: server.seed != null ? `${server.worldSize} · seed ${server.seed}` : String(server.worldSize), inline: true })
|
||||
}
|
||||
if (server.wipedAt) fields.push({ name: 'Last wipe', value: c.when(server.wipedAt, now), inline: false })
|
||||
const next = c.nextWipeText(server, now)
|
||||
if (next) fields.push({ name: 'Next wipe', value: next, inline: false })
|
||||
|
||||
// An offline server says when it was last heard from, which is the difference
|
||||
// between "down for a restart" and "gone for a week". A server nothing has ever
|
||||
// heard from says so rather than printing the epoch.
|
||||
if (!server.online) {
|
||||
fields.push({ name: 'Last seen', value: server.lastSeenAt ? c.when(server.lastSeenAt, now) : 'never', inline: false })
|
||||
}
|
||||
return { title: server.name, url: c.serverUrl(server), fields }
|
||||
}
|
||||
|
||||
async function handler({ options }) {
|
||||
const now = Date.now()
|
||||
const picked = c.pickServer(await c.listServers(now), options && options.server)
|
||||
const refusal = c.pickRefusal(picked)
|
||||
if (refusal) return refusal
|
||||
if (picked.server) return detail(picked.server, now)
|
||||
|
||||
// An embed takes 25 fields. A fleet larger than that is a site that has a
|
||||
// server list page, and the title links to it.
|
||||
return {
|
||||
title: 'Rust servers',
|
||||
url: c.pageUrl('/rust'),
|
||||
fields: picked.all.slice(0, 25).map((s) => ({ name: s.name, value: summary(s, now), inline: false })),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'status',
|
||||
description: 'Is a Rust server up, how many are on, and when it last wiped',
|
||||
options: [{ name: 'server', type: 'string', description: 'Server name or id (all servers when left out)', required: false }],
|
||||
// Everyone: nothing here is narrower than public, and the gates that DO matter
|
||||
// in other commands are resolved inside their handlers.
|
||||
access: 'everyone',
|
||||
handler,
|
||||
}
|
||||
85
server/commands/top.command.js
Normal file
85
server/commands/top.command.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// ── `/top [stat] [server] [alltime]` — the leaderboard's top ten (D126) ─────
|
||||
//
|
||||
// Public at every setting, as on the web: the leaderboard's NAMES are public, and
|
||||
// only `lastSeen` sits behind presence (a tally refreshes it every minute a player
|
||||
// is on, so it is the Online tab by another name). This answer never carries a
|
||||
// `lastSeen`, and never a Steam id — `events.leaderboard` is asked with
|
||||
// `presence: false`, so the field is not there to leak.
|
||||
|
||||
const c = require('./common')
|
||||
const events = require('../model/events/events.model')
|
||||
|
||||
/** Ten rows: a summary a person reads, not a table they scroll (§32.4 reading 2). */
|
||||
const LIMIT = 10
|
||||
|
||||
// `stat`'s choices are fixed, so they are a `choices` list rather than free
|
||||
// text. The value is the leaderboard's own sort key.
|
||||
const STATS = {
|
||||
kills: { sort: 'kills', label: 'kills', value: (r) => r.kills },
|
||||
deaths: { sort: 'deaths', label: 'deaths', value: (r) => r.deaths },
|
||||
npckills: { sort: 'npcKills', label: 'NPC kills', value: (r) => r.npcKills },
|
||||
playtime: { sort: 'playtime', label: 'playtime', value: (r) => r.playtimeSec, format: hours },
|
||||
}
|
||||
|
||||
function hours(sec) {
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
return h ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
async function handler({ options }) {
|
||||
const now = Date.now()
|
||||
const stat = STATS[(options && options.stat) || 'kills'] || STATS.kills
|
||||
|
||||
// A leaderboard is one server's. With several and none named the command asks
|
||||
// rather than choosing one, privately — the question is for the caller.
|
||||
const picked = c.pickServer(await c.listServers(now), options && options.server)
|
||||
const refusal = c.pickRefusal(picked, { needOne: true })
|
||||
if (refusal) return refusal
|
||||
const { server } = picked
|
||||
|
||||
// The current wipe unless asked for all-time. A server that has not reported
|
||||
// a wipe yet has nothing to scope to, and all-time is the honest answer.
|
||||
const allTime = Boolean(options && options.alltime) || !server.wipeId
|
||||
const rows = await events.leaderboard({
|
||||
serverId: server.id,
|
||||
wipeId: allTime ? null : server.wipeId,
|
||||
sort: stat.sort,
|
||||
limit: LIMIT,
|
||||
presence: false,
|
||||
})
|
||||
|
||||
const scope = allTime ? 'all time' : 'this wipe'
|
||||
const title = `${server.name} — top ${stat.label}, ${scope}`
|
||||
if (!rows.length) return { title, url: c.serverUrl(server), text: `Nobody is on the board for ${scope} yet.` }
|
||||
|
||||
const format = stat.format || String
|
||||
return {
|
||||
title,
|
||||
url: c.serverUrl(server),
|
||||
text: rows.map((r, i) => `${i + 1}. ${r.name || 'Unknown player'} — ${format(stat.value(r))}`).join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'top',
|
||||
description: 'The top ten players on a Rust server by kills, deaths, NPC kills or playtime',
|
||||
options: [
|
||||
{
|
||||
name: 'stat',
|
||||
type: 'string',
|
||||
description: 'What to rank by (kills when left out)',
|
||||
required: false,
|
||||
choices: [
|
||||
{ name: 'Kills', value: 'kills' },
|
||||
{ name: 'Deaths', value: 'deaths' },
|
||||
{ name: 'NPC kills', value: 'npckills' },
|
||||
{ name: 'Playtime', value: 'playtime' },
|
||||
],
|
||||
},
|
||||
{ name: 'server', type: 'string', description: 'Server name or id (needed when there are several)', required: false },
|
||||
{ name: 'alltime', type: 'boolean', description: 'Every wipe rather than the current one', required: false },
|
||||
],
|
||||
access: 'everyone',
|
||||
handler,
|
||||
}
|
||||
38
server/commands/wipe.command.js
Normal file
38
server/commands/wipe.command.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// ── `/wipe [server]` — when it wipes next, and when it last did (D128) ──────
|
||||
//
|
||||
// Public: a wipe date is announced to bring players back. The next wipe is the
|
||||
// operator's schedule, computed on this read (`model/servers/nextWipe.js`); a
|
||||
// server with no schedule says so and shows only the last wipe it reported.
|
||||
|
||||
const c = require('./common')
|
||||
|
||||
function lines(server, now) {
|
||||
return [
|
||||
`Next: ${c.nextWipeText(server, now) || 'no schedule set'}`,
|
||||
`Last: ${server.wipedAt ? c.when(server.wipedAt, now) : 'not reported yet'}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function handler({ options }) {
|
||||
const now = Date.now()
|
||||
const picked = c.pickServer(await c.listServers(now), options && options.server)
|
||||
const refusal = c.pickRefusal(picked)
|
||||
if (refusal) return refusal
|
||||
|
||||
if (picked.server) {
|
||||
return { title: `${picked.server.name} — wipes`, url: c.serverUrl(picked.server), text: lines(picked.server, now) }
|
||||
}
|
||||
return {
|
||||
title: 'Wipes',
|
||||
url: c.pageUrl('/rust'),
|
||||
fields: picked.all.slice(0, 25).map((s) => ({ name: s.name, value: lines(s, now), inline: false })),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'wipe',
|
||||
description: 'When a Rust server wipes next, and when it last wiped',
|
||||
options: [{ name: 'server', type: 'string', description: 'Server name or id (all servers when left out)', required: false }],
|
||||
access: 'everyone',
|
||||
handler,
|
||||
}
|
||||
527
server/configEdit.js
Normal file
527
server/configEdit.js
Normal file
@@ -0,0 +1,527 @@
|
||||
// ── Editing a plugin's config without rewriting the numbers ───────────────
|
||||
//
|
||||
// R18's base tier generates a form from a config file's VALUES — a boolean
|
||||
// becomes a toggle, a number a field, a string a text box — so it works for
|
||||
// whatever plugins an operator happens to have installed, including ones added
|
||||
// after we shipped. This file is the half of that which cannot be done naively.
|
||||
//
|
||||
// ── The trap ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// **JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` yields
|
||||
// the number `1`, and `JSON.stringify` writes it back as `1`. Both frameworks
|
||||
// deserialize a config into typed C# classes, so a naive read-modify-write
|
||||
// silently rewrites every whole-numbered float as an integer — **on fields
|
||||
// nobody touched** — and Newtonsoft may coerce it or may throw. A throw at load
|
||||
// means the plugin does not come back, and R6/R17 make four of them required.
|
||||
//
|
||||
// The fields at risk are exactly the ones a Rust server tunes: gather rates,
|
||||
// multipliers, scales.
|
||||
//
|
||||
// ── So nothing here ever parses, mutates and re-serialises ────────────────
|
||||
//
|
||||
// `scan` is a JSON reader that records, for every value, the **span of source
|
||||
// text** it came from. `applyEdits` splices new literals into those spans and
|
||||
// leaves every other byte of the document exactly as it was — including the
|
||||
// author's indentation, key order, and the `.0` on a float nobody edited.
|
||||
//
|
||||
// Two rules fall out of that and both are deliberate:
|
||||
//
|
||||
// 1. **A number's new value arrives as the literal text an admin typed**, never
|
||||
// as a JavaScript number. `2.50` stays `2.50`; `1.0` stays `1.0`. The value
|
||||
// never becomes a `Number` anywhere in this module, which is the only way to
|
||||
// be sure it cannot be re-serialised into something else.
|
||||
// 2. **The generated form is type-preserving.** An edit may change what a value
|
||||
// IS, never what KIND of thing it is; changing a number into a string, or
|
||||
// adding a key, is a structural change and belongs in the raw-JSON tier,
|
||||
// where the admin is editing the document itself.
|
||||
//
|
||||
// Nothing in this file touches the network, a database, or core.
|
||||
|
||||
/** Value kinds this module names, in the language the form speaks. */
|
||||
const KINDS = ['object', 'array', 'string', 'number', 'boolean', 'null']
|
||||
|
||||
/**
|
||||
* A JSON number, by the grammar rather than by `Number()`.
|
||||
*
|
||||
* Used to judge a literal an admin typed. `Number('0x10')`, `Number('')` and
|
||||
* `Number(' 1 ')` are all happily finite and none of the three is JSON, so the
|
||||
* check has to be the grammar — which is also what keeps `1.0` and `1e3`
|
||||
* acceptable, since preserving those is the entire point.
|
||||
*/
|
||||
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/
|
||||
|
||||
/**
|
||||
* Words that make a value a secret.
|
||||
*
|
||||
* Matched against the key split into WORDS, not as a substring: `Monkey` and
|
||||
* `Keybind` contain "key" and neither is a credential, and a config editor that
|
||||
* masked every third field would teach an operator to ignore the mask.
|
||||
*/
|
||||
const SECRET_WORDS = new Set([
|
||||
'key',
|
||||
'keys',
|
||||
'apikey',
|
||||
'token',
|
||||
'tokens',
|
||||
'secret',
|
||||
'secrets',
|
||||
'password',
|
||||
'passwd',
|
||||
'pass',
|
||||
'webhook',
|
||||
'webhooks',
|
||||
'credential',
|
||||
'credentials',
|
||||
'auth',
|
||||
])
|
||||
|
||||
class JsonScanError extends Error {}
|
||||
|
||||
/**
|
||||
* Reads `text` into a tree of nodes that remember where they came from.
|
||||
*
|
||||
* Every node carries `start` and `end`, the half-open span of the value in the
|
||||
* source. A caller that only wants the data can read `value`; a caller that
|
||||
* wants to CHANGE the data uses the span, because the span is the only thing
|
||||
* that survives a round trip unchanged.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {object} the root node
|
||||
* @throws {JsonScanError} with a position, on anything that is not JSON
|
||||
*/
|
||||
function scan(text) {
|
||||
const src = String(text)
|
||||
let at = 0
|
||||
|
||||
function fail(message) {
|
||||
throw new JsonScanError(`${message} at offset ${at}`)
|
||||
}
|
||||
|
||||
function ws() {
|
||||
while (at < src.length && (src[at] === ' ' || src[at] === '\t' || src[at] === '\n' || src[at] === '\r')) at += 1
|
||||
}
|
||||
|
||||
function literal(word, value) {
|
||||
if (src.startsWith(word, at)) {
|
||||
const start = at
|
||||
at += word.length
|
||||
return { type: word === 'null' ? 'null' : 'boolean', value, start, end: at }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function string() {
|
||||
const start = at
|
||||
at += 1 // the opening quote
|
||||
|
||||
let out = ''
|
||||
|
||||
while (at < src.length) {
|
||||
const ch = src[at]
|
||||
|
||||
if (ch === '"') {
|
||||
at += 1
|
||||
return { type: 'string', value: out, start, end: at }
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
const esc = src[at + 1]
|
||||
at += 2
|
||||
|
||||
if (esc === 'u') {
|
||||
const hex = src.slice(at, at + 4)
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape')
|
||||
out += String.fromCharCode(parseInt(hex, 16))
|
||||
at += 4
|
||||
} else if (esc === 'n') out += '\n'
|
||||
else if (esc === 't') out += '\t'
|
||||
else if (esc === 'r') out += '\r'
|
||||
else if (esc === 'b') out += '\b'
|
||||
else if (esc === 'f') out += '\f'
|
||||
else if (esc === '"' || esc === '\\' || esc === '/') out += esc
|
||||
else fail('bad escape')
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out += ch
|
||||
at += 1
|
||||
}
|
||||
|
||||
return fail('unterminated string')
|
||||
}
|
||||
|
||||
function number() {
|
||||
const start = at
|
||||
if (src[at] === '-') at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
if (src[at] === '.') {
|
||||
at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
}
|
||||
if (src[at] === 'e' || src[at] === 'E') {
|
||||
at += 1
|
||||
if (src[at] === '+' || src[at] === '-') at += 1
|
||||
while (at < src.length && /[0-9]/.test(src[at])) at += 1
|
||||
}
|
||||
|
||||
const raw = src.slice(start, at)
|
||||
if (!JSON_NUMBER.test(raw)) fail(`'${raw}' is not a number`)
|
||||
|
||||
// `raw` is the fact; `value` is a convenience for rendering and comparison,
|
||||
// and is never written back to the document.
|
||||
return { type: 'number', value: Number(raw), raw, start, end: at }
|
||||
}
|
||||
|
||||
function value() {
|
||||
ws()
|
||||
const ch = src[at]
|
||||
|
||||
if (ch === '{') return object()
|
||||
if (ch === '[') return array()
|
||||
if (ch === '"') return string()
|
||||
if (ch === '-' || (ch >= '0' && ch <= '9')) return number()
|
||||
|
||||
const lit = literal('true', true) || literal('false', false) || literal('null', null)
|
||||
if (lit) return lit
|
||||
|
||||
return fail('unexpected character')
|
||||
}
|
||||
|
||||
function object() {
|
||||
const start = at
|
||||
at += 1 // {
|
||||
const children = []
|
||||
ws()
|
||||
|
||||
if (src[at] === '}') {
|
||||
at += 1
|
||||
return { type: 'object', children, start, end: at }
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
ws()
|
||||
if (src[at] !== '"') fail('expected a key')
|
||||
const key = string()
|
||||
ws()
|
||||
if (src[at] !== ':') fail('expected a colon')
|
||||
at += 1
|
||||
|
||||
const child = value()
|
||||
child.key = key.value
|
||||
child.keyStart = key.start
|
||||
child.keyEnd = key.end
|
||||
children.push(child)
|
||||
|
||||
ws()
|
||||
if (src[at] === ',') {
|
||||
at += 1
|
||||
continue
|
||||
}
|
||||
if (src[at] === '}') {
|
||||
at += 1
|
||||
return { type: 'object', children, start, end: at }
|
||||
}
|
||||
return fail('expected a comma or a closing brace')
|
||||
}
|
||||
}
|
||||
|
||||
function array() {
|
||||
const start = at
|
||||
at += 1 // [
|
||||
const children = []
|
||||
ws()
|
||||
|
||||
if (src[at] === ']') {
|
||||
at += 1
|
||||
return { type: 'array', children, start, end: at }
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const child = value()
|
||||
child.index = children.length
|
||||
children.push(child)
|
||||
|
||||
ws()
|
||||
if (src[at] === ',') {
|
||||
at += 1
|
||||
continue
|
||||
}
|
||||
if (src[at] === ']') {
|
||||
at += 1
|
||||
return { type: 'array', children, start, end: at }
|
||||
}
|
||||
return fail('expected a comma or a closing bracket')
|
||||
}
|
||||
}
|
||||
|
||||
const root = value()
|
||||
ws()
|
||||
if (at !== src.length) fail('trailing content')
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/** Splits a config key into words, across camelCase, snake_case, spaces and dots. */
|
||||
function words(key) {
|
||||
return String(key)
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key names a credential. See `SECRET_WORDS`.
|
||||
*
|
||||
* **A field flagged here is not emptied.** D37 decided the raw tier shows real
|
||||
* values — an admin can already read the file over SSH — so the API answers with
|
||||
* the document as it is, the form renders a flagged field masked with a reveal
|
||||
* control, and the flag's load-bearing use is the audit trail, where the values
|
||||
* genuinely never appear.
|
||||
*/
|
||||
function isSecretKey(key) {
|
||||
return words(key).some((w) => SECRET_WORDS.has(w))
|
||||
}
|
||||
|
||||
/** A pointer as a person reads it: `Settings.Rates[0].Wood`. */
|
||||
function pointerPath(pointer) {
|
||||
return pointer
|
||||
.map((step) => (typeof step === 'number' ? `[${step}]` : step))
|
||||
.join('.')
|
||||
.replace(/\.\[/g, '[')
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a scanned tree into the flat description the form is built from.
|
||||
*
|
||||
* **What is NOT here is as deliberate as what is.** There are no descriptions,
|
||||
* no minimums, no maximums and no allowed-value sets, because a config file
|
||||
* carries none: the key name is the entire label. An empty array and a `null`
|
||||
* carry no type at all, so nothing can be inferred for them and they are marked
|
||||
* `advanced` — the raw tier is where a value with no shape gets edited.
|
||||
*
|
||||
* @param {object} root from `scan`
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.maxDepth] past this, a subtree is advanced-only
|
||||
* @param {string[]} [options.locked] top-level keys that may not be edited (D38)
|
||||
*/
|
||||
function describe(root, { maxDepth = 6, locked = [] } = {}) {
|
||||
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
|
||||
const fields = []
|
||||
|
||||
function visit(node, pointer, depth, inheritedSecret, inheritedLock) {
|
||||
const key = pointer.length ? pointer[pointer.length - 1] : ''
|
||||
const secret = inheritedSecret || (typeof key === 'string' && isSecretKey(key))
|
||||
const isLocked =
|
||||
inheritedLock || (pointer.length === 1 && typeof key === 'string' && lockedSet.has(key.toLowerCase()))
|
||||
|
||||
if (node.type === 'object' || node.type === 'array') {
|
||||
const tooDeep = depth >= maxDepth
|
||||
|
||||
fields.push({
|
||||
pointer: [...pointer],
|
||||
path: pointerPath(pointer),
|
||||
key: typeof key === 'number' ? `[${key}]` : key,
|
||||
type: node.type,
|
||||
depth,
|
||||
count: node.children.length,
|
||||
secret,
|
||||
locked: isLocked,
|
||||
// An empty container has nothing to infer a member's shape from, and a
|
||||
// container past the depth limit has more shape than a form should try
|
||||
// to draw. Both are honest reasons to send somebody to the raw tier.
|
||||
advanced: tooDeep || node.children.length === 0,
|
||||
...(tooDeep ? { reason: 'deeper than the form will draw' } : {}),
|
||||
...(node.children.length === 0 ? { reason: 'empty, so there is no shape to read' } : {}),
|
||||
})
|
||||
|
||||
if (tooDeep) return
|
||||
|
||||
node.children.forEach((child, index) => {
|
||||
visit(child, [...pointer, node.type === 'array' ? index : child.key], depth + 1, secret, isLocked)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fields.push({
|
||||
pointer: [...pointer],
|
||||
path: pointerPath(pointer),
|
||||
key: typeof key === 'number' ? `[${key}]` : key,
|
||||
type: node.type,
|
||||
depth,
|
||||
// A number is reported as its LITERAL as well as its value. The literal is
|
||||
// what the form must round-trip; the value is for display and sorting.
|
||||
...(node.type === 'number' ? { raw: node.raw } : {}),
|
||||
value: node.value,
|
||||
secret,
|
||||
locked: isLocked,
|
||||
// `null` has no type, so there is nothing to render but a raw editor.
|
||||
advanced: node.type === 'null',
|
||||
...(node.type === 'null' ? { reason: 'null carries no type to read' } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
visit(root, [], 0, false, false)
|
||||
return fields
|
||||
}
|
||||
|
||||
/** Finds the node a pointer names, or null. */
|
||||
function resolve(root, pointer) {
|
||||
let node = root
|
||||
|
||||
for (const step of pointer) {
|
||||
if (!node || (node.type !== 'object' && node.type !== 'array')) return null
|
||||
|
||||
if (node.type === 'array') {
|
||||
if (typeof step !== 'number') return null
|
||||
node = node.children[step]
|
||||
} else {
|
||||
node = node.children.find((child) => child.key === step)
|
||||
}
|
||||
|
||||
if (!node) return null
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/** The exact source text a node was read from. */
|
||||
function literalOf(text, node) {
|
||||
return String(text).slice(node.start, node.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one edit into the literal that will be spliced in, or explains why not.
|
||||
*
|
||||
* `raw` is used verbatim for a number — that is the whole mechanism — and is
|
||||
* validated against the JSON grammar first, because verbatim and unvalidated
|
||||
* would be a way to write anything at all into somebody's config file.
|
||||
*/
|
||||
function literalFor(node, edit) {
|
||||
if (node.type === 'number') {
|
||||
const raw = String(edit.raw !== undefined && edit.raw !== null ? edit.raw : edit.value).trim()
|
||||
if (!JSON_NUMBER.test(raw)) return { error: `'${raw}' is not a number` }
|
||||
return { literal: raw }
|
||||
}
|
||||
|
||||
if (node.type === 'string') {
|
||||
if (typeof edit.value !== 'string') return { error: 'expected text' }
|
||||
return { literal: JSON.stringify(edit.value) }
|
||||
}
|
||||
|
||||
if (node.type === 'boolean') {
|
||||
if (typeof edit.value !== 'boolean') return { error: 'expected true or false' }
|
||||
return { literal: edit.value ? 'true' : 'false' }
|
||||
}
|
||||
|
||||
return { error: `a ${node.type} is edited in the raw tier` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a set of edits to a document and returns the new text.
|
||||
*
|
||||
* Spans are spliced from the **end of the document backwards**, so that an
|
||||
* earlier edit never moves a later edit's offsets. Every edit is resolved and
|
||||
* checked before any splice happens: a refusal leaves the caller with the
|
||||
* original text rather than a partly-edited one.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {Array<{pointer: Array<string|number>, value?: any, raw?: string}>} edits
|
||||
* @returns {{ text?: string, changes?: object[], error?: string }}
|
||||
*/
|
||||
function applyEdits(text, edits, { locked = [] } = {}) {
|
||||
let root
|
||||
|
||||
try {
|
||||
root = scan(text)
|
||||
} catch (err) {
|
||||
return { error: `the file on the server is not valid JSON: ${err.message}` }
|
||||
}
|
||||
|
||||
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
|
||||
const staged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const edit of edits || []) {
|
||||
const pointer = Array.isArray(edit.pointer) ? edit.pointer : null
|
||||
if (!pointer || pointer.length === 0) return { error: 'an edit must name a field' }
|
||||
|
||||
const path = pointerPath(pointer)
|
||||
if (seen.has(path)) return { error: `'${path}' is edited twice in one save` }
|
||||
seen.add(path)
|
||||
|
||||
if (typeof pointer[0] === 'string' && lockedSet.has(pointer[0].toLowerCase())) {
|
||||
return { error: `'${path}' cannot be edited from the website` }
|
||||
}
|
||||
|
||||
const node = resolve(root, pointer)
|
||||
if (!node) return { error: `'${path}' is not in this file` }
|
||||
|
||||
const { literal, error } = literalFor(node, edit)
|
||||
if (error) return { error: `'${path}': ${error}` }
|
||||
|
||||
staged.push({
|
||||
path,
|
||||
pointer,
|
||||
start: node.start,
|
||||
end: node.end,
|
||||
from: literalOf(text, node),
|
||||
to: literal,
|
||||
secret: pointer.some((step) => typeof step === 'string' && isSecretKey(step)),
|
||||
})
|
||||
}
|
||||
|
||||
// Nothing to do is not an error, but it must not produce a write either: a
|
||||
// save with no changes would spend a reload — and a reload is the one part of
|
||||
// this feature that can take a plugin down.
|
||||
const changed = staged.filter((s) => s.from !== s.to)
|
||||
if (changed.length === 0) return { text: String(text), changes: [] }
|
||||
|
||||
let out = String(text)
|
||||
|
||||
for (const edit of [...changed].sort((a, b) => b.start - a.start)) {
|
||||
out = out.slice(0, edit.start) + edit.to + out.slice(edit.end)
|
||||
}
|
||||
|
||||
// The result must still be JSON. It always is when the pieces are — this is a
|
||||
// guard against a bug in this file, not against the caller.
|
||||
try {
|
||||
scan(out)
|
||||
} catch (err) {
|
||||
return { error: `the edit produced something that is not JSON: ${err.message}` }
|
||||
}
|
||||
|
||||
return { text: out, changes: changed.map(redactChange) }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the audit trail records for one changed field.
|
||||
*
|
||||
* **A secret's values are never written down.** The raw tier shows real values
|
||||
* to an admin who asks for them, which is a deliberate decision (D37) about a
|
||||
* page somebody has to open — but an activity log is read by more people, for
|
||||
* longer, and usually by somebody who was not there. Those are different
|
||||
* exposures and they get different answers.
|
||||
*/
|
||||
function redactChange(change) {
|
||||
return {
|
||||
path: change.path,
|
||||
from: change.secret ? '***' : change.from,
|
||||
to: change.secret ? '***' : change.to,
|
||||
...(change.secret ? { secret: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
JSON_NUMBER,
|
||||
JsonScanError,
|
||||
scan,
|
||||
describe,
|
||||
resolve,
|
||||
applyEdits,
|
||||
isSecretKey,
|
||||
pointerPath,
|
||||
words,
|
||||
}
|
||||
@@ -86,6 +86,14 @@ module.exports = {
|
||||
// that needs an identity needs to *read* one.
|
||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||
|
||||
// One user by id (MODULE_API.md §2.3, 1.1.0). Here for the presence gate
|
||||
// (`model/visibility`): `getUserFromRequest` decodes a token and nothing more,
|
||||
// so the role in it is the role the account had when the token was minted. A
|
||||
// moderator demoted this morning would keep reading who is online until their
|
||||
// token expired. Re-reading the row is what makes a demotion — or a ban — take
|
||||
// effect on the next request, the same promise core's admin tier makes.
|
||||
users: { getById: (...args) => need().users.getById(...args) },
|
||||
|
||||
// Core's middleware, taken as values rather than wrapped: express stores the
|
||||
// function reference at mount time, so a wrapper is what would end up in the
|
||||
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||
@@ -141,9 +149,46 @@ module.exports = {
|
||||
// would be worse, since a module has more than one thing it could reconcile.
|
||||
reconcileEvents: () => need().events.reconcile(),
|
||||
|
||||
// MODULE_API 1.11.0 (PLAN_FIXES F14, D170, D183). The game ended one of this
|
||||
// module's ledgered resources at its own deadline — a zone the plugin erased
|
||||
// when its time was up (`world.expired`). Core files it `expired`: terminal,
|
||||
// never taken back at teardown, and not `orphaned`, which is reconcile's word
|
||||
// for something that vanished with nobody asking. Fire and forget, and a
|
||||
// `{ kind, ref }` no run ledgered is not an error.
|
||||
expireEvent: (resource) => need().events.expired(resource),
|
||||
|
||||
// Teams (MODULE_API.md §2.3, 1.6.0) — the push half of the provider this
|
||||
// module registers (`model/clans/teamProvider.js`). Three calls, all
|
||||
// fire-and-forget, and core's contract is that none of them can make this
|
||||
// module's call site slow or turn a background failure into its error:
|
||||
//
|
||||
// publish(event) a membership or leadership change, as it happened
|
||||
// reconcile({reason}) "the set may have changed, come and ask" — debounced
|
||||
// pushActivity(items) the per-Team feed, idempotent on each item's dedupeKey
|
||||
//
|
||||
// Correctness comes from reconciliation either way; `publish` only makes a
|
||||
// change visible sooner. Wrapped as calls, like `emit`, so a file that takes
|
||||
// `core.teams` at require time still resolves `ctx` when it is used.
|
||||
teams: {
|
||||
publish: (event) => need().teams.publish(event),
|
||||
reconcile: (options) => need().teams.reconcile(options),
|
||||
pushActivity: (items) => need().teams.activity.push(items),
|
||||
},
|
||||
|
||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||
// only correct way to find a file you shipped, because the working directory is
|
||||
// core's and the module's location is the loader's business.
|
||||
get moduleRoot() { return need().paths.moduleRoot },
|
||||
get moduleId() { return need().moduleId },
|
||||
|
||||
// The site's public address, with no trailing slash (§2.3, 1.1.0). Phase 16's
|
||||
// slash commands link an answer's title to its page; a chat message has no
|
||||
// origin to be relative to. A getter over core's getter, so an operator who
|
||||
// changes `APP_BASE_URL` is read at the call, never captured at load.
|
||||
get baseUrl() { return need().site.baseUrl },
|
||||
|
||||
// Whether this is the public demo (MODULE_API 1.18.0, DEMO_MODE.md §3.4, §3.5).
|
||||
// Read through `demo.js`, which holds every lock it switches on. Only a real
|
||||
// `true` counts, so a core that predates the member reads as "not the demo".
|
||||
get demoMode() { return need().demoMode === true },
|
||||
}
|
||||
|
||||
97
server/dangerousTreasures.js
Normal file
97
server/dangerousTreasures.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4) ──────────────
|
||||
//
|
||||
// nivex's Dangerous Treasures opens a guarded, burning treasure chest somewhere
|
||||
// on the map. Its events reach this module as three kinds: `dt.started`
|
||||
// (public), `dt.won` (public) and `dt.ended` (staff's). The chest emptied is
|
||||
// *Treasure Hunter*'s count, from the tally's `treasuresWon` (D375). A run's step
|
||||
// opens one (`eventDangerousTreasures.js`).
|
||||
//
|
||||
// ── Who may read what ──────────────────────────────────────────────────────
|
||||
//
|
||||
// As a world event is read (`worldEvents.js`, D353, D376): the event and its
|
||||
// grid are public; the winner's name shows only to the operator's presence
|
||||
// audience, so with presence left at staff a signed-out visitor reads "The
|
||||
// treasure was won at K12"; Steam ids, the invaders' among them, and the
|
||||
// position are staff's. How many fought for it is a count, and names nobody.
|
||||
// The step's key, when a step opened it, is staff's too.
|
||||
|
||||
const { gridOf } = require('./worldEvents')
|
||||
|
||||
const KINDS = Object.freeze(['dt.started', 'dt.won', 'dt.ended'])
|
||||
|
||||
/** DT's three shipped difficulties, as `dtevent <level>` takes them. */
|
||||
const LEVELS = Object.freeze([
|
||||
{ value: 0, label: 'Easy' },
|
||||
{ value: 1, label: 'Medium' },
|
||||
{ value: 2, label: 'Hard' },
|
||||
])
|
||||
|
||||
const STEAM_ID = /^\d{17}$/
|
||||
|
||||
function isKind(kind) {
|
||||
return KINDS.includes(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `dt.*` frame for one viewer: `{ staff, presence, geometry }`. Passes the
|
||||
* envelope, adds `grid`; a win adds `invaderCount`, and `name` for the presence
|
||||
* audience. Staff also get `x`, `z`, the step's `key`, the winner's `steamId` and
|
||||
* the `invaders`. Any other frame is returned as is.
|
||||
*/
|
||||
function shapeFor(frame, { staff = false, presence = false, geometry = null } = {}) {
|
||||
if (!frame || !isKind(frame.kind)) return frame
|
||||
|
||||
const out = {}
|
||||
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
|
||||
const grid = gridOf(geometry, frame.x, frame.z)
|
||||
if (grid) out.grid = grid
|
||||
|
||||
if (frame.kind === 'dt.won') {
|
||||
const invaders = Array.isArray(frame.invaders) ? frame.invaders : []
|
||||
const count = Number(frame.invaderCount)
|
||||
out.invaderCount = Number.isInteger(count) && count >= 0 ? count : invaders.length
|
||||
if ((presence || staff) && typeof frame.name === 'string' && frame.name) out.name = frame.name
|
||||
if (staff) {
|
||||
if (STEAM_ID.test(String(frame.steamId || ''))) out.steamId = String(frame.steamId)
|
||||
out.invaders = invaders.map(String).filter((id) => STEAM_ID.test(id))
|
||||
}
|
||||
}
|
||||
|
||||
if (staff) {
|
||||
for (const key of ['x', 'z', 'key']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's last report about Dangerous Treasures and its console, from
|
||||
* `servers.db.dtReportFor`: `{ dangerousTreasures, consolePlugins }`, each null
|
||||
* where the plugin never said (no state yet, or a plugin before protocol 14).
|
||||
* `dangerousTreasures` is `{ loaded, version }`.
|
||||
*/
|
||||
function reportOf(row) {
|
||||
const parse = (value) => {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const dt = row ? parse(row.dangerousTreasures) : null
|
||||
const probe = row ? parse(row.console) : null
|
||||
return {
|
||||
dangerousTreasures:
|
||||
dt && typeof dt === 'object' ? { loaded: dt.loaded === true, version: typeof dt.version === 'string' ? dt.version : null } : null,
|
||||
consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { KINDS, LEVELS, isKind, shapeFor, reportOf }
|
||||
@@ -19,5 +19,70 @@
|
||||
-- it knows this module registered, because it is the side that knows which
|
||||
-- registrant owned what.
|
||||
|
||||
-- RunicNPC (runicnpc PLAN.md stage 4). Children before `rust_npc_profiles`.
|
||||
DROP TABLE IF EXISTS rust_npc_factions;
|
||||
DROP TABLE IF EXISTS rust_npc_kills;
|
||||
DROP TABLE IF EXISTS rust_npc_sync;
|
||||
DROP TABLE IF EXISTS rust_npc_profile_servers;
|
||||
DROP TABLE IF EXISTS rust_npc_profiles;
|
||||
|
||||
-- Zone presets (PLAN_REDESIGNS §3.1). The server list before its preset.
|
||||
DROP TABLE IF EXISTS rust_zone_preset_servers;
|
||||
DROP TABLE IF EXISTS rust_zone_presets;
|
||||
|
||||
-- The chat-title conditions (PLAN_REDESIGNS §5). No foreign keys; the stats
|
||||
-- columns go with their table below.
|
||||
DROP TABLE IF EXISTS rust_title_categories;
|
||||
DROP TABLE IF EXISTS rust_weapon_kills;
|
||||
|
||||
-- The permission manager, rebuilt (PLAN_REDESIGNS §1). Children before
|
||||
-- `rust_permgroups`, which they reference.
|
||||
DROP TABLE IF EXISTS rust_perm_exceptions;
|
||||
DROP TABLE IF EXISTS rust_perm_steam_grants;
|
||||
DROP TABLE IF EXISTS rust_permgroup_chat;
|
||||
DROP TABLE IF EXISTS rust_permgroup_steam_members;
|
||||
DROP TABLE IF EXISTS rust_permgroup_members;
|
||||
DROP TABLE IF EXISTS rust_permgroup_permissions;
|
||||
DROP TABLE IF EXISTS rust_permgroup_servers;
|
||||
DROP TABLE IF EXISTS rust_permgroups;
|
||||
|
||||
-- Phase 17. `rust_perm_group_chat` before `rust_perm_groups`, which it
|
||||
-- references; the rest of this phase is columns, which go with their tables.
|
||||
DROP TABLE IF EXISTS rust_perm_group_chat;
|
||||
DROP TABLE IF EXISTS rust_title_rules;
|
||||
|
||||
-- Phase 14.
|
||||
DROP TABLE IF EXISTS rust_map_overrides;
|
||||
DROP TABLE IF EXISTS rust_map_images;
|
||||
|
||||
-- Phase 13b.
|
||||
DROP TABLE IF EXISTS rust_perm_run_grants;
|
||||
|
||||
-- Phase 7b.
|
||||
DROP TABLE IF EXISTS rust_clan_boards;
|
||||
DROP TABLE IF EXISTS rust_clan_members;
|
||||
DROP TABLE IF EXISTS rust_clans;
|
||||
DROP TABLE IF EXISTS rust_settings;
|
||||
DROP TABLE IF EXISTS rust_config_writes;
|
||||
|
||||
-- Phase 7. Children before parents: every one of these carries a foreign key
|
||||
-- into `rust_servers`, `users` or `rust_perm_groups`.
|
||||
DROP TABLE IF EXISTS rust_perm_catalogue;
|
||||
DROP TABLE IF EXISTS rust_perm_sync;
|
||||
DROP TABLE IF EXISTS rust_perm_revocations;
|
||||
DROP TABLE IF EXISTS rust_perm_drift;
|
||||
DROP TABLE IF EXISTS rust_perm_pushed;
|
||||
DROP TABLE IF EXISTS rust_perm_grants;
|
||||
DROP TABLE IF EXISTS rust_perm_group_members;
|
||||
DROP TABLE IF EXISTS rust_perm_group_permissions;
|
||||
DROP TABLE IF EXISTS rust_perm_groups;
|
||||
DROP TABLE IF EXISTS rust_account_links;
|
||||
DROP TABLE IF EXISTS rust_ingest_cursor;
|
||||
DROP TABLE IF EXISTS rust_presence;
|
||||
DROP TABLE IF EXISTS rust_events;
|
||||
DROP TABLE IF EXISTS rust_gather_totals;
|
||||
DROP TABLE IF EXISTS rust_player_wipe_stats;
|
||||
DROP TABLE IF EXISTS rust_players;
|
||||
DROP TABLE IF EXISTS rust_wipes;
|
||||
DROP TABLE IF EXISTS rust_server_state;
|
||||
DROP TABLE IF EXISTS rust_servers;
|
||||
|
||||
1315
server/db/schema.sql
1315
server/db/schema.sql
File diff suppressed because it is too large
Load Diff
139
server/demo.js
Normal file
139
server/demo.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
|
||||
//
|
||||
// Core locks this module's two rig-link routes and the player's Steam unlink by
|
||||
// `demoLocks` in module.json. What a route list cannot express lives here, behind
|
||||
// `ctx.demoMode` (MODULE_API 1.18.0), and every handler that enforces one of these
|
||||
// asks this file:
|
||||
//
|
||||
// the staff Steam unlink on Admin → Users (step-5 walk, W4): it serves under
|
||||
// core's users mount, which a module's demoLocks may not name, so its handler
|
||||
// refuses instead. The demo player's seeded link stays put either way;
|
||||
// the console-command deny list (§3.4, DM11): the commands that would break
|
||||
// the rig for every visitor until the next wipe. Asked at save, through
|
||||
// core's `demoRefuses` hook (1.19.0, DM18), and again at run;
|
||||
// the bridge's own config (§3.5, DM14): neither readable nor writable, because
|
||||
// it holds the bridge's link to the sidecar and its credentials;
|
||||
// secrets in every other config: redacted on the way out, read-only on the
|
||||
// way in, and no raw-JSON saves.
|
||||
//
|
||||
// Off the demo nothing here refuses anything. The server is the lock (DM10):
|
||||
// the UI may explain a refusal, but no screen is relied on to make one.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const CODE = 'DEMO_LOCKED'
|
||||
const MESSAGE = 'This is turned off on the demo site.'
|
||||
|
||||
/** Whether this is the public demo. */
|
||||
function isDemo() {
|
||||
return core.demoMode
|
||||
}
|
||||
|
||||
/** The demo's refusal, in core's shape, so core's one toast shows it. */
|
||||
function refuse(res, message = MESSAGE, extra = {}) {
|
||||
return res.status(403).json({ message, code: CODE, ...extra })
|
||||
}
|
||||
|
||||
// ── The console-command deny list (§3.4, DM11) ─────────────────────────────
|
||||
|
||||
/** Denied by first word, exactly. */
|
||||
const DENIED = new Set(['quit', 'restart', 'oxide.unload', 'oxide.reload', 'fps.limit', 'server.tickrate'])
|
||||
|
||||
/** Denied by prefix: every RCON command. */
|
||||
const DENIED_PREFIXES = ['rcon.']
|
||||
|
||||
/**
|
||||
* The command a line runs, as the deny list compares it: the first word,
|
||||
* lower-cased, with a leading `global.` stripped (Rust accepts `global.quit`)
|
||||
* and the `o.` short form spelled `oxide.` (`o.unload`). Only the first word,
|
||||
* so arguments cannot hide a denied command; `;` does not split a Rust console
|
||||
* line (the §16 spike), so there is no second command to look for.
|
||||
*/
|
||||
function commandWord(line) {
|
||||
let word = String(line === undefined || line === null ? '' : line).trim().split(/\s+/)[0].toLowerCase()
|
||||
if (word.startsWith('global.')) word = word.slice('global.'.length)
|
||||
if (word.startsWith('o.')) word = `oxide.${word.slice('o.'.length)}`
|
||||
return word
|
||||
}
|
||||
|
||||
/** Whether one console line is denied on the demo, by its first word. */
|
||||
function isDeniedCommand(line) {
|
||||
const word = commandWord(line)
|
||||
if (!word) return false
|
||||
return DENIED.has(word) || DENIED_PREFIXES.some((prefix) => word.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal sentence for a list of console lines, or null when none is
|
||||
* denied — or when this is not the demo. Names the line as the author typed it.
|
||||
*/
|
||||
function deniedCommand(lines) {
|
||||
if (!isDemo()) return null
|
||||
const items = Array.isArray(lines) ? lines : lines === undefined || lines === null ? [] : [lines]
|
||||
const hit = items.find((line) => isDeniedCommand(line))
|
||||
return hit === undefined ? null : `"${String(hit).trim()}" is turned off on the demo site.`
|
||||
}
|
||||
|
||||
// ── The bridge's own config (§3.5, DM14) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* The bridge plugin's name. The catalogue's `self` is the plugin's own word for
|
||||
* it and is used when it is there; this is the floor, so a catalogue that could
|
||||
* not be read never unlocks the file (the lock fails closed).
|
||||
*/
|
||||
const BRIDGE = 'RunicGateway'
|
||||
|
||||
/**
|
||||
* A config path in the form the host will resolve it to, for the bridge test
|
||||
* only: `.` segments dropped, each segment trimmed of the spaces and trailing
|
||||
* dots Windows ignores, and lower-cased. `./RunicGateway.json` and
|
||||
* `RunicGateway /x.json` are the bridge's files on some host, so they are the
|
||||
* bridge's files here.
|
||||
*/
|
||||
function canonicalSegments(path) {
|
||||
return String(path || '')
|
||||
.split(/[\\/]+/)
|
||||
// A `.` segment trims to nothing and is dropped with the empty ones.
|
||||
.map((segment) => segment.trim().replace(/[. ]+$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a config path belongs to the bridge plugin: its first segment, or its
|
||||
* file name at the root, is the bridge's name. `self` is the catalogue's.
|
||||
*/
|
||||
function isBridgePath(path, self) {
|
||||
const names = new Set([BRIDGE.toLowerCase()])
|
||||
if (self) names.add(String(self).toLowerCase())
|
||||
const parts = canonicalSegments(path)
|
||||
if (!parts.length) return false
|
||||
const first = parts.length > 1 ? parts[0] : parts[0].replace(/\.json$/, '')
|
||||
return names.has(first)
|
||||
}
|
||||
|
||||
/** Whether a plugin name (a catalogue group, an audit row's plugin) is the bridge. */
|
||||
function isBridgePlugin(plugin, self) {
|
||||
const name = String(plugin || '').toLowerCase()
|
||||
return name === BRIDGE.toLowerCase() || (self ? name === String(self).toLowerCase() : false)
|
||||
}
|
||||
|
||||
// ── Secrets in every other config (§3.5) ───────────────────────────────────
|
||||
|
||||
/** What a redacted value reads as, in the text and in the form. */
|
||||
const REDACTED = '(hidden on the demo site)'
|
||||
|
||||
module.exports = {
|
||||
CODE,
|
||||
MESSAGE,
|
||||
BRIDGE,
|
||||
REDACTED,
|
||||
isDemo,
|
||||
refuse,
|
||||
commandWord,
|
||||
isDeniedCommand,
|
||||
deniedCommand,
|
||||
canonicalSegments,
|
||||
isBridgePath,
|
||||
isBridgePlugin,
|
||||
}
|
||||
190
server/demoSeed.js
Normal file
190
server/demoSeed.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// ── The public demo's seed: this module's rows (MODULE_API 1.18.0, DM15) ────
|
||||
//
|
||||
// `npm run seed:demo` hands each module its `modules.<id>` section of the
|
||||
// fixture, with every `"$env:NAME"` already replaced, and the demo accounts' ids
|
||||
// (DEMO_MODE.md §7.3). This module writes two kinds of row from it, through its
|
||||
// own models; core never writes them:
|
||||
//
|
||||
// servers the link to the rig: the sidecar URL and token come from the host's
|
||||
// .env, so the token is never in the repo
|
||||
// links demo-player's stand-in Steam id (DM6), so the player screens have
|
||||
// somebody to show
|
||||
// npcs the NPC faction table and profiles of the demo default set (DM26)
|
||||
// chat a BetterChat style per group (DM26)
|
||||
//
|
||||
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
|
||||
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
|
||||
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }],
|
||||
// "npcs": { "factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
|
||||
// "profiles": [{ "name": "bandit", "servers": ["main"], "body": { … } }] },
|
||||
// "chat": [{ "serverId": "main", "group": "vip", "style": { "Title": "[VIP]", … } }] }
|
||||
//
|
||||
// **Why NPCs and chat come from here, not the rig's baseline (DM26).** The rig is
|
||||
// restored before the site is wiped, and a fresh site imports what it finds
|
||||
// (§7.1). That holds for permissions, but not for these two. The restored rig's
|
||||
// RunicNPC says it is already managed, by the site before this one, so the new
|
||||
// site adopts nothing (D244) and its first push empties the profiles. The import
|
||||
// never adopts a group's chat style. So the site holds them from the start and
|
||||
// pushes them, and the placements in the baseline spawn again.
|
||||
//
|
||||
// A chat style names a group the rig's import has not brought in yet, because
|
||||
// the seed runs first. The group is created for that server, and the import
|
||||
// then adopts the rig's title, rank and permissions into it. A style lists only
|
||||
// the fields that differ from BetterChat's defaults.
|
||||
//
|
||||
// **It runs on every seed, so it upserts.** Core seeds content on a database's
|
||||
// first run only, but calls this every time. The section's shape is checked
|
||||
// before anything is written, and a problem throws, which fails the seed. A
|
||||
// profile is checked by the NPC model as it is saved, as the admin page saves
|
||||
// one, so a profile the model refuses fails the seed too.
|
||||
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const npcShape = require('./model/npcs/npcProfile')
|
||||
const permApply = require('./model/permissions/permissions.apply')
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const chatStyle = require('./model/permissions/chatStyle')
|
||||
const sidecar = require('./sidecarClient')
|
||||
const core = require('./core')
|
||||
|
||||
const log = core.logger('demo')
|
||||
|
||||
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
||||
const STEAM_ID = /^7656119\d{10}$/
|
||||
// Oxide group names as the permission model keeps them: trimmed and lower-cased.
|
||||
const GROUP_NAME = /^[a-z0-9_.-]{1,64}$/
|
||||
|
||||
/** BetterChat's defaults for the group, with the fixture's fields over them. */
|
||||
function fullStyle(group, style) {
|
||||
return { ...chatStyle.defaults(String(group || '')), ...(style && typeof style === 'object' ? style : {}) }
|
||||
}
|
||||
|
||||
/** Every problem with the section, as sentences. Empty when it is good to write. */
|
||||
function checkSection(data, accounts = {}) {
|
||||
const problems = []
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
|
||||
|
||||
const list = (key) => (data[key] === undefined ? [] : data[key])
|
||||
for (const key of ['servers', 'links', 'chat']) {
|
||||
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
|
||||
}
|
||||
const n = data.npcs
|
||||
if (n !== undefined) {
|
||||
if (!n || typeof n !== 'object' || Array.isArray(n)) problems.push('modules.rust.npcs must be an object')
|
||||
else {
|
||||
if (n.factions !== undefined && !Array.isArray(n.factions)) problems.push('modules.rust.npcs.factions must be an array')
|
||||
if (n.profiles !== undefined && !Array.isArray(n.profiles)) problems.push('modules.rust.npcs.profiles must be an array')
|
||||
}
|
||||
}
|
||||
if (problems.length) return problems
|
||||
|
||||
if (n && n.factions !== undefined) {
|
||||
const checked = npcShape.checkFactions(n.factions)
|
||||
if (!checked.ok) problems.push(`modules.rust.npcs.${checked.error}`)
|
||||
}
|
||||
;((n && n.profiles) || []).forEach((p, i) => {
|
||||
const at = `modules.rust.npcs.profiles[${i}]`
|
||||
if (!p || typeof p !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!npcShape.NAME_RULE.test(String(p.name || ''))) problems.push(`${at}.name must be 1 to 40 of a-z, 0-9, _ and -`)
|
||||
if (!p.body || typeof p.body !== 'object' || Array.isArray(p.body)) problems.push(`${at}.body must be an object`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('chat').forEach((c, i) => {
|
||||
const at = `modules.rust.chat[${i}]`
|
||||
if (!c || typeof c !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!SERVER_ID.test(String(c.serverId || ''))) problems.push(`${at}.serverId must be a server id`)
|
||||
if (!GROUP_NAME.test(String(c.group || ''))) problems.push(`${at}.group must be a group name`)
|
||||
const checked = chatStyle.validateStyle(fullStyle(c.group, c.style))
|
||||
if (!checked.ok) problems.push(`${at}.style: ${checked.errors.join(' ')}`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('servers').forEach((s, i) => {
|
||||
const at = `modules.rust.servers[${i}]`
|
||||
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!SERVER_ID.test(String(s.id || ''))) problems.push(`${at}.id must be lowercase letters, digits and hyphens`)
|
||||
if (typeof s.name !== 'string' || !s.name.trim()) problems.push(`${at}.name is required`)
|
||||
if (!/^https?:\/\//.test(String(s.sidecarBaseUrl || ''))) problems.push(`${at}.sidecarBaseUrl must be an http(s) URL`)
|
||||
if (typeof s.sidecarToken !== 'string' || !s.sidecarToken) problems.push(`${at}.sidecarToken is required`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
list('links').forEach((l, i) => {
|
||||
const at = `modules.rust.links[${i}]`
|
||||
if (!l || typeof l !== 'object') return problems.push(`${at} must be an object`)
|
||||
if (!accounts[l.username]) problems.push(`${at}.username must be one of the demo accounts`)
|
||||
if (!STEAM_ID.test(String(l.steamId || ''))) problems.push(`${at}.steamId must be a 17-digit Steam id`)
|
||||
if (l.serverId !== undefined && !SERVER_ID.test(String(l.serverId))) problems.push(`${at}.serverId must be a server id`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
/** The hook core calls: `onDemoSeed(data, { accounts })`. */
|
||||
async function onDemoSeed(data, { accounts = {} } = {}) {
|
||||
const problems = checkSection(data, accounts)
|
||||
if (problems.length) throw new Error(`the Rust demo seed has problems: ${problems.join('; ')}`)
|
||||
|
||||
for (const s of data.servers || []) {
|
||||
await serversDb.upsertServer({
|
||||
id: s.id,
|
||||
name: s.name.trim(),
|
||||
sidecarBaseUrl: s.sidecarBaseUrl,
|
||||
sidecarTokenEnc: servers.encryptToken(s.sidecarToken),
|
||||
protocol: s.protocol === undefined ? sidecar.PROTOCOL_VERSION : Number(s.protocol),
|
||||
enabled: s.enabled === undefined ? true : Boolean(s.enabled),
|
||||
sortOrder: s.sortOrder === undefined ? 0 : Number(s.sortOrder),
|
||||
})
|
||||
log.info('demo server ready', { server: s.id })
|
||||
}
|
||||
|
||||
// A link is a plain insert by design (D23), so the upsert is spelled out here:
|
||||
// the right owner already holds it, or it is moved to them. Only the seed may
|
||||
// move a link, and only to a demo account.
|
||||
for (const l of data.links || []) {
|
||||
const userId = accounts[l.username]
|
||||
const steamId = String(l.steamId)
|
||||
const existing = await linksDb.getBySteamId(steamId)
|
||||
if (existing && Number(existing.userId) === Number(userId)) continue
|
||||
if (existing) await linksDb.removeBySteamId(steamId)
|
||||
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
|
||||
log.info('demo link ready', { username: l.username })
|
||||
}
|
||||
|
||||
// After the servers: a profile is refused for a server the site does not have.
|
||||
const n = data.npcs || {}
|
||||
if (n.factions !== undefined) {
|
||||
await npcs.setFactions(n.factions)
|
||||
log.info('demo NPC factions ready', { pairs: n.factions.length })
|
||||
}
|
||||
if (n.profiles && n.profiles.length) {
|
||||
const existing = await npcsDb.listProfiles()
|
||||
for (const p of n.profiles) {
|
||||
const input = { name: p.name, body: p.body, servers: p.servers, allServers: p.allServers, killsScope: p.killsScope }
|
||||
const same = existing.find((e) => e.name === p.name && !e.replaced)
|
||||
if (same) await npcs.update(same.id, input)
|
||||
else await npcs.create(input)
|
||||
log.info('demo NPC profile ready', { profile: p.name })
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of data.chat || []) {
|
||||
const name = String(c.group)
|
||||
let group = await permApply.groupOn(name, c.serverId)
|
||||
if (!group) {
|
||||
const id = await permDb.insertGroup({ name, title: name })
|
||||
await permDb.setGroupServers(id, { allServers: false, servers: [c.serverId] })
|
||||
group = { id }
|
||||
}
|
||||
await permDb.setGroupChat(group.id, chatStyle.validateStyle(fullStyle(name, c.style)).fields)
|
||||
await permDb.markDirty(c.serverId)
|
||||
log.info('demo chat style ready', { server: c.serverId, group: name })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkSection, onDemoSeed }
|
||||
71
server/dynamicPvp.js
Normal file
71
server/dynamicPvp.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// ── Dynamic PVP's zones (protocol 14, PLAN_REDESIGNS §14.3.3) ──────────────
|
||||
//
|
||||
// HunterZ's Dynamic PVP opens its own temporary PvP zones around a downed
|
||||
// patrol helicopter, a Bradley, a locked crate, an airdrop, the cargo ship and
|
||||
// the rest. They are the plugin's own: there is no step and no trigger here,
|
||||
// only the feed's lines (`pvp.zone.created`, public; `pvp.zone.deleted`,
|
||||
// staff's) and the map's `pvp` layer.
|
||||
//
|
||||
// ── Who may read what ──────────────────────────────────────────────────────
|
||||
//
|
||||
// As a world event is read (`worldEvents.js`, D353): the zone and its grid are
|
||||
// public, and its position is staff's, because the map has its own audience for
|
||||
// where things are (D111). A zone names nobody.
|
||||
//
|
||||
// **Only Dynamic PVP's own vanilla events carry their name to the public.** Its
|
||||
// monument zones are named after the monument and an operator's own events by
|
||||
// whatever the operator typed into its config; those reach staff as written and
|
||||
// everyone else as "A PvP zone opened at G7".
|
||||
|
||||
const { gridOf } = require('./worldEvents')
|
||||
|
||||
/** Dynamic PVP 5.2.0's own events (its `GeneralEventType`), as its hooks name them. */
|
||||
const GENERAL_EVENTS = Object.freeze([
|
||||
'Bradley',
|
||||
'Helicopter',
|
||||
'Satellite',
|
||||
'TimedSupply',
|
||||
'SupplySignal',
|
||||
'CargoShip',
|
||||
'HackableCrate',
|
||||
'ExcavatorIgnition',
|
||||
'GhostShip',
|
||||
'DeepSeaIsland',
|
||||
'IslandCannon',
|
||||
])
|
||||
|
||||
const KINDS = Object.freeze(['pvp.zone.created', 'pvp.zone.deleted'])
|
||||
|
||||
function isGeneral(event) {
|
||||
return GENERAL_EVENTS.includes(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `pvp.zone.*` frame for one viewer: `{ staff, geometry }`. Passes
|
||||
* `kind`, `type`, `t`, `serverId`, `wipeId`, `radius`, `shape`, `durationSec`,
|
||||
* adds `grid`, and `event` when it is one of Dynamic PVP's own or the viewer is
|
||||
* staff. Staff also get `x`, `z` and `zoneId`. Any other frame is returned as is.
|
||||
*/
|
||||
function shapeFor(frame, { staff = false, geometry = null } = {}) {
|
||||
if (!frame || !KINDS.includes(frame.kind)) return frame
|
||||
|
||||
const out = {}
|
||||
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId', 'radius', 'shape', 'durationSec']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
|
||||
const grid = gridOf(geometry, frame.x, frame.z)
|
||||
if (grid) out.grid = grid
|
||||
|
||||
if (typeof frame.event === 'string' && frame.event && (staff || isGeneral(frame.event))) out.event = frame.event
|
||||
|
||||
if (staff) {
|
||||
for (const key of ['x', 'z', 'zoneId']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { GENERAL_EVENTS, KINDS, isGeneral, shapeFor }
|
||||
107
server/engagement/audiences.js
Normal file
107
server/engagement/audiences.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// ── Named sets of people, over this module's own data ─────────────────────
|
||||
//
|
||||
// `registerAudiences` (MODULE_API.md §2.4; PLAN.md §25.2). An operator points a
|
||||
// rule or a segment at one of these; core calls `resolve` when a rule fires.
|
||||
//
|
||||
// Three properties, each the contract rather than a style:
|
||||
//
|
||||
// • **A resolver returns website user ids and nothing else.** Never an
|
||||
// address, a channel or a Steam id: core maps ids to people after the
|
||||
// preferences, the suppression list and the verification gate, and a module
|
||||
// that could hand it anything else would have a way to send mail.
|
||||
// • **One that fails answers NOBODY** — never everybody, never its last good
|
||||
// answer. A throw here is caught and returned as `[]`, and core treats a
|
||||
// throw the same way; both are here so the property does not rest on
|
||||
// either side alone.
|
||||
// • **Params are constants**, fixed when an operator saves the rule. "The clan
|
||||
// this event was about" is therefore not expressible as an audience — a
|
||||
// clan trigger carries its own recipients instead (`emit.js`).
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
const log = core.logger('audiences')
|
||||
|
||||
const LINKS = 'rust_account_links'
|
||||
|
||||
/** Wraps a resolver so a failure is an empty set, logged, and never a throw. */
|
||||
function safe(id, fn) {
|
||||
return async (params) => {
|
||||
try {
|
||||
const rows = await fn(params || {})
|
||||
return rows.map((r) => Number(r.userId)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
} catch (err) {
|
||||
log.warn('an audience could not be resolved; it answers nobody', { audience: id, error: err.message })
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
|
||||
const AUDIENCES = Object.freeze([
|
||||
{
|
||||
id: 'rust.clan.members',
|
||||
label: 'Members of a clan',
|
||||
params: [{ id: 'clan', type: 'string', required: true }],
|
||||
ceiling: 'members',
|
||||
// A clan's LINKED members, as the store holds them now. A clan that has
|
||||
// been disbanded has no roster, so a rule saved against it resolves to
|
||||
// nobody — which is the truth, and not the same as the audience being gone.
|
||||
resolve: safe('rust.clan.members', async ({ clan }) => {
|
||||
const key = text(clan)
|
||||
if (!key) return []
|
||||
return core.query(
|
||||
`SELECT DISTINCT l.user_id AS userId
|
||||
FROM rust_clan_members m
|
||||
JOIN rust_clans c ON c.external_id = m.external_id AND c.gone_at IS NULL
|
||||
JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
||||
WHERE m.external_id = ?`,
|
||||
[key],
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'rust.server.players',
|
||||
label: 'Everyone who has played on a server',
|
||||
params: [{ id: 'serverId', type: 'string', required: true }],
|
||||
ceiling: 'authenticated',
|
||||
// Linked accounts with a stats row on this server in ANY wipe. The stats
|
||||
// table is the record of having played, and it outlives both wipes and the
|
||||
// raw event history (R12).
|
||||
resolve: safe('rust.server.players', async ({ serverId }) => {
|
||||
const id = text(serverId)
|
||||
if (!id) return []
|
||||
return core.query(
|
||||
`SELECT DISTINCT l.user_id AS userId
|
||||
FROM rust_player_wipe_stats s
|
||||
JOIN ${LINKS} l ON l.steam_id = s.steam_id
|
||||
WHERE s.server_id = ?`,
|
||||
[id],
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'rust.wipe.participants',
|
||||
label: 'Everyone playing a server\'s current wipe',
|
||||
params: [{ id: 'serverId', type: 'string', required: true }],
|
||||
ceiling: 'authenticated',
|
||||
// The same, narrowed to the wipe the server is on NOW. Resolved at send
|
||||
// time, so a rule saved last month reaches this month's players — which is
|
||||
// what "current" has to mean for a parameter fixed when the rule was saved.
|
||||
// A server with no known wipe resolves to nobody rather than to every wipe.
|
||||
resolve: safe('rust.wipe.participants', async ({ serverId }) => {
|
||||
const id = text(serverId)
|
||||
if (!id) return []
|
||||
return core.query(
|
||||
`SELECT DISTINCT l.user_id AS userId
|
||||
FROM rust_server_state st
|
||||
JOIN rust_player_wipe_stats s ON s.server_id = st.server_id AND s.wipe_id = st.wipe_id
|
||||
JOIN ${LINKS} l ON l.steam_id = s.steam_id
|
||||
WHERE st.server_id = ? AND st.wipe_id IS NOT NULL`,
|
||||
[id],
|
||||
)
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
module.exports = { AUDIENCES }
|
||||
879
server/engagement/emit.js
Normal file
879
server/engagement/emit.js
Normal file
@@ -0,0 +1,879 @@
|
||||
// ── What happened, told to core's engagement engine ───────────────────────
|
||||
//
|
||||
// The fan-out behind `triggers.js` (PLAN.md §25). Ingest calls `onEvent` for
|
||||
// every frame it stores; the refresh calls `serverObserved` for every poll;
|
||||
// ingest calls `checkLeader` after a batch; the prune timer calls
|
||||
// `sweepLoginDenied`; the link route calls `linked`.
|
||||
//
|
||||
// **Nothing here decides who is told.** It says what happened and, for a
|
||||
// personal or clan event, who it is ABOUT. Core applies the rule, the ceiling,
|
||||
// the preference, the suppression list and the verification gate. A module
|
||||
// cannot send mail (MODULE_API.md §2.7), and this file is not the back door.
|
||||
//
|
||||
// **Nothing here throws into its caller.** Every entry point catches, because
|
||||
// its callers are the ingest cursor and the refresh loop — a malformed frame or
|
||||
// a core-side contract problem must cost one notification and never the feed.
|
||||
//
|
||||
// ── Three rules the whole file follows ────────────────────────────────────
|
||||
//
|
||||
// 1. **Emit on the transition, never on the poll** (R7). A server that is
|
||||
// still up is not news. Transitions are tracked in memory, and a FIRST
|
||||
// sighting is never one — so a website restart announces nothing.
|
||||
//
|
||||
// 2. **A replayed event notifies only while it is still news** (D63). After an
|
||||
// outage the cursor replays hours of frames. A broadcast older than 15
|
||||
// minutes tells nobody; a personal or staff event is kept for 24 hours,
|
||||
// because "your base was raided at 03:10" is still true and still wanted.
|
||||
//
|
||||
// 3. **Every emit carries a dedupe key made from the EVENT, not the store.**
|
||||
// Core's outbox is unique on (rule, user, channel, key), so the same frame
|
||||
// replayed after a crash is a no-op. The key is built from what the event
|
||||
// says — its server, time and subject — rather than from the sidecar's row
|
||||
// id, because a sidecar whose database is replaced starts its ids again
|
||||
// and would otherwise have every new alert swallowed as a repeat of an old
|
||||
// one.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
const clans = require('../model/clans/clans.model')
|
||||
const clansDb = require('../model/clans/clans.db')
|
||||
const eventsDb = require('../model/events/events.db')
|
||||
const linksDb = require('../model/links/links.db')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const mapDb = require('../model/map/map.db')
|
||||
const worldEvents = require('../worldEvents')
|
||||
const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers')
|
||||
|
||||
const log = core.logger('engagement')
|
||||
|
||||
/** How old a broadcast may be and still be news (D63). */
|
||||
const BROADCAST_MAX_AGE_MS = 15 * 60 * 1000
|
||||
|
||||
/** How old a personal or staff event may be and still be worth telling (D63). */
|
||||
const PERSONAL_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* How long a login attempt waits for its approval before it counts as denied
|
||||
* (D64, PLAN.md §16.5). The game raises no rejection hook, so a denial is the
|
||||
* ABSENCE of an approval — which is only knowable after a wait.
|
||||
*/
|
||||
const LOGIN_APPROVAL_WINDOW_MS = 60 * 1000
|
||||
|
||||
/** How far BEFORE an attempt an approval may be stamped and still answer it — clock grain, not policy. */
|
||||
const LOGIN_APPROVAL_SLACK_MS = 5 * 1000
|
||||
|
||||
const STRUCTURE_LABELS = Object.freeze({
|
||||
block: 'building block',
|
||||
door: 'door',
|
||||
wall: 'external wall',
|
||||
cupboard: 'tool cupboard',
|
||||
})
|
||||
|
||||
// ── Small helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const str = (value) => (value === undefined || value === null || value === '' ? undefined : String(value))
|
||||
|
||||
/** `rust:<what>:<sha1 of the parts>` — readable prefix, bounded length. */
|
||||
function dedupeKey(what, ...parts) {
|
||||
const digest = crypto.createHash('sha1').update(parts.map((p) => String(p ?? '')).join('\u0000')).digest('hex')
|
||||
return `rust:${what}:${digest}`
|
||||
}
|
||||
|
||||
function frameTime(item, frame) {
|
||||
const t = Number(frame && frame.t) || Number(item && item.t)
|
||||
return Number.isFinite(t) && t > 0 ? t : Date.now()
|
||||
}
|
||||
|
||||
/** D63, as a question: is an event from `t` still worth telling, for this family? */
|
||||
function stillNews(t, maxAgeMs, now = Date.now()) {
|
||||
return now - t <= maxAgeMs
|
||||
}
|
||||
|
||||
function serverVars(server) {
|
||||
const serverId = String(server.id)
|
||||
return { serverId, server: server.name || serverId, serverUrl: serverPath(serverId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline every trigger carries (`triggers.js` HEADLINE).
|
||||
*
|
||||
* Core's generic bodies fall back to a trigger's LABEL and DESCRIPTION when the
|
||||
* payload has no `title`/`intro`, and on a multi-server site that fallback says
|
||||
* "A server came online" without ever saying which. So the sentence is written
|
||||
* here, from the payload, and core renders it. Plain register, no conditionals:
|
||||
* a missing part falls back to a neutral word rather than leaving a hole.
|
||||
*/
|
||||
const HEADLINES = Object.freeze({
|
||||
'rust.base.destroyed': (d) => ({
|
||||
title: `Your base on ${d.server} is being raided`,
|
||||
intro: `A ${d.structure} was destroyed${d.atGrid || ''} on ${d.server}.`,
|
||||
}),
|
||||
'rust.wipe.started': (d) => ({
|
||||
title: `${d.server} has wiped`,
|
||||
intro: `A new wipe has started on ${d.server}: a fresh map, and a fresh start for everyone.`,
|
||||
}),
|
||||
'rust.server.online': (d) => ({
|
||||
title: `${d.server} is online`,
|
||||
intro: `${d.server} is back up and talking to the website.`,
|
||||
}),
|
||||
'rust.server.offline': (d) => ({
|
||||
title: `${d.server} is offline`,
|
||||
intro: `${d.server} stopped, or stopped talking to the website.`,
|
||||
}),
|
||||
'rust.leaderboard.topped': (d) => ({
|
||||
title: `${d.leader} leads ${d.server}`,
|
||||
intro: `${d.leader} now leads this wipe's kills on ${d.server}, with ${d.kills}.`,
|
||||
}),
|
||||
'rust.player.linked': (d) => ({
|
||||
title: 'A Steam account was linked to your account',
|
||||
intro: `The Steam account ${d.player || d.steamId} was linked with an in-game code. `
|
||||
+ 'If that was not you, unlink it from your Rust account page.',
|
||||
}),
|
||||
'rust.kit.entitled': (d) => ({
|
||||
title: `You earned ${d.kit} on ${d.server}`,
|
||||
intro: `An event on ${d.server} rewarded you: the ${d.kit} kit is waiting in the Kits menu, `
|
||||
+ 'with one extra use. Redeem it in game.',
|
||||
}),
|
||||
'rust.clan.member.left': (d) => ({
|
||||
title: `${d.member || 'A member'} left ${d.clan}`,
|
||||
intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.clan.member.kicked': (d) => ({
|
||||
title: `${d.member || 'A member'} was removed from ${d.clan}`,
|
||||
intro: `${d.by || 'A clan leader'} removed ${d.member || 'a member'} from ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.clan.disbanded': (d) => ({
|
||||
title: `${d.clan} was disbanded`,
|
||||
intro: `${d.by || 'A clan leader'} disbanded ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.player.reported': (d) => ({
|
||||
title: `${d.player || d.steamId} was reported on ${d.server}`,
|
||||
intro: `${d.reporter || 'A player'} reported ${d.player || d.steamId}`
|
||||
+ `${d.reportType ? ` (${d.reportType})` : ''}${d.topic ? `: ${d.topic}` : '.'}`,
|
||||
}),
|
||||
'rust.player.banned': (d) => ({
|
||||
title: `${d.player || d.steamId} was banned on ${d.server}`,
|
||||
intro: d.reason ? `Reason given: ${d.reason}` : 'No reason was given.',
|
||||
}),
|
||||
'rust.player.unbanned': (d) => ({
|
||||
title: `${d.player || d.steamId} was unbanned on ${d.server}`,
|
||||
intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`,
|
||||
}),
|
||||
'rust.npc.died': (d) => ({
|
||||
title: `${d.npc || d.profile} was killed on ${d.server}`,
|
||||
intro: `${d.killer ? `${d.killer} killed` : 'Something killed'} ${d.npc || 'an NPC'} (${d.profile}) on ${d.server}.`,
|
||||
}),
|
||||
'rust.npc.health': (d) => ({
|
||||
title: `${d.npc || d.profile} is below ${d.percent}% on ${d.server}`,
|
||||
intro: `${d.npc || 'An NPC'} (${d.profile}) fell to ${d.percent}% of its health on ${d.server}.`,
|
||||
}),
|
||||
'rust.boss.spawned': (d) => ({
|
||||
title: `${d.boss} has appeared on ${d.server}`,
|
||||
intro: `${d.boss} is up on ${d.server}.`,
|
||||
}),
|
||||
'rust.boss.phase': (d) => ({
|
||||
title: `${d.boss} is down to ${d.percent}% on ${d.server}`,
|
||||
intro: `${d.boss} fell to ${d.percent}% of its health on ${d.server} and entered phase ${d.phase}.`,
|
||||
}),
|
||||
// Dangerous Treasures (protocol 14, §14.3.4). A missing grid drops out.
|
||||
'rust.dt.started': (d) => ({
|
||||
title: `A treasure event has started on ${d.server}`,
|
||||
intro: `A Dangerous Treasures chest opened${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. It is guarded, and it is burning.`,
|
||||
}),
|
||||
'rust.dt.won': (d) => ({
|
||||
title: `The treasure was won on ${d.server}`,
|
||||
intro: `${d.player || 'Somebody'} won the treasure${d.grid ? ` at ${d.grid}` : ''} on ${d.server}`
|
||||
+ `${d.invaders > 1 ? `, with ${d.invaders} players fighting for it` : ''}.`,
|
||||
}),
|
||||
// Guarded Crate (protocol 14, §14.3.5). A missing grid drops out.
|
||||
'rust.gc.started': (d) => ({
|
||||
title: `A guarded crate landed on ${d.server}`,
|
||||
intro: `A guarded crate landed${d.grid ? ` at ${d.grid}` : ''} on ${d.server}. Kill its guards to claim it.`,
|
||||
}),
|
||||
'rust.gc.ended': (d) => ({
|
||||
title: `The guarded crate was cleared on ${d.server}`,
|
||||
intro: `${d.player} cleared the guarded crate${d.grid ? ` at ${d.grid}` : ''} on ${d.server}.`,
|
||||
}),
|
||||
'rust.boss.killed': (d) => ({
|
||||
title: `${d.boss} was killed on ${d.server}`,
|
||||
intro: `${d.killer ? `${d.killer} landed the killing blow` : `${d.boss} fell`}`
|
||||
+ `${d.damagers > 1 ? `, with ${d.damagers} players fighting it` : ''}.`,
|
||||
}),
|
||||
'rust.login.denied': (d) => ({
|
||||
title: `A login to ${d.server} was not approved`,
|
||||
intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`,
|
||||
}),
|
||||
})
|
||||
|
||||
/** Where a crate is, as a sentence ends: "on Large Oil Rig", "dropped by a Chinook". */
|
||||
const CRATE_PLACES = Object.freeze({
|
||||
oilrig_large: 'on Large Oil Rig',
|
||||
oilrig_small: 'on Small Oil Rig',
|
||||
cargo: 'on the cargo ship',
|
||||
ghostship: 'on a ghost ship',
|
||||
chinook: 'dropped by a Chinook',
|
||||
})
|
||||
|
||||
/**
|
||||
* The headline of a world event (protocol 14), one sentence per type. Plain
|
||||
* register like the rest; a part that is missing drops out rather than leaving
|
||||
* a hole, so "at G7" disappears when the server has no map geometry yet.
|
||||
*/
|
||||
function worldHeadline(event, d) {
|
||||
const at = d.grid ? ` at ${d.grid}` : ''
|
||||
const on = ` on ${d.server}`
|
||||
const by = d.player ? ` by ${d.player}` : ''
|
||||
const place = CRATE_PLACES[d.where] || 'somewhere on the map'
|
||||
const bradley = d.where === 'launch_site' ? "Launch Site's Bradley" : d.where === 'road' ? 'A road Bradley' : 'A Bradley'
|
||||
const lines = {
|
||||
'heli.arrived': ['A patrol helicopter is overhead', `A patrol helicopter flew onto the map${at}${on}.`],
|
||||
'heli.left': ['The patrol helicopter has left', `The patrol helicopter left the map${on}.`],
|
||||
'heli.downed': ['Patrol helicopter downed', `The patrol helicopter was downed${by}${at}${on}.`],
|
||||
'bradley.spawned': [
|
||||
d.where === 'road' ? (d.count > 1 ? `${d.count} road Bradleys are out` : 'A road Bradley is out') : 'Bradley is patrolling Launch Site',
|
||||
d.where === 'road' ? `Road Bradleys took to the roads${on}.` : `Launch Site's Bradley is back${on}.`,
|
||||
],
|
||||
'bradley.destroyed': [`${bradley} was destroyed`, `${bradley} was destroyed${by}${at}${on}.`],
|
||||
'cargo.arrived': ['The cargo ship has arrived', `The cargo ship sailed onto the map${at}${on}.`],
|
||||
'cargo.docking': ['The cargo ship is docking', `The cargo ship is coming in to dock${d.harbour ? ` at ${d.harbour}` : ''}${on}.`],
|
||||
'cargo.docked': ['The cargo ship has docked', `The cargo ship docked${d.harbour ? ` at ${d.harbour}` : ''}${on}, with fresh crates.`],
|
||||
'cargo.undocked': ['The cargo ship cast off', `The cargo ship left${d.harbour ? ` ${d.harbour}` : ' the harbour'}${on}.`],
|
||||
'cargo.left': ['The cargo ship is leaving', `The cargo ship turned for the edge of the map${on}.`],
|
||||
'chinook.arrived': ['A Chinook is inbound', `A Chinook flew onto the map${at}${on} with a locked crate.`],
|
||||
'chinook.dropped': ['A Chinook dropped a locked crate', `A Chinook dropped a locked crate${d.monument ? ` at ${d.monument}` : at}${on}.`],
|
||||
'chinook.downed': ['A Chinook was shot down', `A Chinook was shot down${by}${at}${on}.`],
|
||||
'airdrop.called': ['An airdrop is on its way', `An airdrop is on its way${at}${on}${d.player ? `, called by ${d.player}` : ''}.`],
|
||||
'airdrop.landed': ['An airdrop has landed', `An airdrop landed${at}${on}.`],
|
||||
'airdrop.looted': ['An airdrop was looted', `${d.player || 'Somebody'} looted an airdrop${at}${on}.`],
|
||||
'crate.hack.started': ['A locked crate is being hacked', `${d.player || 'Somebody'} started hacking the locked crate ${place}${on}.`],
|
||||
'crate.unlocked': ['A locked crate is open', `The locked crate ${place} finished unlocking${on}.`],
|
||||
'crate.looted': ['A locked crate was looted', `${d.player || 'Somebody'} looted the locked crate ${place}${on}.`],
|
||||
'vendor.arrived': ['The Travelling Vendor is here', `The Travelling Vendor drove onto the map${at}${on}.`],
|
||||
'vendor.left': ['The Travelling Vendor has left', `The Travelling Vendor left the map${on}.`],
|
||||
'satellite.crashed': ['A satellite has crashed', `A satellite came down${at}${on}. Its crates are burning.`],
|
||||
'deepsea.opened': ['The Deep Sea is open', `The Deep Sea opened${on}.`],
|
||||
'deepsea.closed': ['The Deep Sea has closed', `The Deep Sea closed${on}.`],
|
||||
}
|
||||
const line = lines[event]
|
||||
return line ? { title: `${line[0]}${on}`, intro: line[1] } : {}
|
||||
}
|
||||
|
||||
const WORLD_PREFIX = 'rust.world.'
|
||||
|
||||
function headline(triggerId, data) {
|
||||
const make = HEADLINES[triggerId]
|
||||
if (make) return make(data || {})
|
||||
return String(triggerId).startsWith(WORLD_PREFIX) ? worldHeadline(triggerId.slice(WORLD_PREFIX.length), data || {}) : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one event to core. Never throws.
|
||||
*
|
||||
* Core throws on a contract mismatch outside production, which is how a
|
||||
* declaration and an emitter drifting apart is meant to be found. It is logged
|
||||
* at `error` here rather than re-thrown, because the caller is the ingest
|
||||
* cursor — and an `error` line is what a rig walk reads.
|
||||
*/
|
||||
function fire(triggerId, envelope) {
|
||||
try {
|
||||
const data = envelope.data || {}
|
||||
core.emit(triggerId, { ...envelope, data: { ...headline(triggerId, data), ...data } })
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error('core refused an emit', { trigger: triggerId, error: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam id -> website user id, for the ids that are linked. Unlinked ones are simply absent. */
|
||||
async function usersFor(steamIds) {
|
||||
const ids = [...new Set((steamIds || []).map(String).filter(Boolean))]
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await linksDb.userIdsForSteamIds(ids)
|
||||
return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
}
|
||||
|
||||
// ── Per-kind handlers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The raid alert (D59-D61, D66, D67).
|
||||
*
|
||||
* One emit per authorised, LINKED person, each with `ownerUserId` — so the
|
||||
* `owner` ceiling holds per emit and "nobody else" is structural rather than a
|
||||
* filter somebody could forget. Two Steam accounts held by one website user are
|
||||
* one person: they get one alert, online if either account is.
|
||||
*/
|
||||
async function onRaid(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
|
||||
|
||||
// D67: no cupboard, nobody to tell. Absent — not empty — is also what a
|
||||
// protocol-6 plugin sends, so a half-upgraded deployment alerts nobody rather
|
||||
// than guessing an owner from the placer.
|
||||
if (!frame.buildingId || !Array.isArray(frame.authorized)) return 0
|
||||
|
||||
const authorized = frame.authorized.filter((a) => a && a.steamId)
|
||||
const attacker = str(frame.attackerId)
|
||||
|
||||
// An authorised attacker is demolishing their own base, or a teammate's.
|
||||
if (attacker && authorized.some((a) => String(a.steamId) === attacker)) return 0
|
||||
|
||||
const users = await usersFor(authorized.map((a) => a.steamId))
|
||||
if (!users.size) return 0
|
||||
|
||||
const byUser = new Map()
|
||||
for (const a of authorized) {
|
||||
const userId = users.get(String(a.steamId))
|
||||
if (!userId) continue
|
||||
byUser.set(userId, byUser.get(userId) === true || a.online === true)
|
||||
}
|
||||
|
||||
const base = {
|
||||
...serverVars(server),
|
||||
building: String(frame.buildingId),
|
||||
structure: STRUCTURE_LABELS[frame.structure] || 'structure',
|
||||
grid: str(frame.grid),
|
||||
atGrid: str(frame.grid) ? ` in ${frame.grid}` : undefined,
|
||||
}
|
||||
const key = dedupeKey('raid', server.id, frame.buildingId, t, frame.prefab)
|
||||
|
||||
let sent = 0
|
||||
for (const [userId, online] of byUser) {
|
||||
if (fire(T['rust.base.destroyed'], {
|
||||
data: { ...base, ownerOnline: online },
|
||||
ownerUserId: userId,
|
||||
dedupeKey: key,
|
||||
occurredAt: t,
|
||||
})) sent += 1
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
async function onWipe(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
|
||||
const wipeId = str(frame.wipeId)
|
||||
if (!wipeId) return 0
|
||||
|
||||
return fire(T['rust.wipe.started'], {
|
||||
data: { ...serverVars(server), wipeId },
|
||||
dedupeKey: dedupeKey('wipe', server.id, wipeId),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Clan departures and disbands. Recipients travel on the envelope, because
|
||||
* "the clan this was about" is a different set every firing.
|
||||
*
|
||||
* Nobody is told about what they did themselves: the leaver is not told they
|
||||
* left, the one who kicked is not told they kicked, the one who disbanded is not
|
||||
* told they disbanded. The one KICKED is told — it happened to them.
|
||||
*/
|
||||
async function onClan(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
|
||||
|
||||
const externalId = await clans.resolveExternalId(server.id, frame)
|
||||
if (!externalId) return 0
|
||||
|
||||
const kind = frame.kind
|
||||
const subject = str(frame.steamId)
|
||||
let steamIds
|
||||
let actor
|
||||
|
||||
if (kind === 'clan.disbanded') {
|
||||
// From the frame (protocol 7): by the time this runs the next board may
|
||||
// already have removed the roster the store would answer with.
|
||||
steamIds = Array.isArray(frame.members) ? frame.members.map(String) : null
|
||||
if (!steamIds) steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId))
|
||||
actor = subject
|
||||
} else {
|
||||
steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId))
|
||||
if (kind === 'clan.member.kicked') {
|
||||
if (subject) steamIds.push(subject)
|
||||
actor = str(frame.bySteamId)
|
||||
} else {
|
||||
actor = subject
|
||||
}
|
||||
}
|
||||
|
||||
const users = await usersFor(steamIds.filter((id) => id !== actor))
|
||||
const recipientUserIds = [...new Set(users.values())]
|
||||
if (!recipientUserIds.length) return 0
|
||||
|
||||
const data = {
|
||||
...serverVars(server),
|
||||
clanKey: externalId,
|
||||
clan: str(frame.clanName) || 'your clan',
|
||||
clanUrl: clanPath(externalId),
|
||||
}
|
||||
if (kind === 'clan.member.left') data.member = str(frame.name)
|
||||
if (kind === 'clan.member.kicked') {
|
||||
data.member = str(frame.name)
|
||||
data.by = str(frame.byName)
|
||||
}
|
||||
if (kind === 'clan.disbanded') data.by = str(frame.name)
|
||||
|
||||
const triggerId = kind === 'clan.disbanded' ? T['rust.clan.disbanded'] : T[`rust.${kind}`]
|
||||
|
||||
return fire(triggerId, {
|
||||
data,
|
||||
recipientUserIds,
|
||||
dedupeKey: dedupeKey(kind, server.id, externalId, subject, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
async function onReported(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
|
||||
const steamId = str(frame.targetId)
|
||||
if (!steamId) return 0
|
||||
|
||||
return fire(T['rust.player.reported'], {
|
||||
data: {
|
||||
...serverVars(server),
|
||||
steamId,
|
||||
player: str(frame.targetName),
|
||||
reporter: str(frame.reporterName),
|
||||
reportType: str(frame.reportType),
|
||||
topic: str(frame.subject),
|
||||
message: str(frame.message),
|
||||
},
|
||||
dedupeKey: dedupeKey('reported', server.id, steamId, frame.reporterId, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
async function onBan(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
|
||||
const steamId = str(frame.steamId)
|
||||
if (!steamId) return 0
|
||||
|
||||
const banned = frame.kind === 'player.banned'
|
||||
const data = { ...serverVars(server), steamId, player: str(frame.name) }
|
||||
// The address the frame carries is deliberately NOT copied: no trigger
|
||||
// declares one, so no template can ever put it in a mail.
|
||||
if (banned) data.reason = str(frame.reason)
|
||||
|
||||
return fire(banned ? T['rust.player.banned'] : T['rust.player.unbanned'], {
|
||||
data,
|
||||
dedupeKey: dedupeKey(frame.kind, server.id, steamId, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* RunicNPC's NPCs (runicnpc stage 4): a death, and a health threshold. What a
|
||||
* phase gate counts, so the window is short: a death an hour old is history, not
|
||||
* a wave falling. The dedupe key is the NPC's net id, which the game never
|
||||
* reuses within a boot.
|
||||
*/
|
||||
const NPC_MAX_AGE_MS = 15 * 60 * 1000
|
||||
|
||||
function npcVars(server, frame) {
|
||||
return {
|
||||
...serverVars(server),
|
||||
profile: str(frame.profile),
|
||||
npc: str(frame.name),
|
||||
byEvent: Boolean(frame.runId),
|
||||
runId: str(frame.runId),
|
||||
placement: str(frame.placement),
|
||||
}
|
||||
}
|
||||
|
||||
async function onNpc(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, NPC_MAX_AGE_MS) || !str(frame.profile)) return 0
|
||||
|
||||
if (frame.kind === 'npc.died') {
|
||||
const contributors = Array.isArray(frame.contributors) ? frame.contributors : []
|
||||
return fire(T['rust.npc.died'], {
|
||||
data: { ...npcVars(server, frame), killer: str(frame.killerName), killerSteamId: str(frame.killerId), contributors: contributors.length },
|
||||
dedupeKey: dedupeKey('npc.died', server.id, frame.netId, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
const percent = Math.round((Number(frame.threshold) || 0) * 100)
|
||||
return fire(T['rust.npc.health'], {
|
||||
data: { ...npcVars(server, frame), percent },
|
||||
dedupeKey: dedupeKey('npc.health', server.id, frame.netId, percent),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A RunicNPC boss (runicnpc stage 6, D277): spawned, a phase, killed. Public
|
||||
* news, so a replay older than a broadcast's window tells nobody (D63). The
|
||||
* dedupe key is the boss's net id, with the phase for a phase.
|
||||
*/
|
||||
async function onBoss(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, BROADCAST_MAX_AGE_MS) || !str(frame.profile)) return 0
|
||||
const base = { ...serverVars(server), profile: str(frame.profile), boss: str(frame.name) || str(frame.profile), byEvent: Boolean(frame.runId) }
|
||||
|
||||
if (frame.kind === 'npc.boss.spawned') {
|
||||
return fire(T['rust.boss.spawned'], { data: base, dedupeKey: dedupeKey('boss.spawned', server.id, frame.netId, t), occurredAt: t }) ? 1 : 0
|
||||
}
|
||||
|
||||
if (frame.kind === 'npc.boss.phase') {
|
||||
const phase = Number(frame.phase) || 0
|
||||
return fire(T['rust.boss.phase'], {
|
||||
data: { ...base, phaseKey: `${base.profile}:${phase}`, phase, percent: Math.round((Number(frame.at) || 0) * 100) },
|
||||
dedupeKey: dedupeKey('boss.phase', server.id, frame.netId, phase),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
return fire(T['rust.boss.killed'], {
|
||||
data: { ...base, killer: str(frame.killerName), damagers: Number(frame.damagers) || 0 },
|
||||
dedupeKey: dedupeKey('boss.killed', server.id, frame.netId, t),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust's own world events (protocol 14, D355): one trigger per type. Public
|
||||
* news, so a replay older than a broadcast's window tells nobody (D63). The
|
||||
* grid comes from the map's geometry, as the feed's does; a downed helicopter,
|
||||
* Chinook or Bradley names whom the server's credit rule names, as its feed
|
||||
* line and its count do (D372). Names only, never a Steam id.
|
||||
*/
|
||||
async function onWorld(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
const event = str(frame.event)
|
||||
if (!event || !worldEvents.isKnownType(event) || !stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
|
||||
|
||||
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
|
||||
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
|
||||
const type = worldEvents.TYPES[event]
|
||||
const crate = event.startsWith('crate.')
|
||||
|
||||
const data = {
|
||||
...serverVars(server),
|
||||
subject: crate ? `${server.id}:${event}:${frame.where || ''}` : `${server.id}:${event}`,
|
||||
...(grid ? { grid } : {}),
|
||||
...(frame.where !== undefined ? { where: str(frame.where) } : {}),
|
||||
...(event === 'bradley.spawned' ? { count: Math.max(1, Math.floor(Number(frame.count) || 1)) } : {}),
|
||||
...(frame.harbourName || frame.harbour ? { harbour: str(frame.harbourName) || str(frame.harbour) } : {}),
|
||||
...(frame.monumentName || frame.monument ? { monument: str(frame.monumentName) || str(frame.monument) } : {}),
|
||||
}
|
||||
|
||||
if (type.player && str(frame.name)) data.player = str(frame.name)
|
||||
|
||||
if (type.credit) {
|
||||
const ids = worldEvents.credited(frame, worldEvents.creditRuleOf(server.creditRule))
|
||||
const phrase = worldEvents.namesPhrase(ids.map((id) => worldEvents.nameOf(frame, id)), ids.length)
|
||||
data.credited = ids.length
|
||||
if (phrase) data.player = phrase
|
||||
}
|
||||
|
||||
return fire(`${WORLD_PREFIX}${event}`, {
|
||||
data,
|
||||
dedupeKey: dedupeKey('world', server.id, event, t, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Dangerous Treasures (protocol 14, §14.3.4): a chest opened, and one won.
|
||||
* Public news, so a replay older than a broadcast's window tells nobody (D63).
|
||||
* The grid comes from the map's geometry, as the feed's does; the winner by
|
||||
* in-game name only, never a Steam id. An ending raises nothing.
|
||||
*/
|
||||
async function onDangerousTreasures(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
|
||||
|
||||
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
|
||||
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
|
||||
const data = { ...serverVars(server), subject: `${server.id}:${frame.kind}`, ...(grid ? { grid } : {}) }
|
||||
|
||||
if (frame.kind === 'dt.started') {
|
||||
return fire(T['rust.dt.started'], {
|
||||
data: { ...data, byEvent: Boolean(str(frame.key)) },
|
||||
dedupeKey: dedupeKey('dt.started', server.id, t, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
const invaders = Number(frame.invaderCount)
|
||||
return fire(T['rust.dt.won'], {
|
||||
data: {
|
||||
...data,
|
||||
...(str(frame.name) ? { player: str(frame.name) } : {}),
|
||||
invaders: Number.isInteger(invaders) && invaders >= 0 ? invaders : Array.isArray(frame.invaders) ? frame.invaders.length : 0,
|
||||
},
|
||||
dedupeKey: dedupeKey('dt.won', server.id, t, frame.steamId, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded Crate (protocol 14, §14.3.5): a crate landed, and one cleared. As a
|
||||
* treasure's are: public news, a replay past a broadcast's window tells nobody
|
||||
* (D63), the clearer by in-game name only. An ending with nobody named, and a
|
||||
* timeout, raise nothing.
|
||||
*/
|
||||
async function onGuardedCrate(server, item, frame) {
|
||||
const t = frameTime(item, frame)
|
||||
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
|
||||
if (frame.kind === 'gc.ended' && !str(frame.name)) return 0
|
||||
|
||||
const meta = frame.x !== undefined ? await mapDb.getMeta(server.id).catch(() => null) : null
|
||||
const grid = meta ? worldEvents.gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, frame.x, frame.z) : null
|
||||
const data = { ...serverVars(server), subject: `${server.id}:${frame.kind}`, ...(grid ? { grid } : {}) }
|
||||
|
||||
if (frame.kind === 'gc.started') {
|
||||
return fire(T['rust.gc.started'], {
|
||||
data: { ...data, byEvent: Boolean(str(frame.key)) },
|
||||
dedupeKey: dedupeKey('gc.started', server.id, t, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
return fire(T['rust.gc.ended'], {
|
||||
data: { ...data, player: str(frame.name) },
|
||||
dedupeKey: dedupeKey('gc.ended', server.id, t, frame.steamId, frame.x, frame.z),
|
||||
occurredAt: t,
|
||||
}) ? 1 : 0
|
||||
}
|
||||
|
||||
const HANDLERS = Object.freeze({
|
||||
'dt.started': onDangerousTreasures,
|
||||
'dt.won': onDangerousTreasures,
|
||||
'gc.started': onGuardedCrate,
|
||||
'gc.ended': onGuardedCrate,
|
||||
'world.event': onWorld,
|
||||
'npc.boss.spawned': onBoss,
|
||||
'npc.boss.phase': onBoss,
|
||||
'npc.boss.died': onBoss,
|
||||
'npc.died': onNpc,
|
||||
'npc.health': onNpc,
|
||||
'entity.destroyed': onRaid,
|
||||
'server.wipe': onWipe,
|
||||
'clan.member.left': onClan,
|
||||
'clan.member.kicked': onClan,
|
||||
'clan.disbanded': onClan,
|
||||
'player.reported': onReported,
|
||||
'player.banned': onBan,
|
||||
'player.unbanned': onBan,
|
||||
})
|
||||
|
||||
/**
|
||||
* One stored frame. Called by ingest BEFORE the frame is applied, because
|
||||
* applying a disband deletes the roster a clan notification is sent to.
|
||||
*
|
||||
* @returns {Promise<number>} emits handed to core, for the log and the tests
|
||||
*/
|
||||
async function onEvent(server, item) {
|
||||
const frame = (item && item.frame) || {}
|
||||
const kind = (item && item.kind) || frame.kind
|
||||
const handler = HANDLERS[kind]
|
||||
if (!handler || !server) return 0
|
||||
|
||||
try {
|
||||
return await handler(server, item, { ...frame, kind })
|
||||
} catch (err) {
|
||||
log.warn('could not raise a notification', { server: server.id, kind, error: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transitions tracked in memory ──────────────────────────────────────────
|
||||
//
|
||||
// Deliberately NOT persisted. The question each one answers is "has THIS
|
||||
// process seen a previous value", and a value restored from the database would
|
||||
// make the first poll after a restart a transition against state the game may
|
||||
// have left hours ago.
|
||||
|
||||
const tracker = { online: new Map(), leader: new Map() }
|
||||
|
||||
/** Forget every tracked value. For the tests. */
|
||||
function reset() {
|
||||
tracker.online.clear()
|
||||
tracker.leader.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll's verdict on one server (D68): is its game connected now?
|
||||
*
|
||||
* Synchronous and fire-and-forget — the refresh must not wait on core.
|
||||
*/
|
||||
function serverObserved(server, connected) {
|
||||
try {
|
||||
if (!server) return 0
|
||||
const id = String(server.id)
|
||||
const now = Boolean(connected)
|
||||
const before = tracker.online.get(id)
|
||||
tracker.online.set(id, now)
|
||||
|
||||
// First sight is never a transition: a restart announces nothing.
|
||||
if (before === undefined || before === now) return 0
|
||||
|
||||
return fire(now ? T['rust.server.online'] : T['rust.server.offline'], {
|
||||
data: serverVars(server),
|
||||
// A transition observed by a poll is observed NOW, so it needs no age check;
|
||||
// the key is per minute so that one real flap is one event even if two
|
||||
// polls land either side of a restart of this process.
|
||||
dedupeKey: dedupeKey(now ? 'online' : 'offline', id, Math.floor(Date.now() / 60000)),
|
||||
}) ? 1 : 0
|
||||
} catch (err) {
|
||||
log.warn('could not raise a server transition', { server: server && server.id, error: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a batch: has somebody new taken the lead in this wipe's kills? (D64)
|
||||
*
|
||||
* Only a STRICT lead counts. The leaderboard breaks a tie on who was seen last,
|
||||
* so two players level on kills trade the top row every time either one moves —
|
||||
* and reading the top row alone would announce a new leader each time.
|
||||
*/
|
||||
async function checkLeader(server) {
|
||||
try {
|
||||
const state = await serversDb.getState(server.id)
|
||||
const wipeId = state && state.wipeId
|
||||
if (!wipeId) return 0
|
||||
|
||||
const rows = await eventsDb.leaderboard({ serverId: server.id, wipeId, sort: 'kills', limit: 2 })
|
||||
const top = rows[0]
|
||||
const kills = top ? Number(top.kills) || 0 : 0
|
||||
const id = String(server.id)
|
||||
const before = tracker.leader.get(id)
|
||||
|
||||
if (!top || kills <= 0) {
|
||||
tracker.leader.set(id, { wipeId, steamId: null })
|
||||
return 0
|
||||
}
|
||||
|
||||
const tied = rows[1] && Number(rows[1].kills) === kills
|
||||
const steamId = String(top.steamId)
|
||||
|
||||
// First sight, or a new wipe: remember, announce nothing.
|
||||
if (!before || before.wipeId !== wipeId) {
|
||||
tracker.leader.set(id, { wipeId, steamId: tied ? null : steamId })
|
||||
return 0
|
||||
}
|
||||
|
||||
if (tied || before.steamId === steamId) return 0
|
||||
|
||||
tracker.leader.set(id, { wipeId, steamId })
|
||||
|
||||
return fire(T['rust.leaderboard.topped'], {
|
||||
data: {
|
||||
...serverVars(server),
|
||||
leader: str(top.name) || 'A player',
|
||||
kills,
|
||||
leaderboardUrl: leaderboardPath(id),
|
||||
},
|
||||
dedupeKey: dedupeKey('leader', id, wipeId, steamId, kills),
|
||||
}) ? 1 : 0
|
||||
} catch (err) {
|
||||
log.warn('could not check the leaderboard', { server: server && server.id, error: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login attempts that were never approved (D64).
|
||||
*
|
||||
* A query over what is stored rather than a timer per attempt, so a restart
|
||||
* loses nothing and running it twice is a no-op (the key is the attempt's own
|
||||
* server, Steam id and time). Bounded by D63's personal age: an attempt a day
|
||||
* old is not worth a staff mail.
|
||||
*/
|
||||
async function sweepLoginDenied(servers, now = Date.now()) {
|
||||
let sent = 0
|
||||
for (const server of servers || []) {
|
||||
try {
|
||||
const rows = await eventsDb.unapprovedLogins({
|
||||
serverId: server.id,
|
||||
from: now - PERSONAL_MAX_AGE_MS,
|
||||
to: now - LOGIN_APPROVAL_WINDOW_MS,
|
||||
windowMs: LOGIN_APPROVAL_WINDOW_MS,
|
||||
slackMs: LOGIN_APPROVAL_SLACK_MS,
|
||||
})
|
||||
for (const row of rows) {
|
||||
const t = Number(row.t)
|
||||
if (fire(T['rust.login.denied'], {
|
||||
data: {
|
||||
...serverVars(server),
|
||||
steamId: String(row.steamId),
|
||||
player: str(row.name),
|
||||
attemptedAt: new Date(t),
|
||||
},
|
||||
dedupeKey: dedupeKey('login-denied', server.id, row.steamId, t),
|
||||
occurredAt: t,
|
||||
})) sent += 1
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not sweep login attempts', { server: server && server.id, error: err.message })
|
||||
}
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
/** A Steam account was just linked (R1). Called by the link route, once, on a NEW link. */
|
||||
function linked({ userId, steamId, name }) {
|
||||
try {
|
||||
const uid = Number(userId)
|
||||
if (!Number.isInteger(uid) || uid < 1 || !steamId) return 0
|
||||
return fire(T['rust.player.linked'], {
|
||||
data: { steamId: String(steamId), player: str(name), accountUrl: PATHS.account },
|
||||
ownerUserId: uid,
|
||||
dedupeKey: dedupeKey('linked', steamId, uid),
|
||||
}) ? 1 : 0
|
||||
} catch (err) {
|
||||
log.warn('could not raise the link notification', { error: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `rust.kit.entitled` for each user one reward step granted (phase 13b). Never
|
||||
* throws: a notification that could not be raised must not fail the grant it
|
||||
* is about. Returns how many were raised.
|
||||
*/
|
||||
function entitled({ userIds, kit, server, mode, runId, stepId }) {
|
||||
let raised = 0
|
||||
try {
|
||||
const rewardKey = `${runId}:${stepId}`
|
||||
for (const userId of new Set(userIds || [])) {
|
||||
const uid = Number(userId)
|
||||
if (!Number.isInteger(uid) || uid < 1) continue
|
||||
const ok = fire(T['rust.kit.entitled'], {
|
||||
data: { rewardKey, kit: String(kit), ...serverVars(server), ...(mode ? { mode: String(mode) } : {}), accountUrl: PATHS.account },
|
||||
ownerUserId: uid,
|
||||
dedupeKey: dedupeKey('entitled', runId, stepId, uid),
|
||||
})
|
||||
if (ok) raised++
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not raise the reward notification', { error: err.message })
|
||||
}
|
||||
return raised
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
onEvent,
|
||||
serverObserved,
|
||||
checkLeader,
|
||||
sweepLoginDenied,
|
||||
linked,
|
||||
entitled,
|
||||
reset,
|
||||
dedupeKey,
|
||||
headline,
|
||||
stillNews,
|
||||
BROADCAST_MAX_AGE_MS,
|
||||
PERSONAL_MAX_AGE_MS,
|
||||
LOGIN_APPROVAL_WINDOW_MS,
|
||||
STRUCTURE_LABELS,
|
||||
}
|
||||
334
server/engagement/seeds.js
Normal file
334
server/engagement/seeds.js
Normal file
@@ -0,0 +1,334 @@
|
||||
// ── What the notifications read like, and the rules that use them ─────────
|
||||
//
|
||||
// `registerEngagementSeeds` (MODULE_API.md §2.4 and §1.1 under 1.9.0; PLAN.md
|
||||
// §25.2). Data only: nothing here names a recipient, and nothing here turns a
|
||||
// rule on.
|
||||
//
|
||||
// ── Two bespoke bodies, and why only two ──────────────────────────────────
|
||||
//
|
||||
// A body earns its place when the message has something to say that core's
|
||||
// structural projection cannot. The raid alert does — it is the one message
|
||||
// here somebody acts on at 3am, and it must say WHERE and WHAT in the first
|
||||
// line. The wipe does — it is the one broadcast a whole community waits for.
|
||||
// Everything else is "this happened, here is the link", which is exactly what
|
||||
// core's `notify.event` / `inapp.event` already say, so it points at those and
|
||||
// authors nothing (§4.6.1 property 1).
|
||||
//
|
||||
// The register is plain, not in-universe. Rust has no court or herald to write
|
||||
// in the voice of, and a raid alert dressed as fiction is a raid alert read a
|
||||
// second later than it should be.
|
||||
//
|
||||
// ── Three rules for editing a body ────────────────────────────────────────
|
||||
//
|
||||
// 1. **No conditionals, and never an optional inside a clause.** An unset
|
||||
// optional interpolates to the EMPTY STRING. `atGrid` is a fragment that
|
||||
// carries its own leading space for exactly that reason; `grid` on its own
|
||||
// belongs on a line of its own or nowhere.
|
||||
// 2. **No brand.** `siteName` and friends are supplied by the renderer, so one
|
||||
// image mails as whichever site it is running as.
|
||||
// 3. **Bump `seedVersion` when a body changes, never for a comment.** It is how
|
||||
// a better default reaches deployments whose operators did not edit it.
|
||||
//
|
||||
// ── One rule group per family ─────────────────────────────────────────────
|
||||
//
|
||||
// A group is seeded ONCE (per deployment, per key), so a rule appended to a
|
||||
// group in a later version reaches fresh installs only. Eight families, eight
|
||||
// keys: a future raid rule takes `raid-v2` without disturbing anybody's clan
|
||||
// rules. Every rule is disabled — core ignores `enabled` rather than trusting it
|
||||
// — so installing this module mails nobody until an operator decides it should.
|
||||
|
||||
// ── Block helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const text = (id, body, opts = {}) => ({
|
||||
id,
|
||||
type: 'email.text',
|
||||
props: opts.muted ? { text: body, muted: true } : { text: body },
|
||||
})
|
||||
const heading = (id, body, level = 'h1') => ({ id, type: 'email.heading', props: { level, text: body } })
|
||||
const button = (id, label, url, textLead) => ({
|
||||
id,
|
||||
type: 'email.button',
|
||||
props: textLead ? { label, url, textLead } : { label, url },
|
||||
})
|
||||
const divider = (id) => ({ id, type: 'email.divider', props: {} })
|
||||
|
||||
// Every email ends with the unsubscribe pair; `unsubscribeUrl` is core's
|
||||
// per-delivery variable, not something a trigger declares.
|
||||
const unsubscribe = () => [
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these messages, use this link:'),
|
||||
]
|
||||
|
||||
const email = (key, name, triggerId, subject, blocks) => ({
|
||||
key,
|
||||
name,
|
||||
channel: 'email',
|
||||
triggerId,
|
||||
triggerVersion: 1,
|
||||
seedVersion: 1,
|
||||
subject,
|
||||
blocks: [...blocks, ...unsubscribe()],
|
||||
})
|
||||
|
||||
/** In-app: heading = the row's title, button = its one action, the rest = its body. */
|
||||
const inapp = (key, name, triggerId, title, body, action, url) => ({
|
||||
key,
|
||||
name,
|
||||
channel: 'inapp',
|
||||
triggerId,
|
||||
triggerVersion: 1,
|
||||
seedVersion: 1,
|
||||
subject: null,
|
||||
blocks: [heading('h', title, 'h3'), text('intro', body), button('cta', action, url)],
|
||||
})
|
||||
|
||||
const TEMPLATES = Object.freeze([
|
||||
email(
|
||||
'rust.base.destroyed',
|
||||
'Rust — your base was raided',
|
||||
'rust.base.destroyed',
|
||||
'Your base on {{server}} is being raided',
|
||||
[
|
||||
heading('h', 'Your base is being raided'),
|
||||
text('p1', 'A {{structure}} of a base you are authorised on was destroyed{{atGrid}} on {{server}}.'),
|
||||
text('p2',
|
||||
'You are getting this because you are on the base\'s tool cupboard. Further damage to the '
|
||||
+ 'same base will not send another alert for a while.', { muted: true }),
|
||||
button('cta', 'Open the server page', '{{serverUrl}}'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'rust.base.destroyed-inapp',
|
||||
'Rust — your base was raided (in-app)',
|
||||
'rust.base.destroyed',
|
||||
'Your base is being raided',
|
||||
'A {{structure}} was destroyed{{atGrid}} on {{server}}.',
|
||||
'Open the server',
|
||||
'{{serverUrl}}',
|
||||
),
|
||||
email(
|
||||
'rust.wipe.started',
|
||||
'Rust — a server wiped',
|
||||
'rust.wipe.started',
|
||||
'{{server}} has wiped',
|
||||
[
|
||||
heading('h', '{{server}} has wiped'),
|
||||
text('p1', 'A new wipe has started on {{server}}: a fresh map, and a fresh start for everyone.'),
|
||||
button('cta', 'Open the server page', '{{serverUrl}}'),
|
||||
],
|
||||
),
|
||||
inapp(
|
||||
'rust.wipe.started-inapp',
|
||||
'Rust — a server wiped (in-app)',
|
||||
'rust.wipe.started',
|
||||
'{{server}} has wiped',
|
||||
'A new wipe has started: a fresh map, and a fresh start for everyone.',
|
||||
'Open the server',
|
||||
'{{serverUrl}}',
|
||||
),
|
||||
])
|
||||
|
||||
// ── The rules — every one of them off ──────────────────────────────────────
|
||||
|
||||
/** Core's generic bodies (§4.6.1 property 1). */
|
||||
const GENERIC = { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' }
|
||||
|
||||
/** This module's bodies for a trigger, and core's digest. */
|
||||
const bodies = (key) => ({ email: key, inapp: `${key}-inapp`, digest: 'notify.digest' })
|
||||
|
||||
const RULE_GROUPS = Object.freeze([
|
||||
{
|
||||
key: 'raid-v1',
|
||||
note: 'module-rust: the raid alert (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.base.destroyed',
|
||||
name: 'Raid alert — offline owners',
|
||||
audience: 'owner',
|
||||
// Push is allowed because this trigger is also a stream (D65); the
|
||||
// tickle carries no content, and the app pulls the inbox row.
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
template_keys: bodies('rust.base.destroyed'),
|
||||
// Per BUILDING (the subjectKey): a raid is dozens of walls and one alert.
|
||||
cooldown_seconds: 1800,
|
||||
max_sends_per_hour: 500,
|
||||
// D61: "offline raid alert" is this condition, not code. An operator who
|
||||
// wants online raids too deletes it.
|
||||
conditions: { variable: 'ownerOnline', cmp: 'eq', value: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'wipe-v1',
|
||||
note: 'module-rust: wipe announcements (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.wipe.started',
|
||||
name: 'Server wiped',
|
||||
audience: 'subscribers',
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
template_keys: bodies('rust.wipe.started'),
|
||||
cooldown_seconds: 6 * 3600,
|
||||
max_sends_per_hour: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'server-v1',
|
||||
note: 'module-rust: server up and down (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.server.online',
|
||||
name: 'Server came online',
|
||||
audience: 'subscribers',
|
||||
channels: ['inapp', 'push'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 2000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.server.offline',
|
||||
name: 'Server went offline',
|
||||
audience: 'subscribers',
|
||||
channels: ['inapp', 'push'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 2000,
|
||||
// A plugin reload, or a restart that is back within five minutes, is not
|
||||
// an outage anybody needs to hear about. `cancel_on` withdraws the
|
||||
// pending notice when the server comes back inside the window.
|
||||
delay_seconds: 300,
|
||||
cancel_on: ['rust.server.online'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'leaderboard-v1',
|
||||
note: 'module-rust: a new kills leader (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.leaderboard.topped',
|
||||
name: 'New kills leader',
|
||||
audience: 'subscribers',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 2000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'account-v1',
|
||||
note: 'module-rust: a Steam account was linked (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.player.linked',
|
||||
name: 'Steam account linked',
|
||||
audience: 'owner',
|
||||
// Email as well as in-app: the case this exists for is a link the person
|
||||
// did NOT make, and they will not be looking at the site's inbox for it.
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Its own group, not a rule appended to `account-v1`: a group is seeded once,
|
||||
// so an appended rule would reach fresh installs only (R7).
|
||||
key: 'rewards-v1',
|
||||
note: 'module-rust: an event rewarded you a kit (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.kit.entitled',
|
||||
name: 'Kit reward earned',
|
||||
audience: 'owner',
|
||||
// Email as well: a reward granted at 03:00 is news the person reads the
|
||||
// next morning, before they are next in game or on the site.
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'clans-v1',
|
||||
note: 'module-rust: clan departures and disbands (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.clan.member.left',
|
||||
name: 'Clan — a member left',
|
||||
audience: 'members',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.clan.member.kicked',
|
||||
name: 'Clan — a member was removed',
|
||||
audience: 'members',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.clan.disbanded',
|
||||
name: 'Clan — disbanded',
|
||||
audience: 'members',
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'moderation-v1',
|
||||
note: 'module-rust: reports, bans and unapproved logins, to staff (disabled)',
|
||||
rules: [
|
||||
{
|
||||
trigger_id: 'rust.player.reported',
|
||||
name: 'Player reported',
|
||||
audience: 'staff',
|
||||
channels: ['email', 'inapp'],
|
||||
template_keys: GENERIC,
|
||||
// Per REPORTED player: a pile-on of ten reports is one notice an hour.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.player.banned',
|
||||
name: 'Player banned',
|
||||
audience: 'staff',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.player.unbanned',
|
||||
name: 'Player unbanned',
|
||||
audience: 'staff',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'rust.login.denied',
|
||||
name: 'Login not approved',
|
||||
audience: 'staff',
|
||||
channels: ['inapp'],
|
||||
template_keys: { inapp: GENERIC.inapp },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
module.exports = { TEMPLATES, RULE_GROUPS }
|
||||
53
server/engagement/streams.js
Normal file
53
server/engagement/streams.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── The push facet: which triggers may reach a phone ──────────────────────
|
||||
//
|
||||
// `registerNotificationStreams` (MODULE_API.md §2.4). A stream is what a device
|
||||
// subscribes to, and **core delivers an engagement rule's push only to devices
|
||||
// subscribed to a stream whose id IS the trigger id** (`pushChannel.deliver` ->
|
||||
// `publishToUsers(row.trigger_id)`). So a trigger with no stream here can never
|
||||
// buzz a phone, however its rule is set — which is exactly how the families
|
||||
// that should not are kept off it (D65).
|
||||
//
|
||||
// Every id here is ALSO a trigger in `triggers.js`. That is the one namespace
|
||||
// core enforces across both facets: one event, with a payload contract and a
|
||||
// subscription toggle, owned by one module. An id that appeared only here would
|
||||
// be a toggle nothing could ever fire.
|
||||
//
|
||||
// **The tickle carries nothing.** A push is `{ stream, ref }` and the app pulls
|
||||
// the real item over the authenticated inbox API, so a leaked relay topic says
|
||||
// that something happened and not what. That is core's guarantee and it is why
|
||||
// a raid alert may be a push at all.
|
||||
|
||||
const STREAMS = Object.freeze([
|
||||
{
|
||||
id: 'rust.base.destroyed',
|
||||
label: 'Your base was raided',
|
||||
description: 'Part of a base you are authorised on was destroyed by another player.',
|
||||
// Delivered only to the owner's devices, never fanned out: `owner` ceiling,
|
||||
// one emit per authorised person (D59).
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
},
|
||||
{
|
||||
id: 'rust.server.online',
|
||||
label: 'A server came online',
|
||||
description: 'A Rust server started or came back.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'rust.server.offline',
|
||||
label: 'A server went offline',
|
||||
description: 'A Rust server stopped or stopped answering.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'rust.wipe.started',
|
||||
label: 'A server wiped',
|
||||
description: 'A Rust server started a new wipe.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
])
|
||||
|
||||
module.exports = { STREAMS }
|
||||
719
server/engagement/triggers.js
Normal file
719
server/engagement/triggers.js
Normal file
@@ -0,0 +1,719 @@
|
||||
// ── What can happen, as core's engagement engine is told it ───────────────
|
||||
//
|
||||
// The payload contracts behind every notification this module can cause
|
||||
// (MODULE_API.md §2.4, `registerEventTriggers`; PLAN.md §25). A trigger says
|
||||
// what an event IS, what a template may interpolate, and — the part that is a
|
||||
// security boundary — the widest audience a rule on it may EVER be given.
|
||||
//
|
||||
// ── The ceiling is containment, not size ──────────────────────────────────
|
||||
//
|
||||
// `owner` is not a small `staff`, and `staff` does not permit `owner`. For the
|
||||
// raid alert "one person" is the person whose base it was; for a ban it is
|
||||
// nobody outside the staff room. Each ceiling below is chosen against that
|
||||
// lattice and not against a ladder, and core refuses a rule that widens one.
|
||||
//
|
||||
// ── What no variable here carries, on purpose ─────────────────────────────
|
||||
//
|
||||
// • An IP address. The login and ban frames carry one; the triggers do not,
|
||||
// so no template an operator writes can put an address in a mail. The
|
||||
// admin feed still shows it, to staff, where it is useful.
|
||||
// • The raider (D66). The raid alert says what was destroyed, where and
|
||||
// when. Who did it is gameplay intelligence the game does not hand the
|
||||
// victim, and a variable that is not declared cannot be interpolated.
|
||||
// • A Steam id other than the subject's own.
|
||||
//
|
||||
// ── Why `subjectKey` is what it is ────────────────────────────────────────
|
||||
//
|
||||
// Core's cooldown is per (rule, user, subject, channel). So the subject is the
|
||||
// thing a recipient should hear about once per cooldown: a BUILDING for a raid
|
||||
// (however many walls fall), a SERVER for a broadcast (however often it
|
||||
// bounces), a CLAN for a membership change. A subject that changed every firing
|
||||
// — a boot id, a timestamp — would make every cooldown a no-op.
|
||||
//
|
||||
// ── `version` ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The prop-schema version a template records it was authored against. Bump one
|
||||
// on a rename or a type change, never for a label.
|
||||
|
||||
const ID = 'rust'
|
||||
|
||||
/** Site-relative paths, built the way the client registers them. */
|
||||
const PATHS = {
|
||||
servers: `/${ID}`,
|
||||
account: `/player/${ID}`,
|
||||
}
|
||||
|
||||
// A server id is VARCHAR(64) of the operator's choosing, and a clan's
|
||||
// `externalId` is `<serverId>:<clanId>:<createdMs>`. Core validates a `url`
|
||||
// variable against a character class with no `:` in it, so every id that goes
|
||||
// into a path is percent-encoded — without it the clan link would be dropped at
|
||||
// emit in production, silently, for every clan there is.
|
||||
const serverPath = (serverId) => `/${ID}/servers/${encodeURIComponent(serverId)}`
|
||||
const leaderboardPath = (serverId) => `${serverPath(serverId)}?tab=leaderboard`
|
||||
const clanPath = (externalId) => `/${ID}/clans/${encodeURIComponent(externalId)}`
|
||||
|
||||
const V1 = 1
|
||||
|
||||
// ── Shared variables ───────────────────────────────────────────────────────
|
||||
|
||||
// **Every trigger carries its own headline.** Most rules here point at core's
|
||||
// generic `notify.event` / `inapp.event`, and core's structural projection fills
|
||||
// `title` and `intro` from the trigger's LABEL and DESCRIPTION only when the
|
||||
// payload does not define them — "the payload wins, the projection fills gaps"
|
||||
// (ENGAGEMENT.md §4.6.1). Without these two, the phase-10 walk rendered a
|
||||
// multi-server site's notice as "A server came online. A server's game
|
||||
// started…" — true, and useless, because it never said which. The emitter
|
||||
// writes the sentence (`emit.js` `headline`); an operator's own template can
|
||||
// still ignore it and interpolate the parts.
|
||||
const HEADLINE = [
|
||||
{ name: 'title', type: 'string', required: false, example: 'Main is back online',
|
||||
description: 'A one-line headline naming what happened and where. Core generic bodies use it as the title.' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'Main is back up and taking players.',
|
||||
description: 'One sentence of detail. Core generic bodies use it as the body.' },
|
||||
]
|
||||
|
||||
const SERVER = [
|
||||
{ name: 'serverId', type: 'string', required: true, example: 'main',
|
||||
description: 'The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts.' },
|
||||
{ name: 'server', type: 'string', required: true, example: 'Runic Gateway | Main',
|
||||
description: 'The server\'s display name.' },
|
||||
{ name: 'serverUrl', type: 'url', required: false, example: '/rust/servers/main',
|
||||
description: 'Site-relative path to the server\'s page.' },
|
||||
]
|
||||
|
||||
const CLAN = [
|
||||
{ name: 'clanKey', type: 'string', required: true, example: 'main:12:1790142840000',
|
||||
description: 'The clan\'s stable identity. The cooldown subject; not meant for display.' },
|
||||
{ name: 'clan', type: 'string', required: true, example: 'The Rust Belt',
|
||||
description: 'The clan\'s name.' },
|
||||
{ name: 'clanUrl', type: 'url', required: false, example: '/rust/clans/main%3A12%3A1790142840000',
|
||||
description: 'Site-relative path to the clan\'s page.' },
|
||||
]
|
||||
|
||||
// ── The raid alert ─────────────────────────────────────────────────────────
|
||||
|
||||
const RAID = {
|
||||
id: 'rust.base.destroyed',
|
||||
label: 'Your base was raided',
|
||||
description:
|
||||
'Part of a base you are authorised on was destroyed by another player: a wall, a door, ' +
|
||||
'an external wall or gate, or the tool cupboard.',
|
||||
kind: 'event',
|
||||
// One per base per cooldown, however many walls fall. The building is the
|
||||
// tool cupboard's id — the game's own answer to "which base is this".
|
||||
subjectKey: 'building',
|
||||
// One emit per authorised, linked person, each with `ownerUserId` set (D59).
|
||||
// `owner` is the ceiling AND the default: there is nobody else this may reach.
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'building', type: 'string', required: true, example: '8113',
|
||||
description: 'The base, as the id of its tool cupboard. The cooldown subject.' },
|
||||
{ name: 'structure', type: 'string', required: true, example: 'door',
|
||||
description: 'What was destroyed: "building block", "door", "external wall" or "tool cupboard".' },
|
||||
{ name: 'grid', type: 'string', required: false, example: 'H7',
|
||||
description: 'The map grid square. Absent when the server could not work one out.' },
|
||||
// A FRAGMENT, for use inside a sentence. An unset optional interpolates to
|
||||
// the empty string, so "your door in {{grid}} was destroyed" reads "your
|
||||
// door in was destroyed" when the grid is unknown; this carries its own
|
||||
// leading space and vanishes cleanly instead.
|
||||
{ name: 'atGrid', type: 'string', required: false, example: ' in H7',
|
||||
description: 'Sentence fragment: " in H7" with its own leading space, or nothing when the grid is unknown.' },
|
||||
{ name: 'ownerOnline', type: 'boolean', required: true, example: false,
|
||||
description: 'Whether YOU were online when it happened. The seeded rule alerts only when this is false.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Server lifecycle ───────────────────────────────────────────────────────
|
||||
//
|
||||
// `everyone` because a server being up is what a server page already says to
|
||||
// anyone. The DEFAULT is `subscribers` — the people who asked — and an operator
|
||||
// widens deliberately.
|
||||
|
||||
const BROADCASTS = [
|
||||
{
|
||||
id: 'rust.wipe.started',
|
||||
label: 'A server wiped',
|
||||
description: 'A server started a new wipe: a fresh map, and everything built on the old one gone.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234',
|
||||
description: 'The new wipe\'s identity.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.server.online',
|
||||
label: 'A server came online',
|
||||
description: 'A server\'s game started, or came back after being unreachable.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE],
|
||||
},
|
||||
{
|
||||
id: 'rust.server.offline',
|
||||
label: 'A server went offline',
|
||||
description: 'A server\'s game stopped, crashed, or stopped talking to the website.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE],
|
||||
},
|
||||
{
|
||||
id: 'rust.leaderboard.topped',
|
||||
label: 'A new kills leader',
|
||||
description: 'Somebody new leads the current wipe\'s kills on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'leader', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'The new leader\'s in-game name.' },
|
||||
{ name: 'kills', type: 'int', required: true, example: 42,
|
||||
description: 'Their kills this wipe.' },
|
||||
{ name: 'leaderboardUrl', type: 'url', required: false, example: '/rust/servers/main?tab=leaderboard',
|
||||
description: 'Site-relative path to the server\'s leaderboard.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── The player's own account ───────────────────────────────────────────────
|
||||
|
||||
const ACCOUNT = {
|
||||
id: 'rust.player.linked',
|
||||
label: 'A Steam account was linked',
|
||||
description: 'A Steam account was linked to your website account with an in-game code.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
// PLAN.md §10 said `self`; core has no such ceiling (§25.1). `owner` with the
|
||||
// linking user as `ownerUserId` is the value that exists and means the same.
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000001',
|
||||
description: 'The Steam account that was linked. Also the cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'The in-game name the game reported when it was linked.' },
|
||||
{ name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
|
||||
description: 'Site-relative path to your Rust account page.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── A reward (phase 13b) ───────────────────────────────────────────────────
|
||||
//
|
||||
// Deferred from phase 10 (D64) to the phase that grants something. Emitted once
|
||||
// per recipient USER when an event's `rust.kit.entitle` writes their rows, with
|
||||
// that user as `ownerUserId` — so, like the link notice, `owner` is both the
|
||||
// ceiling and the only audience there is: a reward is nobody else's news.
|
||||
//
|
||||
// The subject is the run and the step, so one award is one notification however
|
||||
// often a retried step writes the same rows.
|
||||
|
||||
const REWARD = {
|
||||
id: 'rust.kit.entitled',
|
||||
label: 'An event rewarded you a kit',
|
||||
description: 'An event on a Rust server rewarded you: a kit is waiting in the in-game Kits menu, with one extra use.',
|
||||
kind: 'event',
|
||||
subjectKey: 'rewardKey',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...HEADLINE,
|
||||
{ name: 'rewardKey', type: 'string', required: true, example: '41:7',
|
||||
description: 'The run and step that awarded it. The cooldown subject; not meant for display.' },
|
||||
{ name: 'kit', type: 'string', required: true, example: 'vip-starter',
|
||||
description: 'The kit name, as the server Kits plugin has it.' },
|
||||
...SERVER,
|
||||
{ name: 'mode', type: 'string', required: false, example: 'top',
|
||||
description: 'How the recipients were chosen: everyone, top, minScore, random or topPercent.' },
|
||||
{ name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
|
||||
description: 'Site-relative path to your Rust account page.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Clans ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `members` ceiling — clan membership is the clan's business (D49). Recipients
|
||||
// travel on the envelope as `recipientUserIds`, because "the clan this was
|
||||
// about" is a different answer every firing and cannot be a saved audience.
|
||||
//
|
||||
// No `rust.clan.member.added`: core already fires `team.member.joined` for our
|
||||
// clans through the Team sync, and a second trigger would notify twice (D64).
|
||||
|
||||
const CLANS = [
|
||||
{
|
||||
id: 'rust.clan.member.left',
|
||||
label: 'Someone left your clan',
|
||||
description: 'A member left a clan you are in.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'member', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'Who left.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.clan.member.kicked',
|
||||
label: 'Someone was removed from your clan',
|
||||
description: 'A member was removed from a clan you are in — or you were.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'member', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'Who was removed.' },
|
||||
{ name: 'by', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who removed them.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.clan.disbanded',
|
||||
label: 'Your clan was disbanded',
|
||||
description: 'A clan you were in was disbanded.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'by', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who disbanded it.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Moderation — staff, and never wider ────────────────────────────────────
|
||||
|
||||
const MODERATION = [
|
||||
{
|
||||
id: 'rust.player.reported',
|
||||
label: 'A player was reported',
|
||||
description: 'A player filed an in-game report against another.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The reported player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The reported player\'s name.' },
|
||||
{ name: 'reporter', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who filed the report.' },
|
||||
{ name: 'reportType', type: 'string', required: false, example: 'cheat',
|
||||
description: 'The category the reporter chose.' },
|
||||
{ name: 'topic', type: 'string', required: false, example: 'Aimbot at the dome',
|
||||
description: 'The report\'s subject line.' },
|
||||
{ name: 'message', type: 'string', required: false, example: 'Headshots through two walls.',
|
||||
description: 'The report\'s text.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.player.banned',
|
||||
label: 'A player was banned',
|
||||
description: 'A player was banned on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The banned player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The banned player\'s name.' },
|
||||
{ name: 'reason', type: 'string', required: false, example: 'Cheating',
|
||||
description: 'The reason given.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.player.unbanned',
|
||||
label: 'A player was unbanned',
|
||||
description: 'A ban on a server was lifted.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The player\'s name.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.login.denied',
|
||||
label: 'A login was not approved',
|
||||
description:
|
||||
'Somebody tried to join a server and was not let in within a minute: a ban, a failed ' +
|
||||
'authentication, or a player who gave up while connecting.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The Steam id that tried to connect. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The name it connected with.' },
|
||||
{ name: 'attemptedAt', type: 'datetime', required: true, example: '2026-09-23T03:10:00Z',
|
||||
description: 'When the attempt was made.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── RunicNPC (docs/runicnpc/PLAN.md stage 4) ───────────────────────────────
|
||||
//
|
||||
// What an event's phase waits on: "8 guards died" (waves) and "the boss fell
|
||||
// below 50%". Core counts a gate's firings from phase entry and does not know
|
||||
// which run an NPC belongs to, so each firing says its profile, whether an event
|
||||
// placed it and which run did; a gate's `where` names what it waits for. A
|
||||
// profile only events place is the plain way to count only an event's NPCs.
|
||||
//
|
||||
// `staff`, both halves: a death names who killed it. A boss announced to
|
||||
// players is stage 6's, with its own trigger.
|
||||
|
||||
const NPC_VARS = [
|
||||
{ name: 'profile', type: 'string', required: true, example: 'guard',
|
||||
description: 'The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names.' },
|
||||
{ name: 'npc', type: 'string', required: false, example: 'Gate Guard',
|
||||
description: 'The NPC\'s own name, as the victim\'s death screen shows it.' },
|
||||
{ name: 'byEvent', type: 'boolean', required: true, example: true,
|
||||
description: 'Whether an event placed it (rather than an admin\'s placement or another plugin).' },
|
||||
{ name: 'runId', type: 'string', required: false, example: '42',
|
||||
description: 'The event run that placed it, when one did.' },
|
||||
{ name: 'placement', type: 'string', required: false, example: 'guard-3',
|
||||
description: 'The placement it came from, when it came from one.' },
|
||||
]
|
||||
|
||||
const NPCS = [
|
||||
{
|
||||
id: 'rust.npc.died',
|
||||
label: 'A RunicNPC NPC died',
|
||||
description: 'One of RunicNPC\'s NPCs was killed. A phase can wait for a number of them: "8 guards died".',
|
||||
kind: 'event',
|
||||
subjectKey: 'profile',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
...NPC_VARS,
|
||||
{ name: 'killer', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'The player who landed the killing blow. Absent when no player did.' },
|
||||
{ name: 'killerSteamId', type: 'string', required: false, example: '76561198000000002',
|
||||
description: 'Their Steam id.' },
|
||||
{ name: 'contributors', type: 'int', required: true, example: 3,
|
||||
description: 'How many players took health from it, the killer included.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.npc.health',
|
||||
label: 'A RunicNPC NPC fell to a health threshold',
|
||||
description: 'One of RunicNPC\'s NPCs fell to a fraction of its health its profile names. A phase can wait for "the boss below 50%".',
|
||||
kind: 'event',
|
||||
subjectKey: 'profile',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
...NPC_VARS,
|
||||
{ name: 'percent', type: 'int', required: true, example: 50,
|
||||
description: 'The threshold it fell to, as a percentage of its health: 50 for half.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── A RunicNPC boss (docs/runicnpc/PLAN.md stage 6, D277) ───────────────────
|
||||
//
|
||||
// Public, unlike the two above: a boss rising, changing phase and falling is
|
||||
// server news, as a wipe is. `everyone` is the ceiling and `subscribers` the
|
||||
// default, as for the broadcasts. The game also says these lines in chat; these
|
||||
// are for the app, the in-app inbox and email. Discord hears a boss only through
|
||||
// an event's own announce step (D277), so core does not change.
|
||||
//
|
||||
// No Steam id is declared, the killer's included: a public notice names the
|
||||
// killer by their in-game name only.
|
||||
|
||||
const BOSS_VARS = [
|
||||
{ name: 'profile', type: 'string', required: true, example: 'juggernaut',
|
||||
description: 'The RunicNPC profile the boss was made from.' },
|
||||
{ name: 'boss', type: 'string', required: true, example: 'Juggernaut',
|
||||
description: 'The boss\'s own name, as players see it.' },
|
||||
{ name: 'byEvent', type: 'boolean', required: true, example: true,
|
||||
description: 'Whether an event placed it.' },
|
||||
]
|
||||
|
||||
const BOSSES = [
|
||||
{
|
||||
id: 'rust.boss.spawned',
|
||||
label: 'A boss appeared',
|
||||
description: 'A RunicNPC boss spawned on a server: an admin\'s placement, an event, or another plugin.',
|
||||
kind: 'event',
|
||||
// One per kind of boss per cooldown, however often it respawns.
|
||||
subjectKey: 'profile',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE, ...BOSS_VARS],
|
||||
},
|
||||
{
|
||||
id: 'rust.boss.phase',
|
||||
label: 'A boss changed phase',
|
||||
description: 'A RunicNPC boss fell to one of its phases\' health thresholds and changed: stronger, faster, or calling for help.',
|
||||
kind: 'event',
|
||||
// A profile's phase: phase 2 is not swallowed by phase 1's cooldown.
|
||||
subjectKey: 'phaseKey',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
...BOSS_VARS,
|
||||
{ name: 'phaseKey', type: 'string', required: true, example: 'juggernaut:2',
|
||||
description: 'The profile and the phase. The cooldown subject; not meant for display.' },
|
||||
{ name: 'phase', type: 'int', required: true, example: 2,
|
||||
description: 'Which phase it entered, counting from 1.' },
|
||||
{ name: 'percent', type: 'int', required: true, example: 25,
|
||||
description: 'The health it fell to, as a percentage.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.boss.killed',
|
||||
label: 'A boss was killed',
|
||||
description: 'Players killed a RunicNPC boss on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'profile',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
...BOSS_VARS,
|
||||
{ name: 'killer', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'The player who landed the killing blow, by in-game name. Absent when no player did.' },
|
||||
{ name: 'damagers', type: 'int', required: true, example: 5,
|
||||
description: 'How many players hurt it, the killer included.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1, D355) ─────
|
||||
//
|
||||
// One trigger per `world.event` type, `rust.world.<type>`. Public news, as a
|
||||
// boss is: `everyone` is the ceiling and `subscribers` the default. A phase
|
||||
// gate reads the same catalogue, so "advance when Bradley is destroyed" is
|
||||
// `{ on: 'rust.world.bradley.destroyed' }` with no further code.
|
||||
//
|
||||
// The cooldown subject is the server and the type, and the crate types add
|
||||
// where the crate is — one line per oil rig per cooldown, not one per hack on
|
||||
// the map. `player` is the in-game name only, as a boss's killer is; no Steam
|
||||
// id is declared.
|
||||
|
||||
const WORLD_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:heli.downed',
|
||||
description: 'The server and the event (and, for a crate, where it is). The cooldown subject; not meant for display.' }
|
||||
const WORLD_GRID = { name: 'grid', type: 'string', required: false, example: 'G7',
|
||||
description: 'The map grid square. Absent when the server has no map geometry yet, or the event is off the map.' }
|
||||
const WORLD_PLAYER = { name: 'player', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'The player it names, by in-game name. Absent when nobody is named.' }
|
||||
const WORLD_CREDITED = [
|
||||
{ name: 'player', type: 'string', required: false, example: 'Marisol and Darrow',
|
||||
description: 'Who is credited, by in-game name and by the server\'s credit rule: "Alice", "Alice and Bob", "Alice, Bob and 2 others". Absent when nobody is.' },
|
||||
{ name: 'credited', type: 'int', required: true, example: 2,
|
||||
description: 'How many players the credit rule names.' },
|
||||
]
|
||||
const WORLD_WHERE = (example, list) => ({ name: 'where', type: 'string', required: true, example,
|
||||
description: `Where it is: ${list}.` })
|
||||
const CRATE_WHERE = WORLD_WHERE('oilrig_large', 'oilrig_large, oilrig_small, cargo, ghostship or chinook (a crate a Chinook dropped)')
|
||||
const WORLD_HARBOUR = { name: 'harbour', type: 'string', required: false, example: 'Harbor',
|
||||
description: 'The harbour, by the game\'s own name for it.' }
|
||||
const WORLD_MONUMENT = { name: 'monument', type: 'string', required: false, example: 'Water Treatment Plant',
|
||||
description: 'The monument it dropped at, by the game\'s own name for it.' }
|
||||
const WORLD_COUNT = { name: 'count', type: 'int', required: true, example: 12,
|
||||
description: 'How many: road Bradleys come several at once.' }
|
||||
|
||||
/** Each world event's label, description and the variables it carries beyond the server's. */
|
||||
const WORLD_EVENTS = [
|
||||
['heli.arrived', 'A patrol helicopter arrived', 'A patrol helicopter flew onto the map.', [WORLD_GRID]],
|
||||
['heli.left', 'The patrol helicopter left', 'A patrol helicopter left the map without being downed.', [WORLD_GRID]],
|
||||
['heli.downed', 'A patrol helicopter was downed', 'Players brought down a patrol helicopter.', [WORLD_GRID, ...WORLD_CREDITED]],
|
||||
['bradley.spawned', 'Bradley is out', 'Launch Site\'s Bradley spawned, or road Bradleys took to the roads.', [WORLD_WHERE('road', 'launch_site or road'), WORLD_COUNT]],
|
||||
['bradley.destroyed', 'A Bradley was destroyed', 'Players destroyed a Bradley APC: Launch Site\'s, a road one, or another.', [WORLD_WHERE('launch_site', 'launch_site, road or other'), WORLD_GRID, ...WORLD_CREDITED]],
|
||||
['cargo.arrived', 'The cargo ship arrived', 'The cargo ship sailed onto the map.', [WORLD_GRID]],
|
||||
['cargo.docking', 'The cargo ship is docking', 'The cargo ship turned in to dock at a harbour.', [WORLD_GRID, WORLD_HARBOUR]],
|
||||
['cargo.docked', 'The cargo ship docked', 'The cargo ship docked at a harbour, with fresh crates.', [WORLD_GRID, WORLD_HARBOUR]],
|
||||
['cargo.undocked', 'The cargo ship cast off', 'The cargo ship left the harbour it was docked at.', [WORLD_GRID, WORLD_HARBOUR]],
|
||||
['cargo.left', 'The cargo ship is leaving', 'The cargo ship turned for the edge of the map.', [WORLD_GRID]],
|
||||
['chinook.arrived', 'A Chinook arrived', 'A Chinook flew onto the map with a locked crate.', [WORLD_GRID]],
|
||||
['chinook.dropped', 'A Chinook dropped its crate', 'A Chinook dropped a locked crate at a monument.', [WORLD_GRID, WORLD_MONUMENT]],
|
||||
['chinook.downed', 'A Chinook was shot down', 'Players shot down a Chinook.', [WORLD_GRID, ...WORLD_CREDITED]],
|
||||
['airdrop.called', 'An airdrop is on its way', 'A cargo plane is bringing an airdrop: called by a supply signal, or by the schedule.', [WORLD_GRID, WORLD_PLAYER]],
|
||||
['airdrop.landed', 'An airdrop landed', 'An airdrop reached the ground.', [WORLD_GRID, WORLD_PLAYER]],
|
||||
['airdrop.looted', 'An airdrop was looted', 'Somebody opened an airdrop first.', [WORLD_GRID, WORLD_PLAYER]],
|
||||
['crate.hack.started', 'A locked crate is being hacked', 'Somebody started hacking a locked crate. On an oil rig this also calls its scientists.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
|
||||
['crate.unlocked', 'A locked crate unlocked', 'A hacked locked crate finished unlocking.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
|
||||
['crate.looted', 'A locked crate was looted', 'Somebody opened an unlocked locked crate first.', [CRATE_WHERE, WORLD_GRID, WORLD_PLAYER]],
|
||||
['vendor.arrived', 'The Travelling Vendor arrived', 'The Travelling Vendor drove onto the map.', [WORLD_GRID]],
|
||||
['vendor.left', 'The Travelling Vendor left', 'The Travelling Vendor left the map.', [WORLD_GRID]],
|
||||
['satellite.crashed', 'A satellite crashed', 'A satellite came down, and its crates are burning.', [WORLD_GRID]],
|
||||
['deepsea.opened', 'The Deep Sea opened', 'The Deep Sea is open: its ghost ships and islands can be reached.', []],
|
||||
['deepsea.closed', 'The Deep Sea closed', 'The Deep Sea closed.', []],
|
||||
]
|
||||
|
||||
const WORLD = WORLD_EVENTS.map(([event, label, description, vars]) => ({
|
||||
id: `rust.world.${event}`,
|
||||
label,
|
||||
description,
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE, WORLD_SUBJECT, ...vars],
|
||||
}))
|
||||
|
||||
// ── Dangerous Treasures (protocol 14, PLAN_REDESIGNS §14.3.4, §14.3.9) ──────
|
||||
//
|
||||
// A treasure event opening and being won are triggers, as a world event is
|
||||
// (D355): public news, `everyone` the ceiling and `subscribers` the default.
|
||||
// The cooldown subject is the server, so a busy hour of treasures is one line
|
||||
// per cooldown. `player` is the winner's in-game name only; no Steam id is
|
||||
// declared. Its ending is staff's on the feed and raises nothing.
|
||||
|
||||
const DT_SUBJECT = { name: 'subject', type: 'string', required: true, example: 'main:dt.won',
|
||||
description: 'The server and the event. The cooldown subject; not meant for display.' }
|
||||
const DT_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
|
||||
description: 'Whether one of this site\'s events started it (a Start a Dangerous Treasures event step).' }
|
||||
|
||||
const DANGEROUS_TREASURES = [
|
||||
{
|
||||
id: 'rust.dt.started',
|
||||
label: 'A treasure event started',
|
||||
description: 'Dangerous Treasures opened a guarded, burning treasure chest on a server: on its own schedule, by an admin, or by an event.',
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE, DT_SUBJECT, WORLD_GRID, DT_BY_EVENT],
|
||||
},
|
||||
{
|
||||
id: 'rust.dt.won',
|
||||
label: 'A treasure was won',
|
||||
description: 'A player emptied a Dangerous Treasures chest on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
DT_SUBJECT,
|
||||
WORLD_GRID,
|
||||
{ name: 'player', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who emptied the chest, by in-game name.' },
|
||||
{ name: 'invaders', type: 'int', required: true, example: 3,
|
||||
description: 'How many players fought for it, the winner included.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Guarded Crate (protocol 14, PLAN_REDESIGNS §14.3.5, §14.3.9) ────────────
|
||||
//
|
||||
// A guarded crate landing and being cleared are triggers, as Dangerous
|
||||
// Treasures' are: public news, the server the cooldown subject. `player` is the
|
||||
// clearer's in-game name only. A crate whose guards went with nobody killing
|
||||
// the last (NpcSpawn reloaded under it) and one that timed out raise nothing:
|
||||
// neither is news.
|
||||
|
||||
const GC_BY_EVENT = { name: 'byEvent', type: 'boolean', required: true, example: false,
|
||||
description: 'Whether one of this site\'s events placed it (a Place a guarded crate step).' }
|
||||
|
||||
const GUARDED_CRATE = [
|
||||
{
|
||||
id: 'rust.gc.started',
|
||||
label: 'A guarded crate landed',
|
||||
description: 'Guarded Crate dropped a locked crate ringed by guards on a server: on its own schedule, by an admin, or by an event.',
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE, { ...DT_SUBJECT, example: 'main:gc.started' }, WORLD_GRID, GC_BY_EVENT],
|
||||
},
|
||||
{
|
||||
id: 'rust.gc.ended',
|
||||
label: 'A guarded crate was cleared',
|
||||
description: 'A player killed the last guard of a guarded crate on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'subject',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ ...DT_SUBJECT, example: 'main:gc.ended' },
|
||||
WORLD_GRID,
|
||||
{ name: 'player', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'Who killed the last guard, by in-game name.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES, ...WORLD, ...DANGEROUS_TREASURES, ...GUARDED_CRATE])
|
||||
|
||||
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
|
||||
|
||||
module.exports = { TRIGGERS, TRIGGER_IDS, PATHS, serverPath, leaderboardPath, clanPath }
|
||||
489
server/eventCommands.js
Normal file
489
server/eventCommands.js
Normal file
@@ -0,0 +1,489 @@
|
||||
// ── Console commands as event steps and rewards (PLAN_REDESIGNS.md §16, D382–D385) ──
|
||||
//
|
||||
// Two steps that run console commands the author types, so a plugin with no
|
||||
// first-class support can still be used from the scheduler — RaidableBases'
|
||||
// `rbe …` as a step, `sr add {steamid} 100` as a reward:
|
||||
//
|
||||
// rust.command.run runs the commands once, on one server; optional
|
||||
// teardown commands run when the run ends or is
|
||||
// cancelled (`irreversible` / `ledger`)
|
||||
// rust.command.reward runs them once per recipient a kit reward's picker
|
||||
// picks, `{steamid}` and `{name}` filled in by the
|
||||
// bridge (`irreversible` / `none`)
|
||||
//
|
||||
// One protocol-14 frame carries both, `command.run` through `POST
|
||||
// /commands/run`. The bridge runs each key at most once: it writes the key down
|
||||
// before anything runs, so a retried step is answered from the first attempt.
|
||||
//
|
||||
// ── The safety is core's, and the author's own "I am sure" (D384) ───────────
|
||||
//
|
||||
// No allowlist and no blocklist: the commands are a free text box (core's
|
||||
// `multiple` string field, MODULE_API 1.14.0). What bounds them is what bounds
|
||||
// every world-changing step — off until an admin switches it on, authored only
|
||||
// by an admin, the switchboard's caps on `rust.commands` — and a required toggle
|
||||
// that says the author meant it. Unticked, the dry run and the run refuse.
|
||||
//
|
||||
// ── The teardown has two copies (D385) ─────────────────────────────────────
|
||||
//
|
||||
// The ledger row carries the teardown commands as they were when the step ran,
|
||||
// so an edit to the definition mid-run changes nothing. But core records no
|
||||
// resource for a step that FAILED (a misspelled second command after a first
|
||||
// that started a base) or whose answer was lost, and reverts it with only the
|
||||
// key. So the run also hands the teardown to the bridge, which keeps it with
|
||||
// the key; a revert that has only the key asks every enabled server to run the
|
||||
// teardown it kept under that key, which a server the step never reached
|
||||
// answers with nothing run.
|
||||
|
||||
const client = require('./sidecarClient')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const core = require('./core')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
const rewards = require('./eventRewards')
|
||||
const demo = require('./demo')
|
||||
|
||||
const log = core.logger('commands')
|
||||
|
||||
const { MAX_RECIPIENTS, MODES, oneOf, checkCount, pickRecipients, peopleOf, readTally, entitlementRef, refParts } = rewards
|
||||
|
||||
/** At most this many commands in one field (§16.2). */
|
||||
const MAX_COMMANDS = 10
|
||||
|
||||
/** The longest command, in characters (§16.2). */
|
||||
const COMMAND_MAX = 512
|
||||
|
||||
/** What the bridge fills in for each recipient, and only for a reward (§16.3). */
|
||||
const PLACEHOLDER = /\{(steamid|name)\}/i
|
||||
|
||||
/** A ledger row's kind: the teardown commands one step left to run. */
|
||||
const TEARDOWN_KIND = 'command-teardown'
|
||||
|
||||
/** The key a step's teardown runs under: its own, so it too runs at most once. */
|
||||
const teardownKey = (key) => `${key}:teardown`
|
||||
|
||||
/** The bridge's refusals a second attempt would repeat. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'unknown-command',
|
||||
// Some commands may have run before a reload cut the run off, and none is run
|
||||
// again: a retry would only be answered the same.
|
||||
'interrupted',
|
||||
'failed',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.commands',
|
||||
label: 'Console commands',
|
||||
unit: 'commands',
|
||||
description:
|
||||
'Console commands an event runs, as typed by its author: one per command, teardown commands included, and one per command per recipient for a reward. A mode that is not a count is priced at the most it could reward.',
|
||||
},
|
||||
]
|
||||
|
||||
// ── The params both steps check ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One field's commands as a list, checked: trimmed, blanks dropped, each at most
|
||||
* COMMAND_MAX characters on one line, at most MAX_COMMANDS. Core hands a
|
||||
* `multiple` param over as an array (MODULE_API 1.14.0); a bare value is a list
|
||||
* of one. Order is kept: the commands run as listed.
|
||||
*/
|
||||
function checkCommands(raw, { field = 'commands', required = true } = {}) {
|
||||
const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const line = String(item === undefined || item === null ? '' : item).trim()
|
||||
if (!line) continue
|
||||
if (line.length > COMMAND_MAX) return { ok: false, error: `a command is at most ${COMMAND_MAX} characters, and one of the ${field} is ${line.length}` }
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\u0000-\u001f\u007f]/.test(line)) return { ok: false, error: `a command is one line: one of the ${field} has a line break or a control character in it` }
|
||||
out.push(line)
|
||||
}
|
||||
if (required && !out.length) return { ok: false, error: 'type at least one command' }
|
||||
if (out.length > MAX_COMMANDS) return { ok: false, error: `a step runs at most ${MAX_COMMANDS} ${field}, not ${out.length}` }
|
||||
return { ok: true, value: out }
|
||||
}
|
||||
|
||||
/** The "I am sure" toggle (§16.2, §16.3, D343's). Only a real `true` passes; a string is not a tick. */
|
||||
function checkConfirm(params, sentence) {
|
||||
if (params.confirm === true) return { ok: true }
|
||||
return { ok: false, error: `Tick "${sentence}" to run these commands` }
|
||||
}
|
||||
|
||||
/** The first command that names a placeholder, or null: only a reward has someone to fill one with. */
|
||||
const withPlaceholder = (lines) => lines.find((line) => PLACEHOLDER.test(line)) || null
|
||||
|
||||
/** A `multiple` param as a list: a bare value is a list of one, nothing is none. */
|
||||
const asList = (raw) => (raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw])
|
||||
|
||||
/** A command list's price: at least one, so the switchboard sees the dimension. */
|
||||
function pricedCommands(raw) {
|
||||
const checked = checkCommands(raw, { required: false })
|
||||
return checked.ok ? checked.value.length : 1
|
||||
}
|
||||
|
||||
/** How many teardown commands a step names, as its price counts them; none is none. */
|
||||
function teardownCount(raw) {
|
||||
const checked = checkCommands(raw, { required: false })
|
||||
return checked.ok ? checked.value.length : 0
|
||||
}
|
||||
|
||||
/** People priced before the tally is read: the count for a count, the bound for every other mode. */
|
||||
function pricedPeople(p) {
|
||||
const mode = oneOf(p.recipients, MODES)
|
||||
const n = Math.round(Number(p.count) || 0)
|
||||
return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
|
||||
}
|
||||
|
||||
/** A transport failure; permanent only when the server is not set up to be asked at all. */
|
||||
function transportFailure(server, result, what) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge's refusal, in words, with how many commands ran before it: a typo
|
||||
* in the second command of three says the first one ran.
|
||||
*/
|
||||
function refusal(server, data, what) {
|
||||
const name = server.name || server.id
|
||||
const ran = Number(data.ran) || 0
|
||||
const said = pluginError(data, `${name} did not run the ${what}`)
|
||||
const tail = ran ? ` (${ran} ran before it)` : ''
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: `${name}: ${said}${tail}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the run log shows of an answer: how many ran, what the console said
|
||||
* (the bridge keeps the first 20), and who was skipped as offline.
|
||||
*/
|
||||
function replyDetail(data) {
|
||||
const replies = Array.isArray(data.replies) ? data.replies : []
|
||||
const skipped = Array.isArray(data.skipped) ? data.skipped.map(String) : []
|
||||
const skippedCount = Number(data.skippedCount) || skipped.length
|
||||
return {
|
||||
ran: Number(data.ran) || 0,
|
||||
...(replies.length
|
||||
? { replies: replies.map((r) => ({ command: String(r.command || ''), reply: String(r.reply || ''), ...(r.steamId ? { steamId: String(r.steamId) } : {}) })) }
|
||||
: {}),
|
||||
...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran' } : {}),
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing was run twice' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run console commands (§16.2) ─────────────────────────────────────────────
|
||||
|
||||
const RUN_SURE = 'I am sure I want to run these commands on the server'
|
||||
|
||||
/**
|
||||
* Everything the step checks before touching anything, in the order an author
|
||||
* fixes them: `{ ok, commands, teardown, server }` or a refusal core reads as
|
||||
* permanent.
|
||||
*/
|
||||
async function checkRun(params) {
|
||||
const commands = checkCommands(params.commands)
|
||||
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
|
||||
|
||||
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
|
||||
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
|
||||
|
||||
// The demo's deny list (DEMO_MODE.md §3.4), at run: core asked at save
|
||||
// (demoRefuses), but a definition can predate the list.
|
||||
const denied = demo.deniedCommand([...commands.value, ...teardown.value])
|
||||
if (denied) return { ok: false, retry: false, error: denied }
|
||||
|
||||
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
|
||||
if (placeholder) {
|
||||
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
|
||||
}
|
||||
|
||||
const confirm = checkConfirm(params, RUN_SURE)
|
||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||
|
||||
const serverId = String(params.server || '').trim()
|
||||
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
|
||||
return { ok: true, commands: commands.value, teardown: teardown.value, server: found.server }
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back what a step left to undo: run its teardown commands, once.
|
||||
*
|
||||
* With a resource, the ledger's own copy goes to the server it names. With
|
||||
* none — the step failed, or its answer was lost — every enabled server is
|
||||
* asked to run what it kept under the step's key (D385); a server the step
|
||||
* never reached, or one where nothing ran, runs nothing and says so. A server
|
||||
* that cannot be asked leaves the row visible rather than guessing.
|
||||
*/
|
||||
async function revertRun({ resources, idempotencyKey }) {
|
||||
const errors = []
|
||||
const failed = []
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
if (!idempotencyKey) return { ok: true }
|
||||
for (const server of await servers.listForPolling()) {
|
||||
const result = await client.commandsRun(server, { key: teardownKey(idempotencyKey), teardownOf: idempotencyKey })
|
||||
if (!result.ok) errors.push(transportError(server, result, 'teardown commands'))
|
||||
else if (!result.data || result.data.kind !== 'command.ran') errors.push(refusal(server, result.data || {}, 'teardown commands').error)
|
||||
}
|
||||
return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true }
|
||||
}
|
||||
|
||||
for (const r of resources) {
|
||||
const payload = r.payload || {}
|
||||
const teardown = Array.isArray(payload.teardown) ? payload.teardown : []
|
||||
if (!teardown.length) continue
|
||||
|
||||
const found = await serverFor(payload.serverId || refParts(r.ref).serverId)
|
||||
if (!found.ok) {
|
||||
failed.push(r.ref)
|
||||
errors.push(found.error)
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await client.commandsRun(found.server, { key: teardownKey(payload.key || r.ref), commands: teardown })
|
||||
if (!result.ok) {
|
||||
failed.push(r.ref)
|
||||
errors.push(transportError(found.server, result, 'teardown commands'))
|
||||
continue
|
||||
}
|
||||
|
||||
// A 200 is not a success on this bridge: a refusal (`unknown-command`) comes
|
||||
// back as one, carrying `command.error`.
|
||||
if (!result.data || result.data.kind !== 'command.ran') {
|
||||
failed.push(r.ref)
|
||||
errors.push(refusal(found.server, result.data || {}, 'teardown commands').error)
|
||||
continue
|
||||
}
|
||||
|
||||
log.info('teardown commands ran', { server: found.server.id, ref: r.ref, ran: Number(result.data.ran) || 0, repeat: !!result.data.repeat })
|
||||
}
|
||||
|
||||
if (!failed.length) return { ok: true }
|
||||
if (failed.length === resources.length) return { ok: false, error: errors.join('; ') }
|
||||
return { ok: true, failed }
|
||||
}
|
||||
|
||||
const commandRun = {
|
||||
id: 'rust.command.run',
|
||||
label: 'Run console commands',
|
||||
description:
|
||||
'Runs console commands you type, once, on one server, as the server console would: a plugin\'s own command, such as RaidableBases\' rbe, or a game command. Optional teardown commands run when the event ends or is cancelled. The site cannot know what a command does, so the step is rated the most dangerous class: it is off until an admin switches it on. The run log shows what the console answered; "it ran" is not "it worked".',
|
||||
// The site cannot know what a typed command does (§16.2), so the most
|
||||
// dangerous class — and with it core's default failure rule: abort the run.
|
||||
risk: 'irreversible',
|
||||
// The teardown is the ledger's: core reverts a ledgered step at the end of
|
||||
// every run, which is exactly when the teardown commands should run.
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server the commands run on. One server per step; add a step per server to reach more.' },
|
||||
{ name: 'commands', type: 'string', required: true, example: 'rbe 1', multiple: true, max: MAX_COMMANDS,
|
||||
description: `The commands, one per entry, run in the order listed — at most ${MAX_COMMANDS}, ${COMMAND_MAX} characters each. Type each as you would at the server console. A command the server does not know stops the step there.` },
|
||||
{ name: 'teardown', type: 'string', required: false, example: 'rbe stop', multiple: true, max: MAX_COMMANDS,
|
||||
description: 'Optional commands run once when the event ends or is cancelled, such as a plugin\'s own "stop". They are kept as they were when the step ran.' },
|
||||
{ name: 'confirm', type: 'boolean', required: true, example: false,
|
||||
description: `${RUN_SURE}. Unticked, the step refuses in the dry run and when it runs.` },
|
||||
],
|
||||
|
||||
// One unit per command, start and teardown together (§16.2).
|
||||
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) + teardownCount(p.teardown) }),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const step = await checkRun(params)
|
||||
if (!step.ok) return step
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const { commands, teardown, server } = step
|
||||
const name = server.name || server.id
|
||||
|
||||
const result = await client.commandsRun(server, { key: idempotencyKey, commands, ...(teardown.length ? { teardown } : {}) })
|
||||
if (!result.ok) return transportFailure(server, result, 'console commands')
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'command.ran') return refusal(server, data, 'console commands')
|
||||
|
||||
log.info('console commands ran', { server: server.id, run: runId, step: stepId, ran: Number(data.ran) || 0, repeat: !!data.repeat })
|
||||
|
||||
const resources = teardown.length
|
||||
? [{ kind: TEARDOWN_KIND, ref: entitlementRef(server.id, runId, stepId), payload: { serverId: server.id, key: idempotencyKey, teardown } }]
|
||||
: []
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...(resources.length ? { resources } : {}),
|
||||
detail: {
|
||||
server: name,
|
||||
...replyDetail(data),
|
||||
...(teardown.length ? { teardown: `${teardown.length} command(s) run when the event ends` } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
revert: revertRun,
|
||||
|
||||
// The demo's deny list at save (MODULE_API 1.19.0, DM18). Core asks only on the
|
||||
// demo; null lets the step through.
|
||||
demoRefuses: (p) => demo.deniedCommand([...asList(p.commands), ...asList(p.teardown)]),
|
||||
|
||||
// The site cannot ask a server whether a command's effect is still there, so
|
||||
// a teardown not yet run is always in force: "I do not know" is never "gone".
|
||||
async reconcile({ resources }) {
|
||||
return { ok: true, inForce: (resources || []).map((r) => r.ref) }
|
||||
},
|
||||
}
|
||||
|
||||
// ── Reward by console command (§16.3) ────────────────────────────────────────
|
||||
|
||||
const REWARD_SURE = 'I am sure I want to run these commands for every recipient'
|
||||
|
||||
/**
|
||||
* The people a mode picks, by Steam id: `{ ok, people }`, each `{ steamId, name
|
||||
* }`. No website account is needed (§16.3): a command goes by Steam id. `linked`
|
||||
* reads no tally: every linked player of the server.
|
||||
*/
|
||||
async function recipientsOf({ server, mode, count, runId, idempotencyKey }) {
|
||||
const name = server.name || server.id
|
||||
|
||||
if (mode === 'linked') {
|
||||
// One past the bound, so "too many" is a sentence and not a silent cut.
|
||||
const linked = await linksDb.linkedOnServer(server.id, MAX_RECIPIENTS + 1)
|
||||
if (linked.length > MAX_RECIPIENTS) {
|
||||
return { ok: false, retry: false, error: `${name} has more than ${MAX_RECIPIENTS} linked players, and a reward runs commands for at most ${MAX_RECIPIENTS} in one step — reward from a tally instead` }
|
||||
}
|
||||
const seen = new Set()
|
||||
const people = []
|
||||
for (const r of linked) {
|
||||
const steamId = String(r.steamId)
|
||||
if (seen.has(steamId)) continue
|
||||
seen.add(steamId)
|
||||
people.push({ steamId, name: r.name ? String(r.name) : steamId })
|
||||
}
|
||||
return { ok: true, people }
|
||||
}
|
||||
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
const picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey)
|
||||
const bound = Math.min(Number(tally.data.maxRecipients) || MAX_RECIPIENTS, MAX_RECIPIENTS)
|
||||
if (picked.length > bound) {
|
||||
return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} runs reward commands for at most ${bound} in one step — pick a count-based mode or a higher bar` }
|
||||
}
|
||||
return { ok: true, people: picked.map((p) => ({ steamId: p.steamId, name: p.name })) }
|
||||
}
|
||||
|
||||
const commandReward = {
|
||||
id: 'rust.command.reward',
|
||||
label: 'Reward by console command',
|
||||
description:
|
||||
'Runs console commands you type once for each person a mode picks from this run\'s tally — or every linked player of the server — with {steamid} and {name} filled in for each, such as "sr add {steamid} 100". Only people on the server when it runs are rewarded; anyone offline is skipped and named in the run log. Nothing is taken back when the event ends.',
|
||||
// Irreversible, as `rust.perm.give` is (D346): core reverts a ledgered step at
|
||||
// the end of every run, and a reward command cannot be run backwards.
|
||||
risk: 'irreversible',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server the commands run on.' },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who is rewarded: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
|
||||
{ name: 'commands', type: 'string', required: true, example: 'sr add {steamid} 100', multiple: true, max: MAX_COMMANDS,
|
||||
description: `The commands, run in order for each recipient — at most ${MAX_COMMANDS}, ${COMMAND_MAX} characters each. {steamid} is the recipient's Steam id; {name} is their name, passed as one quoted argument (do not put quotes around it).` },
|
||||
{ name: 'confirm', type: 'boolean', required: true, example: false,
|
||||
description: `${REWARD_SURE}. Unticked, the step refuses in the dry run and when it runs.` },
|
||||
],
|
||||
|
||||
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
|
||||
|
||||
demoRefuses: (p) => demo.deniedCommand(asList(p.commands)),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const commands = checkCommands(params.commands)
|
||||
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
|
||||
|
||||
const denied = demo.deniedCommand(commands.value)
|
||||
if (denied) return { ok: false, retry: false, error: denied }
|
||||
|
||||
const confirm = checkConfirm(params, REWARD_SURE)
|
||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||
|
||||
const mode = oneOf(params.recipients, MODES)
|
||||
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
|
||||
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const serverId = String(params.server || '').trim()
|
||||
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
const name = server.name || server.id
|
||||
|
||||
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
||||
return { ok: false, retry: false, error: `${name} runs reward commands for at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey })
|
||||
if (!picked.ok) return picked
|
||||
|
||||
const base = {
|
||||
server: name,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
people: picked.people.length,
|
||||
}
|
||||
|
||||
// Nobody qualified: nothing to send, and nothing for the bridge to remember.
|
||||
if (!picked.people.length) return { ok: true, detail: { ...base, ran: 0, note: 'nobody qualified, so no command ran' } }
|
||||
|
||||
const result = await client.commandsRun(server, { key: idempotencyKey, commands: commands.value, recipients: picked.people })
|
||||
if (!result.ok) return transportFailure(server, result, 'reward commands')
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'command.ran') return refusal(server, data, 'reward commands')
|
||||
|
||||
log.info('reward commands ran', { server: server.id, run: runId, step: stepId, mode, people: picked.people.length, rewarded: Number(data.rewarded) || 0, repeat: !!data.repeat })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
...base,
|
||||
rewarded: Number(data.rewarded) || 0,
|
||||
...replyDetail(data),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const ACTIONS = [commandRun, commandReward]
|
||||
|
||||
module.exports = {
|
||||
MAX_COMMANDS,
|
||||
COMMAND_MAX,
|
||||
TEARDOWN_KIND,
|
||||
PERMANENT,
|
||||
BUDGETS,
|
||||
ACTIONS,
|
||||
checkCommands,
|
||||
checkConfirm,
|
||||
teardownKey,
|
||||
replyDetail,
|
||||
}
|
||||
174
server/eventDangerousTreasures.js
Normal file
174
server/eventDangerousTreasures.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ── Starting a Dangerous Treasures event (protocol 14, PLAN_REDESIGNS §14.3.4) ──
|
||||
//
|
||||
// `rust.dt.start`, "Start a Dangerous Treasures event": one of nivex's treasure
|
||||
// events, at the difficulty the step picks, where DT picks. The plugin runs DT's
|
||||
// own console command, `dtevent <level>`, and joins the start DT raises to the
|
||||
// step's key, so a retry is answered with the first start and never opens a
|
||||
// second chest.
|
||||
//
|
||||
// **Kept (`reversible: 'none'`).** DT guards, unlocks and despawns its own chest
|
||||
// (900 s after it unlocks, if nobody empties it); a run ending takes nothing back.
|
||||
// `risk: 'change'`, so an operator switches it on deliberately.
|
||||
//
|
||||
// **Refused at verify** where the server said DT is not loaded, or that its
|
||||
// framework cannot run another plugin's console command (hello's
|
||||
// `console.plugins`, D363, Rust-Plugins#34). A server that has not said (a plugin
|
||||
// before protocol 14, or never reached) is left to the plugin, as a zone's
|
||||
// TruePVE is (§14.3.16).
|
||||
//
|
||||
// ── D365: DT's warm-up ─────────────────────────────────────────────────────
|
||||
//
|
||||
// For about ten minutes after it loads DT is still finding where events may go,
|
||||
// and every start fails. The plugin calls that `dt-not-ready`; the step reports
|
||||
// "Dangerous Treasures is not ready yet" and asks core to try once more in ten
|
||||
// minutes (`retryAfterMs`, MODULE_API 1.15.0, D371). `dt-no-spot` retries on
|
||||
// core's ordinary delay. Past core's attempts the run pauses on its own
|
||||
// `on_failure`, and staff press Retry on the paused step (EVENTS.md §I), which
|
||||
// sends the same key.
|
||||
|
||||
const client = require('./sidecarClient')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
const { gridOf } = require('./worldEvents')
|
||||
const { LEVELS, reportOf } = require('./dangerousTreasures')
|
||||
const live = require('./liveStatus')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const mapDb = require('./model/map/map.db')
|
||||
|
||||
/** How long core waits before trying a start DT was not ready for (D365). */
|
||||
const NOT_READY_RETRY_MS = 10 * 60 * 1000
|
||||
|
||||
/** What the step says DT is not ready, in the plan's words (D365). */
|
||||
const NOT_READY = 'Dangerous Treasures is not ready yet'
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'bad-level',
|
||||
'dt-missing',
|
||||
'console-unavailable',
|
||||
// DT's own refusal in its own words: a difficulty its config no longer has, or
|
||||
// its console command renamed. Neither is fixed by waiting.
|
||||
'refused',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.dt.events',
|
||||
label: 'Dangerous Treasures events',
|
||||
unit: 'events',
|
||||
description: 'Dangerous Treasures events an event starts, each one counted. Dangerous Treasures despawns its own chest; nothing is taken back when the run ends.',
|
||||
},
|
||||
]
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.dt_levels',
|
||||
label: 'Dangerous Treasures difficulty',
|
||||
description: "Dangerous Treasures' three difficulties, as it ships them.",
|
||||
async resolve() {
|
||||
return LEVELS.map((l) => ({ value: l.value, label: l.label }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** A level param: 0, 1 or 2, or null for anything else. Blank is Easy. */
|
||||
function levelOf(raw) {
|
||||
if (raw === undefined || raw === null || raw === '') return 0
|
||||
const n = Number(raw)
|
||||
return LEVELS.some((l) => l.value === n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a server cannot take the step, from what it last said, or null. Only a
|
||||
* report that SAYS no refuses; one that said nothing is the plugin's to answer.
|
||||
*/
|
||||
function missingOn(report, name) {
|
||||
if (report.dangerousTreasures && !report.dangerousTreasures.loaded) {
|
||||
return `Dangerous Treasures is not installed on ${name}`
|
||||
}
|
||||
if (report.consolePlugins === false) {
|
||||
return `${name}'s modding framework cannot run Dangerous Treasures' console command from a plugin, so a treasure event cannot be started there yet (Rust-Plugins#34)`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The grid an opened chest is in, or null without the map's geometry. */
|
||||
async function gridAt(serverId, x, z) {
|
||||
const meta = await mapDb.getMeta(serverId).catch(() => null)
|
||||
if (!meta) return null
|
||||
return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z)
|
||||
}
|
||||
|
||||
const dtStart = {
|
||||
id: 'rust.dt.start',
|
||||
label: 'Start a Dangerous Treasures event',
|
||||
description:
|
||||
'Starts one Dangerous Treasures event: a guarded, burning treasure chest that unlocks after a few minutes. Dangerous Treasures picks the spot, and despawns the chest itself if nobody empties it. Needs Dangerous Treasures on the server. In the first ten minutes after Dangerous Treasures loads it is not ready, and the step tries again ten minutes later.',
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server. It needs Dangerous Treasures installed.' },
|
||||
{ name: 'level', type: 'int', required: false, example: 1, source: 'rust.options.dt_levels',
|
||||
description: 'The difficulty: 0 Easy, 1 Medium, 2 Hard. Left blank, Easy.' },
|
||||
],
|
||||
|
||||
cost: () => ({ 'rust.dt.events': 1 }),
|
||||
|
||||
async perform({ idempotencyKey, params, verify }) {
|
||||
const level = levelOf(params.level)
|
||||
if (level === null) {
|
||||
return { ok: false, retry: false, error: `a Dangerous Treasures event is 0 (Easy), 1 (Medium) or 2 (Hard), not "${params.level}"` }
|
||||
}
|
||||
|
||||
const serverId = String(params.server || '').trim()
|
||||
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
|
||||
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
const name = found.server.name || found.server.id
|
||||
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.dtReportFor(found.server.id)), name), Boolean)
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await client.dtStart(found.server, { key: idempotencyKey, level })
|
||||
|
||||
if (!result.ok) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'Dangerous Treasures start') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'dt.opened') {
|
||||
if (data.reason === 'dt-not-ready') {
|
||||
return { ok: false, retry: true, retryAfterMs: NOT_READY_RETRY_MS, error: `${NOT_READY} on ${name}` }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${name} did not start the Dangerous Treasures event`),
|
||||
}
|
||||
}
|
||||
|
||||
const grid = await gridAt(found.server.id, data.x, data.z)
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
level: LEVELS.find((l) => l.value === Number(data.level))?.label || String(data.level),
|
||||
...(grid ? { grid } : {}),
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was started' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const ACTIONS = [dtStart]
|
||||
|
||||
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, NOT_READY, NOT_READY_RETRY_MS, levelOf, missingOn }
|
||||
199
server/eventGuardedCrate.js
Normal file
199
server/eventGuardedCrate.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// ── Placing a guarded crate (protocol 14, PLAN_REDESIGNS §14.3.5) ────────────
|
||||
//
|
||||
// `rust.gc.place`, "Place a guarded crate": one of Guarded Crate's events (Easy,
|
||||
// Medium, Hard and Elite as it ships; an operator may add or rename them), at a
|
||||
// monument or a point. The plugin runs Guarded Crate's own console command,
|
||||
// `gcrate position <event> x,y,z`, and joins the start Guarded Crate raises to
|
||||
// the step's key, so a retry is answered with the first placement and never
|
||||
// places a second crate.
|
||||
//
|
||||
// **Kept (`reversible: 'none'`, D373).** Guarded Crate has no way to end one
|
||||
// event: `gcrate stop all` ends every one on the server, other people's
|
||||
// included, and raises no hook. So a crate a run placed stays until its last
|
||||
// guard dies or its own timer runs out, and the step's line in the run log says
|
||||
// so when it is placed (D380): core gives a module nothing at a run's end for a
|
||||
// step that is kept. `risk: 'change'`, so an operator switches it on deliberately.
|
||||
//
|
||||
// **Refused at verify (D364)** where the server said Guarded Crate is not
|
||||
// loaded, that NpcSpawn is missing (Guarded Crate's `position` does not check,
|
||||
// and the crate would land unguarded), that its framework cannot run another
|
||||
// plugin's console command (hello's `console.plugins`, D363, Rust-Plugins#34),
|
||||
// or that it has no event of the name asked for. A server that has not said is
|
||||
// left to the plugin, as a Dangerous Treasures step is (§14.3.18).
|
||||
|
||||
const client = require('./sidecarClient')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS, location } = require('./eventWorld')
|
||||
const { gridOf } = require('./worldEvents')
|
||||
const { reportOf } = require('./guardedCrate')
|
||||
const live = require('./liveStatus')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const mapDb = require('./model/map/map.db')
|
||||
|
||||
/** Guarded Crate's events as it ships them, offered when no server has said its own. */
|
||||
const DEFAULT_EVENTS = Object.freeze(['Easy', 'Medium', 'Hard', 'Elite'])
|
||||
|
||||
/**
|
||||
* What the run log says of a crate the run leaves behind (D373, D380). No
|
||||
* number: each of Guarded Crate's events has its own time limit, and the
|
||||
* server's report names the events but not their limits (W18).
|
||||
*/
|
||||
const KEPT = "kept when the run ends: it ends when its last guard dies, or when nobody has killed one within its event's time limit in Guarded Crate"
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. `gc-too-close` is not one: the other crate may be cleared. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'bad-event',
|
||||
'bad-position',
|
||||
'gc-missing',
|
||||
'npcspawn-missing',
|
||||
'console-unavailable',
|
||||
'no-monument',
|
||||
'out-of-range',
|
||||
'off-map',
|
||||
// Guarded Crate's own refusal in its own words, or its command switched off.
|
||||
'refused',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.gc.crates',
|
||||
label: 'Guarded crates',
|
||||
unit: 'crates',
|
||||
description: 'Guarded crates an event places, each one counted. A guarded crate is kept when the run ends: it ends when its guards are cleared or after its own time.',
|
||||
},
|
||||
]
|
||||
|
||||
/** One event name as `events` lists it, ignoring case as Guarded Crate does, or null. */
|
||||
function eventIn(events, wanted) {
|
||||
const lower = String(wanted || '').trim().toLowerCase()
|
||||
return (events || []).find((e) => e.toLowerCase() === lower) || null
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.gc_events',
|
||||
label: 'Guarded Crate events',
|
||||
description: "The events each server's Guarded Crate has, by name; Guarded Crate's four defaults where no server has said.",
|
||||
async resolve() {
|
||||
const rows = await serversDb.gcReports().catch(() => [])
|
||||
const byName = new Map()
|
||||
for (const row of rows) {
|
||||
const report = reportOf(row).guardedCrate
|
||||
if (!report || !report.loaded || !report.events) continue
|
||||
for (const name of report.events) {
|
||||
const key = name.toLowerCase()
|
||||
if (!byName.has(key)) byName.set(key, { value: name, servers: [] })
|
||||
byName.get(key).servers.push(row.name || row.serverId)
|
||||
}
|
||||
}
|
||||
if (byName.size === 0) {
|
||||
return DEFAULT_EVENTS.map((name) => ({ value: name, label: `${name} (as Guarded Crate ships)` }))
|
||||
}
|
||||
return [...byName.values()].map((e) => ({ value: e.value, label: `${e.value} — ${e.servers.join(', ')}` }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Why a server cannot take the step, from what it last said, or null. Only a
|
||||
* report that SAYS no refuses; one that said nothing is the plugin's to answer.
|
||||
*/
|
||||
function missingOn(report, name, wanted) {
|
||||
const gc = report.guardedCrate
|
||||
if (gc && !gc.loaded) return `Guarded Crate is not installed on ${name}`
|
||||
if (gc && gc.npcSpawn && !gc.npcSpawn.loaded) {
|
||||
return `Guarded Crate is loaded on ${name} but NpcSpawn is missing: its events cannot start`
|
||||
}
|
||||
if (report.consolePlugins === false) {
|
||||
return `${name}'s modding framework cannot run Guarded Crate's console command from a plugin, so a guarded crate cannot be placed there yet (Rust-Plugins#34)`
|
||||
}
|
||||
if (gc && gc.events && !eventIn(gc.events, wanted)) {
|
||||
return `"${wanted}" is not one of Guarded Crate's events on ${name} (${gc.events.join(', ')})`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The grid a placed crate is in, or null without the map's geometry. */
|
||||
async function gridAt(serverId, x, z) {
|
||||
const meta = await mapDb.getMeta(serverId).catch(() => null)
|
||||
if (!meta) return null
|
||||
return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z)
|
||||
}
|
||||
|
||||
const gcPlace = {
|
||||
id: 'rust.gc.place',
|
||||
label: 'Place a guarded crate',
|
||||
description:
|
||||
"Places one Guarded Crate event at a monument or a point: a cargo plane drops a locked crate there, ringed by guards, and whoever kills the last guard has cleared it. Needs Guarded Crate and NpcSpawn on the server. The crate is kept when the run ends — Guarded Crate cannot end one alone — and ends when its guards are cleared or after its own time.",
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'event', type: 'string', required: true, example: 'Elite', source: 'rust.options.gc_events',
|
||||
description: "Which of Guarded Crate's events: how many guards, and how hard. Easy, Medium, Hard or Elite as it ships." },
|
||||
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server. A monument already says; for x and z, required.' },
|
||||
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
|
||||
description: 'Where, by monument. Give this OR x and z.' },
|
||||
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
|
||||
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
|
||||
{ name: 'x', type: 'float', required: false, example: -604, description: 'World x, instead of a monument.' },
|
||||
{ name: 'z', type: 'float', required: false, example: -342, description: 'World z, instead of a monument.' },
|
||||
],
|
||||
|
||||
cost: () => ({ 'rust.gc.crates': 1 }),
|
||||
|
||||
async perform({ idempotencyKey, params, verify }) {
|
||||
const wanted = String(params.event || '').trim()
|
||||
if (!wanted) return { ok: false, retry: false, error: "the step does not say which of Guarded Crate's events — pick one" }
|
||||
|
||||
const where = location(params)
|
||||
if (!where.ok) return { ok: false, retry: false, error: where.error }
|
||||
// A height means nothing here: the crate falls to the ground, and its guards stand on it.
|
||||
const { y, ...wire } = where.wire
|
||||
|
||||
const found = await serverFor(where.serverId)
|
||||
if (!found.ok) return found
|
||||
const name = found.server.name || found.server.id
|
||||
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const missing = await live.confirmed(found.server, async () => missingOn(reportOf(await serversDb.gcReportFor(found.server.id)), name, wanted), Boolean)
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await client.gcPlace(found.server, { key: idempotencyKey, event: wanted, ...wire })
|
||||
|
||||
if (!result.ok) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'guarded crate placement') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'gc.placed') {
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${name} did not place the guarded crate`),
|
||||
}
|
||||
}
|
||||
|
||||
const grid = await gridAt(found.server.id, data.x, data.z)
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
event: String(data.event || wanted),
|
||||
...(grid ? { grid } : {}),
|
||||
note: data.repeat ? `answered from the first attempt; nothing new was placed. The crate is ${KEPT}` : `The crate is ${KEPT}`,
|
||||
...(data.repeat ? { repeat: true } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const ACTIONS = [gcPlace]
|
||||
|
||||
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, DEFAULT_EVENTS, KEPT, eventIn, missingOn }
|
||||
526
server/eventLeases.js
Normal file
526
server/eventLeases.js
Normal file
@@ -0,0 +1,526 @@
|
||||
// ── What an event may BORROW on a Rust server (PLAN.md §27, protocol 8) ─────
|
||||
//
|
||||
// The module never takes a lease and never bounds one. An author puts core's
|
||||
// `core.lease` in a step naming a lease, a target, a value and a number of
|
||||
// minutes; core reads the baseline, reserves `<lease id>#<target>` against the
|
||||
// two-events-one-target index, applies the value with its deadline and restores
|
||||
// it at teardown. What is here is the four callables each lease ships, and the
|
||||
// option sources that fill its target field.
|
||||
//
|
||||
// ── The target names the server (D73) ─────────────────────────────────────
|
||||
//
|
||||
// `core.lease` hands a lease only `{ target }` — never the run's scope — and
|
||||
// reserves `<id>#<target>`. So every lease here is TARGETED and every target
|
||||
// begins with the server id: `srv-a` for a single value, `srv-a/bear.population`
|
||||
// or `srv-a/default/kits.vip` for a family. That makes the ledger's unique index
|
||||
// bite at exactly the granularity Rust has: two runs on two servers never
|
||||
// collide, and one value on one server has one holder.
|
||||
//
|
||||
// ── Game convars only (D74) ───────────────────────────────────────────────
|
||||
//
|
||||
// Vanilla Rust has no gather, craft or smelt rate convar; what it has, and what
|
||||
// the plugin's allowlist lends, is decay, the population system, and its two
|
||||
// minimum scalars — plus a group's permissions, the "weekend VIP" (D75). The
|
||||
// plugin holds the allowlist, the bounds, the seven-day ceiling and the deadline
|
||||
// timer. The bounds are declared here AS WELL, because this pair is what core
|
||||
// checks when an author saves — a bad value is a refusal on a form rather than a
|
||||
// step failing unattended at four in the morning.
|
||||
|
||||
const core = require('./core')
|
||||
const client = require('./sidecarClient')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
|
||||
const log = core.logger('leases')
|
||||
|
||||
/** Seven days (D77). The plugin holds the same ceiling independently and refuses past it. */
|
||||
const MAX_LEASE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
/** Core's bound on one option source's answer. A source that would exceed it says so in the log. */
|
||||
const MAX_OPTIONS = 2000
|
||||
|
||||
/** The wire key of the one lease that is not a convar. */
|
||||
const GROUP_PERMISSION_KEY = 'group.permission'
|
||||
|
||||
/**
|
||||
* Split a target into its server and the rest (D73).
|
||||
*
|
||||
* At the FIRST slash: a server id is `[a-z0-9-]` and never contains one, while
|
||||
* what follows may (a group name is free text an operator typed).
|
||||
*/
|
||||
function splitTarget(target) {
|
||||
const text = String(target || '').trim()
|
||||
const slash = text.indexOf('/')
|
||||
if (slash < 0) return { serverId: text, rest: '' }
|
||||
return { serverId: text.slice(0, slash), rest: text.slice(slash + 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The server a target names, with its token — or a refusal.
|
||||
*
|
||||
* **`retry: false`**, because the second attempt carries the same params: a
|
||||
* target naming a server that is not configured (or is switched off) is an
|
||||
* authoring mistake or a deleted server, and neither is fixed by waiting.
|
||||
*/
|
||||
async function serverFor(serverId) {
|
||||
if (!serverId) return { ok: false, retry: false, error: 'the target does not name a server' }
|
||||
const row = await serversDb.getServer(serverId)
|
||||
if (!row) return { ok: false, retry: false, error: `there is no Rust server "${serverId}" on this site` }
|
||||
if (!row.enabled) return { ok: false, retry: false, error: `the Rust server "${row.name || serverId}" is switched off` }
|
||||
return { ok: true, server: servers.withToken(row) }
|
||||
}
|
||||
|
||||
/** The sentence for a transport failure, naming the server — every notice says which (§25.6). */
|
||||
function transportError(server, result, what) {
|
||||
const name = (server && (server.name || server.id)) || 'the server'
|
||||
switch (result.status) {
|
||||
case 'http-503':
|
||||
return `${name} has no game connected, so its ${what} could not be reached`
|
||||
case 'http-504':
|
||||
case 'timeout':
|
||||
return `${name} did not answer about its ${what} in time`
|
||||
case 'protocol-mismatch':
|
||||
return `${name}'s sidecar speaks a different protocol — update the module or the sidecar`
|
||||
default:
|
||||
return `${name} could not be reached about its ${what} (${result.status})`
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin's own refusal, which carries a sentence of its own. */
|
||||
function pluginError(data, fallback) {
|
||||
return (data && (data.message || data.reason)) || fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the four callables one lease shares with every other.
|
||||
*
|
||||
* `wire(rest)` turns what follows the server id into the plugin's `{ key,
|
||||
* target }`, or a refusal. The callables differ in nothing else, so they are
|
||||
* built rather than repeated: four copies of this would be four chances for one
|
||||
* of them to forget the drift check, which is the one thing §F says a lease must
|
||||
* not be allowed to skip.
|
||||
*/
|
||||
function lease({ id, label, description, type, min, max, family, targetLabel, source, example, valueExample, wire }) {
|
||||
async function resolve(target) {
|
||||
const { serverId, rest } = splitTarget(target)
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
const w = wire(rest)
|
||||
if (!w.ok) return { ok: false, retry: false, error: w.error }
|
||||
return { ok: true, server: found.server, key: w.key, target: w.target || undefined }
|
||||
}
|
||||
|
||||
/** The plugin's row for this key and target, or a refusal. */
|
||||
async function row(r) {
|
||||
const result = await client.leaseList(r.server, { key: r.key, target: r.target })
|
||||
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease catalogue') }
|
||||
const rows = (result.data && result.data.leases) || []
|
||||
const found = rows.find((x) => x && x.key === r.key && (r.target === undefined || x.target === r.target))
|
||||
if (!found) return { ok: false, retry: false, error: `${r.server.name || r.server.id} does not lend ${r.key}` }
|
||||
if (family && found.family !== family) {
|
||||
return { ok: false, retry: false, error: `${r.key} is not a ${family} value` }
|
||||
}
|
||||
return { ok: true, row: found, data: result.data }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
type,
|
||||
...(min === undefined ? {} : { min }),
|
||||
...(max === undefined ? {} : { max }),
|
||||
// The VALUE's example (core MODULE_API 1.13.0), beside the target's below.
|
||||
...(valueExample === undefined ? {} : { example: valueExample }),
|
||||
maxDurationMs: MAX_LEASE_MS,
|
||||
target: { label: targetLabel, source, example },
|
||||
|
||||
async read({ target } = {}) {
|
||||
const r = await resolve(target)
|
||||
if (!r.ok) return r
|
||||
const found = await row(r)
|
||||
if (!found.ok) return found
|
||||
|
||||
// **A key the plugin already holds reads as its BASELINE, not its
|
||||
// current value.** Core's reservation means a second run can never get
|
||||
// this far, so a hold core does not know about is the first attempt of
|
||||
// THIS run whose answer was lost — and the baseline to give back at the
|
||||
// end is what was there before anybody borrowed it, not that attempt's
|
||||
// value. Recording the current value here would restore the event's own
|
||||
// change at teardown and call it baseline.
|
||||
if (found.row.held && found.row.baseline !== undefined && found.row.baseline !== null) {
|
||||
return { ok: true, value: String(found.row.baseline) }
|
||||
}
|
||||
|
||||
if (found.row.unreadable) return { ok: false, retry: false, error: found.row.unreadable }
|
||||
if (found.row.current === undefined || found.row.current === null) {
|
||||
return { ok: false, error: `${r.server.name || r.server.id} could not read ${r.key}` }
|
||||
}
|
||||
return { ok: true, value: String(found.row.current) }
|
||||
},
|
||||
|
||||
async apply(value, until, { target } = {}) {
|
||||
const r = await resolve(target)
|
||||
if (!r.ok) return r
|
||||
|
||||
// **A duration, not the deadline.** `until` is an absolute time computed
|
||||
// here and honoured there, which is a deadline measured against two
|
||||
// clocks; a game host ten minutes fast would end a ten-minute lease the
|
||||
// instant it took it. The absolute time still rides along, for display.
|
||||
const untilMs = new Date(until).getTime()
|
||||
const holdMs = untilMs - Date.now()
|
||||
if (!Number.isFinite(holdMs) || holdMs <= 0) {
|
||||
return { ok: false, error: 'the lease deadline has already passed' }
|
||||
}
|
||||
|
||||
const body = {
|
||||
key: r.key,
|
||||
...(r.target === undefined ? {} : { target: r.target }),
|
||||
...(family ? { family } : {}),
|
||||
value: String(value),
|
||||
holdMs: Math.round(holdMs),
|
||||
untilMs,
|
||||
}
|
||||
|
||||
const result = await client.leaseApply(r.server, body)
|
||||
|
||||
if (!result.ok) {
|
||||
// **An apply this end gave up on may still land.** The client's lease
|
||||
// timeout is below the sidecar's own, so the command can still reach
|
||||
// the game after core has been told it failed — and core then releases
|
||||
// its reservation, believing nothing was taken. A release follows it
|
||||
// down the same link, which the plugin handles in order: if the apply
|
||||
// landed, the hold's own baseline goes back; if it never did, the
|
||||
// compare finds nothing held and changes nothing. Not awaited: its
|
||||
// answer changes nothing about this one.
|
||||
if (result.status === 'timeout' || result.status === 'http-504') {
|
||||
client
|
||||
.leaseRelease(r.server, { key: r.key, target: r.target, expected: String(value) })
|
||||
.catch(() => {})
|
||||
}
|
||||
return { ok: false, error: transportError(r.server, result, 'lease') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind === 'lease.ok') return { ok: true }
|
||||
|
||||
// A refusal the second attempt would repeat is `retry: false` — the
|
||||
// switch is off, the key is not lent, the value is out of range. One that
|
||||
// might pass later (a value the game could not read this second) is left
|
||||
// to core's default.
|
||||
const permanent = ['events-disabled', 'unknown-key', 'out-of-range', 'too-long', 'unresolved', 'target-gone', 'malformed']
|
||||
return {
|
||||
ok: false,
|
||||
...(permanent.includes(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${r.server.name || r.server.id} refused the lease`),
|
||||
}
|
||||
},
|
||||
|
||||
async restore(baseline, { expected, target } = {}) {
|
||||
const r = await resolve(target)
|
||||
if (!r.ok) return r
|
||||
|
||||
const result = await client.leaseRelease(r.server, {
|
||||
key: r.key,
|
||||
...(r.target === undefined ? {} : { target: r.target }),
|
||||
expected: expected === undefined || expected === null ? undefined : String(expected),
|
||||
baseline: baseline === undefined || baseline === null ? undefined : String(baseline),
|
||||
})
|
||||
|
||||
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease release') }
|
||||
|
||||
const data = result.data || {}
|
||||
|
||||
// **Drift is a 200 carrying `lease.drifted`, not a failure of the call.**
|
||||
// The plugin did what it was asked: it compared, and declined to
|
||||
// overwrite somebody's deliberate change. Core records that as its own
|
||||
// outcome, with the current value beside it.
|
||||
if (data.kind === 'lease.drifted') return { ok: false, drifted: true, current: data.current }
|
||||
|
||||
// A group deleted mid-hold has nothing to give back and nothing owed: a
|
||||
// successful release, not a failure that would leave a ledger row
|
||||
// unresolved for ever over something that is gone.
|
||||
if (data.kind === 'lease.ok') return { ok: true }
|
||||
|
||||
return { ok: false, error: pluginError(data, `${r.server.name || r.server.id} could not give ${r.key} back`) }
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the plugin still has a record of the hold.
|
||||
*
|
||||
* **Never a comparison with `read()`** (MODULE_API §1.1). A value that
|
||||
* differs from what the run applied is DRIFT, which `restore()` reports so
|
||||
* the row lands `drifted`; answering "not in force" here would orphan the
|
||||
* row first. A convar hold is memory-only on the game, so a restart ends it
|
||||
* and this answers `held: false` — exactly the case core cannot otherwise
|
||||
* see.
|
||||
*/
|
||||
async inForce({ target } = {}) {
|
||||
const r = await resolve(target)
|
||||
if (!r.ok) return r
|
||||
const result = await client.leaseList(r.server, { key: r.key, target: r.target })
|
||||
if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease catalogue') }
|
||||
const holds = (result.data && result.data.holds) || []
|
||||
const held = holds.some((h) => h && h.key === r.key && String(h.target || '') === String(r.target || ''))
|
||||
return { ok: true, held }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A convar named in the target, of this family. */
|
||||
const convarIn = (family) => (rest) =>
|
||||
rest ? { ok: true, key: rest.toLowerCase() } : { ok: false, error: `name the ${family} value after the server, as server/convar` }
|
||||
|
||||
const LEASES = [
|
||||
lease({
|
||||
id: 'rust.decay.scale',
|
||||
label: 'Decay rate',
|
||||
description:
|
||||
'How fast unprotected buildings decay. 1 is normal, 0 switches decay off, 2 doubles it. Read on every decay tick, so it takes effect at the next one.',
|
||||
type: 'float',
|
||||
min: 0,
|
||||
max: 10,
|
||||
family: 'decay',
|
||||
targetLabel: 'Which server',
|
||||
source: 'rust.options.servers',
|
||||
example: 'main',
|
||||
wire: (rest) => (rest ? { ok: false, error: 'the decay rate takes only a server as its target' } : { ok: true, key: 'decay.scale' }),
|
||||
}),
|
||||
lease({
|
||||
id: 'rust.population',
|
||||
label: 'Population',
|
||||
description:
|
||||
'How many of one animal or vehicle the game keeps topped up, per square kilometre. Applied on the next spawn tick, so the world fills toward the new number rather than jumping to it.',
|
||||
type: 'float',
|
||||
min: 0,
|
||||
max: 50,
|
||||
family: 'population',
|
||||
targetLabel: 'Which server and population',
|
||||
source: 'rust.options.populations',
|
||||
example: 'main/bear.population',
|
||||
wire: convarIn('population'),
|
||||
}),
|
||||
lease({
|
||||
id: 'rust.spawn.scalar',
|
||||
label: 'Spawn scalar',
|
||||
description:
|
||||
"The population system's minimum spawn rate or density — what it runs at on an empty or quiet server, scaling up toward the maximum as players arrive.",
|
||||
type: 'float',
|
||||
min: 0,
|
||||
max: 10,
|
||||
family: 'spawn',
|
||||
targetLabel: 'Which server and scalar',
|
||||
source: 'rust.options.spawnscalars',
|
||||
example: 'main/spawn.min_rate',
|
||||
wire: convarIn('spawn'),
|
||||
}),
|
||||
lease({
|
||||
id: 'rust.group.permission',
|
||||
label: 'Group permission',
|
||||
description:
|
||||
"Whether a permission group carries a permission — \"group default holds kits.vip until Monday\" makes everybody VIP for the weekend. Given back at the end whether or not the site is still up; the game holds the deadline.",
|
||||
type: 'bool',
|
||||
family: null,
|
||||
targetLabel: 'Which server, group and permission',
|
||||
source: 'rust.options.grouppermissions',
|
||||
example: 'main/default/kits.vip',
|
||||
valueExample: true,
|
||||
wire: (rest) => {
|
||||
const slash = rest.lastIndexOf('/')
|
||||
if (slash <= 0 || slash >= rest.length - 1) {
|
||||
return { ok: false, error: 'a group permission is named as server/group/permission' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
key: GROUP_PERMISSION_KEY,
|
||||
target: `${rest.slice(0, slash).trim().toLowerCase()}/${rest.slice(slash + 1).trim().toLowerCase()}`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
// ── Option sources (D78: only what this phase's leases read) ─────────────────
|
||||
//
|
||||
// Every one resolves live, and a server that does not answer contributes
|
||||
// nothing rather than failing the whole answer — one server being down must
|
||||
// never blank the form for the other five (§9). A source that returns `[]`
|
||||
// degrades its field to free text on core's side, which is the right failure:
|
||||
// the operator very often already knows the value.
|
||||
|
||||
/** Bound one source's answer, and say so in the log when there was more. */
|
||||
function bounded(rows, sourceId) {
|
||||
if (rows.length <= MAX_OPTIONS) return rows
|
||||
log.warn('option source truncated', { source: sourceId, available: rows.length, served: MAX_OPTIONS })
|
||||
return rows.slice(0, MAX_OPTIONS)
|
||||
}
|
||||
|
||||
/** Every enabled server's own answer, in parallel, skipping the ones that fail. */
|
||||
async function perServer(ask) {
|
||||
const list = await servers.listForPolling()
|
||||
const settled = await Promise.allSettled(list.map(async (server) => ({ server, result: await ask(server) })))
|
||||
return settled.filter((s) => s.status === 'fulfilled' && s.value.result && s.value.result.ok).map((s) => s.value)
|
||||
}
|
||||
|
||||
/** The convars one family lends, per server, as whole targets. */
|
||||
async function familyOptions(family, sourceId) {
|
||||
const answers = await perServer((server) => client.leaseList(server))
|
||||
const rows = []
|
||||
for (const { server, result } of answers) {
|
||||
for (const r of (result.data && result.data.leases) || []) {
|
||||
if (!r || r.family !== family || r.unreadable) continue
|
||||
rows.push({ value: `${server.id}/${r.key}`, label: r.key, group: server.name || server.id })
|
||||
}
|
||||
}
|
||||
return bounded(rows, sourceId)
|
||||
}
|
||||
|
||||
// ── The kit warning (PLAN_REDESIGNS.md §2.6, protocol 14) ────────────────────
|
||||
//
|
||||
// "Kit vip: 2 uses, 24 h cooldown — 9 of 41 linked players have used it up."
|
||||
// Shown under a group-permission target that is a kit's permission, which is
|
||||
// what a kit weekend borrows: it tells the author that the weekend's extra use
|
||||
// (step 2) is what lets those players take the kit again.
|
||||
|
||||
/** How long one server's kit counts are reused. The editor asks while typing. */
|
||||
const KIT_NOTE_TTL_MS = 60_000
|
||||
|
||||
/** The most kits one server's notes ask about. One `kits.uses` each. */
|
||||
const KIT_NOTE_MAX_KITS = 25
|
||||
|
||||
const kitNoteCache = new Map()
|
||||
|
||||
/** "24 h", "90 min", "45 s". */
|
||||
function durationWords(seconds) {
|
||||
const s = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
if (s >= 3600 && s % 3600 === 0) return `${s / 3600} h`
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`
|
||||
return `${s} s`
|
||||
}
|
||||
|
||||
/** One kit's note, from its `kits.list` row and its `kits.uses` answer. */
|
||||
function kitNote(kit, uses) {
|
||||
const limit = Number(kit.max) > 0 ? `${kit.max} use${Number(kit.max) === 1 ? '' : 's'}` : 'no use limit'
|
||||
const cooldown = Number(kit.cooldown) > 0 ? `, ${durationWords(kit.cooldown)} cooldown` : ''
|
||||
const asked = Number(uses.asked) || 0
|
||||
if (!asked) return `Kit ${kit.name}: ${limit}${cooldown} — no linked player has played on this server yet.`
|
||||
const used = Number(kit.max) > 0
|
||||
? `${Number(uses.exhausted) || 0} of ${asked} linked players have used it up`
|
||||
: `${asked} linked players`
|
||||
const waiting = Number(uses.onCooldown) > 0 ? `, ${uses.onCooldown} are on its cooldown` : ''
|
||||
return `Kit ${kit.name}: ${limit}${cooldown} — ${used}${waiting}. A kit weekend's extra use is what lets them take it again.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission → note, for the kits on one server that a permission gates.
|
||||
* Cached a minute per server; anything that fails is simply no note — a warning
|
||||
* is advice, and its absence must never break the field it advises.
|
||||
*/
|
||||
async function kitNotes(server) {
|
||||
const cached = kitNoteCache.get(server.id)
|
||||
if (cached && Date.now() - cached.at < KIT_NOTE_TTL_MS) return cached.notes
|
||||
|
||||
const notes = new Map()
|
||||
try {
|
||||
const listed = await client.kits(server)
|
||||
const gated = ((listed.ok && listed.data && listed.data.kits) || [])
|
||||
.filter((k) => k && k.permission)
|
||||
.slice(0, KIT_NOTE_MAX_KITS)
|
||||
|
||||
if (gated.length) {
|
||||
// eslint-disable-next-line global-require
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const steamIds = (await linksDb.linkedOnServer(server.id)).map((r) => String(r.steamId))
|
||||
|
||||
for (const kit of gated) {
|
||||
const uses = await client.kitsUses(server, kit.name, steamIds)
|
||||
if (!uses.ok || !uses.data || uses.data.kind !== 'kits.uses') continue
|
||||
const permission = String(kit.permission).toLowerCase()
|
||||
// Two kits behind one permission: both are named, one after the other.
|
||||
const note = kitNote(kit, uses.data)
|
||||
notes.set(permission, notes.has(permission) ? `${notes.get(permission)} ${note}` : note)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('kit notes could not be read', { server: server.id, message: err.message })
|
||||
}
|
||||
|
||||
kitNoteCache.set(server.id, { at: Date.now(), notes })
|
||||
return notes
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.servers',
|
||||
label: 'Rust servers',
|
||||
description: 'Every enabled server on this site. A lease holds a value on one of them.',
|
||||
async resolve() {
|
||||
const list = await servers.listForPolling()
|
||||
return list.map((s) => ({ value: s.id, label: s.name || s.id }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rust.options.populations',
|
||||
label: 'Populations',
|
||||
description: 'The animal and vehicle populations each server lends, read live from the game.',
|
||||
async resolve() {
|
||||
return familyOptions('population', 'rust.options.populations')
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rust.options.spawnscalars',
|
||||
label: 'Spawn scalars',
|
||||
description: "The population system's rate and density scalars each server lends.",
|
||||
async resolve() {
|
||||
return familyOptions('spawn', 'rust.options.spawnscalars')
|
||||
},
|
||||
},
|
||||
{
|
||||
// Groups times registered permissions is a catalogue bigger than a dropdown
|
||||
// holds on any server with a few plugins, so it narrows by the term.
|
||||
id: 'rust.options.grouppermissions',
|
||||
label: 'Group permissions',
|
||||
description: 'A permission group and a permission some loaded plugin registered, on each server.',
|
||||
searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
const answers = await perServer((server) => client.permCatalogue(server))
|
||||
const rows = []
|
||||
for (const { server, result } of answers) {
|
||||
const data = result.data || {}
|
||||
const perms = (data.permissions || []).map((p) => String(p).toLowerCase())
|
||||
// The kit warning (PLAN_REDESIGNS.md §2.6): a row that grants a kit's
|
||||
// permission says how many of the server's linked players have used it
|
||||
// up. A kit's permission is whatever its operator typed into Kits, not
|
||||
// always `kits.*`, so every server is asked — once a minute (cached).
|
||||
const notes = await kitNotes(server)
|
||||
for (const g of data.groups || []) {
|
||||
const group = g && g.name ? String(g.name).toLowerCase() : null
|
||||
if (!group) continue
|
||||
for (const perm of perms) {
|
||||
const value = `${server.id}/${group}/${perm}`
|
||||
if (term && !value.includes(term)) continue
|
||||
const note = notes.get(perm)
|
||||
rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id, ...(note ? { note } : {}) })
|
||||
}
|
||||
}
|
||||
}
|
||||
return bounded(rows, 'rust.options.grouppermissions')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
MAX_LEASE_MS,
|
||||
MAX_OPTIONS,
|
||||
LEASES,
|
||||
OPTION_SOURCES,
|
||||
splitTarget,
|
||||
serverFor,
|
||||
transportError,
|
||||
pluginError,
|
||||
perServer,
|
||||
bounded,
|
||||
kitNote,
|
||||
kitNotes,
|
||||
kitNoteCache,
|
||||
}
|
||||
524
server/eventPermRewards.js
Normal file
524
server/eventPermRewards.js
Normal file
@@ -0,0 +1,524 @@
|
||||
// ── Permissions as event rewards (PLAN_REDESIGNS.md §12, D342–D346) ───────
|
||||
//
|
||||
// Any permission a server's plugins registered can be a reward — night vision,
|
||||
// a chat colour, a `/remove` command — with no code written for the perk. Two
|
||||
// verbs, because core gives an action one risk class (D346):
|
||||
//
|
||||
// rust.perm.lend until the run ends: a kit reward's lease, one
|
||||
// `rust_perm_run_grants` row per recipient per permission,
|
||||
// withdrawn by the step's revert (`change` / `ledger`)
|
||||
// rust.perm.give for good: a standing `rust_perm_grants` row per website
|
||||
// user per permission, scoped to the step's server, source
|
||||
// `event` (`irreversible` / `none`); staff take it back in
|
||||
// the permission manager
|
||||
//
|
||||
// …and the picker both use, `rust.options.permissions`: the enabled servers'
|
||||
// catalogues, grouped by the plugin that registered each permission.
|
||||
//
|
||||
// Nothing here is new machinery. Both write rows the permission mirror already
|
||||
// pushes, so there is no plugin code, no sidecar code and no protocol change
|
||||
// (§12.1). The message is D333's notice and rides `perm.sync` like a kit
|
||||
// reward's.
|
||||
//
|
||||
// ── The safety is the author's own "I am sure" (D343) ───────────────────────
|
||||
//
|
||||
// No blocklist and no allowlist: the bridge's own `runicgateway.*` permissions
|
||||
// are in the picker like any other. A required toggle in the step says the
|
||||
// author meant it, and unticked the dry run and the run refuse, naming what the
|
||||
// step would have given.
|
||||
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const core = require('./core')
|
||||
const { serverFor, bounded } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
const rewards = require('./eventRewards')
|
||||
|
||||
const log = core.logger('perm-rewards')
|
||||
|
||||
const { MAX_RECIPIENTS, MAX_CHAT, DELIVERIES, MODES, oneOf, checkCount, pickRecipients, peopleOf, usersFor, readTally, entitlementRef } = rewards
|
||||
|
||||
/** At most this many permissions in one step (§12.6): a perk bundle, with a price that stays readable. */
|
||||
const MAX_PERMISSIONS = 10
|
||||
|
||||
/** A permission name as Oxide and Carbon store it: lower case, and at most the column's width. */
|
||||
const PERMISSION_MAX = 128
|
||||
|
||||
/** What a for-good grant says it came from, in the permission manager (§12.5). */
|
||||
const SOURCE = 'event'
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.permissions',
|
||||
label: 'Permissions lent',
|
||||
unit: 'permissions',
|
||||
description:
|
||||
'Permissions an event lends until it ends: one per recipient per permission. A mode that is not a count is priced at the most it could lend.',
|
||||
},
|
||||
{
|
||||
id: 'rust.permissions.kept',
|
||||
label: 'Permissions given for good',
|
||||
unit: 'permissions',
|
||||
description:
|
||||
'Permissions an event gives for good: one per recipient per permission. Ending the event does not take them back; staff remove them in the permission manager. A mode that is not a count is priced at the most it could give.',
|
||||
},
|
||||
]
|
||||
|
||||
// ── The params both steps check ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The picked permissions as a list, checked: lower case, trimmed, each once, at
|
||||
* least one and at most MAX_PERMISSIONS. Core hands a `multiple` param over as
|
||||
* an array (MODULE_API 1.14.0); a bare value is a list of one.
|
||||
*/
|
||||
function checkPermissions(raw) {
|
||||
const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const name = String(item === undefined || item === null ? '' : item).trim().toLowerCase()
|
||||
if (!name) continue
|
||||
if (name.length > PERMISSION_MAX || /\s/.test(name)) return { ok: false, error: `"${item}" is not a permission name` }
|
||||
if (!out.includes(name)) out.push(name)
|
||||
}
|
||||
if (!out.length) return { ok: false, error: 'pick at least one permission' }
|
||||
if (out.length > MAX_PERMISSIONS) return { ok: false, error: `a step gives at most ${MAX_PERMISSIONS} permissions, not ${out.length}` }
|
||||
return { ok: true, value: out }
|
||||
}
|
||||
|
||||
/** The "I am sure" toggle (D343). Only a real `true` passes; a string is not a tick. */
|
||||
function checkConfirm(params, permissions) {
|
||||
if (params.confirm === true) return { ok: true }
|
||||
return { ok: false, error: `Tick "I am sure" to make ${permissions.join(', ')} a reward` }
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's message, checked like a kit reward's (D333): `{ ok, text,
|
||||
* delivery }`, text null for a step that tells nobody — the default (§12.6),
|
||||
* because a permission's name means nothing to a player and only the author can
|
||||
* say what it does. `{permissions}` is the names; measured with them in place.
|
||||
*/
|
||||
function permNotice(params, permissions) {
|
||||
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
||||
if (!text) return { ok: true, text: null, delivery: null }
|
||||
|
||||
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
||||
const delivery = oneOf(rawDelivery, DELIVERIES)
|
||||
if (!delivery) return { ok: false, error: `a reward's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
||||
|
||||
const said = sayPermissions(text, permissions)
|
||||
if (said.length > MAX_CHAT) {
|
||||
return { ok: false, error: `a reward's message is at most ${MAX_CHAT} characters with the permissions in it, not ${said.length}` }
|
||||
}
|
||||
return { ok: true, text, delivery }
|
||||
}
|
||||
|
||||
const sayPermissions = (text, permissions) => text.replace(/\{permissions\}/g, permissions.join(', '))
|
||||
|
||||
/**
|
||||
* Every picked permission must be in the server's catalogue (§12.2), and one
|
||||
* that is not is refused by name rather than skipped (§12.6): the plugin may be
|
||||
* unloaded for the wipe, and a reward that silently gives less than it says is
|
||||
* the worse failure. Runs in the dry run too.
|
||||
*/
|
||||
async function checkCatalogue(server, permissions) {
|
||||
const name = server.name || server.id
|
||||
const registered = new Set(
|
||||
(await permDb.listCatalogue())
|
||||
.filter((row) => row.serverId === server.id)
|
||||
.map((row) => String(row.permission).toLowerCase()),
|
||||
)
|
||||
if (!registered.size) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${name} has reported no permissions yet — its plugins' permissions arrive with its next permission sync`,
|
||||
}
|
||||
}
|
||||
const missing = permissions.filter((p) => !registered.has(p))
|
||||
if (missing.length) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${name} has no permission ${missing.join(', ')} — its plugins did not register ${missing.length === 1 ? 'it' : 'them'}`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything both steps check before touching anything, in the order an author
|
||||
* fixes them: the permissions, the toggle, the mode and its count, the message,
|
||||
* the server, its catalogue, the bound. `{ ok, permissions, mode, count, notice,
|
||||
* server }` or a refusal core reads as permanent.
|
||||
*/
|
||||
async function checkStep(params, verb) {
|
||||
const permissions = checkPermissions(params.permissions)
|
||||
if (!permissions.ok) return { ok: false, retry: false, error: permissions.error }
|
||||
|
||||
const confirm = checkConfirm(params, permissions.value)
|
||||
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
|
||||
|
||||
const mode = oneOf(params.recipients, MODES)
|
||||
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
|
||||
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const notice = permNotice(params, permissions.value)
|
||||
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
|
||||
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
|
||||
const listed = await checkCatalogue(server, permissions.value)
|
||||
if (!listed.ok) return listed
|
||||
|
||||
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
||||
return { ok: false, retry: false, error: `${server.name || server.id} ${verb} permissions to at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
|
||||
}
|
||||
|
||||
return { ok: true, permissions: permissions.value, mode, count, notice, server }
|
||||
}
|
||||
|
||||
/**
|
||||
* The people a mode picks, as website users (D101, D103): `{ ok, people,
|
||||
* missed }`, each person `{ steamId, name, userId }` and each website user once —
|
||||
* of two linked accounts that both took part, the earlier in the list, the
|
||||
* higher score, is the one. `linked` reads no tally (§2.5).
|
||||
*/
|
||||
async function recipientsOf({ server, mode, count, runId, idempotencyKey, verb }) {
|
||||
const name = server.name || server.id
|
||||
let picked
|
||||
let bound = MAX_RECIPIENTS
|
||||
let users
|
||||
|
||||
if (mode === 'linked') {
|
||||
// One past the bound, so "too many" is a sentence and not a silent cut.
|
||||
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
|
||||
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
||||
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
if (picked.length > bound) {
|
||||
return { ok: false, retry: false, error: `${name} has more than ${bound} linked players, and ${verb} permissions to at most ${bound} in one step — reward from a tally instead` }
|
||||
}
|
||||
} else {
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey)
|
||||
bound = Number(tally.data.maxRecipients) || bound
|
||||
if (picked.length > bound) {
|
||||
return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} ${verb} permissions to at most ${bound} in one step — pick a count-based mode or a higher bar` }
|
||||
}
|
||||
users = await usersFor(picked.map((p) => p.steamId))
|
||||
}
|
||||
|
||||
const people = []
|
||||
const byUser = new Set()
|
||||
const missed = []
|
||||
for (const p of picked) {
|
||||
const userId = users.get(p.steamId)
|
||||
if (!userId) {
|
||||
missed.push(p.name)
|
||||
continue
|
||||
}
|
||||
if (byUser.has(userId)) continue
|
||||
byUser.add(userId)
|
||||
people.push({ ...p, userId })
|
||||
}
|
||||
return { ok: true, people, missed }
|
||||
}
|
||||
|
||||
const missedDetail = (missed) =>
|
||||
missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}
|
||||
|
||||
/** People priced before the tally is read: the count for a count, the bound for every other mode. */
|
||||
function pricedPeople(p) {
|
||||
const mode = oneOf(p.recipients, MODES)
|
||||
const n = Math.round(Number(p.count) || 0)
|
||||
return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
|
||||
}
|
||||
|
||||
/** How many permissions a step names, as its price counts them: at least one, at most the bound. */
|
||||
function pricedPermissions(p) {
|
||||
const checked = checkPermissions(p.permissions)
|
||||
return checked.ok ? checked.value.length : 1
|
||||
}
|
||||
|
||||
// ── The params, declared once for both steps (§12.5: "the same params") ─────
|
||||
|
||||
const paramsFor = (messageExample) => [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server the permissions are given on. Each one must be registered there by one of its plugins.' },
|
||||
{ name: 'permissions', type: 'string', required: true, example: 'nightvision.allowed', source: 'rust.options.permissions',
|
||||
multiple: true, max: MAX_PERMISSIONS,
|
||||
description: `The permissions, by the plugin that registered them — at most ${MAX_PERMISSIONS}.` },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who gets them: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
|
||||
{ name: 'message', type: 'string', required: false, example: messageExample,
|
||||
description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {permissions} is the names. Leave it empty to say nothing — a permission\'s name means little to a player, so say what it does.' },
|
||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
||||
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
||||
{ name: 'confirm', type: 'boolean', required: true, example: false,
|
||||
description: 'I am sure I want to give these permissions as a reward. Unticked, the step refuses in the dry run and when it runs.' },
|
||||
]
|
||||
|
||||
// ── Until the run ends (§12.4) ───────────────────────────────────────────────
|
||||
|
||||
const kitEntitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
|
||||
|
||||
const permLend = {
|
||||
id: 'rust.perm.lend',
|
||||
label: 'Permissions until the event ends',
|
||||
description:
|
||||
'Lends the permissions you pick to the people a mode picks from this run\'s tally — or every linked player of the server — until the run ends. Waits for them if they are offline. Someone who already held one keeps it when the event ends.',
|
||||
// A kit reward's lease with other permissions (D346): the same rows, the same
|
||||
// revert, so the same risk. Core reverts it at the end of every run.
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: paramsFor('You can use /nv until the event ends'),
|
||||
|
||||
cost: (p) => ({ 'rust.permissions': pricedPeople(p) * pricedPermissions(p) }),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const step = await checkStep(params, 'lends')
|
||||
if (!step.ok) return step
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const { permissions, mode, count, notice, server } = step
|
||||
const name = server.name || server.id
|
||||
const ref = entitlementRef(server.id, runId, stepId)
|
||||
const resource = { kind: 'permission-lease', ref, payload: { serverId: server.id, permissions } }
|
||||
|
||||
// As a kit reward's: the rows ARE the lease, and a repeated key finds them.
|
||||
const existing = await permDb.listRunGrantsForStep(runId, stepId)
|
||||
if (existing.length) {
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
|
||||
}
|
||||
}
|
||||
|
||||
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'lends' })
|
||||
if (!picked.ok) return picked
|
||||
|
||||
// One row per recipient per permission; the message on the first only, so
|
||||
// each person is told once (the notice key is per run, step and user).
|
||||
const said = notice.text ? sayPermissions(notice.text, permissions) : null
|
||||
const rows = []
|
||||
for (const p of picked.people) {
|
||||
permissions.forEach((permission, i) => {
|
||||
rows.push({
|
||||
runId,
|
||||
stepId,
|
||||
idemKey: idempotencyKey,
|
||||
userId: p.userId,
|
||||
serverId: server.id,
|
||||
steamId: p.steamId,
|
||||
permission,
|
||||
kit: '',
|
||||
credit: false,
|
||||
...(said && i === 0 ? { notice: said, noticeDelivery: notice.delivery } : {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await permDb.insertRunGrants(rows)
|
||||
await permDb.markDirty(server.id)
|
||||
|
||||
log.info('permissions lent', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, missed: picked.missed.length })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
resources: [resource],
|
||||
detail: {
|
||||
server: name,
|
||||
permissions,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
lent: picked.people.length,
|
||||
...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
|
||||
...missedDetail(picked.missed),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// The lease ends as a kit reward's does: the step's rows go and the next sync
|
||||
// revokes what nobody else grants. A player who already held a permission
|
||||
// keeps it, because the push reads the union (§12.1).
|
||||
revert: (ctx) => kitEntitle.revert(ctx),
|
||||
reconcile: (ctx) => kitEntitle.reconcile(ctx),
|
||||
}
|
||||
|
||||
// ── For good (§12.5) ─────────────────────────────────────────────────────────
|
||||
|
||||
/** What a for-good grant's note says, in the permission manager. */
|
||||
const grantNote = (runId, stepId) => `event run ${runId}, step ${stepId}`
|
||||
|
||||
const permGive = {
|
||||
id: 'rust.perm.give',
|
||||
label: 'Permissions for good',
|
||||
description:
|
||||
'Gives the permissions you pick to the people a mode picks, for good. Ending or cancelling the event does not take them back; staff remove them in the permission manager.',
|
||||
// Irreversible, as `rust.coins.pay` is (D335, D346): core reverts a ledgered
|
||||
// step at the end of EVERY run, so a ledger here would take the gift back.
|
||||
risk: 'irreversible',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: paramsFor('You can use /nv from now on'),
|
||||
|
||||
cost: (p) => ({ 'rust.permissions.kept': pricedPeople(p) * pricedPermissions(p) }),
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const step = await checkStep(params, 'gives')
|
||||
if (!step.ok) return step
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const { permissions, mode, count, notice, server } = step
|
||||
const name = server.name || server.id
|
||||
|
||||
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'gives' })
|
||||
if (!picked.ok) return picked
|
||||
|
||||
// A grant already there is left alone (§12.5). One this step's own first
|
||||
// attempt wrote carries its note, and is counted as given, not as held: a
|
||||
// retry after a lost answer reads the same as the attempt that landed.
|
||||
const note = grantNote(runId, stepId)
|
||||
const held = await permDb.listGrantsForUsers(picked.people.map((p) => p.userId), server.id)
|
||||
const heldBy = new Map(held.map((g) => [`${g.userId}:${String(g.permission).toLowerCase()}`, g]))
|
||||
|
||||
const rows = []
|
||||
let alreadyHeld = 0
|
||||
let repeated = 0
|
||||
for (const p of picked.people) {
|
||||
for (const permission of permissions) {
|
||||
const there = heldBy.get(`${p.userId}:${permission}`)
|
||||
if (there) {
|
||||
if (there.source === SOURCE && there.note === note) repeated++
|
||||
else alreadyHeld++
|
||||
continue
|
||||
}
|
||||
rows.push({ userId: p.userId, permission, scope: server.id, source: SOURCE, note })
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await permDb.insertGrants(rows)
|
||||
|
||||
// The message rides a notice-only row (§12.5): no permission, no kit, no
|
||||
// credit, so the push grants nothing for it and tells it once. Nothing
|
||||
// reverts this step, so the row stays — a sentence that has been said.
|
||||
const said = notice.text ? sayPermissions(notice.text, permissions) : null
|
||||
if (said && picked.people.length) {
|
||||
await permDb.insertRunGrants(picked.people.map((p) => ({
|
||||
runId,
|
||||
stepId,
|
||||
idemKey: idempotencyKey,
|
||||
userId: p.userId,
|
||||
serverId: server.id,
|
||||
steamId: p.steamId,
|
||||
permission: '',
|
||||
kit: '',
|
||||
credit: false,
|
||||
notice: said,
|
||||
noticeDelivery: notice.delivery,
|
||||
})))
|
||||
}
|
||||
|
||||
if (inserted || said) await permDb.markDirty(server.id)
|
||||
|
||||
log.info('permissions given', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, given: inserted, alreadyHeld, repeated })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
server: name,
|
||||
permissions,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
people: picked.people.length,
|
||||
given: inserted + repeated,
|
||||
...(alreadyHeld ? { alreadyHeld, alreadyHeldNote: 'already held: the grant was there before, and is left as it was' } : {}),
|
||||
...(repeated ? { repeat: true, repeatNote: 'answered from the first attempt; nothing was given twice' } : {}),
|
||||
...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
|
||||
...missedDetail(picked.missed),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ── The picker (§12.2) ───────────────────────────────────────────────────────
|
||||
|
||||
/** A permission's group: the plugin that registered it, or its prefix, saying so (the permission manager's rule). */
|
||||
function groupOf(permission, owner) {
|
||||
if (owner) return owner
|
||||
const prefix = permission.includes('.') ? permission.slice(0, permission.indexOf('.')) : permission
|
||||
return `${prefix} (not registered by a plugin)`
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's rows from the catalogue and the enabled servers. Pure, so the
|
||||
* rule is testable without a database: each permission once, grouped by its
|
||||
* plugin, sorted by group and then permission without case, and a permission not
|
||||
* on every enabled server says where it is (`note`, MODULE_API 1.13.0).
|
||||
*/
|
||||
function permissionRows(catalogue, enabled) {
|
||||
const names = new Map(enabled.map((s) => [s.id, s.name || s.id]))
|
||||
const byPermission = new Map()
|
||||
for (const row of catalogue) {
|
||||
if (!names.has(row.serverId)) continue
|
||||
const permission = String(row.permission || '').trim()
|
||||
if (!permission) continue
|
||||
const key = permission.toLowerCase()
|
||||
if (!byPermission.has(key)) byPermission.set(key, { permission, owner: null, on: new Set() })
|
||||
const entry = byPermission.get(key)
|
||||
if (!entry.owner && row.owner) entry.owner = String(row.owner)
|
||||
entry.on.add(row.serverId)
|
||||
}
|
||||
|
||||
const rows = [...byPermission.values()].map((e) => {
|
||||
const group = groupOf(e.permission, e.owner)
|
||||
const row = { value: e.permission, label: e.permission, group }
|
||||
if (e.on.size < names.size) {
|
||||
const where = enabled.filter((s) => e.on.has(s.id)).map((s) => names.get(s.id))
|
||||
row.note = `Only on ${where.join(', ')}`
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
const fold = (s) => s.toLowerCase()
|
||||
rows.sort((a, b) => fold(a.group).localeCompare(fold(b.group)) || fold(a.value).localeCompare(fold(b.value)))
|
||||
return rows
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.permissions',
|
||||
label: 'Permissions',
|
||||
description: "Every permission the enabled servers' plugins registered, grouped by plugin; one missing from a server says where it is.",
|
||||
async resolve() {
|
||||
const [catalogue, enabled] = await Promise.all([permDb.listCatalogue(), servers.listForPolling()])
|
||||
return bounded(permissionRows(catalogue, enabled), 'rust.options.permissions')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const ACTIONS = [permLend, permGive]
|
||||
|
||||
module.exports = {
|
||||
MAX_PERMISSIONS,
|
||||
SOURCE,
|
||||
BUDGETS,
|
||||
ACTIONS,
|
||||
OPTION_SOURCES,
|
||||
checkPermissions,
|
||||
checkConfirm,
|
||||
permNotice,
|
||||
permissionRows,
|
||||
grantNote,
|
||||
}
|
||||
90
server/eventProgress.js
Normal file
90
server/eventProgress.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// ── How a Place NPCs step stands, for its event's page (runicnpc stage 8) ──
|
||||
//
|
||||
// MODULE_API 1.12.0: core asks an action's `progress()` while a run is live and
|
||||
// renders the answer as text it does not interpret. D304 settles what this one
|
||||
// says: a line per Place NPCs step — "Bandits: 3 of 8 left" — and a boss as its
|
||||
// health, "The Juggernaut: 62%". A boss's adds are not counted.
|
||||
//
|
||||
// **Where it comes from.** The step's ledger rows say which server and how many
|
||||
// were placed; the map's live answer (`mapLive`, one `map.live` per server per
|
||||
// five seconds, whoever asks) says which of them still stand, by the step's key,
|
||||
// and a boss's health. So a busy event page costs the game nothing the map does
|
||||
// not already cost it.
|
||||
//
|
||||
// **The map's events layer decides who may read it.** A page must never tell a
|
||||
// reader what the map would hide from them, so a reader who may not see the
|
||||
// events layer on that server gets no line at all.
|
||||
//
|
||||
// Never throws: core drops a throw, but a quiet `null` is the honest answer when
|
||||
// the game or a setting cannot be read.
|
||||
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const npcs = require('./model/npcs/npcs.model')
|
||||
const mapModel = require('./model/map/map.model')
|
||||
const mapLive = require('./mapLive')
|
||||
const core = require('./core')
|
||||
const log = core.logger('progress')
|
||||
|
||||
/** The label for Rust's own NPCs, by their allowlist key, set by `eventWorld`. */
|
||||
let ownLabels = new Map()
|
||||
|
||||
/** Called once by eventWorld with its PLACEABLE list. */
|
||||
function setOwnLabels(placeable) {
|
||||
ownLabels = new Map(placeable.filter((p) => p.kind === 'npc').map((p) => [p.key, p.label]))
|
||||
}
|
||||
|
||||
/** The server a step's NPCs are on, from its ledger rows. */
|
||||
function serverOf(resources) {
|
||||
for (const r of resources || []) {
|
||||
if (r && r.payload && r.payload.serverId) return String(r.payload.serverId)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* `progress()` for `rust.npc.place`: `{ label, left, of }`, `{ label, percent }`
|
||||
* or null.
|
||||
*/
|
||||
async function npcProgress({ runId, idempotencyKey, params, resources, viewer }) {
|
||||
try {
|
||||
const of = (resources || []).length
|
||||
if (of === 0) return null
|
||||
|
||||
const serverId = serverOf(resources)
|
||||
if (!serverId) return null
|
||||
|
||||
const access = await mapModel.access({ user: viewer && viewer.userId != null ? { id: viewer.userId } : null }, serverId)
|
||||
if (!access.layers.events.visible) return null
|
||||
|
||||
const row = await serversDb.getServer(serverId)
|
||||
if (!row || !row.enabled) return null
|
||||
|
||||
const picked = String((params && params.prefab) || '')
|
||||
const profileName = picked.startsWith(npcs.PROFILE_PREFIX) ? picked.slice(npcs.PROFILE_PREFIX.length) : null
|
||||
const profile = profileName ? await npcs.profileOn(serverId, profileName) : null
|
||||
const label = profile ? profile.label : ownLabels.get(picked) || picked
|
||||
if (!label) return null
|
||||
|
||||
const live = await mapLive.live(servers.withToken(row))
|
||||
if (!live.ok || !live.data) return null
|
||||
|
||||
const standing = (live.data.events || []).filter(
|
||||
(e) => e && e.kind === 'npc' && String(e.runId) === String(runId) && e.key === idempotencyKey && !e.add,
|
||||
)
|
||||
|
||||
// A boss is its health; a dead one, nothing left.
|
||||
if (profile && profile.boss) {
|
||||
const boss = standing.find((e) => e.boss)
|
||||
const health = boss && typeof boss.health === 'number' ? boss.health : 0
|
||||
return { label, percent: Math.round(Math.max(0, Math.min(1, health)) * 100) }
|
||||
}
|
||||
|
||||
return { label, left: Math.min(standing.length, of), of }
|
||||
} catch (err) {
|
||||
log.warn('could not say how a Place NPCs step stands', { run: runId, error: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { npcProgress, setOwnLabels, serverOf }
|
||||
471
server/eventRaidableBases.js
Normal file
471
server/eventRaidableBases.js
Normal file
@@ -0,0 +1,471 @@
|
||||
// ── Spawning a RaidableBases base (protocol 14, PLAN_REDESIGNS §11.4) ───────
|
||||
//
|
||||
// `rust.raidbase.spawn`, "Spawn a raid base": one of RaidableBases' bases for a
|
||||
// run, at a difficulty, a profile or a named base, where RaidableBases picks
|
||||
// (D203) or at a spot the admin gave (D207), with settings for this one spawn
|
||||
// (D205) and the lock to the first attacker set for it (D206).
|
||||
//
|
||||
// ── Two routes, by what the server has (D348, D387) ─────────────────────────
|
||||
//
|
||||
// * **With the raids helper** (`RunicGatewayRaids.cs`, D204) the base is the
|
||||
// RUN's: the plugin answers with an id, the base is a ledger resource like a
|
||||
// zone (`reversible: 'ledger'`), teardown despawns that one base and no
|
||||
// other, and if RaidableBases ends it first (its own timer, or raided) the
|
||||
// row is marked `expired` (D170). A spot, a profile, a base and the settings
|
||||
// all need it.
|
||||
// * **Without it** the plugin can still be asked for a base at a difficulty
|
||||
// (the paid edition's own call), or for any base (the free edition's console
|
||||
// command, which says nothing back). That base is NOT the run's: it records
|
||||
// no resource, despawns on RaidableBases' own timer, and the run log says so.
|
||||
//
|
||||
// ── Names are the plugin's (D386) ──────────────────────────────────────────
|
||||
//
|
||||
// Difficulty, profile and base pickers list what each server's installed copy
|
||||
// reported at hello. Nothing here knows "Easy" or "Nightmare": an operator's
|
||||
// own difficulty is offered as written, and a server that has said nothing
|
||||
// offers nothing.
|
||||
//
|
||||
// ── The spot check (D208, D388, D389, D391) ─────────────────────────────────
|
||||
//
|
||||
// At run time the plugin refuses a spot with a player, a building, a deployable
|
||||
// or a raid base on it, or water under it unless the profile is built for
|
||||
// water; rocks and cliffs are warnings, carried into the run log. The dry run
|
||||
// asks the same check and returns its findings as WARNINGS (MODULE_API 1.16.0):
|
||||
// advice, because the world can change before the event runs.
|
||||
//
|
||||
// **RaidableBases' grid.** For up to ten minutes after it loads (666 s on the
|
||||
// rigs' 6000 map, §11.4.2) the plugin refuses every spawn; the plugin calls that
|
||||
// `rb-not-ready`, and the step asks core to try again in ten minutes
|
||||
// (`retryAfterMs`, D371), as a Dangerous Treasures start does (D365).
|
||||
|
||||
const client = require('./sidecarClient')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS, location, revert, reconcile } = require('./eventWorld')
|
||||
const { gridOf } = require('./worldEvents')
|
||||
const { reportOf, helperPatched } = require('./raidableBases')
|
||||
const live = require('./liveStatus')
|
||||
const serversDb = require('./model/servers/servers.db')
|
||||
const mapDb = require('./model/map/map.db')
|
||||
|
||||
/** The resource kind a run's base is ledgered under: the world registry's, so `eventWorld`'s revert and reconcile serve it. */
|
||||
const OWNED_KIND = 'world'
|
||||
|
||||
/** How long core waits before trying a spawn RaidableBases was not ready for (its grid, D365's pattern). */
|
||||
const NOT_READY_RETRY_MS = 10 * 60 * 1000
|
||||
|
||||
/** What the step says while the grid builds. */
|
||||
const NOT_READY = 'RaidableBases is not ready yet: it is still building its spawn grid'
|
||||
|
||||
/** What the run log says of a base that is not the run's. */
|
||||
const OWN_TIMER = 'not the run’s: RaidableBases despawns it on its own timer, and teardown leaves it'
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. `rb-busy` and `rb-not-ready` are not: they pass. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'rb-missing',
|
||||
'copypaste-missing',
|
||||
'helper-needed',
|
||||
'helper-unsupported',
|
||||
'bad-difficulty',
|
||||
'bad-profile',
|
||||
'bad-base',
|
||||
'console-unavailable',
|
||||
'no-monument',
|
||||
'out-of-range',
|
||||
'off-map',
|
||||
'in-water',
|
||||
// The spot is taken. A player may walk off, a deployable stays: a refusal on a
|
||||
// spot is reported, and the run pauses on its own `on_failure` rather than
|
||||
// spawning the base somewhere the admin did not choose (D208).
|
||||
'spot-blocked',
|
||||
'refused',
|
||||
])
|
||||
|
||||
/** The on/off switches a step may set for one spawn; blank keeps the profile's own (or, for the lock, the config's). */
|
||||
const SWITCHES = Object.freeze([
|
||||
{ value: 'on', label: 'On' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.raids',
|
||||
label: 'RaidableBases bases',
|
||||
unit: 'bases',
|
||||
description: 'RaidableBases bases an event spawns, each one counted. With the raids helper a base is the run’s and despawns at teardown; without it RaidableBases despawns it on its own timer.',
|
||||
},
|
||||
]
|
||||
|
||||
/** Every enabled server's report, with its name. */
|
||||
async function reports() {
|
||||
const rows = await serversDb.rbReports().catch(() => [])
|
||||
return rows.map((row) => ({ serverId: row.serverId, name: row.name || row.serverId, ...reportOf(row) }))
|
||||
}
|
||||
|
||||
/** Each value once, with the servers that have it, as `{ value, label }`. */
|
||||
function byValue(entries) {
|
||||
const out = new Map()
|
||||
for (const { value, label, server } of entries) {
|
||||
const key = value.toLowerCase()
|
||||
if (!out.has(key)) out.set(key, { value, labels: new Map() })
|
||||
// Labels compare ignoring case, as the names do.
|
||||
const labels = out.get(key).labels
|
||||
const said = label.toLowerCase()
|
||||
if (!labels.has(said)) labels.set(said, { label, servers: [] })
|
||||
if (!labels.get(said).servers.includes(server)) labels.get(said).servers.push(server)
|
||||
}
|
||||
// One name, one row. Where servers describe it differently (a profile Hard on
|
||||
// one and Expert on another), each server's own words, not the first's (W13).
|
||||
return [...out.values()].map((e) => ({
|
||||
value: e.value,
|
||||
label: [...e.labels.values()].map(({ label, servers }) => `${label} — ${servers.join(', ')}`).join('; '),
|
||||
}))
|
||||
}
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'rust.options.raid_difficulties',
|
||||
label: 'RaidableBases difficulties',
|
||||
description: "The difficulties each server's RaidableBases lists, by its own names and levels. Nothing is offered for a server that has not said.",
|
||||
async resolve() {
|
||||
const entries = []
|
||||
for (const r of await reports()) {
|
||||
const rb = r.raidableBases
|
||||
if (!rb || !rb.loaded) continue
|
||||
for (const d of rb.difficulties) entries.push({ value: d.name, label: `${d.name} (level ${d.level})`, server: r.name })
|
||||
}
|
||||
return byValue(entries)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rust.options.raid_profiles',
|
||||
label: 'RaidableBases profiles',
|
||||
description: "The profiles each server's RaidableBases loaded, with their difficulty. Needs the raids helper, which is what reads them.",
|
||||
async resolve() {
|
||||
const entries = []
|
||||
for (const r of await reports()) {
|
||||
const profiles = r.raidableBases && r.raidableBases.loaded ? r.raidableBases.profiles : null
|
||||
for (const p of profiles || []) {
|
||||
entries.push({ value: p.name, label: `${p.name}${p.difficulty ? ` · ${p.difficulty}` : ''}${p.enabled ? '' : ' (disabled)'}`, server: r.name })
|
||||
}
|
||||
}
|
||||
return byValue(entries)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rust.options.raid_bases',
|
||||
label: 'RaidableBases bases',
|
||||
searchable: true,
|
||||
description: "The bases each server's RaidableBases can paste (those with a CopyPaste file), by profile. Needs the raids helper.",
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
const entries = []
|
||||
for (const r of await reports()) {
|
||||
const profiles = r.raidableBases && r.raidableBases.loaded ? r.raidableBases.profiles : null
|
||||
for (const p of profiles || []) {
|
||||
for (const b of p.bases) {
|
||||
if (term && !b.toLowerCase().includes(term) && !p.name.toLowerCase().includes(term)) continue
|
||||
entries.push({ value: b, label: `${b} · ${p.name}${p.difficulty ? ` (${p.difficulty})` : ''}`, server: r.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
return byValue(entries)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rust.options.raid_switch',
|
||||
label: 'On or off',
|
||||
description: 'On or off for this one base. Left blank, the profile’s own setting stands (the lock: RaidableBases’ config).',
|
||||
async resolve() {
|
||||
return SWITCHES.map((s) => ({ ...s }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** A switch param: true, false, or undefined for blank. Null for anything else. */
|
||||
function switchOf(raw) {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
const text = String(raw).trim().toLowerCase()
|
||||
if (text === 'on' || text === 'true') return true
|
||||
if (text === 'off' || text === 'false') return false
|
||||
return null
|
||||
}
|
||||
|
||||
/** A whole-number param in a range, undefined for blank, null when it is not one. */
|
||||
function intOf(raw, min, max) {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
const n = Number(raw)
|
||||
return Number.isInteger(n) && n >= min && n <= max ? n : null
|
||||
}
|
||||
|
||||
/** A number param, undefined for blank, null when it is not one. */
|
||||
function floatOf(raw) {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's params as the wire's, or `{ error }`. Validates shapes only; the
|
||||
* names are checked against the server's report by `missingOn`.
|
||||
*/
|
||||
function wireOf(params) {
|
||||
const wire = {}
|
||||
const text = (v) => String(v === undefined || v === null ? '' : v).trim()
|
||||
|
||||
for (const field of ['difficulty', 'profile', 'base']) {
|
||||
if (text(params[field])) wire[field] = text(params[field])
|
||||
}
|
||||
|
||||
const spot = Boolean(text(params.monument)) || params.x !== undefined && params.x !== '' || params.z !== undefined && params.z !== ''
|
||||
let serverId = text(params.server)
|
||||
|
||||
if (spot) {
|
||||
const where = location(params)
|
||||
if (!where.ok) return { error: where.error }
|
||||
const { y, ...at } = where.wire
|
||||
Object.assign(wire, at)
|
||||
serverId = where.serverId
|
||||
|
||||
const adjust = floatOf(params.heightAdjust)
|
||||
const height = floatOf(params.height)
|
||||
if (adjust === null || height === null) return { error: 'a height is a number of metres' }
|
||||
if (adjust !== undefined && height !== undefined) return { error: 'a spot takes a height adjustment or an exact height, not both' }
|
||||
if (adjust !== undefined) wire.heightAdjust = adjust
|
||||
if (height !== undefined) wire.height = height
|
||||
} else if (floatOf(params.heightAdjust) !== undefined || floatOf(params.height) !== undefined) {
|
||||
return { error: 'a height needs a spot: a monument, or x and z' }
|
||||
}
|
||||
|
||||
if (!serverId) return { error: 'the step does not say which server — pick one' }
|
||||
|
||||
for (const flag of ['pvp', 'npcs', 'lock']) {
|
||||
const value = switchOf(params[flag])
|
||||
if (value === null) return { error: `${flag} is on, off or blank, not "${params[flag]}"` }
|
||||
if (value !== undefined) wire[flag] = value
|
||||
}
|
||||
|
||||
const npcCount = intOf(params.npcCount, 0, 20)
|
||||
if (npcCount === null) return { error: `the NPC count is a whole number from 0 to 20, not "${params.npcCount}"` }
|
||||
if (npcCount !== undefined) wire.npcCount = npcCount
|
||||
|
||||
const despawn = intOf(params.despawnMinutes, 1, 24 * 60)
|
||||
if (despawn === null) return { error: `despawn minutes are a whole number from 1 to 1440, not "${params.despawnMinutes}"` }
|
||||
if (despawn !== undefined) wire.despawnMinutes = despawn
|
||||
|
||||
return { serverId, wire, spot }
|
||||
}
|
||||
|
||||
/** Whether the wire asks for anything only the raids helper can do. */
|
||||
function needsHelper(wire, spot) {
|
||||
return spot || ['profile', 'base', 'pvp', 'npcs', 'npcCount', 'despawnMinutes', 'lock'].some((k) => wire[k] !== undefined)
|
||||
}
|
||||
|
||||
const eq = (a, b) => String(a).toLowerCase() === String(b).toLowerCase()
|
||||
|
||||
/**
|
||||
* Why a server cannot take the step, from what it last said, or null. Only a
|
||||
* report that SAYS no refuses; one that said nothing is the plugin's to answer.
|
||||
*/
|
||||
function missingOn(report, name, wire, spot) {
|
||||
const rb = report.raidableBases
|
||||
if (!rb) return null
|
||||
if (!rb.loaded) return `RaidableBases is not installed on ${name}`
|
||||
if (rb.copyPaste && !rb.copyPaste.loaded) return `RaidableBases is loaded on ${name} but CopyPaste is missing: it pastes every base`
|
||||
|
||||
const patched = helperPatched(report)
|
||||
if (needsHelper(wire, spot) && !patched) {
|
||||
const state = rb.helper.state
|
||||
const why = state === 'missing'
|
||||
? `RunicGatewayRaids.cs is not installed on ${name}`
|
||||
: `RunicGatewayRaids.cs could not patch ${name}'s RaidableBases (${rb.helper.reason || state})`
|
||||
return `${why}, so a spot, a profile, a base and settings for one spawn cannot be used there; without them RaidableBases picks the spot and despawns the base on its own timer`
|
||||
}
|
||||
|
||||
if (!patched && !rb.spawnCall && report.consolePlugins === false) {
|
||||
return `${name}'s modding framework cannot run RaidableBases' console command from a plugin, and this edition has no spawn call another plugin can use; install RunicGatewayRaids.cs to spawn a base there (Rust-Plugins#34)`
|
||||
}
|
||||
|
||||
if (wire.difficulty && rb.difficulties.length > 0 && !rb.difficulties.some((d) => eq(d.name, wire.difficulty))) {
|
||||
return `"${wire.difficulty}" is not one of RaidableBases' difficulties on ${name} (${rb.difficulties.map((d) => d.name).join(', ')})`
|
||||
}
|
||||
|
||||
if (rb.profiles) {
|
||||
const profile = wire.profile ? rb.profiles.find((p) => eq(p.name, wire.profile)) : null
|
||||
if (wire.profile && !profile) return `RaidableBases on ${name} has no profile "${wire.profile}"`
|
||||
if (profile && wire.difficulty && profile.difficulty && !eq(profile.difficulty, wire.difficulty)) {
|
||||
return `profile "${profile.name}" is ${profile.difficulty}, not ${wire.difficulty}`
|
||||
}
|
||||
if (wire.base) {
|
||||
const owner = rb.profiles.find((p) => p.bases.some((b) => eq(b, wire.base)))
|
||||
if (!owner) return `RaidableBases on ${name} has no base "${wire.base}" with a CopyPaste file`
|
||||
if (profile && owner !== profile) return `base "${wire.base}" belongs to profile "${owner.name}", not "${profile.name}"`
|
||||
if (wire.difficulty && owner.difficulty && !eq(owner.difficulty, wire.difficulty)) {
|
||||
return `base "${wire.base}" is ${owner.difficulty}, not ${wire.difficulty}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** The grid at a point, or null without the map's geometry. */
|
||||
async function gridAt(serverId, x, z) {
|
||||
const meta = await mapDb.getMeta(serverId).catch(() => null)
|
||||
if (!meta) return null
|
||||
return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z)
|
||||
}
|
||||
|
||||
/** The dry run's advice on a spot (D208, D391): warnings, never a refusal. */
|
||||
async function spotAdvice(server, name, wire) {
|
||||
const { pvp, npcs, npcCount, despawnMinutes, lock, heightAdjust, height, ...ask } = wire
|
||||
const result = await client.raidCheck(server, ask)
|
||||
if (!result.ok) return [`the spot could not be checked now (${transportError(server, result, 'spot check')})`]
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'raid.checked') return [`the spot could not be checked now: ${pluginError(data, `${name} did not check the spot`)}`]
|
||||
const advice = (Array.isArray(data.warnings) ? data.warnings : []).map((w) => `at the spot now: ${w}`)
|
||||
if (!data.ok) {
|
||||
advice.unshift(`the spot is refused now (${data.reason || 'refused'}): ${data.message || 'no reason given'}. It is checked again when the event runs`)
|
||||
}
|
||||
return advice
|
||||
}
|
||||
|
||||
const raidSpawn = {
|
||||
id: 'rust.raidbase.spawn',
|
||||
label: 'Spawn a raid base',
|
||||
description:
|
||||
'Spawns one RaidableBases base: where RaidableBases picks, or at a spot you give. Needs RaidableBases and CopyPaste on the server. With RunicGatewayRaids.cs (installed with the plugin) the base is the event’s — it despawns when the event ends, and may have its own PvP, NPCs, despawn time and lock — and a spot, a profile or a named base may be chosen; without it RaidableBases picks everything and despawns the base on its own timer. For up to ten minutes after RaidableBases loads it is not ready, and the step tries again ten minutes later.',
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server. A monument already says; otherwise required.' },
|
||||
{ name: 'difficulty', type: 'string', required: false, example: 'Hard', source: 'rust.options.raid_difficulties',
|
||||
description: 'One of the server’s own RaidableBases difficulties. Blank: any (or the profile’s or base’s own).' },
|
||||
{ name: 'profile', type: 'string', required: false, example: 'Hard Bases', source: 'rust.options.raid_profiles',
|
||||
description: 'A RaidableBases profile; a base is picked from it at random. Needs RunicGatewayRaids.cs.' },
|
||||
{ name: 'base', type: 'string', required: false, example: 'raidhard2', source: 'rust.options.raid_bases',
|
||||
description: 'One base by name (its CopyPaste file). Needs RunicGatewayRaids.cs.' },
|
||||
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
|
||||
description: 'A spot by monument (with the offsets), instead of where RaidableBases picks. Needs RunicGatewayRaids.cs.' },
|
||||
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
|
||||
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
|
||||
{ name: 'x', type: 'float', required: false, example: 650, description: 'A spot by world x and z, instead of where RaidableBases picks. Needs RunicGatewayRaids.cs.' },
|
||||
{ name: 'z', type: 'float', required: false, example: 2037, description: 'World z, with x.' },
|
||||
{ name: 'heightAdjust', type: 'float', required: false, example: 5,
|
||||
description: 'At a spot: metres added to the profile’s own paste height. Give this or an exact height, not both.' },
|
||||
{ name: 'height', type: 'float', required: false, example: 30,
|
||||
description: 'At a spot: the exact height the base is pasted at (the profile’s forced height).' },
|
||||
{ name: 'pvp', type: 'string', required: false, example: 'off', source: 'rust.options.raid_switch',
|
||||
description: 'PvP at this base: on or off. Blank keeps the profile’s (and RaidableBases’ own convert switches).' },
|
||||
{ name: 'npcs', type: 'string', required: false, example: 'on', source: 'rust.options.raid_switch',
|
||||
description: 'NPCs at this base: on or off. Blank keeps the profile’s.' },
|
||||
{ name: 'npcCount', type: 'int', required: false, example: 6,
|
||||
description: 'How many NPCs, 0 to 20, in the profile’s own mix of scientists and murderers. 0 is none.' },
|
||||
{ name: 'despawnMinutes', type: 'int', required: false, example: 90,
|
||||
description: 'Minutes until RaidableBases despawns the base on its own, 1 to 1440. Teardown despawns it sooner.' },
|
||||
{ name: 'lock', type: 'string', required: false, example: 'on', source: 'rust.options.raid_switch',
|
||||
description: 'Lock the base to the first player or team to attack it: on or off. Blank follows RaidableBases’ config.' },
|
||||
],
|
||||
|
||||
cost: () => ({ 'rust.raids': 1 }),
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const parsed = wireOf(params || {})
|
||||
if (parsed.error) return { ok: false, retry: false, error: parsed.error }
|
||||
|
||||
const found = await serverFor(parsed.serverId)
|
||||
if (!found.ok) return found
|
||||
const name = found.server.name || found.server.id
|
||||
|
||||
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
||||
const { report, missing } = await live.confirmed(
|
||||
found.server,
|
||||
async () => {
|
||||
const read = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null))
|
||||
return { report: read, missing: missingOn(read, name, parsed.wire, parsed.spot) }
|
||||
},
|
||||
(r) => Boolean(r.missing),
|
||||
)
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
|
||||
if (verify) {
|
||||
// D391: the spot's check is advice in a dry run. A server that has not
|
||||
// said it has the helper is not asked.
|
||||
if (parsed.spot && helperPatched(report)) {
|
||||
const warnings = await spotAdvice(found.server, name, parsed.wire)
|
||||
if (warnings.length) return { ok: true, detail: { warnings } }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const result = await client.raidSpawn(found.server, { key: idempotencyKey, runId: String(runId), ...parsed.wire })
|
||||
|
||||
if (!result.ok) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'raid base spawn') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'raid.queued') {
|
||||
if (data.reason === 'rb-not-ready') {
|
||||
return { ok: false, retry: true, retryAfterMs: NOT_READY_RETRY_MS, error: `${NOT_READY} on ${name}` }
|
||||
}
|
||||
const warned = Array.isArray(data.warnings) && data.warnings.length ? ` (also: ${data.warnings.join('; ')})` : ''
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: `${pluginError(data, `${name} did not spawn the raid base`)}${warned}`,
|
||||
}
|
||||
}
|
||||
|
||||
const grid = data.x !== undefined && data.z !== undefined ? await gridAt(found.server.id, data.x, data.z) : null
|
||||
const warnings = Array.isArray(data.warnings) ? data.warnings.map(String) : []
|
||||
const detail = {
|
||||
route: String(data.route || ''),
|
||||
...(data.difficulty ? { difficulty: String(data.difficulty) } : {}),
|
||||
...(data.profile ? { profile: String(data.profile) } : {}),
|
||||
...(data.base ? { base: String(data.base) } : {}),
|
||||
...(grid ? { grid } : {}),
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
...(data.repeat ? { repeat: true } : {}),
|
||||
}
|
||||
|
||||
const notes = []
|
||||
if (data.repeat) notes.push('answered from the first attempt; nothing new was spawned')
|
||||
if (data.x === undefined) notes.push('RaidableBases picks the spot; the map shows it once the base opens')
|
||||
if (data.despawns !== 'teardown') notes.push(OWN_TIMER)
|
||||
// Each note after the first starts a sentence (W14).
|
||||
if (notes.length) detail.note = notes.map((n, i) => (i ? n[0].toUpperCase() + n.slice(1) : n)).join('. ')
|
||||
|
||||
// Only a base the helper made the run's is a resource teardown can take back.
|
||||
if (!data.id) return { ok: true, detail }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
detail,
|
||||
resources: [
|
||||
{
|
||||
kind: OWNED_KIND,
|
||||
ref: `${found.server.id}:${data.id}`,
|
||||
payload: {
|
||||
serverId: found.server.id,
|
||||
what: 'raidbase',
|
||||
...(data.base ? { name: String(data.base) } : {}),
|
||||
...(data.difficulty ? { difficulty: String(data.difficulty) } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
revert,
|
||||
reconcile,
|
||||
}
|
||||
|
||||
const ACTIONS = [raidSpawn]
|
||||
|
||||
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, NOT_READY, NOT_READY_RETRY_MS, OWN_TIMER, wireOf, missingOn, switchOf, needsHelper }
|
||||
1441
server/eventRewards.js
Normal file
1441
server/eventRewards.js
Normal file
File diff suppressed because it is too large
Load Diff
154
server/eventTemplates.js
Normal file
154
server/eventTemplates.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// ── Event templates (PLAN_REDESIGNS.md §2.5, D164, D192) ────────────────────
|
||||
//
|
||||
// A named starting point for a new event, through core's
|
||||
// `api.registerEventTemplates` (MODULE_API 1.13.0). The author fills in a few
|
||||
// inputs; `build()` turns them into an ordinary draft — phases, steps, params —
|
||||
// which the editor opens unsaved. Core validates the plan as a spec and keeps
|
||||
// nothing about the template, so this file can change without touching a
|
||||
// single event it was ever used for.
|
||||
//
|
||||
// ── The kit weekend ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// "Everybody gets kit vip this weekend." One phase that lasts the weekend, and
|
||||
// two steps in it:
|
||||
//
|
||||
// 1. core.lease → rust.group.permission: the `default` group holds the kit's
|
||||
// permission for the weekend, so every player can see and redeem it. The
|
||||
// game holds the deadline and gives it back on time whether or not the site
|
||||
// is up.
|
||||
// 2. rust.kit.entitle → that kit, every linked player of the server (`linked`),
|
||||
// one more use each — the use a player who already spent theirs needs to
|
||||
// take the kit again. Withdrawn at teardown if not yet redeemed (D103).
|
||||
//
|
||||
// A kit with no permission is open to everyone already, so its weekend is step
|
||||
// 2 alone; a kit with no use limit has no "one more use", so its weekend is step
|
||||
// 1 alone. A kit with neither is refused by `readKit`, because a weekend of it
|
||||
// gives nobody anything.
|
||||
|
||||
const { serverFor } = require('./eventLeases')
|
||||
const { readKit, splitKit, rewardNotice, MAX_CHAT } = require('./eventRewards')
|
||||
|
||||
/** The lease's own ceiling (D77): a weekend longer than seven days is refused by the game. */
|
||||
const MAX_WEEKEND_MINUTES = 7 * 24 * 60
|
||||
|
||||
/** "48 hours", "90 minutes". */
|
||||
function minutesWords(n) {
|
||||
if (n >= 120 && n % 60 === 0) return `${n / 60} hours`
|
||||
return `${n} minute${n === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
const kitWeekend = {
|
||||
id: 'rust.template.kit_weekend',
|
||||
label: 'Kit weekend',
|
||||
description:
|
||||
'A kit for everybody, for a while: the default group holds the kit\'s permission for the length of the weekend, and every linked player of the server gets one more use of it. You can change every part of the plan before saving it.',
|
||||
inputs: [
|
||||
{
|
||||
name: 'kit',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'main/vip',
|
||||
source: 'rust.options.kits',
|
||||
description: 'The kit, as server/kit. The weekend happens on that server only.',
|
||||
},
|
||||
{
|
||||
name: 'minutes',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 2880,
|
||||
duration: 'minutes',
|
||||
description: `How long the weekend lasts, up to ${MAX_WEEKEND_MINUTES} (seven days). 2880 is two days.`,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'Kit weekend! One more use of {kit} — type /kit {kit}',
|
||||
description: `Told once to each linked player in game, at once or when they next wake. {kit} is the kit's name; at most ${MAX_CHAT} characters. Leave it empty to say nothing.`,
|
||||
},
|
||||
{
|
||||
name: 'delivery',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'popup',
|
||||
source: 'rust.options.delivery',
|
||||
description: 'Chat, or a popup where the server has PopupNotifications.',
|
||||
},
|
||||
],
|
||||
|
||||
async build({ inputs }) {
|
||||
const parsed = splitKit(inputs.kit)
|
||||
if (!parsed) return { ok: false, error: `"${inputs.kit}" is not a kit — pick one from the list, as server/kit` }
|
||||
|
||||
const minutes = Number(inputs.minutes)
|
||||
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_WEEKEND_MINUTES) {
|
||||
return { ok: false, error: `a kit weekend lasts 1 to ${MAX_WEEKEND_MINUTES} minutes, not ${inputs.minutes}` }
|
||||
}
|
||||
|
||||
const notice = rewardNotice({ message: inputs.message, delivery: inputs.delivery }, parsed.kit)
|
||||
if (!notice.ok) return { ok: false, error: notice.error }
|
||||
|
||||
const found = await serverFor(parsed.serverId)
|
||||
if (!found.ok) return { ok: false, error: found.error }
|
||||
const server = found.server
|
||||
const serverName = server.name || server.id
|
||||
|
||||
// The kit as its server describes it, now. A server that is down cannot say
|
||||
// which permission gates the kit, and a plan guessed at would be wrong.
|
||||
const kit = await readKit(server, parsed.kit)
|
||||
if (!kit.ok) return { ok: false, error: kit.error }
|
||||
|
||||
const steps = []
|
||||
if (kit.permission) {
|
||||
steps.push({
|
||||
actionId: 'core.lease',
|
||||
label: `Open ${kit.kit} to everyone`,
|
||||
params: {
|
||||
lease: 'rust.group.permission',
|
||||
target: `${server.id}/default/${kit.permission}`,
|
||||
value: 'true',
|
||||
minutes,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (kit.max > 0) {
|
||||
steps.push({
|
||||
actionId: 'rust.kit.entitle',
|
||||
label: 'One more use for every linked player',
|
||||
params: {
|
||||
kit: `${server.id}/${kit.kit}`,
|
||||
recipients: 'linked',
|
||||
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const parts = [
|
||||
kit.permission ? `everybody on ${serverName} may redeem ${kit.kit}` : null,
|
||||
kit.max > 0 ? 'every linked player gets one more use of it' : null,
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
title: `Kit weekend: ${kit.kit} on ${serverName}`,
|
||||
summary: `For ${minutesWords(minutes)}, ${parts.join(', and ')}.`,
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'The weekend',
|
||||
// The phase IS the weekend: it holds the run open for as long as the
|
||||
// lease lasts, so teardown gives the permission back on time and not
|
||||
// a second after the steps finish (§2.2, U-6).
|
||||
advance: { after: `${minutes}m` },
|
||||
steps,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const TEMPLATES = [kitWeekend]
|
||||
|
||||
module.exports = { TEMPLATES, MAX_WEEKEND_MINUTES, minutesWords }
|
||||
1061
server/eventWorld.js
Normal file
1061
server/eventWorld.js
Normal file
File diff suppressed because it is too large
Load Diff
191
server/eventWorldCall.js
Normal file
191
server/eventWorldCall.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// ── Calling one of Rust's own world events (protocol 14, PLAN_REDESIGNS §14.3.1) ──
|
||||
//
|
||||
// `rust.world.call`, "Call a world event": a patrol helicopter, a cargo ship, a
|
||||
// Chinook, an airdrop, the Travelling Vendor, road Bradleys, the F-15 or a
|
||||
// satellite crash, now, the way the game's own schedule runs them (D354, D367).
|
||||
// The plugin runs the schedule's `TriggeredEvent` — no console command — so it
|
||||
// works the same on Carbon.
|
||||
//
|
||||
// **Kept (`reversible: 'none'`).** Nothing a call starts is removed when the run
|
||||
// ends: a cargo ship or a drop may already hold players or their loot (D354).
|
||||
// `risk: 'change'` all the same, so an operator switches it on deliberately.
|
||||
//
|
||||
// **A position only where the game has a route to one** (stage 1's spike): the
|
||||
// airdrop's drop point and the patrol helicopter's first destination. The
|
||||
// Chinook drops only at the map's drop zones, and the one route to a chosen
|
||||
// zone borrows the Airfield terminal's shared state, so it takes no position
|
||||
// (D378); the rest go where the game puts them.
|
||||
|
||||
const client = require('./sidecarClient')
|
||||
const { serverFor, transportError, pluginError } = require('./eventLeases')
|
||||
const { BUDGET_MS, location } = require('./eventWorld')
|
||||
|
||||
/** The plugin's ceiling on calls per run (`WorldCallsHardMax`); an operator may set it lower. */
|
||||
const MAX_CALLS_PER_RUN = 10
|
||||
|
||||
/**
|
||||
* The events a call may run, mirroring the plugin's list. `max` is how many one
|
||||
* step may call at once: the game runs the cargo ship, the vendor and road
|
||||
* Bradleys (twelve at a time) one at a time.
|
||||
*/
|
||||
const EVENTS = Object.freeze([
|
||||
{ value: 'patrol_heli', label: 'Patrol helicopter', max: 5, placeable: true },
|
||||
{ value: 'airdrop', label: 'Airdrop', max: 5, placeable: true },
|
||||
{ value: 'chinook', label: 'Chinook (drops a locked crate where the game chooses)', max: 5 },
|
||||
{ value: 'cargo', label: 'Cargo ship', max: 1 },
|
||||
{ value: 'bradley_road', label: 'Road Bradleys (several at once)', max: 1 },
|
||||
{ value: 'travelling_vendor', label: 'Travelling Vendor', max: 1 },
|
||||
{ value: 'satellite', label: 'Satellite crash', max: 5 },
|
||||
{ value: 'f15', label: 'F-15 flyover (decoration only)', max: 5 },
|
||||
])
|
||||
|
||||
/** The plugin's refusals a second attempt would repeat. */
|
||||
const PERMANENT = new Set([
|
||||
'events-disabled',
|
||||
'malformed',
|
||||
'bad-event',
|
||||
'bad-count',
|
||||
'not-placeable',
|
||||
'too-many',
|
||||
'out-of-range',
|
||||
'no-monument',
|
||||
'off-map',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
{
|
||||
id: 'rust.world.calls',
|
||||
label: 'World events called',
|
||||
unit: 'events',
|
||||
description: "Rust's own world events an event calls — patrol helicopters, cargo ships, airdrops and the rest — each one counted. What a call starts is kept when the run ends.",
|
||||
},
|
||||
]
|
||||
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
// The plugin's own list, mirrored, so the form fills with every server off.
|
||||
id: 'rust.options.world_events',
|
||||
label: 'World events',
|
||||
description: "Rust's own world events a step can call. Only the airdrop and the patrol helicopter take a position; the rest go where the game puts them.",
|
||||
async resolve() {
|
||||
return EVENTS.map((e) => ({ value: e.value, label: e.label }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Whether a step gives a position at all: a monument, or x or z. */
|
||||
function hasPosition(params) {
|
||||
const blank = (v) => v === undefined || v === null || v === ''
|
||||
return !blank(params.monument) || !blank(params.x) || !blank(params.z)
|
||||
}
|
||||
|
||||
const worldCall = {
|
||||
id: 'rust.world.call',
|
||||
label: 'Call a world event',
|
||||
description:
|
||||
"Calls one of Rust's own world events now — a patrol helicopter, an airdrop, a Chinook, the cargo ship, road Bradleys, the Travelling Vendor, a satellite crash or an F-15 flyover — as the game's own schedule would. An airdrop or a patrol helicopter can be sent to a monument or a point; the rest go where the game puts them. What it starts is kept when the run ends.",
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'event', type: 'string', required: true, example: 'airdrop', source: 'rust.options.world_events',
|
||||
description: 'Which world event to call.' },
|
||||
{ name: 'count', type: 'int', required: false, example: 1,
|
||||
description: 'How many at once: 1 to 5, and 1 for the cargo ship, road Bradleys and the vendor. Left blank, 1.' },
|
||||
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
|
||||
description: 'Which server. A monument already says; otherwise required.' },
|
||||
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
|
||||
description: 'Airdrop and patrol helicopter only: where to send it, by monument. Give this OR x and z, or neither for where the game chooses.' },
|
||||
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
|
||||
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
|
||||
{ name: 'x', type: 'float', required: false, example: -604, description: 'Airdrop and patrol helicopter only: world x, instead of a monument.' },
|
||||
{ name: 'z', type: 'float', required: false, example: -342, description: 'Airdrop and patrol helicopter only: world z, instead of a monument.' },
|
||||
],
|
||||
|
||||
cost: (p) => ({ 'rust.world.calls': Math.max(1, Math.round(Number(p.count) || 1)) }),
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const event = EVENTS.find((e) => e.value === String(params.event || '').trim())
|
||||
if (!event) {
|
||||
return { ok: false, retry: false, error: `"${params.event}" is not one of the world events a Rust server calls` }
|
||||
}
|
||||
|
||||
const blank = params.count === undefined || params.count === null || params.count === ''
|
||||
const count = blank ? 1 : Number(params.count)
|
||||
if (!Number.isInteger(count) || count < 1 || count > event.max) {
|
||||
return { ok: false, retry: false, error: `call 1 to ${event.max} of the ${event.label.toLowerCase()} at a time, and "${params.count}" is not that` }
|
||||
}
|
||||
|
||||
let serverId = String(params.server || '').trim()
|
||||
let wire = {}
|
||||
|
||||
if (hasPosition(params)) {
|
||||
if (!event.placeable) {
|
||||
return { ok: false, retry: false, error: `the ${event.label.toLowerCase()} goes where the game puts it; only an airdrop and a patrol helicopter take a position` }
|
||||
}
|
||||
const where = location(params)
|
||||
if (!where.ok) return { ok: false, retry: false, error: where.error }
|
||||
serverId = where.serverId
|
||||
// A height means nothing to a call: the drop falls to the ground and the
|
||||
// helicopter flies at its own.
|
||||
const { y, ...flat } = where.wire
|
||||
wire = flat
|
||||
} else if (!serverId) {
|
||||
return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
|
||||
}
|
||||
|
||||
const found = await serverFor(serverId)
|
||||
if (!found.ok) return found
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await client.worldCall(found.server, {
|
||||
runId: String(runId),
|
||||
key: idempotencyKey,
|
||||
event: event.value,
|
||||
count,
|
||||
...wire,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const permanent = result.status === 'not-configured' || result.status === 'no-token'
|
||||
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'world event call') }
|
||||
}
|
||||
|
||||
const data = result.data || {}
|
||||
|
||||
// The game runs one Travelling Vendor at a time and quietly starts nothing
|
||||
// while one is out. The plugin looks first and says so (W3). Not a failure:
|
||||
// what the step asked for is on the map.
|
||||
if (data.reason === 'already-out') {
|
||||
return {
|
||||
ok: true,
|
||||
detail: { event: event.value, called: 0, note: `a ${event.label} is already out on ${found.server.name || found.server.id}, and the game runs one at a time; nothing new was called` },
|
||||
}
|
||||
}
|
||||
|
||||
if (data.kind !== 'world.called') {
|
||||
return {
|
||||
ok: false,
|
||||
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
|
||||
error: pluginError(data, `${found.server.name || found.server.id} refused the ${event.label.toLowerCase()}`),
|
||||
}
|
||||
}
|
||||
|
||||
const called = Number(data.called) || 0
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
event: event.value,
|
||||
called,
|
||||
...(called < count ? { note: `the game started ${called} of the ${count} asked for` } : {}),
|
||||
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing new was called' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const ACTIONS = [worldCall]
|
||||
|
||||
module.exports = { EVENTS, MAX_CALLS_PER_RUN, PERMANENT, BUDGETS, ACTIONS, OPTION_SOURCES, hasPosition }
|
||||
97
server/guardedCrate.js
Normal file
97
server/guardedCrate.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// ── Guarded Crate, with NpcSpawn (protocol 14, PLAN_REDESIGNS §14.3.5) ─────
|
||||
//
|
||||
// Bazz3l's Guarded Crate drops a locked crate from a cargo plane and rings it
|
||||
// with NpcSpawn's scientists; whoever kills the last guard has cleared it. Its
|
||||
// events reach this module as three kinds: `gc.started` (public), `gc.ended`
|
||||
// (public) and `gc.failed` (staff's, the timeout). The crate cleared is
|
||||
// *Warden*'s count, from the tally's `guardedWon` (D375). A run's step places
|
||||
// one (`eventGuardedCrate.js`).
|
||||
//
|
||||
// ── Who may read what ──────────────────────────────────────────────────────
|
||||
//
|
||||
// As Dangerous Treasures' are (`dangerousTreasures.js`, D376): the event and its
|
||||
// grid are public; the clearer's name shows only to the operator's presence
|
||||
// audience, so with presence left at staff a signed-out visitor reads "The
|
||||
// guarded crate was cleared · M4"; the Steam id, the position and a step's key
|
||||
// are staff's. Whether a player cleared it at all is public (`won`), since an
|
||||
// ending with nobody named (NpcSpawn reloaded under it) names nobody either.
|
||||
|
||||
const { gridOf } = require('./worldEvents')
|
||||
|
||||
const KINDS = Object.freeze(['gc.started', 'gc.ended', 'gc.failed'])
|
||||
|
||||
const STEAM_ID = /^\d{17}$/
|
||||
|
||||
function isKind(kind) {
|
||||
return KINDS.includes(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `gc.*` frame for one viewer: `{ staff, presence, geometry }`. Passes the
|
||||
* envelope, adds `grid`; an ending adds `won`, and `name` for the presence
|
||||
* audience. Staff also get `x`, `z`, the step's `key` and the clearer's
|
||||
* `steamId`. Any other frame is returned as is.
|
||||
*/
|
||||
function shapeFor(frame, { staff = false, presence = false, geometry = null } = {}) {
|
||||
if (!frame || !isKind(frame.kind)) return frame
|
||||
|
||||
const out = {}
|
||||
for (const key of ['kind', 'type', 't', 'serverId', 'wipeId']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
|
||||
const grid = gridOf(geometry, frame.x, frame.z)
|
||||
if (grid) out.grid = grid
|
||||
|
||||
if (frame.kind === 'gc.ended') {
|
||||
out.won = STEAM_ID.test(String(frame.steamId || ''))
|
||||
if (out.won && (presence || staff) && typeof frame.name === 'string' && frame.name) out.name = frame.name
|
||||
if (out.won && staff) out.steamId = String(frame.steamId)
|
||||
}
|
||||
|
||||
if (staff) {
|
||||
for (const key of ['x', 'z', 'key']) {
|
||||
if (frame[key] !== undefined) out[key] = frame[key]
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's last report about Guarded Crate and its console, from
|
||||
* `servers.db.gcReportFor`: `{ guardedCrate, consolePlugins }`, each null where
|
||||
* the plugin never said (no state yet, or a plugin before protocol 14).
|
||||
* `guardedCrate` is `{ loaded, version, npcSpawn: { loaded, version }, events }`;
|
||||
* `npcSpawn` is null where it was not said, and `events` null where the
|
||||
* plugin could not read Guarded Crate's data file.
|
||||
*/
|
||||
function reportOf(row) {
|
||||
const parse = (value) => {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const integration = (body) =>
|
||||
body && typeof body === 'object' ? { loaded: body.loaded === true, version: typeof body.version === 'string' ? body.version : null } : null
|
||||
|
||||
const gc = row ? parse(row.guardedCrate) : null
|
||||
const probe = row ? parse(row.console) : null
|
||||
return {
|
||||
guardedCrate:
|
||||
gc && typeof gc === 'object'
|
||||
? {
|
||||
...integration(gc),
|
||||
npcSpawn: integration(gc.npcSpawn),
|
||||
events: Array.isArray(gc.events) ? gc.events.filter((e) => typeof e === 'string' && e.trim()) : null,
|
||||
}
|
||||
: null,
|
||||
consolePlugins: probe && typeof probe === 'object' && typeof probe.plugins === 'boolean' ? probe.plugins : null,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { KINDS, isKind, shapeFor, reportOf }
|
||||
162
server/index.js
162
server/index.js
@@ -50,7 +50,24 @@ module.exports = function register(ctx, api) {
|
||||
const publicRust = require('./router/public/rust.router')
|
||||
const playerRust = require('./router/player/rust.router')
|
||||
const adminRust = require('./router/admin/rust.router')
|
||||
const usersRust = require('./router/admin/usersRust.router')
|
||||
const teamProvider = require('./model/clans/teamProvider')
|
||||
const { TRIGGERS } = require('./engagement/triggers')
|
||||
const { STREAMS } = require('./engagement/streams')
|
||||
const { AUDIENCES } = require('./engagement/audiences')
|
||||
const seeds = require('./engagement/seeds')
|
||||
const eventLeases = require('./eventLeases')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const eventRewards = require('./eventRewards')
|
||||
const eventWorldCall = require('./eventWorldCall')
|
||||
const eventDangerousTreasures = require('./eventDangerousTreasures')
|
||||
const eventGuardedCrate = require('./eventGuardedCrate')
|
||||
const eventRaidableBases = require('./eventRaidableBases')
|
||||
const eventPermRewards = require('./eventPermRewards')
|
||||
const eventCommands = require('./eventCommands')
|
||||
const eventTemplates = require('./eventTemplates')
|
||||
const boot = require('./boot')
|
||||
const commands = require('./commands')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const log = core.logger()
|
||||
@@ -78,6 +95,57 @@ module.exports = function register(ctx, api) {
|
||||
admin: { '/rust': adminRust },
|
||||
})
|
||||
|
||||
// R13's first extension slot (§2.4). Core declares `admin.users.detail` on
|
||||
// `/api/v1/admin/users/:id` and we fill it; the router receives the parent's
|
||||
// `req.params.id` through `mergeParams`. Core's own routes on the resource are
|
||||
// declared before the slot is mounted, so core wins any path conflict — it owns
|
||||
// the user, and this module owns what it can say about one.
|
||||
//
|
||||
// **It is declared twice, in two different places, on purpose.** This call is
|
||||
// the SERVER half and `module.json`'s `extensions` array is held against it by
|
||||
// the loader. The CLIENT half is `registry.registerExtension(ID,
|
||||
// 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array —
|
||||
// phase 1 found that the hard way with `site.footer.status`, which is a client
|
||||
// slot and fails the load outright when named there.
|
||||
api.registerExtension('admin.users.detail', usersRust)
|
||||
|
||||
// Teams (R5, PLAN.md §24). A first-party Rust clan is a Team, and this module
|
||||
// becomes the deployment's one authoritative source of them. Core asks; the
|
||||
// provider answers from the clan boards (`model/clans`), and refuses rather
|
||||
// than guessing whenever no board is current.
|
||||
//
|
||||
// **One provider per deployment**, so a site running module-uo as well cannot
|
||||
// have both — the second registration is a collision core reports against the
|
||||
// module that made it. That is core's rule and a real constraint on a mixed
|
||||
// UO + Rust site; it is recorded in §24 rather than worked around here.
|
||||
api.registerTeamProvider(teamProvider)
|
||||
|
||||
// Notifications and engagement (R7, PLAN.md §25). Four registrations that are
|
||||
// one decision, because they only mean something together:
|
||||
//
|
||||
// triggers what can happen, what a template may say about it, and the
|
||||
// widest audience a rule on it may EVER have — the security
|
||||
// boundary; core refuses a rule that widens a ceiling
|
||||
// streams which of those may reach a phone. Core pushes an engagement
|
||||
// rule only to devices subscribed to a stream of the SAME id, so
|
||||
// a trigger missing here can never buzz anybody (D65)
|
||||
// audiences named sets of people over this module's data, for an operator
|
||||
// to point a rule at; each answers user ids and nothing else
|
||||
// seeds the two bodies worth writing, and one disabled rule group per
|
||||
// family — installing this module mails nobody
|
||||
//
|
||||
// What fires them is `engagement/emit.js`, off the ingest cursor and the
|
||||
// refresh. Registration is a claim, not a call: nothing here touches the
|
||||
// database, and the seeds are written by core after the schema is up.
|
||||
//
|
||||
// The announce leg arrived with phase 13b (D62, D104), when there was a chat
|
||||
// verb to deliver through; it is registered below with the rewards. There is
|
||||
// still no post hook: nothing in game mirrors a post as state.
|
||||
api.registerEventTriggers(TRIGGERS)
|
||||
api.registerNotificationStreams(STREAMS)
|
||||
api.registerAudiences(AUDIENCES)
|
||||
api.registerEngagementSeeds({ templates: seeds.TEMPLATES, ruleGroups: seeds.RULE_GROUPS })
|
||||
|
||||
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
||||
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
||||
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||
@@ -90,16 +158,96 @@ module.exports = function register(ctx, api) {
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
// Everything else this module will register — the Team provider, the event
|
||||
// triggers and audiences, the engagement seeds, the four event catalogues, the
|
||||
// notification streams, the slash commands and the two extension slots — is
|
||||
// deliberately absent. Each arrives with the phase that has something real to
|
||||
// put in it. A registration with nothing behind it is worse than a missing one:
|
||||
// a declared trigger nothing emits and a declared slot nothing fills are both
|
||||
// surfaces an operator can configure and then wait on.
|
||||
// Sign in with Steam (MODULE_API 1.17.0; PLAN_REDESIGNS §15). A Steam account
|
||||
// linked on the site is a linked Rust player (D392): core asks before it writes
|
||||
// the sign-in identity, and this module refuses a Steam id that is another
|
||||
// account's Rust link (D396); once it is written, the Rust link is made. Both
|
||||
// are in the links model beside `/link`'s own write, and neither runs at
|
||||
// registration — core calls them from a signed-in user's link flow.
|
||||
const links = require('./model/links/links.model') // eslint-disable-line global-require
|
||||
api.onIdentityLinking(links.checkSteamSignin)
|
||||
api.onIdentityLinked(links.linkFromSteamSignin)
|
||||
|
||||
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
|
||||
// demo-player's Steam link, written by this module from its part of the
|
||||
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The
|
||||
// demo's other locks are `demoLocks` in module.json and `demo.js`.
|
||||
const demoSeed = require('./demoSeed') // eslint-disable-line global-require
|
||||
api.onDemoSeed(demoSeed.onDemoSeed)
|
||||
|
||||
// The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
|
||||
// and must give back. Core's `core.lease` is the verb; these are the values it
|
||||
// may name and the four callables each ships. Every lease is targeted and the
|
||||
// target names the server (D73), which is how one value on one server gets
|
||||
// exactly one holder without core learning what a server is.
|
||||
//
|
||||
// The option sources are the three targets' own (D78).
|
||||
api.registerEventLeases(eventLeases.LEASES)
|
||||
|
||||
// The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
|
||||
// back — a zone, crates, NPCs — and the budgets that price them, each declared
|
||||
// beside the verb that spends it (D79, D89). A lease spends none of them.
|
||||
//
|
||||
// The rewards (PLAN.md §29, protocol 10) join them: the tally, the kit reward
|
||||
// and the chat line, with the two budgets they spend. Registered in the same
|
||||
// calls' neighbours, not merged into eventWorld's arrays, so each file keeps
|
||||
// its own statement of what it declares.
|
||||
//
|
||||
// Permissions as rewards (PLAN_REDESIGNS.md §12) beside them: any registered
|
||||
// permission, lent until the run ends or given for good, each on its own budget.
|
||||
//
|
||||
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
|
||||
// 14) beside them: kept when the run ends, on a budget of its own. Starting a
|
||||
// Dangerous Treasures event (§14.3.4) and placing a guarded crate (§14.3.5)
|
||||
// likewise, each on its own budget; and console commands an author typed
|
||||
// (§16, D382-D385), priced in commands; and spawning a RaidableBases base
|
||||
// (§11.4), ledgered like a zone when the raids helper makes it the run's.
|
||||
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS, ...eventCommands.BUDGETS, ...eventRaidableBases.BUDGETS])
|
||||
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS, ...eventCommands.ACTIONS, ...eventRaidableBases.ACTIONS])
|
||||
|
||||
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
|
||||
// as a starting point on core's New event screen. Core keeps nothing about a
|
||||
// template; what it builds is an ordinary draft.
|
||||
api.registerEventTemplates(eventTemplates.TEMPLATES)
|
||||
|
||||
// News in game chat (D104). Core enqueues every registered leg for every
|
||||
// published post, so the leg itself sends only to the servers whose switch an
|
||||
// operator turned on — off by default, and a server that is down is skipped.
|
||||
api.registerAnnounceLeg(eventRewards.LEG)
|
||||
|
||||
// ONE call for every option source: core takes a batch once, as this module's
|
||||
// complete statement, and refuses a second.
|
||||
api.registerEventOptionSources([
|
||||
...eventLeases.OPTION_SOURCES,
|
||||
...eventWorld.OPTION_SOURCES,
|
||||
...eventRewards.OPTION_SOURCES,
|
||||
...eventPermRewards.OPTION_SOURCES,
|
||||
...eventWorldCall.OPTION_SOURCES,
|
||||
...eventDangerousTreasures.OPTION_SOURCES,
|
||||
...eventGuardedCrate.OPTION_SOURCES,
|
||||
...eventRaidableBases.OPTION_SOURCES,
|
||||
])
|
||||
|
||||
// The slash commands (phase 16, R11). The handlers run HERE, in the website
|
||||
// process; the bot pulls the definitions and dispatches each call back. Every
|
||||
// command is `access: 'everyone'` and resolves its own audience gate inside the
|
||||
// handler, and every answer narrower than public is sent to the caller alone
|
||||
// (D127) — see `commands/common.js`.
|
||||
api.registerSlashCommands(commands)
|
||||
|
||||
log.info('registered', {
|
||||
version: require('../module.json').version,
|
||||
routes: 'public:/rust player:/rust admin:/rust',
|
||||
extensions: 'admin.users.detail',
|
||||
teams: 'first-party clans',
|
||||
triggers: TRIGGERS.length,
|
||||
streams: STREAMS.length,
|
||||
audiences: AUDIENCES.length,
|
||||
leases: eventLeases.LEASES.length,
|
||||
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length + eventCommands.ACTIONS.length + eventRaidableBases.ACTIONS.length,
|
||||
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length + eventRaidableBases.BUDGETS.length,
|
||||
announceLeg: eventRewards.LEG.leg,
|
||||
commands: commands.map((cmd) => cmd.name).join(' '),
|
||||
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length + eventRaidableBases.OPTION_SOURCES.length,
|
||||
})
|
||||
}
|
||||
|
||||
433
server/ingest.js
Normal file
433
server/ingest.js
Normal file
@@ -0,0 +1,433 @@
|
||||
// ── Reading a sidecar's feed, and turning it into a record ────────────────
|
||||
//
|
||||
// One job: move each server's cursor forward, and apply what it passed.
|
||||
//
|
||||
// ── Why a cursor and not a socket ─────────────────────────────────────────
|
||||
//
|
||||
// The obvious design is a WebSocket — the sidecar has one, and module-uo takes
|
||||
// exactly that route for the UO bridge. This module polls a cursor instead, and
|
||||
// the reason is not laziness about latency.
|
||||
//
|
||||
// Core runs on Node 20, where a global `WebSocket` is still behind a flag, so a
|
||||
// socket means taking `ws` as a runtime dependency — and this module's release
|
||||
// asserts that it has none (D5: everything it needs arrives on `ctx`, and the
|
||||
// bundle ships no `node_modules`). That is a cost worth paying for latency, but
|
||||
// the deciding argument is the other one: **a socket needs a cursor anyway.**
|
||||
// Whatever a feed misses while a module is restarting has to be caught up from
|
||||
// somewhere, and the catch-up path is the one that must be right. A socket on
|
||||
// top of a cursor is two mechanisms where the second is load-bearing; a cursor
|
||||
// alone is one mechanism that is exercised every few seconds rather than only
|
||||
// after an outage nobody planned.
|
||||
//
|
||||
// What it costs is seconds of latency on a killfeed. What it buys is that the
|
||||
// path which recovers from a five-hour outage is the same path that ran a moment
|
||||
// ago.
|
||||
//
|
||||
// ── The ordering the whole thing rests on ─────────────────────────────────
|
||||
//
|
||||
// **The cursor advances after the batch is written, never before.** A crash
|
||||
// between the two re-reads events already counted, which inflates a total; a
|
||||
// crash the other way round loses them silently and for ever. Neither is good and
|
||||
// they are not equally bad — one is visible and bounded, the other is invisible
|
||||
// and permanent — so the code is arranged to fail in the visible direction.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const clans = require('./model/clans/clans.model')
|
||||
const configDb = require('./model/config/config.db')
|
||||
const configModel = require('./model/config/config.model')
|
||||
const db = require('./model/events/events.db')
|
||||
const engagement = require('./engagement/emit')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const links = require('./model/links/links.model')
|
||||
const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const permissionsDb = require('./model/permissions/permissions.db')
|
||||
const sidecar = require('./sidecarClient')
|
||||
const worldEvents = require('./worldEvents')
|
||||
|
||||
const log = core.logger('ingest')
|
||||
|
||||
/** How many events to ask for at once. */
|
||||
const BATCH = 200
|
||||
|
||||
/**
|
||||
* How many batches one tick will drain before letting the loop breathe.
|
||||
*
|
||||
* A module that has been down for a day has thousands of events waiting, and
|
||||
* draining them in one unbounded loop would hold the tick — and a pool
|
||||
* connection — for as long as that takes. Bounded, it catches up over several
|
||||
* ticks and the site stays responsive while it does.
|
||||
*/
|
||||
const MAX_BATCHES_PER_TICK = 10
|
||||
|
||||
/**
|
||||
* Applies one feed item.
|
||||
*
|
||||
* Every frame is stored raw, and only some of them move a counter. That split is
|
||||
* deliberate: the raw row is what an admin reads and what a later phase can
|
||||
* re-derive from, and the counters are what a leaderboard sums. A kind this
|
||||
* build has never heard of still lands in `rust_events` — it costs nothing and
|
||||
* the alternative is losing the one copy of an event the next version will know
|
||||
* how to read.
|
||||
*/
|
||||
async function apply(serverId, item, server = null) {
|
||||
// A downed helicopter, Chinook or Bradley is stored with the rule that
|
||||
// credits it now, so its line and its counts agree for good (W4).
|
||||
const frame = worldEvents.stampRule((item && item.frame) || {}, server && server.creditRule)
|
||||
const kind = item.kind || frame.kind
|
||||
const wipeId = frame.wipeId || null
|
||||
|
||||
// A wipe exists because something mentioned it. There is no "a wipe started"
|
||||
// call and there must not be one: the website is not there when a wipe happens.
|
||||
await db.touchWipe(serverId, wipeId, frame.saveCreatedAt || null)
|
||||
|
||||
await db.insertEvent({
|
||||
serverId,
|
||||
wipeId,
|
||||
kind,
|
||||
t: Number(frame.t) || item.t || Date.now(),
|
||||
steamId: frame.steamId || null,
|
||||
raw: frame,
|
||||
})
|
||||
|
||||
// What core's engagement engine is told (PLAN.md §25). BEFORE the frame is
|
||||
// applied, because applying a disband deletes the roster the notification is
|
||||
// for. Never throws, and does not hold the cursor on core: `onEvent` resolves
|
||||
// who a frame is about and hands it over, and delivery is core's own time.
|
||||
if (server) await engagement.onEvent(server, item)
|
||||
|
||||
const at = { serverId, wipeId, steamId: frame.steamId }
|
||||
|
||||
switch (kind) {
|
||||
case 'player.connected':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
case 'player.disconnected': {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
|
||||
// `sessionSec` is ABSENT when the plugin never saw the connect — a player
|
||||
// already on the server when it loaded. Absent is not zero: adding a zero
|
||||
// would be recording a session of no length, which is a different claim
|
||||
// from recording no session, and it is the one that quietly under-reports
|
||||
// playtime for ever.
|
||||
const seconds = Number(frame.sessionSec)
|
||||
await db.addStats(at, {
|
||||
sessions: Number.isFinite(seconds) ? 1 : 0,
|
||||
playtimeSec: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.death': {
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
|
||||
// A suicide is a death AND a suicide, not one instead of the other: the
|
||||
// deaths column is "how many times did this player die", and a leaderboard
|
||||
// that silently omitted self-inflicted ones would disagree with the
|
||||
// killfeed sitting next to it on the same page.
|
||||
await db.addStats(at, { deaths: 1, suicides: frame.attackerType === 'self' ? 1 : 0 })
|
||||
|
||||
// Only a real player's kill counts. `npc` and `environment` have no
|
||||
// attacker to credit, and `self` must not credit the victim with a kill —
|
||||
// which is the one line here that would look right in review and produce a
|
||||
// leaderboard topped by whoever died the most.
|
||||
if (frame.attackerType === 'player' && frame.attackerId) {
|
||||
await db.touchPlayer(frame.attackerId, frame.attackerName || null)
|
||||
await db.addStats({ ...at, steamId: frame.attackerId }, { kills: 1 })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.tally': {
|
||||
// Protocol 14 (§11.2.2): the player's Economics balance, a VALUE written
|
||||
// over rather than added — unlike everything below it.
|
||||
const hasBalance = frame.balance !== undefined && frame.balance !== null
|
||||
if (hasBalance) await db.setBalance(at, frame.balance)
|
||||
|
||||
// A balance with no name is an offline player's: a payment or a staff
|
||||
// adjustment reaches somebody who is not there, and the plugin names only
|
||||
// a connected player. It says nothing about play, so it must not move
|
||||
// anybody's last-seen — which every writer below does.
|
||||
if (hasBalance && !frame.name) break
|
||||
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
await db.addStats(at, {
|
||||
npcKills: Number(frame.npcKills) || 0,
|
||||
structures: Number(frame.structures) || 0,
|
||||
})
|
||||
|
||||
// A tally is a DELTA since the last flush, which is what makes adding it
|
||||
// correct. If it ever becomes a running total this loop doubles every
|
||||
// number in it, slowly, and looks right the whole time.
|
||||
const gathered = frame.gathered || {}
|
||||
for (const [resource, amount] of Object.entries(gathered)) {
|
||||
await db.addGathered(at, resource, Number(amount) || 0)
|
||||
}
|
||||
|
||||
// Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Deltas
|
||||
// too, except the two distances, which are this interval's best.
|
||||
await db.addTitleStats(at, frame)
|
||||
|
||||
const weapons = frame.weaponKills && typeof frame.weaponKills === 'object' ? frame.weaponKills : {}
|
||||
for (const [weapon, kills] of Object.entries(weapons)) {
|
||||
await db.addWeaponKills(at, weapon, Math.floor(Number(kills) || 0))
|
||||
}
|
||||
|
||||
// RunicNPC's NPCs, by profile name (D247). Credited to the site profile
|
||||
// that name was last pushed as on this server, so "this profile only"
|
||||
// can be counted; 0 for a name the site never pushed.
|
||||
const profiles = frame.npcProfileKills && typeof frame.npcProfileKills === 'object' ? frame.npcProfileKills : {}
|
||||
for (const [profile, kills] of Object.entries(profiles)) {
|
||||
const n = Math.floor(Number(kills) || 0)
|
||||
if (n > 0) await npcsDb.addKills(at, profile, await npcs.siteProfileFor(serverId, profile), n)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'player.chat':
|
||||
case 'player.respawned':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
// ── Protocol 3: the one frame that changes something other than a counter ──
|
||||
//
|
||||
// `/unlink` in game severs the site's link, and it is the only way out of a
|
||||
// link on the wrong account: the site REFUSES to move a Steam id another
|
||||
// website account already holds (D23), so without this a player who linked
|
||||
// while signed in as the wrong account would need staff.
|
||||
//
|
||||
// It arrives here rather than through a route because the plugin has nothing
|
||||
// to delete — the site is the author of record and the game holds no link —
|
||||
// so `/unlink` is the game reporting what the player asked for, applied off
|
||||
// the feed like every other frame.
|
||||
//
|
||||
// **The authority is the Steam account itself.** Whoever is connected to the
|
||||
// game as it is who it is, which is a stronger proof of ownership than the
|
||||
// site can obtain any other way, so this is not scoped by website user.
|
||||
case 'account.unlinked':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
await links.unlinkFromGame(frame.steamId)
|
||||
break
|
||||
|
||||
// Stored and counted as a sighting, nothing more. The code is deliberately
|
||||
// NOT on this frame — it travels through the player — so there is nothing
|
||||
// here to redeem and no pending state for the site to hold. It exists so an
|
||||
// operator can see linking being used at all.
|
||||
case 'account.link.requested':
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
break
|
||||
|
||||
// ── Protocol 4: somebody changed the permission store, and it was not us ──
|
||||
//
|
||||
// The plugin raises this only for writes it did not make itself — its own
|
||||
// sync suppresses the hooks while it applies (PROTOCOL.md §10.4). What
|
||||
// arrives here is therefore a hand edit, a console command, or another
|
||||
// plugin granting something.
|
||||
//
|
||||
// **It is a reason to reconcile, not the reconciliation.** This frame cannot
|
||||
// say whether the change is foreign: only the desired set can, and that
|
||||
// comparison happens in the sync. So the server is marked dirty and the next
|
||||
// tick produces the authoritative answer — which means a hook that stops
|
||||
// firing on a framework upgrade costs latency and nothing else. The audit
|
||||
// interval finds the same drift within fifteen minutes either way.
|
||||
case 'perm.drift':
|
||||
await permissionsDb.markDirty(serverId)
|
||||
break
|
||||
|
||||
// ── Protocol 13: a plugin loaded or unloaded (F8, D184) ─────────────────
|
||||
//
|
||||
// A grant for a plugin that was not loaded stays `unresolved` until that
|
||||
// plugin comes back, and the first walk watched one land thirteen minutes
|
||||
// late, on the fifteen-minute audit. The frame carries the permissions the
|
||||
// plugin added or removed, and only a non-empty list is a reason to sync —
|
||||
// a plugin that registers nothing cannot have changed what a grant resolves
|
||||
// to. `perm.drift`'s posture: a reason, and the next tick (30 s) is the answer.
|
||||
case 'plugin.loaded':
|
||||
case 'plugin.unloaded':
|
||||
if (Array.isArray(frame.permissions) && frame.permissions.length > 0) {
|
||||
await permissionsDb.markDirty(serverId)
|
||||
}
|
||||
break
|
||||
|
||||
// ── Protocol 13: a zone reached its deadline (F13, F14, D170, D183) ──────
|
||||
//
|
||||
// Core records the run's resource row as `expired`. Not awaited: it is
|
||||
// core's bookkeeping, fire-and-forget by contract.
|
||||
case 'world.expired':
|
||||
eventWorld.expired(serverId, frame)
|
||||
break
|
||||
|
||||
// ── Protocol 13: how a configuration save's reload ended (F9, D179) ─────
|
||||
//
|
||||
// The save was answered before the reload finished and recorded as
|
||||
// `reloading`; this is the rest of it. Only a row still `reloading` moves,
|
||||
// so a replayed frame changes nothing, and one that arrives after the page
|
||||
// gave up on it (`lost`) still lands — the audit row ends up true either way.
|
||||
case 'config.outcome': {
|
||||
const outcome = configModel.outcomeOf(frame)
|
||||
if (outcome) await configDb.settleWrite(serverId, outcome)
|
||||
break
|
||||
}
|
||||
|
||||
// ── Protocol 14: Rust's own world events (§14.3.1) ──────────────────────
|
||||
//
|
||||
// Stored like everything else, and read per viewer by the feed. The one
|
||||
// thing a world event COUNTS is a downed patrol helicopter, Chinook or
|
||||
// Bradley: the plugin sends every candidate and the server's rule picks
|
||||
// (D372, D379), so Rotorbane and Tankbreaker always agree with the line.
|
||||
// A protocol-13 plugin still sends `heliKills` / `apcKills` on its tally
|
||||
// instead, and the tally path above adds those as it always has.
|
||||
case 'world.event': {
|
||||
for (const kill of worldEvents.killsOf(frame, worldEvents.ruleOf(frame, server && server.creditRule))) {
|
||||
await db.touchPlayer(kill.steamId, kill.name || null)
|
||||
await db.addTitleStats({ serverId, wipeId, steamId: kill.steamId }, { [kill.field]: 1 })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Protocol 14: Economics wiped every balance (§11.2.2) ────────────────
|
||||
//
|
||||
// `ecowipe` puts everyone back at the start in the wipe in play, so none of
|
||||
// its balances is known any more. A new save's wipe (`newSave`) opens a new
|
||||
// map whose balances arrive as players connect; the last wipe's stay as the
|
||||
// record of how it ended.
|
||||
case 'economics.wiped':
|
||||
if (!frame.newSave) await db.clearBalances(serverId, wipeId)
|
||||
break
|
||||
|
||||
// ── Protocol 6: first-party clans ──────────────────────────────────────
|
||||
//
|
||||
// Each one is told to core as it happens (`ctx.teams.publish`) and written
|
||||
// to the clan's Team feed as a members-only line (D49). Neither is the
|
||||
// record: the `clans` board the plugin re-sends a few seconds later is what
|
||||
// the store is rebuilt from, so an event this module never saw costs a
|
||||
// feed line and nothing else.
|
||||
//
|
||||
// No `touchPlayer` here, on purpose: it moves `last_seen`, and a kick is
|
||||
// done TO somebody who may be offline. `model/clans` notes names without it.
|
||||
case 'clan.created':
|
||||
case 'clan.disbanded':
|
||||
case 'clan.member.added':
|
||||
case 'clan.member.left':
|
||||
case 'clan.member.kicked':
|
||||
await clans.applyEvent(serverId, frame)
|
||||
break
|
||||
|
||||
default:
|
||||
// Stored, not counted. Moderation frames, the server lifecycle, and
|
||||
// anything a newer protocol sends that this build does not understand.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings one server's cursor up to date.
|
||||
*
|
||||
* Returns the number of events applied, for the log and for the tests.
|
||||
*/
|
||||
async function ingestServer(server) {
|
||||
const cursor = await db.getCursor(server.id)
|
||||
|
||||
// A server this module has never ingested starts at the sidecar's CURRENT end,
|
||||
// not at zero. A module installed today against a sidecar that has been running
|
||||
// for a month should read what happens next — replaying a fortnight of deaths
|
||||
// into stats for wipes it never saw is not a catch-up, it is inventing a
|
||||
// history it was not present for. `/feed` with no `since` asks exactly that
|
||||
// question, which is why the sidecar answers it that way.
|
||||
if (!cursor) {
|
||||
const tail = await sidecar.feedTail(server)
|
||||
|
||||
if (!tail.ok || !tail.data) {
|
||||
// Unreachable. Write nothing: a cursor of 0 written now would replay the
|
||||
// whole retained history the moment the sidecar came back.
|
||||
return 0
|
||||
}
|
||||
|
||||
await db.setCursor(server.id, Number(tail.data.lastId) || 0, 0)
|
||||
log.info('cursor started at the feed tail', { server: server.id, at: tail.data.lastId })
|
||||
return 0
|
||||
}
|
||||
|
||||
let since = Number(cursor.lastEventId) || 0
|
||||
let applied = 0
|
||||
|
||||
for (let batch = 0; batch < MAX_BATCHES_PER_TICK; batch += 1) {
|
||||
const res = await sidecar.feed(server, since, BATCH)
|
||||
|
||||
if (!res.ok || !res.data) return applied
|
||||
|
||||
const items = Array.isArray(res.data.items) ? res.data.items : []
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await apply(server.id, item, server)
|
||||
applied += 1
|
||||
} catch (err) {
|
||||
// One malformed event must not wedge a server's cursor for ever. It is
|
||||
// logged with its id so it can be found, and the cursor moves past it:
|
||||
// the alternative is an ingest that stops at a single bad row and then
|
||||
// silently stops being a feed at all.
|
||||
log.warn('could not apply an event', {
|
||||
server: server.id,
|
||||
id: item && item.id,
|
||||
kind: item && item.kind,
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const lastId = Number(res.data.lastId)
|
||||
|
||||
if (Number.isFinite(lastId) && lastId > since) {
|
||||
// AFTER the batch. See the header.
|
||||
await db.setCursor(server.id, lastId, items.length)
|
||||
since = lastId
|
||||
}
|
||||
|
||||
if (!res.data.more) break
|
||||
}
|
||||
|
||||
if (applied > 0) {
|
||||
log.info('ingested', { server: server.id, events: applied, cursor: since })
|
||||
// A leader can only change when something was applied. Never throws.
|
||||
await engagement.checkLeader(server)
|
||||
}
|
||||
|
||||
return applied
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the boards: what is true right now, rather than what happened.
|
||||
*
|
||||
* `players.online` replaces the presence rows wholesale, because that is what a
|
||||
* board is. Storing it as history is the mistake the wire's `type` field exists
|
||||
* to prevent, and it would be a poor return for the sidecar's trouble to make it
|
||||
* here after it went out of its way not to make it there.
|
||||
*/
|
||||
async function applyBoards(serverId, boards) {
|
||||
const presence = boards && boards['players.online']
|
||||
|
||||
if (presence && Array.isArray(presence.players)) {
|
||||
await db.replacePresence(serverId, presence.players)
|
||||
}
|
||||
|
||||
// Clans only once the game has spoken at all. A sidecar that has never heard
|
||||
// from its plugin holds no boards, and recording "no clan board" then would
|
||||
// blame the plugin's protocol for a game server that is simply not up. Left
|
||||
// alone, the stored board ages past fresh on its own, which is the true answer.
|
||||
if (boards && boards['server.hello']) {
|
||||
// Fenced: clans are the one board here that core's Teams depend on, and a
|
||||
// failure applying them must cost the clans rather than the presence board
|
||||
// above or the server state the caller writes next.
|
||||
try {
|
||||
await clans.applyBoard(serverId, boards.clans)
|
||||
await clans.reofferActivity(serverId)
|
||||
} catch (err) {
|
||||
log.warn('could not apply the clan board', { server: serverId, error: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { apply, applyBoards, ingestServer, BATCH, MAX_BATCHES_PER_TICK }
|
||||
95
server/liveStatus.js
Normal file
95
server/liveStatus.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// ── Asking a server how it is NOW, before saying no (W7, W11) ──────────────
|
||||
//
|
||||
// Every "is it loaded" refusal — Economics (its step and the staff page),
|
||||
// Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes,
|
||||
// TruePVE, RunicNPC, and the console probe — reads the status the board poll
|
||||
// stored, and the poll runs every 30 seconds
|
||||
// (`boot.REFRESH_MS`). Right after a restart, or a plugin loaded by hand, the
|
||||
// stored status still says no for up to a minute while the game already says
|
||||
// yes.
|
||||
//
|
||||
// So a refusal is CONFIRMED before it is given: the step asks the sidecar once
|
||||
// more, writes what it said exactly as a poll tick would, and reads the stored
|
||||
// status again. Only a "no" that survives that is a refusal. A "yes" is never
|
||||
// re-asked: a plugin unloaded since the last poll still passes the dry run, and
|
||||
// the plugin itself refuses at the run, as it always has.
|
||||
//
|
||||
// One ask per server at a time, and none within `FRESH_MS` of the last, so a
|
||||
// form's dry run of ten steps against one server asks once.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const log = core.logger('live-status')
|
||||
|
||||
/** A status asked for this recently is as fresh as asking again. */
|
||||
const FRESH_MS = 5 * 1000
|
||||
|
||||
/**
|
||||
* How long a step waits for the ask. `/boards` and `/health` are the sidecar's
|
||||
* own, no game round trip, so a sidecar that is up answers well inside it. The
|
||||
* bound keeps the ask plus the step's own call inside the step's `budgetMs`
|
||||
* (15 s) when a sidecar is slow; past it the stored status stands, and the ask
|
||||
* still finishes and writes, as a poll tick would.
|
||||
*/
|
||||
const ASK_MS = 3 * 1000
|
||||
|
||||
const inFlight = new Map()
|
||||
const askedAt = new Map()
|
||||
|
||||
/**
|
||||
* Ask `server`'s sidecar now and store the answer, as a board poll does.
|
||||
* Resolves true once the stored status is current, false when it could not be
|
||||
* asked (the stored status stands). Never rejects.
|
||||
*/
|
||||
function refresh(server) {
|
||||
if (!server || !server.id) return Promise.resolve(false)
|
||||
const id = server.id
|
||||
|
||||
if (inFlight.has(id)) return inFlight.get(id)
|
||||
if (Date.now() - (askedAt.get(id) || 0) < FRESH_MS) return Promise.resolve(true)
|
||||
|
||||
// Required here, not at the top: boot.js requires the event steps that
|
||||
// require this file.
|
||||
const ask = Promise.resolve()
|
||||
.then(() => require('./boot').refreshOne(server))
|
||||
.then(
|
||||
() => {
|
||||
askedAt.set(id, Date.now())
|
||||
return true
|
||||
},
|
||||
(err) => {
|
||||
log.warn('could not ask a server how it is', { server: id, error: err.message })
|
||||
return false
|
||||
},
|
||||
)
|
||||
.finally(() => inFlight.delete(id))
|
||||
|
||||
inFlight.set(id, ask)
|
||||
|
||||
// The caller waits at most `ASK_MS`; the ask itself runs to the end.
|
||||
let timer
|
||||
const late = new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), ASK_MS)
|
||||
if (timer.unref) timer.unref()
|
||||
})
|
||||
return Promise.race([ask, late]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* `read()`'s answer, confirmed live when `refuses` says it would refuse: one
|
||||
* `refresh(server)`, then `read()` again. The answer the caller acts on.
|
||||
*/
|
||||
async function confirmed(server, read, refuses) {
|
||||
const first = await read()
|
||||
if (!refuses(first)) return first
|
||||
if (!(await module.exports.refresh(server))) return first
|
||||
return read()
|
||||
}
|
||||
|
||||
/** For tests: forget every ask. */
|
||||
function _reset() {
|
||||
inFlight.clear()
|
||||
askedAt.clear()
|
||||
}
|
||||
|
||||
module.exports = { FRESH_MS, ASK_MS, refresh, confirmed, _reset }
|
||||
277
server/mapImages.js
Normal file
277
server/mapImages.js
Normal file
@@ -0,0 +1,277 @@
|
||||
// ── The map's picture: noticing a new map and fetching it ─────────────────
|
||||
//
|
||||
// R9's first half, as the rig rewrote it (PLAN.md §30.0). The picture is not
|
||||
// extracted from anything: the game's Rust+ service already holds a JPEG of the
|
||||
// map in memory, and the plugin hands it over in 512 KiB slices. That changes
|
||||
// the one rule R9 carried over from the asset bridge, where a fetch was an
|
||||
// expensive extraction:
|
||||
//
|
||||
// **D110 — the site fetches the picture BY ITSELF the first time it sees a map
|
||||
// it does not have**, because reading the cache costs the game nothing. It is
|
||||
// triggered off the board poll (`boot.js`): a new boot, wipe, seed or world
|
||||
// size, or a server with no row at all, is a reason to ask `map.info`, and a
|
||||
// key or hash that differs from the row is a reason to fetch.
|
||||
//
|
||||
// **D109 — a render is never automatic.** A server with no Rust+ cache has no
|
||||
// picture until an admin presses Render now, which stalls that game for seconds
|
||||
// and is said so beside the button. The render answers at once and happens on a
|
||||
// later frame; this file then polls `map.info` until the picture exists and
|
||||
// fetches it like any other.
|
||||
//
|
||||
// Three rules the fetch keeps:
|
||||
//
|
||||
// • **One fetch per server at a time**, held here. A second trigger while one
|
||||
// runs is dropped, and an admin's Fetch again is told one is running.
|
||||
// • **A fetch that straddles a map change is abandoned whole.** The plugin
|
||||
// refuses a slice with `stale` when the key or hash moved, and the bytes are
|
||||
// checked against the hash before anything is written: two maps are never
|
||||
// spliced, and a half-fetched picture is never stored.
|
||||
// • **A failure keeps the old row** and retries on a backoff. The page keeps
|
||||
// drawing the last picture it had, which is still the right map until the
|
||||
// key says otherwise.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const db = require('./model/map/map.db')
|
||||
const model = require('./model/map/map.model')
|
||||
const sidecar = require('./sidecarClient')
|
||||
|
||||
const log = core.logger('map')
|
||||
|
||||
/** Backoff after a failed fetch: 30 s doubling to 10 minutes. */
|
||||
const BACKOFF_MIN_MS = 30 * 1000
|
||||
const BACKOFF_MAX_MS = 10 * 60 * 1000
|
||||
|
||||
/** How soon to ask again when the plugin is still hashing, or the world still loading. */
|
||||
const SOON_MS = 3000
|
||||
|
||||
/** How long a render is watched for before this side gives up on it (a 6000 map stalls ~40 s). */
|
||||
const RENDER_WATCH_MS = 5 * 60 * 1000
|
||||
const RENDER_POLL_MS = 3000
|
||||
|
||||
/** The first protocol whose plugin knows the map verbs. */
|
||||
const MAP_PROTOCOL = 11
|
||||
|
||||
/** The most slices one picture may have: 16 MiB, MEDIUMBLOB's ceiling. */
|
||||
const MAX_CHUNKS = 32
|
||||
|
||||
/**
|
||||
* Per server: the observation last acted on, whether a fetch is running, and
|
||||
* when the next try may happen. In memory, deliberately — a restart of the
|
||||
* website forgets it and asks once more, which costs one `map.info`.
|
||||
*/
|
||||
const state = new Map()
|
||||
|
||||
function stateOf(serverId) {
|
||||
if (!state.has(serverId)) {
|
||||
state.set(serverId, { sig: null, running: null, nextTryAt: 0, failures: 0, rendering: null, lastError: null })
|
||||
}
|
||||
return state.get(serverId)
|
||||
}
|
||||
|
||||
/** What about a server makes its map worth asking about again. */
|
||||
const signature = ({ bootId, wipeId, seed, worldSize }) => [bootId, wipeId, seed, worldSize].map((v) => (v == null ? '' : String(v))).join('|')
|
||||
|
||||
/**
|
||||
* Called by every board poll for a CONNECTED server whose world is ready. Cheap
|
||||
* when nothing moved: one map lookup and a string compare. When something did,
|
||||
* it starts a fetch in the background and returns — the poll never waits on a
|
||||
* picture.
|
||||
*/
|
||||
function observe(server, frame, now = Date.now()) {
|
||||
if (!server || !frame || frame.worldReady === false) return
|
||||
// The game link has no version handshake: a plugin older than protocol 11
|
||||
// never answers `map.info`, and asking would cost a full reply timeout a try.
|
||||
if (!(Number(frame.protocol) >= MAP_PROTOCOL)) return
|
||||
const s = stateOf(server.id)
|
||||
const sig = signature(frame)
|
||||
|
||||
if (s.running || now < s.nextTryAt) return
|
||||
if (s.sig === sig) return
|
||||
|
||||
run(server, sig).catch((err) => log.warn('map fetch failed', { server: server.id, error: err.message }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One fetch cycle. Resolves `{ ok, outcome, detail }`, never rejects in normal
|
||||
* operation; the outcome words are what the admin card shows.
|
||||
*
|
||||
* fetched a new picture is stored
|
||||
* current the stored picture is already this map's
|
||||
* none the game has no picture; geometry stored so layers can be drawn
|
||||
* busy another fetch is running (admin only)
|
||||
* failed something did not answer; the old row is kept
|
||||
*/
|
||||
function run(server, sig = null, { force = false } = {}) {
|
||||
const s = stateOf(server.id)
|
||||
if (s.running) return Promise.resolve({ ok: false, outcome: 'busy', detail: 'a fetch is already running for this server' })
|
||||
|
||||
s.running = (async () => {
|
||||
try {
|
||||
const result = await fetchOnce(server, { force })
|
||||
if (result.retrySoon) {
|
||||
s.nextTryAt = Date.now() + SOON_MS
|
||||
return result
|
||||
}
|
||||
if (result.ok) {
|
||||
s.sig = sig
|
||||
s.failures = 0
|
||||
s.nextTryAt = 0
|
||||
s.lastError = null
|
||||
} else {
|
||||
s.failures += 1
|
||||
s.nextTryAt = Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** (s.failures - 1))
|
||||
s.lastError = result.detail || result.outcome
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
s.running = null
|
||||
}
|
||||
})()
|
||||
|
||||
return s.running
|
||||
}
|
||||
|
||||
/** The plugin's refusal, as a sentence, or null when the answer was not one. */
|
||||
function refusal(res) {
|
||||
if (!res.ok) return `the sidecar did not answer (${res.status})`
|
||||
if (res.data && res.data.kind === 'map.error') return res.data.message || res.data.reason || 'refused'
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchOnce(server, { force }) {
|
||||
const info = await sidecar.mapInfo(server)
|
||||
const why = refusal(info)
|
||||
|
||||
if (why) {
|
||||
// A world still loading is not a failure worth backing off for.
|
||||
if (info.ok && info.data && info.data.reason === 'not-ready') return { ok: false, retrySoon: true, outcome: 'failed', detail: why }
|
||||
return { ok: false, outcome: 'failed', detail: why }
|
||||
}
|
||||
|
||||
const data = info.data || {}
|
||||
if (data.hashing) return { ok: false, retrySoon: true, outcome: 'failed', detail: 'the plugin is still hashing the picture' }
|
||||
|
||||
const row = model.derive(server.id, data)
|
||||
if (!row.mapKey) return { ok: false, outcome: 'failed', detail: 'map.info named no map' }
|
||||
|
||||
const stored = await db.getMeta(server.id)
|
||||
|
||||
if (row.source === 'none') {
|
||||
// The same map with a picture already stored keeps it: nothing about the map
|
||||
// changed, only where a picture would come from now. A different map, or no
|
||||
// row at all, stores the geometry so the page can draw the layers.
|
||||
if (stored && stored.mapKey === row.mapKey && stored.sha256) {
|
||||
await db.putGeometry({ ...row, source: stored.source, width: Number(stored.width), height: Number(stored.height), background: stored.background })
|
||||
return { ok: true, outcome: 'current', detail: 'the game has no picture now; the stored one is the same map' }
|
||||
}
|
||||
await db.putImage(row)
|
||||
return { ok: true, outcome: 'none', detail: 'the game has no picture of its map; an admin can render one' }
|
||||
}
|
||||
|
||||
if (!force && stored && stored.mapKey === row.mapKey && stored.sha256 === row.sha256) {
|
||||
if (Number(stored.derivation) !== model.DERIVATION_VERSION || stored.source !== row.source) await db.putGeometry(row)
|
||||
return { ok: true, outcome: 'current', detail: 'the stored picture is this map' }
|
||||
}
|
||||
|
||||
const chunks = Number(data.chunks)
|
||||
if (!Number.isInteger(chunks) || chunks < 1 || chunks > MAX_CHUNKS) {
|
||||
return { ok: false, outcome: 'failed', detail: `the plugin described ${data.chunks} slices` }
|
||||
}
|
||||
|
||||
const parts = []
|
||||
for (let n = 0; n < chunks; n += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await sidecar.mapChunk(server, { mapKey: row.mapKey, sha256: row.sha256, n })
|
||||
const no = refusal(res)
|
||||
if (no) {
|
||||
// `stale` means the map moved under the fetch: start again soon rather
|
||||
// than back off, because the next `map.info` describes the new one.
|
||||
if (res.ok && res.data && res.data.reason === 'stale') return { ok: false, retrySoon: true, outcome: 'failed', detail: no }
|
||||
return { ok: false, outcome: 'failed', detail: `slice ${n}: ${no}` }
|
||||
}
|
||||
if (!res.data || res.data.kind !== 'map.chunk' || Number(res.data.chunk) !== n || typeof res.data.data !== 'string') {
|
||||
return { ok: false, outcome: 'failed', detail: `slice ${n} was not the slice asked for` }
|
||||
}
|
||||
parts.push(Buffer.from(res.data.data, 'base64'))
|
||||
}
|
||||
|
||||
const bytes = Buffer.concat(parts)
|
||||
const hash = crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
|
||||
if (hash !== row.sha256 || (Number(data.bytes) > 0 && bytes.length !== Number(data.bytes))) {
|
||||
return { ok: false, outcome: 'failed', detail: 'the picture did not match its own hash; nothing was stored' }
|
||||
}
|
||||
|
||||
await db.putImage({ ...row, bytes })
|
||||
log.info('map picture stored', { server: server.id, mapKey: row.mapKey, source: row.source, bytes: bytes.length })
|
||||
return { ok: true, outcome: 'fetched', detail: `${bytes.length} bytes from ${row.source}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* An admin's Render now (D109). Refused here when a render is already being
|
||||
* watched; the plugin refuses the rest (a picture exists, the world is loading).
|
||||
* On `accepted` the render is watched in the background: `map.info` is polled
|
||||
* until its source is `rendered` — through a stall that makes the game answer
|
||||
* nothing at all for its duration — and then fetched like any picture.
|
||||
*/
|
||||
async function render(server, actor) {
|
||||
const s = stateOf(server.id)
|
||||
if (s.rendering) return { ok: false, status: 409, message: 'A render is already running for this server.' }
|
||||
|
||||
const res = await sidecar.mapRender(server, { actor: actor || null })
|
||||
const no = refusal(res)
|
||||
if (no) {
|
||||
const reason = res.ok && res.data ? res.data.reason : null
|
||||
return { ok: false, status: reason === 'has-picture' || reason === 'busy' ? 409 : 502, message: no, reason }
|
||||
}
|
||||
|
||||
s.rendering = { startedAt: Date.now() }
|
||||
watchRender(server).catch((err) => log.warn('render watch failed', { server: server.id, error: err.message }))
|
||||
return { ok: true, worldSize: res.data && res.data.worldSize, stallSeconds: model.renderStallSeconds(res.data && res.data.worldSize) }
|
||||
}
|
||||
|
||||
async function watchRender(server) {
|
||||
const s = stateOf(server.id)
|
||||
const until = Date.now() + RENDER_WATCH_MS
|
||||
try {
|
||||
while (Date.now() < until) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
const t = setTimeout(resolve, RENDER_POLL_MS)
|
||||
if (typeof t.unref === 'function') t.unref()
|
||||
})
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const info = await sidecar.mapInfo(server)
|
||||
const data = info.ok && info.data ? info.data : null
|
||||
if (!data || data.kind !== 'map.info') continue // the game is mid-stall and answering nothing
|
||||
if (data.rendering) continue
|
||||
if (data.source === 'none') {
|
||||
s.lastError = 'the render finished without a picture — see rg.map on the server console'
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await run(server, null, { force: false })
|
||||
return
|
||||
}
|
||||
s.lastError = 'the render did not finish within five minutes'
|
||||
} finally {
|
||||
s.rendering = null
|
||||
}
|
||||
}
|
||||
|
||||
/** For the admin card: what this side is doing about each server's picture. */
|
||||
function statusOf(serverId) {
|
||||
const s = state.get(serverId)
|
||||
if (!s) return { fetching: false, rendering: false, lastError: null }
|
||||
return { fetching: Boolean(s.running), rendering: Boolean(s.rendering), lastError: s.lastError }
|
||||
}
|
||||
|
||||
/** Test seam: forget everything. */
|
||||
function _reset() {
|
||||
state.clear()
|
||||
}
|
||||
|
||||
module.exports = { observe, run, render, statusOf, signature, MAX_CHUNKS, _reset }
|
||||
58
server/mapLive.js
Normal file
58
server/mapLive.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// ── What moves on the map, asked for while somebody is looking ────────────
|
||||
//
|
||||
// D111: positions are request/reply, never a board. A board would run with
|
||||
// nobody looking and keep where everybody is at rest in the sidecar's database;
|
||||
// this asks `map.live` only when a page does, and keeps the answer IN MEMORY for
|
||||
// five seconds.
|
||||
//
|
||||
// **Any number of viewers cost one ask.** Every request inside the window is
|
||||
// served from the same answer, and a request that arrives while an ask is in
|
||||
// flight waits for that ask rather than starting a second — so the sidecar sees
|
||||
// at most one `map.live` per server per window, whoever and however many are
|
||||
// watching (§30.4 step 7).
|
||||
//
|
||||
// The cache holds EVERY layer, unfiltered; each viewer's answer is projected
|
||||
// from it by `model/map`. That is why it never leaves this process.
|
||||
|
||||
const sidecar = require('./sidecarClient')
|
||||
const model = require('./model/map/map.model')
|
||||
|
||||
/** serverId → { at, answer } for the last good answer, and { pending } while one is in flight. */
|
||||
const cache = new Map()
|
||||
|
||||
/**
|
||||
* One server's live answer, from the cache when it is fresh. Resolves `{ ok,
|
||||
* data, status }` like every sidecar call; never rejects.
|
||||
*/
|
||||
async function live(server, now = Date.now) {
|
||||
const hit = cache.get(server.id)
|
||||
if (hit && hit.answer && now() - hit.at < model.LIVE_CACHE_MS) return hit.answer
|
||||
if (hit && hit.pending) return hit.pending
|
||||
|
||||
const pending = (async () => {
|
||||
const res = await sidecar.mapLive(server)
|
||||
const refused = res.ok && res.data && res.data.kind === 'map.error'
|
||||
const answer = res.ok && !refused
|
||||
? { ok: true, status: 'ok', data: res.data }
|
||||
: { ok: false, status: refused ? res.data.reason || 'refused' : res.status, data: null }
|
||||
// A failure is cached for the window too: a game that is down must not be
|
||||
// asked once per viewer per poll while it stays down.
|
||||
cache.set(server.id, { at: now(), answer })
|
||||
return answer
|
||||
})()
|
||||
|
||||
cache.set(server.id, { ...(hit || {}), pending })
|
||||
try {
|
||||
return await pending
|
||||
} finally {
|
||||
const entry = cache.get(server.id)
|
||||
if (entry && entry.pending === pending) delete entry.pending
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
function _reset() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
module.exports = { live, _reset }
|
||||
36
server/model/backpacks/backpacks.db.js
Normal file
36
server/model/backpacks/backpacks.db.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// ── SQL for Backpacks (PLAN_REDESIGNS §11.3.2) ────────────────────────────
|
||||
//
|
||||
// What each server's last hello said about Backpacks. The backpacks themselves
|
||||
// are never stored (§11.3.2: live, never stored); this file only reads the
|
||||
// report the plugin makes about the plugin.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const STATE = 'rust_server_state'
|
||||
|
||||
/**
|
||||
* One server's report: `{ serverId, backpacks }`, with `backpacks` as the
|
||||
* driver hands JSON_EXTRACT back — text, or null for a server whose plugin
|
||||
* never said (no state yet, or a plugin before protocol 14).
|
||||
*/
|
||||
async function reportFor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId,
|
||||
JSON_EXTRACT(raw, '$.integrations.backpacks') AS backpacks
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every server's report, for the step's size list. */
|
||||
async function reports() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId,
|
||||
JSON_EXTRACT(raw, '$.integrations.backpacks') AS backpacks
|
||||
FROM ${STATE}`,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { reportFor, reports }
|
||||
129
server/model/backpacks/backpacks.model.js
Normal file
129
server/model/backpacks/backpacks.model.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// ── Backpacks, as the site sees it (PLAN_REDESIGNS §11.3.2, D199, D337, D338) ─
|
||||
//
|
||||
// WhiteThunder's Backpacks is optional (D199): a server reports it in hello's
|
||||
// `integrations.backpacks`, with the sizes its `backpacks.size.N` permissions
|
||||
// offer, and every surface that needs it refuses with a reason where it is
|
||||
// absent. Two readers share this file:
|
||||
//
|
||||
// • the step (`rust.backpack.size`, in eventRewards.js), which leases one of
|
||||
// those sizes for a run (D337) and needs the list to check its param;
|
||||
// • the staff view and the player's own page, which read one backpack live.
|
||||
//
|
||||
// **Live, never stored.** Each view is one `backpacks.read`, and nothing about
|
||||
// a backpack is written to the database. The plugin only reads (R2).
|
||||
|
||||
const client = require('../../sidecarClient')
|
||||
const db = require('./backpacks.db')
|
||||
const servers = require('../servers/servers.model')
|
||||
const { pluginError, transportError } = require('../../eventLeases')
|
||||
|
||||
/** The sentence every surface says where a server has no Backpacks (D199). */
|
||||
const MISSING = 'Backpacks is not installed on this server'
|
||||
|
||||
/** JSON_EXTRACT comes back as text from the driver; an object passes through. */
|
||||
function parse(value) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's Backpacks as its last hello said: `{ loaded: false }`, or loaded
|
||||
* with its version and its sizes, ascending and whole.
|
||||
*/
|
||||
function shape(raw) {
|
||||
const b = parse(raw)
|
||||
if (!b || b.loaded !== true) return { loaded: false, sizes: [] }
|
||||
|
||||
const sizes = [...new Set((Array.isArray(b.sizes) ? b.sizes : []).map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
sizes.sort((x, y) => x - y)
|
||||
|
||||
return { loaded: true, version: typeof b.version === 'string' ? b.version : null, sizes }
|
||||
}
|
||||
|
||||
/** One server's report. `{ loaded: false }` for a server never heard from. */
|
||||
async function forServer(serverId) {
|
||||
const row = await db.reportFor(serverId)
|
||||
return shape(row && row.backpacks)
|
||||
}
|
||||
|
||||
/** Every server's report, keyed by id. */
|
||||
async function byServer() {
|
||||
const rows = await db.reports()
|
||||
return new Map(rows.map((r) => [r.serverId, shape(r.backpacks)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* The sizes a step may lease on a server: every size but the smallest, which
|
||||
* is the default everybody holds and so gives nobody anything.
|
||||
*/
|
||||
function leasable(report) {
|
||||
return report && report.loaded ? report.sizes.slice(1) : []
|
||||
}
|
||||
|
||||
/** One stack as the page draws it; nested `contents` keep the shape one level deep. */
|
||||
function stack(raw, nest) {
|
||||
const row = {
|
||||
slot: Number(raw.slot) || 0,
|
||||
shortname: typeof raw.shortname === 'string' ? raw.shortname : null,
|
||||
display: typeof raw.display === 'string' ? raw.display : null,
|
||||
amount: Number(raw.amount) || 0,
|
||||
}
|
||||
if (raw.itemId !== undefined) row.itemId = Number(raw.itemId)
|
||||
if (typeof raw.blueprint === 'string') row.blueprint = raw.blueprint
|
||||
// A skin id is past 2^53 more often than not; it is a label here, never a number to do sums with.
|
||||
if (raw.skin !== undefined && raw.skin !== null) row.skin = String(raw.skin)
|
||||
if (typeof raw.name === 'string' && raw.name) row.name = raw.name
|
||||
if (raw.overflow === true) row.overflow = true
|
||||
if (nest && Array.isArray(raw.contents) && raw.contents.length) row.contents = raw.contents.filter(Boolean).map((c) => stack(c, false))
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* One player's backpack on one server, read live.
|
||||
*
|
||||
* Resolves `{ ok: true, backpack }` — `{ serverId, server, steamId, exists,
|
||||
* capacity, pageSize, items }`, the last three only when it exists — or
|
||||
* `{ ok: false, status, message, reason? }`: 404 for no such server, 409 for a
|
||||
* server without Backpacks, 503 for one that could not be asked.
|
||||
*/
|
||||
async function read({ serverId, steamId }) {
|
||||
const server = await servers.getForCalling(serverId)
|
||||
if (!server) return { ok: false, status: 404, message: 'No such server' }
|
||||
|
||||
const name = server.name || server.id
|
||||
const result = await client.backpacksRead(server, String(steamId))
|
||||
if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'backpacks') }
|
||||
|
||||
const data = result.data || {}
|
||||
|
||||
if (data.kind === 'backpacks.contents') {
|
||||
const base = { serverId: server.id, server: name, steamId: String(steamId) }
|
||||
if (data.exists !== true) return { ok: true, backpack: { ...base, exists: false } }
|
||||
return {
|
||||
ok: true,
|
||||
backpack: {
|
||||
...base,
|
||||
exists: true,
|
||||
capacity: Number(data.capacity) || 0,
|
||||
pageSize: Number(data.pageSize) || 48,
|
||||
items: (Array.isArray(data.items) ? data.items : []).filter(Boolean).map((s) => stack(s, true)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reason === 'backpacks-missing') return { ok: false, status: 409, reason: data.reason, message: `${MISSING} (${name})` }
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: data.reason === 'bad-id' ? 400 : 502,
|
||||
reason: data.reason || null,
|
||||
message: pluginError(data, `${name} did not answer with a backpack`),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MISSING, shape, forServer, byServer, leasable, stack, read }
|
||||
298
server/model/clans/clans.db.js
Normal file
298
server/model/clans/clans.db.js
Normal file
@@ -0,0 +1,298 @@
|
||||
// ── SQL for first-party clans ─────────────────────────────────────────────
|
||||
//
|
||||
// Three tables (see `schema.sql`): the clans a board carried, their members, and
|
||||
// what this module knows about each server's board. Raw parameterised SQL, as
|
||||
// everywhere in this module; the model decides what any of it means.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const CLANS = 'rust_clans'
|
||||
const MEMBERS = 'rust_clan_members'
|
||||
const BOARDS = 'rust_clan_boards'
|
||||
const LINKS = 'rust_account_links'
|
||||
const PLAYERS = 'rust_players'
|
||||
const SERVERS = 'rust_servers'
|
||||
|
||||
// ── Boards ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One server's board record, or null when it has never sent one. */
|
||||
async function getBoard(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId, board_t AS boardT, seen_at AS seenAt, enabled, supported,
|
||||
truncated, backend, reason, umod_clans AS umodClans, clan_count AS clanCount
|
||||
FROM ${BOARDS} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every configured server beside its board record, which may be absent. */
|
||||
async function listBoards() {
|
||||
return core.query(
|
||||
`SELECT s.id AS serverId, s.name AS serverName, s.enabled AS serverEnabled,
|
||||
b.board_t AS boardT, b.seen_at AS seenAt, b.enabled, b.supported, b.truncated,
|
||||
b.backend, b.reason, b.umod_clans AS umodClans, b.clan_count AS clanCount
|
||||
FROM ${SERVERS} s
|
||||
LEFT JOIN ${BOARDS} b ON b.server_id = s.id
|
||||
ORDER BY s.sort_order ASC, s.id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records what a board said about itself.
|
||||
*
|
||||
* `seenAt` is passed only when the board's `t` ADVANCED, and is then the
|
||||
* website's own now; otherwise the stored one is kept. That is the whole of the
|
||||
* freshness rule (see `schema.sql`), so it is done in SQL rather than trusted to
|
||||
* every caller to read-then-write.
|
||||
*/
|
||||
async function putBoard({ serverId, boardT, advanced, enabled, supported, truncated, backend, reason, umodClans, clanCount }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${BOARDS}
|
||||
(server_id, board_t, seen_at, enabled, supported, truncated, backend, reason, umod_clans, clan_count, updated_at)
|
||||
VALUES (?, ?, ${advanced ? 'CURRENT_TIMESTAMP' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
board_t = VALUES(board_t),
|
||||
seen_at = ${advanced ? 'CURRENT_TIMESTAMP' : 'seen_at'},
|
||||
enabled = VALUES(enabled), supported = VALUES(supported), truncated = VALUES(truncated),
|
||||
backend = VALUES(backend), reason = VALUES(reason), umod_clans = VALUES(umod_clans),
|
||||
clan_count = VALUES(clan_count), updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
serverId,
|
||||
boardT,
|
||||
enabled ? 1 : 0,
|
||||
supported ? 1 : 0,
|
||||
truncated ? 1 : 0,
|
||||
backend || null,
|
||||
reason ? String(reason).slice(0, 255) : null,
|
||||
umodClans ? 1 : 0,
|
||||
clanCount || 0,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Clans ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every clan this module holds for one server, gone or not. */
|
||||
async function listClansForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT external_id AS externalId, clan_id AS clanId, created_ms AS createdMs, name,
|
||||
member_count AS memberCount, gone_at AS goneAt
|
||||
FROM ${CLANS} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every member of one server's current clans, as the board last stated them,
|
||||
* for diffing the next board against. The name is the BOARD's, not the player
|
||||
* table's, because it is compared with the board.
|
||||
*/
|
||||
async function listMembersForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT m.external_id AS externalId, m.steam_id AS steamId, m.role_rank AS rank,
|
||||
m.role_name AS role, m.name
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${CLANS} c ON c.external_id = m.external_id
|
||||
WHERE c.server_id = ? AND c.gone_at IS NULL`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertClan({ externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${CLANS}
|
||||
(external_id, server_id, clan_id, created_ms, name, color, score, member_count, max_members,
|
||||
first_seen, updated_at, gone_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name), color = VALUES(color), score = VALUES(score),
|
||||
member_count = VALUES(member_count), max_members = VALUES(max_members),
|
||||
updated_at = CURRENT_TIMESTAMP, gone_at = NULL`,
|
||||
[externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one clan's members.
|
||||
*
|
||||
* Delete then insert, not wrapped in a transaction — the same trade the presence
|
||||
* board makes (`events.db.replacePresence`): a fraction of a second in which a
|
||||
* roster read might come back short, against holding a lock on a table that core's
|
||||
* reconciler and two public routes read.
|
||||
*/
|
||||
async function replaceMembers(externalId, members) {
|
||||
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id = ?`, [externalId])
|
||||
|
||||
for (const m of members) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await core.query(
|
||||
`INSERT INTO ${MEMBERS} (external_id, steam_id, name, role_rank, role_name, joined_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), role_rank = VALUES(role_rank),
|
||||
role_name = VALUES(role_name), joined_ms = VALUES(joined_ms)`,
|
||||
[externalId, m.steamId, m.name, m.rank, m.role, m.joinedMs],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks clans gone. Their members are removed with them; a gone clan has no roster. */
|
||||
async function markGone(externalIds) {
|
||||
if (!externalIds.length) return
|
||||
const marks = externalIds.map(() => '?').join(', ')
|
||||
await core.query(
|
||||
`UPDATE ${CLANS} SET gone_at = CURRENT_TIMESTAMP WHERE external_id IN (${marks}) AND gone_at IS NULL`,
|
||||
externalIds,
|
||||
)
|
||||
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id IN (${marks})`, externalIds)
|
||||
}
|
||||
|
||||
/** One clan by its Team identity, with its server's name, or null. */
|
||||
async function findClan(externalId) {
|
||||
const rows = await core.query(
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
|
||||
c.clan_id AS clanId, c.created_ms AS createdMs, c.name, c.color, c.score,
|
||||
c.member_count AS memberCount, c.max_members AS maxMembers,
|
||||
c.first_seen AS firstSeen, c.updated_at AS updatedAt, c.gone_at AS goneAt
|
||||
FROM ${CLANS} c
|
||||
JOIN ${SERVERS} s ON s.id = c.server_id
|
||||
WHERE c.external_id = ?`,
|
||||
[externalId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest clan this module holds under a game id on one server, or null.
|
||||
*
|
||||
* The fallback for the one event that can arrive without a creation time
|
||||
* (`clan.member.added`, when the plugin could not read the clan back). Newest,
|
||||
* because an id that the game has re-used belongs to the clan that re-used it.
|
||||
*/
|
||||
async function findByGameId(serverId, clanId) {
|
||||
const rows = await core.query(
|
||||
`SELECT external_id AS externalId, name
|
||||
FROM ${CLANS} WHERE server_id = ? AND clan_id = ?
|
||||
ORDER BY created_ms DESC LIMIT 1`,
|
||||
[serverId, clanId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every clan still on a board, for core's `getTeams`. */
|
||||
async function listActiveClans() {
|
||||
return core.query(
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName, c.clan_id AS clanId,
|
||||
c.name, c.color, c.score, c.member_count AS memberCount
|
||||
FROM ${CLANS} c
|
||||
JOIN ${SERVERS} s ON s.id = c.server_id
|
||||
WHERE c.gone_at IS NULL
|
||||
ORDER BY c.server_id ASC, c.score DESC, c.name ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/** One server's clans still on its board, for the public Clans tab. Best first. */
|
||||
async function listPublicForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT external_id AS externalId, name, color, score, member_count AS memberCount,
|
||||
max_members AS maxMembers
|
||||
FROM ${CLANS}
|
||||
WHERE server_id = ? AND gone_at IS NULL
|
||||
ORDER BY score DESC, name ASC`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One clan's roster, with the website account behind each member when there is
|
||||
* one and whether they are on the clan's server right now.
|
||||
*
|
||||
* Three joins, all of this module's own tables: the link (a Steam id to a user),
|
||||
* the player table (the newest name the game has sent for them) and the presence
|
||||
* board. Presence is joined on the CLAN's server — a member on another server of
|
||||
* the fleet is not online here.
|
||||
*/
|
||||
async function listMembers(externalId) {
|
||||
return core.query(
|
||||
`SELECT m.steam_id AS steamId, COALESCE(p.name, m.name) AS name, m.role_rank AS rank,
|
||||
m.role_name AS role, m.joined_ms AS joinedMs, l.user_id AS userId,
|
||||
(pr.steam_id IS NOT NULL) AS online
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${CLANS} c ON c.external_id = m.external_id
|
||||
LEFT JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = m.steam_id
|
||||
LEFT JOIN rust_presence pr ON pr.server_id = c.server_id AND pr.steam_id = m.steam_id
|
||||
WHERE m.external_id = ?
|
||||
ORDER BY (m.role_rank IS NULL) ASC, m.role_rank ASC, name ASC`,
|
||||
[externalId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a website user holds a linked Steam account that is a member of this clan. */
|
||||
async function userIsMember(externalId, userId) {
|
||||
const rows = await core.query(
|
||||
`SELECT 1 AS yes
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
||||
WHERE m.external_id = ? AND l.user_id = ?
|
||||
LIMIT 1`,
|
||||
[externalId, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent clan events for one server, oldest first, for re-offering their feed
|
||||
* items to core until the Team they name exists (see `model/clans`).
|
||||
*/
|
||||
async function recentClanEvents(serverId, sinceMs) {
|
||||
return core.query(
|
||||
`SELECT id, kind, t, raw
|
||||
FROM rust_events
|
||||
WHERE server_id = ? AND kind LIKE 'clan.%' AND t >= ?
|
||||
ORDER BY t ASC, id ASC
|
||||
LIMIT 200`,
|
||||
[serverId, sinceMs],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes a player's name WITHOUT touching `last_seen`.
|
||||
*
|
||||
* `events.db.touchPlayer` also moves `last_seen`, which is right for a frame that
|
||||
* says a player was on and wrong for a clan frame: a kick is done TO somebody who
|
||||
* may be offline, and a leaderboard's "last seen" would then read as a presence
|
||||
* signal for a player who never connected (PLAN.md §23).
|
||||
*
|
||||
* A player this module has never heard of still gets a on the new row,
|
||||
* because the column is NOT NULL; what matters is that an existing row's is left
|
||||
* alone, and every surface that reads it is behind the presence gate anyway.
|
||||
*/
|
||||
async function rememberName(steamId, name) {
|
||||
if (!steamId) return
|
||||
await core.query(
|
||||
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE name = COALESCE(VALUES(name), name)`,
|
||||
[steamId, name || null],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBoard,
|
||||
listBoards,
|
||||
putBoard,
|
||||
listClansForServer,
|
||||
listMembersForServer,
|
||||
upsertClan,
|
||||
replaceMembers,
|
||||
markGone,
|
||||
findClan,
|
||||
findByGameId,
|
||||
listActiveClans,
|
||||
listPublicForServer,
|
||||
listMembers,
|
||||
userIsMember,
|
||||
recentClanEvents,
|
||||
rememberName,
|
||||
}
|
||||
574
server/model/clans/clans.model.js
Normal file
574
server/model/clans/clans.model.js
Normal file
@@ -0,0 +1,574 @@
|
||||
// ── First-party clans: the board, the events, and who may see a roster ────
|
||||
//
|
||||
// Rust's OWN clan system, which this module turns into core's Teams (R5,
|
||||
// PLAN.md §24). Three jobs, one file, because all three have to agree on what a
|
||||
// clan's identity is:
|
||||
//
|
||||
// applyBoard a `clans` snapshot → the store, plus what changed
|
||||
// applyEvent a `clan.*` event → core (publish) and the Team feed
|
||||
// canSeeRoster D48's audience, for core's `projectRoster` and our own page
|
||||
//
|
||||
// ── The identity (D52) ────────────────────────────────────────────────────
|
||||
//
|
||||
// `<serverId>:<clanId>:<createdMs>`. The game's clan id alone is not one: its
|
||||
// database file carries a hard-coded version, so a game update that bumps it
|
||||
// starts a fresh file and ids restart at 1. Keyed on the id, the new clan #1
|
||||
// would inherit the old clan #1's Team, forum and history.
|
||||
//
|
||||
// ── What a board may conclude, and what it may not ───────────────────────
|
||||
//
|
||||
// A board is authoritative for the clans it CARRIES. It is authoritative about
|
||||
// the clans it does NOT carry only when it is complete: a board truncated at the
|
||||
// game's 100-clan ceiling (D55), or one with a row this build could not read,
|
||||
// proves nothing about a clan it leaves out, and marking that clan gone would
|
||||
// hand core an archive on no evidence.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('./clans.db')
|
||||
const visibility = require('../visibility/visibility.model')
|
||||
|
||||
const log = core.logger('clans')
|
||||
|
||||
/**
|
||||
* How long a board may go without its `t` advancing and still count as current.
|
||||
*
|
||||
* The plugin re-sends it every 60 seconds and this module reads it every 30, so
|
||||
* three minutes tolerates two missed boards before a server stops vouching for
|
||||
* its clans.
|
||||
*/
|
||||
const FRESH_MS = 3 * 60 * 1000
|
||||
|
||||
/**
|
||||
* How far back a clan event's feed item is offered to core again.
|
||||
*
|
||||
* Core writes an item only for a Team it already holds, and a clan founded a
|
||||
* moment ago is not one yet: its Team appears on core's next reconcile, which is
|
||||
* debounced by up to 30 seconds. So the "founded" line — the first line of every
|
||||
* clan's feed — would always be dropped if it were offered once. It is offered
|
||||
* on every board refresh for this long instead, and core's dedupe key makes every
|
||||
* offer after the first that lands a no-op.
|
||||
*/
|
||||
const REOFFER_MS = 10 * 60 * 1000
|
||||
|
||||
/** Team kinds core's `publish` takes, by the clan event that produces them. */
|
||||
const PUBLISH = Object.freeze({
|
||||
'clan.created': 'team.created',
|
||||
'clan.disbanded': 'team.disbanded',
|
||||
'clan.member.added': 'team.member.added',
|
||||
'clan.member.left': 'team.member.removed',
|
||||
'clan.member.kicked': 'team.member.removed',
|
||||
})
|
||||
|
||||
/**
|
||||
* The feed items D49 allows: membership, and nothing else. Every one is
|
||||
* members-only. A disband is not here — it was not one of the four the org lead
|
||||
* chose, and the Team it would be written to is about to be archived anyway.
|
||||
*/
|
||||
const ACTIVITY = Object.freeze({
|
||||
'clan.created': 'rust.clan.founded',
|
||||
'clan.member.added': 'rust.clan.joined',
|
||||
'clan.member.left': 'rust.clan.left',
|
||||
'clan.member.kicked': 'rust.clan.removed',
|
||||
})
|
||||
|
||||
const CLAN_KINDS = Object.freeze(Object.keys(PUBLISH))
|
||||
|
||||
const STEAM_ID = /^\d{1,32}$/
|
||||
const COLOR = /^#[0-9a-f]{6}$/i
|
||||
|
||||
/** The Team identity (D52). */
|
||||
function externalIdOf(serverId, clanId, createdMs) {
|
||||
return `${serverId}:${clanId}:${createdMs}`
|
||||
}
|
||||
|
||||
const text = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null)
|
||||
const int = (value) => (Number.isInteger(Number(value)) && value !== null && value !== '' ? Number(value) : null)
|
||||
|
||||
/**
|
||||
* One board row as this module stores it, or null when it cannot be read.
|
||||
*
|
||||
* A member whose Steam id is not a Steam id is dropped rather than failing the
|
||||
* clan: the roster is still true about everybody else. A clan with no id, no
|
||||
* creation time or no name fails as a whole, because it has no identity to
|
||||
* store it under.
|
||||
*/
|
||||
function normaliseClan(serverId, raw) {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
|
||||
const clanId = int(raw.clanId)
|
||||
const createdMs = int(raw.createdMs)
|
||||
const name = text(raw.name, 191)
|
||||
if (clanId == null || createdMs == null || createdMs <= 0 || !name) return null
|
||||
|
||||
const members = []
|
||||
for (const m of Array.isArray(raw.members) ? raw.members : []) {
|
||||
const steamId = m && typeof m.steamId === 'string' && STEAM_ID.test(m.steamId) ? m.steamId : null
|
||||
if (!steamId) continue
|
||||
members.push({
|
||||
steamId,
|
||||
name: text(m.name, 191),
|
||||
rank: int(m.rank),
|
||||
role: text(m.role, 64),
|
||||
joinedMs: int(m.joinedMs),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
externalId: externalIdOf(serverId, clanId, createdMs),
|
||||
serverId,
|
||||
clanId,
|
||||
createdMs,
|
||||
name,
|
||||
color: typeof raw.color === 'string' && COLOR.test(raw.color) ? raw.color.toLowerCase() : null,
|
||||
score: int(raw.score) || 0,
|
||||
maxMembers: int(raw.maxMembers),
|
||||
memberCount: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/** A member signature, so an unchanged roster is not rewritten every minute. */
|
||||
const signature = (members) =>
|
||||
members
|
||||
.map((m) => `${m.steamId}|${m.rank == null ? '' : m.rank}|${m.role || ''}|${m.name || ''}`)
|
||||
.sort()
|
||||
.join('\n')
|
||||
|
||||
const leadersOf = (members) => new Set(members.filter((m) => Number(m.rank) === 1).map((m) => m.steamId))
|
||||
|
||||
/**
|
||||
* Tells core something, and never lets core's answer become this module's
|
||||
* problem. Both calls are fire-and-forget by contract; the catch is for a core
|
||||
* that throws synchronously all the same.
|
||||
*/
|
||||
function publish(event) {
|
||||
try {
|
||||
Promise.resolve(core.teams.publish(event)).catch((err) => {
|
||||
log.warn('teams publish failed', { kind: event.kind, externalId: event.externalId, error: err.message })
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('teams publish threw', { kind: event.kind, externalId: event.externalId, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function requestReconcile(reason) {
|
||||
try {
|
||||
core.teams.reconcile({ reason })
|
||||
} catch (err) {
|
||||
log.warn('teams reconcile request threw', { reason, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function pushActivity(items) {
|
||||
if (!items.length) return
|
||||
try {
|
||||
Promise.resolve(core.teams.pushActivity(items)).catch((err) => {
|
||||
log.warn('teams activity push failed', { items: items.length, error: err.message })
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('teams activity push threw', { items: items.length, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── The board ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Applies one server's `clans` board.
|
||||
*
|
||||
* `board` is undefined when the sidecar holds none — a plugin older than
|
||||
* protocol 6, or one that has not connected since it was upgraded. That is
|
||||
* recorded as unsupported, and the clans already stored are left exactly as they
|
||||
* are: a missing board is the absence of an answer, not an answer of absence.
|
||||
*
|
||||
* Returns what happened, for the log and the tests.
|
||||
*/
|
||||
async function applyBoard(serverId, board) {
|
||||
if (!board || typeof board !== 'object') {
|
||||
await db.putBoard({
|
||||
serverId,
|
||||
boardT: null,
|
||||
advanced: false,
|
||||
enabled: true,
|
||||
supported: false,
|
||||
truncated: false,
|
||||
backend: null,
|
||||
reason: "this server has not sent a clan board; its plugin may predate protocol 6",
|
||||
umodClans: false,
|
||||
clanCount: 0,
|
||||
})
|
||||
return { applied: false, reason: 'no board' }
|
||||
}
|
||||
|
||||
const previous = await db.getBoard(serverId)
|
||||
const boardT = Number(board.t)
|
||||
const known = previous && previous.boardT != null ? Number(previous.boardT) : null
|
||||
const advanced = Number.isFinite(boardT) && (known == null || boardT > known)
|
||||
|
||||
const supported = board.supported === true
|
||||
const raw = supported && Array.isArray(board.clans) ? board.clans : null
|
||||
|
||||
const clans = []
|
||||
let unreadable = 0
|
||||
for (const row of raw || []) {
|
||||
const clan = normaliseClan(serverId, row)
|
||||
if (clan) clans.push(clan)
|
||||
else unreadable += 1
|
||||
}
|
||||
|
||||
// A row this build could not read is treated like the ceiling: the board no
|
||||
// longer vouches for what it leaves out.
|
||||
const truncated = board.truncated === true || unreadable > 0
|
||||
|
||||
await db.putBoard({
|
||||
serverId,
|
||||
boardT: Number.isFinite(boardT) ? boardT : null,
|
||||
advanced,
|
||||
enabled: board.enabled !== false,
|
||||
supported,
|
||||
truncated,
|
||||
backend: text(board.backend, 64),
|
||||
reason: supported ? null : text(board.reason, 255) || 'the plugin could not read this server\'s clans',
|
||||
umodClans: board.umodClans === true,
|
||||
clanCount: clans.length,
|
||||
})
|
||||
|
||||
if (unreadable) log.warn('clan board carried rows this build could not read', { server: serverId, unreadable })
|
||||
|
||||
// A board whose `t` has not moved is the one already applied. Re-applying it
|
||||
// would rewrite every roster every 30 seconds to say what it already says.
|
||||
if (!advanced || !raw) return { applied: false, reason: advanced ? 'unsupported' : 'unchanged' }
|
||||
|
||||
const [before, beforeMembers] = await Promise.all([
|
||||
db.listClansForServer(serverId),
|
||||
db.listMembersForServer(serverId),
|
||||
])
|
||||
|
||||
const wasActive = new Map(before.filter((c) => !c.goneAt).map((c) => [c.externalId, c]))
|
||||
const rosterBefore = new Map()
|
||||
for (const m of beforeMembers) {
|
||||
if (!rosterBefore.has(m.externalId)) rosterBefore.set(m.externalId, [])
|
||||
rosterBefore.get(m.externalId).push(m)
|
||||
}
|
||||
|
||||
let created = 0
|
||||
let rosterChanged = 0
|
||||
const leaderEvents = []
|
||||
|
||||
for (const clan of clans) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.upsertClan(clan)
|
||||
|
||||
const old = rosterBefore.get(clan.externalId) || []
|
||||
if (!wasActive.has(clan.externalId)) created += 1
|
||||
|
||||
if (signature(old) !== signature(clan.members)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.replaceMembers(clan.externalId, clan.members)
|
||||
rosterChanged += 1
|
||||
}
|
||||
|
||||
// Leadership is only ever learned here (D54): the game raises no hook when
|
||||
// somebody is promoted. Published only for a clan that was already on the
|
||||
// previous board — a brand-new clan's leaders reach core with the Team.
|
||||
if (wasActive.has(clan.externalId)) {
|
||||
const was = leadersOf(old)
|
||||
const now = leadersOf(clan.members)
|
||||
for (const key of now) if (!was.has(key)) leaderEvents.push({ kind: 'team.leader.added', externalId: clan.externalId, memberKey: key })
|
||||
for (const key of was) if (!now.has(key)) leaderEvents.push({ kind: 'team.leader.removed', externalId: clan.externalId, memberKey: key })
|
||||
}
|
||||
}
|
||||
|
||||
// Only a complete board may say a clan is gone.
|
||||
const onBoard = new Set(clans.map((c) => c.externalId))
|
||||
const gone = truncated ? [] : [...wasActive.keys()].filter((id) => !onBoard.has(id))
|
||||
await db.markGone(gone)
|
||||
|
||||
for (const event of leaderEvents) publish(event)
|
||||
|
||||
if (created || gone.length || rosterChanged) {
|
||||
requestReconcile('rust clans board changed')
|
||||
}
|
||||
|
||||
if (created || gone.length || rosterChanged || leaderEvents.length) {
|
||||
log.info('clan board applied', {
|
||||
server: serverId, clans: clans.length, created, gone: gone.length, rosterChanged,
|
||||
leaderChanges: leaderEvents.length, truncated,
|
||||
})
|
||||
}
|
||||
|
||||
return { applied: true, clans: clans.length, created, gone: gone.length, rosterChanged, leaderChanges: leaderEvents.length }
|
||||
}
|
||||
|
||||
// ── The events ─────────────────────────────────────────────────────────────
|
||||
|
||||
const nameOr = (name) => name || 'A player'
|
||||
|
||||
/** The feed line for one clan event, as core stores it verbatim. */
|
||||
function summaryOf(kind, frame) {
|
||||
switch (kind) {
|
||||
case 'clan.created':
|
||||
return `${nameOr(frame.name)} founded the clan.`
|
||||
case 'clan.member.added':
|
||||
return `${nameOr(frame.name)} joined the clan.`
|
||||
case 'clan.member.left':
|
||||
return `${nameOr(frame.name)} left the clan.`
|
||||
case 'clan.member.kicked':
|
||||
return frame.byName
|
||||
? `${nameOr(frame.name)} was removed from the clan by ${frame.byName}.`
|
||||
: `${nameOr(frame.name)} was removed from the clan.`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key core can dedupe on, from the frame's own content.
|
||||
*
|
||||
* Content rather than this module's event row id, so that the same frame read
|
||||
* twice — a cursor replayed after a crash, or the re-offer below — is the same
|
||||
* item. **Hashed, because core clamps a dedupe key to 40 characters**, and a
|
||||
* readable key long enough to be unique (server, clan, creation time, kind,
|
||||
* player, instant) would be cut short into collisions without a word.
|
||||
*/
|
||||
function dedupeKeyOf(serverId, kind, frame) {
|
||||
const parts = [serverId, frame.clanId, frame.createdMs, kind, frame.steamId || '', frame.t]
|
||||
return crypto.createHash('sha1').update(parts.join('|')).digest('hex')
|
||||
}
|
||||
|
||||
/** One clan event as a Team feed item, or null when D49 does not allow it. */
|
||||
function activityItem(serverId, externalId, kind, frame) {
|
||||
const itemKind = ACTIVITY[kind]
|
||||
const summary = itemKind && summaryOf(kind, frame)
|
||||
if (!summary) return null
|
||||
|
||||
const t = Number(frame.t)
|
||||
return {
|
||||
externalId,
|
||||
kind: itemKind,
|
||||
summary,
|
||||
occurredAt: Number.isFinite(t) ? t : Date.now(),
|
||||
visibility: 'members',
|
||||
actorMemberKey: kind === 'clan.member.kicked' ? frame.bySteamId || null : frame.steamId || null,
|
||||
payload: { serverId, steamId: frame.steamId || null },
|
||||
dedupeKey: dedupeKeyOf(serverId, kind, frame),
|
||||
}
|
||||
}
|
||||
|
||||
/** The Team identity a clan event names, or null when it cannot be worked out. */
|
||||
async function resolveExternalId(serverId, frame) {
|
||||
const clanId = int(frame.clanId)
|
||||
const createdMs = int(frame.createdMs)
|
||||
if (clanId == null) return null
|
||||
if (createdMs != null && createdMs > 0) return externalIdOf(serverId, clanId, createdMs)
|
||||
|
||||
// `clan.member.added` can arrive without a creation time when the plugin could
|
||||
// not read the clan back. Matched on the game id, newest first.
|
||||
const known = await db.findByGameId(serverId, clanId)
|
||||
return known ? known.externalId : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one `clan.*` event: tells core, and writes the Team feed.
|
||||
*
|
||||
* Called from ingest, after the raw frame is stored. The board that follows
|
||||
* every one of these (the plugin re-sends it a few seconds later) is what the
|
||||
* store is rebuilt from; this only makes the change visible sooner and records
|
||||
* the line for the feed.
|
||||
*/
|
||||
async function applyEvent(serverId, frame) {
|
||||
const kind = frame && frame.kind
|
||||
if (!PUBLISH[kind]) return { applied: false }
|
||||
|
||||
if (frame.steamId) await db.rememberName(frame.steamId, text(frame.name, 191))
|
||||
if (frame.bySteamId) await db.rememberName(frame.bySteamId, text(frame.byName, 191))
|
||||
|
||||
const externalId = await resolveExternalId(serverId, frame)
|
||||
if (!externalId) {
|
||||
log.info('clan event names a clan this module has never seen', { server: serverId, kind, clanId: frame.clanId })
|
||||
return { applied: false }
|
||||
}
|
||||
|
||||
// The game said it: this clan is gone. Recorded here as well as by the next
|
||||
// board, because a board truncated at the ceiling would never say so.
|
||||
if (kind === 'clan.disbanded') await db.markGone([externalId])
|
||||
|
||||
const event = { kind: PUBLISH[kind], externalId }
|
||||
if (event.kind.startsWith('team.member.')) {
|
||||
if (!frame.steamId) return { applied: false }
|
||||
event.memberKey = String(frame.steamId)
|
||||
}
|
||||
publish(event)
|
||||
|
||||
const item = activityItem(serverId, externalId, kind, frame)
|
||||
if (item) pushActivity([item])
|
||||
|
||||
return { applied: true, externalId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers the last few minutes of one server's clan feed items to core again.
|
||||
*
|
||||
* See `REOFFER_MS`. Called after each board refresh; idempotent by construction.
|
||||
*/
|
||||
async function reofferActivity(serverId, now = Date.now()) {
|
||||
const rows = await db.recentClanEvents(serverId, now - REOFFER_MS)
|
||||
const items = []
|
||||
|
||||
for (const row of rows) {
|
||||
let frame
|
||||
try {
|
||||
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw
|
||||
} catch (err) {
|
||||
continue
|
||||
}
|
||||
if (!frame || !ACTIVITY[frame.kind]) continue
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const externalId = await resolveExternalId(serverId, frame)
|
||||
const item = externalId && activityItem(serverId, externalId, frame.kind, frame)
|
||||
if (item) items.push(item)
|
||||
}
|
||||
|
||||
pushActivity(items)
|
||||
return items.length
|
||||
}
|
||||
|
||||
// ── Who may see a roster (D48) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* May this viewer see this clan's roster?
|
||||
*
|
||||
* `viewer` is `{ userId, role }` or null — the shape core hands `projectRoster`,
|
||||
* so core's roster and this module's page decide it with one function.
|
||||
*
|
||||
* The viewer's standing is re-read from the `users` row, never taken from what
|
||||
* the caller says, for the same reason the presence gate does it: a moderator
|
||||
* demoted this morning, or an account banned, must lose the roster on the next
|
||||
* request. Everything that cannot be answered answers no.
|
||||
*/
|
||||
async function canSeeRoster(viewer, externalId) {
|
||||
const audience = await visibility.clanRosterAudience()
|
||||
if (audience === 'public') return true
|
||||
if (!viewer || viewer.userId == null) return false
|
||||
|
||||
const user = await core.users.getById(viewer.userId)
|
||||
if (!user || (user.status && user.status !== 'active')) return false
|
||||
|
||||
if (audience === 'signed_in') return true
|
||||
if (user.role === 'admin' || user.role === 'moderator') return true
|
||||
return db.userIsMember(externalId, user.id)
|
||||
}
|
||||
|
||||
// ── The public reads ───────────────────────────────────────────────────────
|
||||
|
||||
const shapeBoard = (board, now = Date.now()) => {
|
||||
if (!board || board.supported == null) {
|
||||
return { supported: false, fresh: false, truncated: false, enabled: true, reason: 'this server has not sent a clan board yet' }
|
||||
}
|
||||
const seenAt = board.seenAt ? new Date(board.seenAt).getTime() : null
|
||||
return {
|
||||
supported: Boolean(board.supported),
|
||||
enabled: Boolean(board.enabled),
|
||||
truncated: Boolean(board.truncated),
|
||||
fresh: Boolean(board.supported) && seenAt != null && now - seenAt < FRESH_MS,
|
||||
reason: board.reason || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The Clans tab (D58): every clan on one server's board, best first. Public. */
|
||||
async function listForServer(serverId, now = Date.now()) {
|
||||
const [clans, board] = await Promise.all([db.listPublicForServer(serverId), db.getBoard(serverId)])
|
||||
return {
|
||||
clans: clans.map((c) => ({
|
||||
externalId: c.externalId,
|
||||
name: c.name,
|
||||
color: c.color || null,
|
||||
score: Number(c.score) || 0,
|
||||
memberCount: Number(c.memberCount) || 0,
|
||||
maxMembers: c.maxMembers == null ? null : Number(c.maxMembers),
|
||||
})),
|
||||
board: shapeBoard(board, now),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One clan, and its roster if the viewer may see it.
|
||||
*
|
||||
* The roster carries no Steam id and no website account id — the same two fields
|
||||
* core withholds from every public roster. `online` is inside the audience by
|
||||
* construction (D48): a viewer who may not see the roster sees no names at all.
|
||||
*/
|
||||
async function getForViewer(externalId, viewer) {
|
||||
const clan = await db.findClan(externalId)
|
||||
if (!clan) return null
|
||||
|
||||
const allowed = await canSeeRoster(viewer, externalId)
|
||||
const audience = await visibility.clanRosterAudience()
|
||||
|
||||
const members = allowed && !clan.goneAt ? await db.listMembers(externalId) : []
|
||||
|
||||
return {
|
||||
clan: {
|
||||
externalId: clan.externalId,
|
||||
name: clan.name,
|
||||
color: clan.color || null,
|
||||
score: Number(clan.score) || 0,
|
||||
memberCount: Number(clan.memberCount) || 0,
|
||||
maxMembers: clan.maxMembers == null ? null : Number(clan.maxMembers),
|
||||
serverId: clan.serverId,
|
||||
serverName: clan.serverName,
|
||||
founded: Number(clan.createdMs) || null,
|
||||
gone: Boolean(clan.goneAt),
|
||||
},
|
||||
roster: {
|
||||
visible: allowed,
|
||||
audience,
|
||||
members: members.map((m) => ({
|
||||
name: m.name || null,
|
||||
role: m.role || null,
|
||||
leader: Number(m.rank) === 1,
|
||||
online: Boolean(Number(m.online)),
|
||||
joined: m.joinedMs == null ? null : Number(m.joinedMs),
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every configured server's clan board as the admin page shows it: whether it is
|
||||
* current, whether it is at the ceiling (D55), why it cannot be read, and
|
||||
* whether the uMod Clans plugin is loaded there (D47) — whose clans are a
|
||||
* separate system and never Teams.
|
||||
*/
|
||||
async function boardsForAdmin(now = Date.now()) {
|
||||
const rows = await db.listBoards()
|
||||
return rows.map((row) => ({
|
||||
id: row.serverId,
|
||||
name: row.serverName,
|
||||
...shapeBoard(row.supported == null ? null : row, now),
|
||||
clans: Number(row.clanCount) || 0,
|
||||
umodClans: Boolean(row.umodClans),
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FRESH_MS,
|
||||
boardsForAdmin,
|
||||
REOFFER_MS,
|
||||
CLAN_KINDS,
|
||||
externalIdOf,
|
||||
normaliseClan,
|
||||
applyBoard,
|
||||
applyEvent,
|
||||
resolveExternalId,
|
||||
reofferActivity,
|
||||
activityItem,
|
||||
dedupeKeyOf,
|
||||
canSeeRoster,
|
||||
shapeBoard,
|
||||
listForServer,
|
||||
getForViewer,
|
||||
}
|
||||
202
server/model/clans/teamProvider.js
Normal file
202
server/model/clans/teamProvider.js
Normal file
@@ -0,0 +1,202 @@
|
||||
// ── module-rust's Team provider ────────────────────────────────────────────
|
||||
//
|
||||
// The questions core asks this module about Teams (MODULE_API.md
|
||||
// `api.registerTeamProvider`, TEAMS.md §2.3). A first-party Rust clan is a Team
|
||||
// (R5); this file is the whole of the translation, and `model/clans` is where
|
||||
// the clans themselves are kept.
|
||||
//
|
||||
// ── The envelope is the contract ──────────────────────────────────────────
|
||||
//
|
||||
// Every method answers `{ ok, ... }` and `{ ok: false, reason }` is an ordinary
|
||||
// answer. Core reads it as "keep what you have" — staleness, never emptiness —
|
||||
// and there is no shape a failure can take that core reads as "zero Teams". An
|
||||
// empty array is the one thing this file must never say while it does not know.
|
||||
//
|
||||
// ── Many servers, one answer (D53) ─────────────────────────────────────────
|
||||
//
|
||||
// `module-uo` has one shard and one socket, so "is the board current" has one
|
||||
// answer. This module has a fleet, and the answer is per server. `getTeams` is
|
||||
// therefore:
|
||||
//
|
||||
// • `complete: true` only when EVERY configured server's board is fresh,
|
||||
// supported and untruncated — then core may archive a
|
||||
// Team that is missing;
|
||||
// • `complete: false` when at least one is current and some are not — core
|
||||
// adds and updates, and removes nothing. One server being
|
||||
// off for a patch must never archive its clans;
|
||||
// • a refusal when none is current.
|
||||
//
|
||||
// A clan is only ever as current as its own server's board, so the roster
|
||||
// methods ask about that server alone.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('./clans.db')
|
||||
const clans = require('./clans.model')
|
||||
const servers = require('../servers/servers.model')
|
||||
|
||||
const log = core.logger('teams')
|
||||
|
||||
const refuse = (reason) => ({ ok: false, reason })
|
||||
|
||||
/** Is this board record current? The rule `model/clans` states, applied to one row. */
|
||||
function isFresh(board, now = Date.now()) {
|
||||
return clans.shapeBoard(board, now).fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeams()` — every clan on every server's board.
|
||||
*
|
||||
* `meta` carries the server and the clan's colour and score, opaquely: core
|
||||
* stores and shows it and never branches on it.
|
||||
*/
|
||||
async function getTeams(now = Date.now()) {
|
||||
try {
|
||||
const configured = await servers.listForPolling()
|
||||
if (!configured.length) return refuse('no Rust servers are configured')
|
||||
|
||||
const boards = await db.listBoards()
|
||||
const byServer = new Map(boards.map((b) => [b.serverId, b]))
|
||||
|
||||
const fresh = []
|
||||
const behind = []
|
||||
for (const server of configured) {
|
||||
const board = byServer.get(server.id)
|
||||
if (isFresh(board, now)) fresh.push(server.id)
|
||||
else behind.push(server.id)
|
||||
}
|
||||
|
||||
if (!fresh.length) {
|
||||
return refuse(`no server has sent a current clan board (${behind.join(', ')})`)
|
||||
}
|
||||
|
||||
// Complete only when nothing is behind, and nothing is at the ceiling. A
|
||||
// server that is configured but switched off in this module is "behind" by
|
||||
// construction — its board is never read — which is the conservative answer:
|
||||
// switching a server off is not a statement that its clans are gone.
|
||||
const truncated = fresh.filter((id) => byServer.get(id).truncated)
|
||||
const complete = behind.length === 0 && truncated.length === 0
|
||||
|
||||
const rows = await db.listActiveClans()
|
||||
const known = new Set(configured.map((s) => s.id))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
complete,
|
||||
teams: rows
|
||||
.filter((row) => known.has(row.serverId))
|
||||
.map((row) => ({
|
||||
externalId: row.externalId,
|
||||
name: row.name,
|
||||
abbr: null,
|
||||
meta: {
|
||||
server: row.serverName || row.serverId,
|
||||
serverId: row.serverId,
|
||||
color: row.color || null,
|
||||
score: Number(row.score) || 0,
|
||||
},
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('getTeams failed', { error: err.message })
|
||||
return refuse(`clans unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A clan and whether its server's board vouches for it right now, or a refusal. */
|
||||
async function currentClan(externalId, now) {
|
||||
const clan = await db.findClan(externalId)
|
||||
if (!clan) return { refusal: refuse(`clan ${externalId} is not on any board`) }
|
||||
if (clan.goneAt) return { refusal: refuse(`clan ${externalId} has left its server's board`) }
|
||||
|
||||
const board = await db.getBoard(clan.serverId)
|
||||
if (!isFresh(board, now)) {
|
||||
return { refusal: refuse(`server ${clan.serverId} has not sent a current clan board`) }
|
||||
}
|
||||
return { clan }
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeamMembers(externalId)` — one clan's roster.
|
||||
*
|
||||
* **A clan with no roster rows is refused, not reported empty**, unless the board
|
||||
* said it has none. A clan always has at least its leader, so an empty roster
|
||||
* beside a non-zero count is a read that happened between two writes, and
|
||||
* reporting it would tell core every member left.
|
||||
*/
|
||||
async function getTeamMembers(externalId, now = Date.now()) {
|
||||
try {
|
||||
const { clan, refusal } = await currentClan(externalId, now)
|
||||
if (refusal) return refusal
|
||||
|
||||
const rows = await db.listMembers(externalId)
|
||||
if (!rows.length && Number(clan.memberCount) > 0) {
|
||||
return refuse(`roster for clan ${externalId} is not stored yet (board says ${clan.memberCount} members)`)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
complete: true,
|
||||
members: rows.map((row) => ({
|
||||
memberKey: row.steamId,
|
||||
displayName: row.name || null,
|
||||
rankLabel: row.role || null,
|
||||
// Rank 1 is leader and several may hold it. A NULL rank — a role id the
|
||||
// board could not match — is not a leader: "not known" must never read
|
||||
// as "leads this clan".
|
||||
leader: Number(row.rank) === 1,
|
||||
online: Boolean(Number(row.online)),
|
||||
userId: Number.isInteger(Number(row.userId)) && Number(row.userId) > 0 ? Number(row.userId) : null,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('getTeamMembers failed', { externalId, error: err.message })
|
||||
return refuse(`roster unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `getTeamLeaders(externalId)` — everyone at rank 1, which may be several. */
|
||||
async function getTeamLeaders(externalId, now = Date.now()) {
|
||||
try {
|
||||
const { refusal } = await currentClan(externalId, now)
|
||||
if (refusal) return refusal
|
||||
|
||||
const rows = await db.listMembers(externalId)
|
||||
return { ok: true, leaders: rows.filter((row) => Number(row.rank) === 1).map((row) => row.steamId) }
|
||||
} catch (err) {
|
||||
log.warn('getTeamLeaders failed', { externalId, error: err.message })
|
||||
return refuse(`leadership unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which roster rows a viewer may see (D48, MODULE_API 1.6.0).
|
||||
*
|
||||
* The one provider method core calls on a REQUEST path, and the one that fails
|
||||
* CLOSED: core serves an empty roster when this refuses, because for a
|
||||
* visibility question "keep what you have" would mean publishing the roster to
|
||||
* whoever asked. So every path that cannot reach a confident answer refuses.
|
||||
*
|
||||
* All or nothing, and that is the model rather than a shortcut: the audience is
|
||||
* a property of the ROSTER, not of a member. There is no setting in which some
|
||||
* of a clan's members are visible and others are not.
|
||||
*/
|
||||
async function projectRoster(externalId, members, viewer) {
|
||||
try {
|
||||
const allowed = await clans.canSeeRoster(viewer, externalId)
|
||||
if (!allowed) return { ok: true, members: [] }
|
||||
return { ok: true, members: (members || []).map((m) => m.member_key).filter(Boolean) }
|
||||
} catch (err) {
|
||||
log.warn('projectRoster could not resolve the audience; withholding the roster', {
|
||||
externalId, error: err.message,
|
||||
})
|
||||
return refuse(`the roster audience could not be resolved: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Where core should point a link at a clan (MODULE_API 1.6.0, TEAMS.md §6.4).
|
||||
// Core substitutes `{externalId}` and nothing else, which is why the page is not
|
||||
// nested under its server (D56): the server is inside the id already.
|
||||
const pageUrlTemplate = '/rust/clans/{externalId}'
|
||||
|
||||
module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, pageUrlTemplate, isFresh }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user