diff --git a/PLAN.md b/PLAN.md index 44f078f..cd3d769 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2365,6 +2365,86 @@ pass found 49 words on seven marketing pages; all are gone. one-time link code, which the existing "everything you read and post" row already covers. Its wording lost "shard", so `PLAY_DATA_SAFETY.md` was regenerated. +### How phase 18 built it + +Two decisions, taken by the org lead on 2026-10-22 while the phase was under way. The count of +record is now **eighty-one**. + +**D80 — a name in a screenshot is a role.** Every username a capture shows — a site account, a +player in a feed, a member of a group — is `admin`, `moderator` or `player`, numbered +(`player2` …) when one screen needs several. A name that says what the account is reads as a +demonstration; an invented person's name reads as somebody's real community. It holds from +this phase on: the phase 9 captures keep their cast, by the org lead's ruling ("no going back +to change the old ones"). + +**D81 — the Rust captures' players are mock data.** The rig has no players. The phase first +staged stand-in players on it with a rig-only plugin, and the org lead stopped that in favour +of writing the game data straight into the demo's database. This amends D4 for the Rust +captures only. What is real in them is still most of the screen: a real Oxide test server +behind a real rust-link sidecar, its status, its world map and markers, the permission groups +its plugins hold, and RunicNPC's sync state. What is mock is the players: the feed, the +leaderboard, the weapon kills, the server list's player count and the linked Steam account. +`scripts/mockRustDemo.mjs` writes them as SQL in the module's own row shapes, and every name +in them is a role (D80). The captions describe what a screen does and never claim a player did +something; `screens.mjs`, `/modules/rust/` and `/app/` say plainly which part is a demo cast. +The homepage's Rust capture (D76) is the live map, because nothing in that frame is mocked. +The alternative, staging real kills with stand-ins, was abandoned after it cost a morning: +the Rust update had left the rig rebuilding its navmesh for ten minutes, and it stopped +answering the console for most of that time. + +**The demo is a second deployment.** One deployment runs one game module (the core refuses a +second at boot), so the Rust demo has its own database, `rgdemorust`, its own core on the +review machine, and Module-Rust v0.5.0. The recipe, in order: + +1. Bring the rig up to date (Rust, Oxide and Carbon, the rig rule). The org lead ruled one rig + enough: the Oxide rig, now Rust 26139947 and Oxide 2.0.7839. A restart did not update Rust: + steamcmd cancelled the delta on a DLL Oxide patches ("Corrupt game files"), and a panel + reinstall did not help either. Removing `steamapps/appmanifest_258550.acf` and starting + made steamcmd verify everything and finish. The Carbon rig was left on 25902425. +2. Create the database (without a `COLLATE` clause) and start the core against it with + `MODULES_DIR` holding the module and `BRAND_NAME="Runic Gateway Demo"`. Create `admin`, + `moderator` and `player` with generated passwords. +3. `node scripts/seedDemo.mjs --game rust`, with `RG_RUST_SERVERS` naming a JSON file outside + the repository that holds the server's id, name, sidecar address and token. It writes the + neutral identity, the two accounts, the server row with a weekly wipe, public visibility + for who is online and the map's players (the demo's choice, said in the seed), two RunicNPC + profiles on a kit the rig's Kits holds, and a VIP group with `player` in it. +4. `node scripts/mockRustDemo.mjs`, piped into the database, after the site has seen the + server once so its current wipe exists. +5. `npm run screens:capture` with the Rust ids. Set the server's player count just before + `rust-servers`, since the live server writes its own every thirty seconds. + +**What was built.** + +- **Nine captures**, all tagged `rust`: six web (the server list, the feed, the leaderboard, + the live map, the permissions screen with a group open, and the NPC profile editor) and three + phone (a server's feed, its map, and the player's Rust account). The phone ones are from an + `Android-app` `edge` debug build on the `s22_ultra` AVD, signed in as `player`; the Rust + screens were not in a release yet. The app's server list was left out: one server card on a + phone-height screen is mostly empty. +- **`captureScreens.mjs` can click.** An entry may carry `click`, the visible text of what to + click before the shot, for a subject behind a tab or a button the route cannot reach — the + permissions group and the profile editor. A step that matches nothing fails the shot. +- **Where they show:** `/modules/rust/` (the public four under what players see, the two staff + screens under theirs, with a sentence saying which part is a demo cast); the homepage's + *What it looks like*, the live map beside the Ultima Online capture (D76); `/app/`'s grid, + whose lede no longer says "not mocked up"; and three docs pages — the live map, permissions + and groups, and RunicNPC's NPC profiles. + +**What the phase found**, reported, not fixed here: + +- **The feed prints prefab names as weapons**: "with ak47u entity", "with bolt rifle entity". + The bridge sends `weapon` as the held entity's short prefab name, and Module-Rust's feed + prints it through `format.js`'s `prefab()`, which only turns dots and underscores into + spaces; the app does the same. It is in the captures as + the product shows it. +- **The app's first-run and sign-in screens say "shard"** ("Connect to your shard", "Sign in + with your shard account"), on every deployment, a Rust one included. +- **The permissions screen's Players tab lists Steam ids the rig already held**, among them + the org lead's own account from the player walk. That is the screen working (D31's reversal + adopts every grant), and why the capture opens the Groups tab; it is noted because any + demo pointed at a rig shows them. + --- [api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md diff --git a/public/screens/admin-rust-npcs.webp b/public/screens/admin-rust-npcs.webp new file mode 100644 index 0000000..cb7bf5f Binary files /dev/null and b/public/screens/admin-rust-npcs.webp differ diff --git a/public/screens/admin-rust-permissions.webp b/public/screens/admin-rust-permissions.webp new file mode 100644 index 0000000..398e949 Binary files /dev/null and b/public/screens/admin-rust-permissions.webp differ diff --git a/public/screens/app-rust-account.webp b/public/screens/app-rust-account.webp new file mode 100644 index 0000000..191ddfc Binary files /dev/null and b/public/screens/app-rust-account.webp differ diff --git a/public/screens/app-rust-map.webp b/public/screens/app-rust-map.webp new file mode 100644 index 0000000..a48010a Binary files /dev/null and b/public/screens/app-rust-map.webp differ diff --git a/public/screens/app-rust-server.webp b/public/screens/app-rust-server.webp new file mode 100644 index 0000000..c2ca3fc Binary files /dev/null and b/public/screens/app-rust-server.webp differ diff --git a/public/screens/rust-feed.webp b/public/screens/rust-feed.webp new file mode 100644 index 0000000..2ed7653 Binary files /dev/null and b/public/screens/rust-feed.webp differ diff --git a/public/screens/rust-leaderboard.webp b/public/screens/rust-leaderboard.webp new file mode 100644 index 0000000..2677a1c Binary files /dev/null and b/public/screens/rust-leaderboard.webp differ diff --git a/public/screens/rust-map.webp b/public/screens/rust-map.webp new file mode 100644 index 0000000..9243c5f Binary files /dev/null and b/public/screens/rust-map.webp differ diff --git a/public/screens/rust-servers.webp b/public/screens/rust-servers.webp new file mode 100644 index 0000000..e6f5483 Binary files /dev/null and b/public/screens/rust-servers.webp differ diff --git a/scripts/captureScreens.mjs b/scripts/captureScreens.mjs index 63ababc..3a8970b 100644 --- a/scripts/captureScreens.mjs +++ b/scripts/captureScreens.mjs @@ -138,6 +138,23 @@ for (const shot of todo) { try { await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 }); + // A screen whose subject is behind a tab or a button the route cannot reach. Each step is + // the visible text the element starts with; the first visible button, link or tab that + // matches is clicked. A step that matches nothing fails the shot rather than capturing + // the wrong screen. + for (const label of shot.click ?? []) { + const clicked = await page.evaluate((text) => { + const candidates = [...document.querySelectorAll('button, a, [role="tab"], [role="button"], li')]; + const hit = candidates.find( + (el) => el.offsetParent !== null && el.textContent.trim().startsWith(text), + ); + if (hit) hit.click(); + return Boolean(hit); + }, label); + if (!clicked) throw new Error(`nothing to click starting "${label}"`); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + if (shot.scrollY) { await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY); } diff --git a/scripts/mockRustDemo.mjs b/scripts/mockRustDemo.mjs new file mode 100644 index 0000000..3474a46 --- /dev/null +++ b/scripts/mockRustDemo.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +/** + * mockRustDemo.mjs — the game data on the Rust demo's screenshots. PLAN.md §16 phase 18, D81. + * + * node scripts/mockRustDemo.mjs > rust-demo.sql + * docker exec -i mariadb -u -p < rust-demo.sql + * + * Prints SQL; it connects to nothing. Run it after `seedDemo.mjs --game rust`, with the demo + * site connected to its server at least once, so the server row and its current wipe exist. + * + * --------------------------------------------------------------------------------------- + * WHY THIS IS MOCK DATA, WHEN THE UO CAPTURES ARE NOT + * --------------------------------------------------------------------------------------- + * D4 asks for real captures, and phase 9 got them from a real shard with a person signed in. + * The Rust rig has no players. Phase 18 first staged stand-in players on it with a rig-only + * plugin; that cost a morning of a server the update had left rebuilding its navmesh, and the + * org lead chose this instead (D81): the feed, the leaderboard and the linked Steam account + * are written into the demo's database here, in the module's own row shapes, and everything + * else on the screens is the real server's (its status, its world map, its sync state). + * + * Every name is a role (D80): player, player2 … player7, and moderator. The Steam ids sit in a + * range no real account uses. + * + * Re-running replaces what it wrote: it deletes the server's events, stats, weapon kills and + * these players first. Real events that arrived from the server are deleted with them. + */ + +const SERVER = 'rust-oxide'; +const ID_BASE = 76561199920000000n; + +/** The cast. `n` makes the Steam id; `user` links one to a site account by username. */ +const CAST = [ + { n: 1, name: 'player', user: 'player' }, + { n: 2, name: 'player2' }, + { n: 3, name: 'player3' }, + { n: 4, name: 'player4' }, + { n: 5, name: 'player5' }, + { n: 6, name: 'player6' }, + { n: 7, name: 'player7' }, + { n: 9, name: 'moderator' }, +]; + +const steam = (n) => String(ID_BASE + BigInt(n)); +const who = (n) => CAST.find((c) => c.n === n).name; +const now = Date.now(); +const ago = (minutes) => now - Math.round(minutes * 60_000); + +// ── The feed, oldest first ───────────────────────────────────────────────────────────── +// +// Each frame is the shape the bridge plugin emits (copied from frames the walk database +// holds), less `serverId`/`wipeId`/`t`/`type`, which `frame()` adds. + +const death = (killer, victim, weapon, distance, grid, extra = {}) => ({ + kind: 'player.death', + steamId: steam(victim), + name: who(victim), + attackerId: steam(killer), + attackerName: who(killer), + attackerType: 'player', + weapon, + distance, + grid, + sleeping: false, + ...extra, +}); + +const world = (event, fields = {}) => ({ kind: 'world.event', event, ...fields }); + +const FEED = [ + [150, { kind: 'player.connected', steamId: steam(1), name: who(1) }], + [148, { kind: 'player.connected', steamId: steam(2), name: who(2) }], + [146, { kind: 'player.connected', steamId: steam(3), name: who(3) }], + [141, world('cargo.arrived', { x: 2900, z: -2100 })], + [137, { kind: 'player.connected', steamId: steam(9), name: who(9) }], + [131, { kind: 'player.chat', steamId: steam(2), name: who(2), channel: 'Global', message: 'anyone selling scrap at outpost?' }], + [126, death(3, 4, 'bolt_rifle.entity', 187.4, 'AF10')], + [119, { kind: 'player.connected', steamId: steam(5), name: who(5) }], + [112, world('crate.hack.started', { steamId: steam(2), name: who(2), where: 'oilrig_small', x: 3358, z: 1721 })], + [104, death(1, 2, 'ak47u.entity', 22.4, 'R18')], + [98, world('heli.arrived', { x: 400, z: -900 })], + [96, world('crate.unlocked', { steamId: steam(5), name: who(5), where: 'oilrig_small', x: 3358, z: 1721 })], + [91, { kind: 'player.connected', steamId: steam(6), name: who(6) }], + [86, death(5, 3, 'lr300.entity', 64.9, 'H7')], + [80, world('heli.downed', { + x: 2453, z: -943, + credit: { + blow: steam(3), last: steam(1), most: steam(1), + damagers: [{ steamId: steam(1), damage: 5200 }, { steamId: steam(3), damage: 3900 }, { steamId: steam(6), damage: 900 }], + }, + names: { [steam(1)]: who(1), [steam(3)]: who(3), [steam(6)]: who(6) }, + creditRule: 'most', + })], + [74, { kind: 'player.chat', steamId: steam(9), name: who(9), channel: 'Global', message: 'wipe is Thursday 19:00, bases stay up till then' }], + [69, { kind: 'npc.boss.died', name: 'Gate Warden', killerName: who(6), killerId: steam(6), damagers: 3 }], + [63, death(6, 1, 'semi_auto_rifle.entity', 41.2, 'K12')], + [58, world('airdrop.looted', { steamId: steam(4), name: who(4), x: 164, z: -1507 })], + [52, { kind: 'player.connected', steamId: steam(7), name: who(7) }], + [47, death(2, 6, 'mp5.entity', 12.6, 'R17', { }) ], + [41, { kind: 'dt.won', steamId: steam(4), name: who(4), invaderCount: 2, invaders: [steam(4), steam(7)], x: 150, z: -800 }], + [36, world('bradley.destroyed', { + where: 'launch_site', x: -1200, z: 300, + credit: { + blow: steam(1), last: steam(1), most: steam(5), + damagers: [{ steamId: steam(5), damage: 630 }, { steamId: steam(1), damage: 385 }], + }, + names: { [steam(1)]: who(1), [steam(5)]: who(5) }, + creditRule: 'most', + })], + [31, death(1, 7, 'ak47u.entity', 96.3, 'J9')], + [25, { kind: 'gc.ended', steamId: steam(7), name: who(7), x: 200, z: -800 }], + [21, { + kind: 'raid.completed', base: 'raideasy7', difficulty: 'Easy', level: 0, pvp: false, + id: 'raid-demo-1', key: 'demo', ownerId: steam(6), ownerName: who(6), raiderCount: 1, + raiders: [{ steamId: steam(6), name: who(6) }], x: 280, z: -800, + }], + [16, death(3, 5, 'bolt_rifle.entity', 241.7, 'AB4', { })], + [12, { kind: 'player.chat', steamId: steam(1), name: who(1), channel: 'Global', message: 'gg on the heli' }], + [8, world('cargo.docking', { harbour: 'harbor_2', harbourName: 'Harbor', x: 2595, z: -2215 })], + [5, death(4, 2, 'thompson.entity', 18.9, 'Q14')], + [2, { kind: 'player.disconnected', steamId: steam(2), name: who(2), reason: 'Disconnected', sessionSec: 8760 }], +]; + +// ── The leaderboard ──────────────────────────────────────────────────────────────────── +// +// Consistent with the feed in spirit, not to the kill: a wipe is days long and the feed above +// is its last two hours. +const STATS = { + 1: { kills: 31, deaths: 12, npc_kills: 58, headshots: 14, best_pvp_m: 212.4, structures: 340, playtime_sec: 61200, heli_kills: 2, apc_kills: 1, crates_hacked: 3, drops_looted: 5 }, + 3: { kills: 27, deaths: 9, npc_kills: 22, headshots: 19, best_pvp_m: 288.1, structures: 120, playtime_sec: 48900, heli_kills: 1, crates_hacked: 1, drops_looted: 2 }, + 5: { kills: 19, deaths: 15, npc_kills: 41, headshots: 6, best_pvp_m: 133.0, structures: 510, playtime_sec: 55800, apc_kills: 1, crates_hacked: 2, drops_looted: 7 }, + 6: { kills: 16, deaths: 11, npc_kills: 30, headshots: 5, best_pvp_m: 97.6, structures: 260, playtime_sec: 40100, raids_completed: 2, drops_looted: 3 }, + 4: { kills: 12, deaths: 18, npc_kills: 25, headshots: 3, best_pvp_m: 74.2, structures: 180, playtime_sec: 37400, treasures_won: 1, drops_looted: 4 }, + 2: { kills: 9, deaths: 21, npc_kills: 47, headshots: 2, best_pvp_m: 58.8, structures: 610, playtime_sec: 52300, crates_hacked: 2, drops_looted: 6 }, + 7: { kills: 6, deaths: 10, npc_kills: 13, headshots: 1, best_pvp_m: 96.3, structures: 90, playtime_sec: 21000, guarded_won: 1, drops_looted: 1 }, + 9: { kills: 2, deaths: 1, npc_kills: 4, headshots: 0, best_pvp_m: 35.0, structures: 40, playtime_sec: 9800 }, +}; + +const WEAPON_KILLS = { + 1: { 'ak47u.entity': 21, 'semi_auto_rifle.entity': 6, 'python.entity': 4 }, + 3: { 'bolt_rifle.entity': 22, 'lr300.entity': 5 }, + 5: { 'lr300.entity': 13, 'mp5.entity': 6 }, + 6: { 'semi_auto_rifle.entity': 10, 'ak47u.entity': 6 }, + 4: { 'thompson.entity': 8, 'mp5.entity': 4 }, + 2: { 'mp5.entity': 7, 'thompson.entity': 2 }, + 7: { 'ak47u.entity': 4, 'python.entity': 2 }, + 9: { 'lr300.entity': 2 }, +}; + +// ── SQL ──────────────────────────────────────────────────────────────────────────────── + +const q = (value) => + value === null || value === undefined ? 'NULL' : `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; + +const ids = CAST.map((c) => q(steam(c.n))).join(', '); +const out = []; + +out.push('-- mockRustDemo.mjs — PLAN.md §16 phase 18 (D81). Generated; do not edit by hand.'); +out.push(`SET @server = ${q(SERVER)};`); +out.push( + "SET @wipe = (SELECT wipe_id FROM rust_wipes WHERE server_id = @server ORDER BY last_seen DESC LIMIT 1);", +); +out.push("SELECT IF(@wipe IS NULL, 'NO WIPE YET: connect the site to the server first', CONCAT('wipe ', @wipe)) AS mock;"); +out.push('DELETE FROM rust_events WHERE server_id = @server;'); +out.push('DELETE FROM rust_player_wipe_stats WHERE server_id = @server;'); +out.push('DELETE FROM rust_weapon_kills WHERE server_id = @server;'); +out.push(`DELETE FROM rust_account_links WHERE steam_id IN (${ids});`); +out.push(`DELETE FROM rust_players WHERE steam_id IN (${ids});`); + +for (const c of CAST) { + out.push( + `INSERT INTO rust_players (steam_id, name, first_seen, last_seen) VALUES (${q(steam(c.n))}, ${q(c.name)}, NOW() - INTERVAL 6 DAY, NOW() - INTERVAL 2 MINUTE);`, + ); +} + +for (const [minutes, body] of FEED) { + const t = ago(minutes); + const raw = JSON.stringify({ ...body, serverId: SERVER, t, type: 'event', wipeId: '@@WIPE@@' }); + const [before, after] = raw.split('"@@WIPE@@"'); + out.push( + `INSERT INTO rust_events (server_id, wipe_id, kind, t, steam_id, raw, created_at) VALUES (@server, @wipe, ${q(body.kind)}, ${t}, ${q(body.steamId ?? null)}, CONCAT(${q(before)}, JSON_QUOTE(@wipe), ${q(after)}), FROM_UNIXTIME(${Math.floor(t / 1000)}));`, + ); +} + +for (const [n, row] of Object.entries(STATS)) { + const cols = Object.keys(row); + out.push( + `INSERT INTO rust_player_wipe_stats (server_id, wipe_id, steam_id, sessions, last_seen, ${cols.join(', ')}) VALUES (@server, @wipe, ${q(steam(Number(n)))}, 6, NOW() - INTERVAL 2 MINUTE, ${cols.map((k) => row[k]).join(', ')});`, + ); +} + +for (const [n, weapons] of Object.entries(WEAPON_KILLS)) { + for (const [weapon, kills] of Object.entries(weapons)) { + out.push( + `INSERT INTO rust_weapon_kills (server_id, wipe_id, steam_id, weapon, kills) VALUES (@server, @wipe, ${q(steam(Number(n)))}, ${q(weapon)}, ${kills});`, + ); + } +} + +for (const c of CAST.filter((member) => member.user)) { + out.push( + `INSERT INTO rust_account_links (steam_id, user_id, name, server_id, linked_at) SELECT ${q(steam(c.n))}, id, ${q(c.name)}, @server, NOW() - INTERVAL 5 DAY FROM users WHERE username = ${q(c.user)};`, + ); +} + +console.log(out.join('\n')); diff --git a/scripts/seedDemo.mjs b/scripts/seedDemo.mjs index 4d4763d..6a89e95 100644 --- a/scripts/seedDemo.mjs +++ b/scripts/seedDemo.mjs @@ -4,17 +4,26 @@ * * node scripts/seedDemo.mjs → seed (idempotent; safe to re-run) * node scripts/seedDemo.mjs --dry-run → say what it would do, write nothing + * node scripts/seedDemo.mjs --game rust → the Rust demo instead (PLAN.md §16 phase 18) * * Environment (all optional; the defaults are this machine's review stack): * * RG_BASE http://localhost:3000 the website the seed drives - * RG_ADMIN_USER demoadmin an existing admin, created by website's own + * RG_ADMIN_USER demoadmin (rust: admin) an existing admin, created by website's own * RG_ADMIN_PASS DemoReview!2026 `npm run seed` — see PLAN.md §13 phase 9 * RG_DEMO_PASS DemoReview!2026 the password every seeded cast member gets * UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard * UOLINK_WS ws://127.0.0.1:8080/ws sidecar WebSocket * UOLINK_TOKEN (unset) sidecar auth token; skipped when absent * UOLINK_PROTOCOL (platform.json) wire protocol to pin — see the note below + * RG_RUST_SERVERS (unset) rust only: a JSON file outside the repository, + * [{ id, name, sidecarBaseUrl, sidecarToken }] + * + * One deployment runs one game module (the core refuses a second at boot), so the Rust demo + * is a second deployment with its own database, and `--game rust` seeds that one. Its cast + * is named for its roles — admin, moderator, player — and so is every stand-in on the game + * server behind it (D80): a name in a screenshot should say what the account is, not + * invent a person. * * --------------------------------------------------------------------------------------- * WHY THE SEED DRIVES THE API AND NEVER THE DATABASE @@ -47,9 +56,17 @@ import { readFileSync } from 'node:fs'; import platform from '../src/data/platform.json' with { type: 'json' }; +const gameAt = process.argv.indexOf('--game'); +const GAME = gameAt === -1 ? 'uo' : process.argv[gameAt + 1]; + +if (!['uo', 'rust'].includes(GAME)) { + console.error(`--game is uo or rust, not "${GAME}"`); + process.exit(1); +} + const BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, ''); const API = `${BASE}/api/v1`; -const ADMIN_USER = process.env.RG_ADMIN_USER || 'demoadmin'; +const ADMIN_USER = process.env.RG_ADMIN_USER || (GAME === 'rust' ? 'admin' : 'demoadmin'); const ADMIN_PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026'; const DEMO_PASS = process.env.RG_DEMO_PASS || 'DemoReview!2026'; const UOLINK_BASE = process.env.UOLINK_BASE || 'http://127.0.0.1:8080'; @@ -109,6 +126,85 @@ const USERS = [ { username: 'mirenavox', role: 'player' }, ]; +// ── The Rust demo (PLAN.md §16 phase 18) ─────────────────────────────────────────────── +// +// The same neutral identity (D43) over a Rust server. The cast is two accounts besides the +// admin, each named for its role (D80). Teams, the feed, the leaderboard and the map are the +// game's, as they are for UO; what the site owns here is the server row, the NPC profiles +// and a permission group, so those are seeded. +const RUST_SETTINGS = { + ...SETTINGS, + status_message: 'Live — the demo server is up.', + homepage_teaser: + 'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' + + 'live Rust server. Everything on this site is real data from the server behind it.', +}; + +const RUST_USERS = [ + { username: 'moderator', role: 'moderator' }, + { username: 'player', role: 'player' }, +]; + +// Two RunicNPC profiles: a guard who fights, and a passive trader who talks. Their kit is +// one the rig's Kits holds; a profile naming a kit Kits does not have is refused by RunicNPC +// and shows as refused on the screen, which is not the screen being shown. The guard's name +// sorts first, so the editor the NPC screenshot opens (its first Edit) is the fuller one. +const NPC_KIT = process.env.RG_NPC_KIT || 'rnpc_campguard'; +const NPC_PROFILES = [ + { + name: 'guard', + allServers: true, + body: { + names: ['Camp Guard', 'Gate Guard'], + kits: [NPC_KIT], + prefab: 'scientistnpc_roam', + role: 'guard', + movement: { mode: 'wander', radius: 12 }, + health: 300, + faction: 'wardens', + alertRadius: 30, + }, + }, + { + name: 'trader', + allServers: true, + body: { + names: ['Quartermaster'], + kits: [NPC_KIT], + prefab: 'scientistnpc_roam', + role: 'passive', + movement: { mode: 'stand', radius: 0 }, + hurtByPlayers: false, + use: { mode: 'chat', lines: ['Back again, {player}?', 'Supplies are short this wipe.'], title: '', text: '' }, + }, + }, +]; + +// One group the site authored, so the permissions screen shows the site doing something +// beside what the server already had. Its permissions are ones the rig's plugins register, +// and its member is the `player` account, through its linked Steam id. +const PERM_GROUP = { + name: 'vip', + title: 'VIP', + rank: 10, + permissions: ['backpacks.use', 'autodoors.use'], + members: ['player'], +}; + +// Who sees who is online, and the map's player layer. Both default to staff, which is right +// for a real community and wrong for a demo whose public pages exist to be looked at: with +// them at staff, the public feed leaves out every death and the map shows no one. The only +// players on the demo server are the stand-ins phase 18 staged, named by role (D80), so +// opening them to the public shows nobody's real name. +const RUST_VISIBILITY = { fleet: 'public', map: { fleet: { players: 'public' } } }; + +// A weekly wipe, so the server list has a next wipe to show: Thursday evening, the usual +// Rust community habit. +const RUST_WIPE = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'America/Chicago' }; + +const siteSettings = GAME === 'rust' ? RUST_SETTINGS : SETTINGS; +const cast = GAME === 'rust' ? RUST_USERS : USERS; + // ── News, five-on-friday, the newsletter ─────────────────────────────────────────────── // // Written as a small community's real output rather than lorem: a patch note, an event, a @@ -312,12 +408,12 @@ async function login() { async function settings() { console.log('\nsite settings (D43 — the neutral demo identity)'); if (DRY) { - for (const [k, v] of Object.entries(SETTINGS)) say('~', `${k} = ${v}`); + for (const [k, v] of Object.entries(siteSettings)) say('~', `${k} = ${v}`); return; } - const res = await call('PUT', '/admin/settings', SETTINGS); + const res = await call('PUT', '/admin/settings', siteSettings); if (!res.ok) return fail('settings', res); - for (const [k, v] of Object.entries(SETTINGS)) say('+', `${k} = ${String(v).slice(0, 60)}`); + for (const [k, v] of Object.entries(siteSettings)) say('+', `${k} = ${String(v).slice(0, 60)}`); } async function uoLink() { @@ -358,7 +454,7 @@ async function users() { if (!list.ok) return fail('list users', list); const rows = Array.isArray(list.data) ? list.data : (list.data?.users ?? []); const have = new Set(rows.map((u) => u.username)); - for (const user of USERS) { + for (const user of cast) { if (have.has(user.username)) { say('=', `${user.username} (${user.role})`); continue; @@ -447,23 +543,175 @@ async function wiki() { } } +// ── The Rust steps ───────────────────────────────────────────────────────────────────── + +/** The servers file, read once. The tokens in it are sent to the site and never printed. */ +function rustServerList() { + const file = process.env.RG_RUST_SERVERS; + if (!file) return []; + return JSON.parse(readFileSync(file, 'utf8')); +} + +async function rustServers() { + console.log('\nRust servers (Admin → Rust → Servers)'); + const wanted = rustServerList(); + if (wanted.length === 0) { + say('~', 'RG_RUST_SERVERS unset — leaving the server list alone'); + return; + } + const now = await call('GET', '/admin/rust/servers'); + if (now.status === 404) { + say('~', 'no /admin/rust route — the rust module is not installed'); + return; + } + if (!now.ok) return fail('list servers', now); + for (const server of wanted) { + const have = (now.data?.servers ?? []).find((row) => row.id === server.id); + if ( + have && + have.sidecarBaseUrl === server.sidecarBaseUrl && + have.enabled && + have.schedule?.rule === RUST_WIPE.wipeRule + ) { + say('=', `${server.id} at ${server.sidecarBaseUrl}`); + continue; + } + if (DRY) { + say('~', `${server.id} at ${server.sidecarBaseUrl}`); + continue; + } + const res = await call('PUT', `/admin/rust/servers/${encodeURIComponent(server.id)}`, { + name: server.name, + sidecarBaseUrl: server.sidecarBaseUrl, + // Omitted on an existing row, which is how the API says "keep the token" — so a re-run + // never needs the token file to hold a token it already sent. + sidecarToken: have ? undefined : server.sidecarToken, + enabled: true, + ...RUST_WIPE, + }); + if (!res.ok) { + fail(`save server ${server.id}`, res); + continue; + } + say('+', `${server.id} "${server.name}" at ${server.sidecarBaseUrl}`); + } +} + +async function rustVisibility() { + console.log('\nvisibility (Admin → Rust → Visibility)'); + const now = await call('GET', '/admin/rust/visibility'); + if (!now.ok) return fail('read visibility', now); + if (now.data?.presence?.fleet === 'public' && now.data?.map?.fleet?.players === 'public') { + say('=', 'who is online and the map’s players are public'); + return; + } + if (DRY) return say('~', 'would make who is online and the map’s players public'); + const res = await call('PUT', '/admin/rust/visibility', RUST_VISIBILITY); + if (!res.ok) return fail('visibility', res); + say('+', 'who is online and the map’s players are public'); +} + +async function npcProfiles() { + console.log('\nRunicNPC profiles (Admin → Rust → NPCs)'); + const now = await call('GET', '/admin/rust/npcs'); + if (!now.ok) return fail('list NPC profiles', now); + const live = (now.data?.profiles ?? []).filter((profile) => !profile.replaced); + for (const profile of NPC_PROFILES) { + const have = live.find((row) => row.name === profile.name); + if (DRY) { + say('~', `profile ${profile.name}`); + continue; + } + // An existing profile is saved again rather than skipped, so a change to its body here + // reaches the demo on the next run. RunicNPC answers the push the same either way. + const res = have + ? await call('PUT', `/admin/rust/npcs/profiles/${have.id}`, profile) + : await call('POST', '/admin/rust/npcs/profiles', profile); + if (!res.ok) { + fail(`${have ? 'save' : 'create'} profile ${profile.name}`, res); + continue; + } + say(have ? '=' : '+', `profile ${profile.name}${have ? ' (saved again)' : ''}`); + } +} + +async function permGroup() { + console.log('\npermission group (Admin → Rust → Permissions)'); + for (const server of rustServerList()) { + const base = `/admin/rust/permissions/servers/${encodeURIComponent(server.id)}`; + const now = await call('GET', base); + if (!now.ok) { + fail(`read permissions on ${server.id}`, now); + continue; + } + if (DRY) { + say('~', `${server.id}: group ${PERM_GROUP.name}`); + continue; + } + let id = (now.data?.groups ?? []).find((group) => group.name === PERM_GROUP.name)?.id; + if (id) { + say('=', `${server.id}: group ${PERM_GROUP.name}`); + } else { + const made = await call('POST', `${base}/groups`, { + name: PERM_GROUP.name, + title: PERM_GROUP.title, + rank: PERM_GROUP.rank, + }); + if (!made.ok) { + fail(`create group ${PERM_GROUP.name} on ${server.id}`, made); + continue; + } + id = made.data.id; + say('+', `${server.id}: group ${PERM_GROUP.name}`); + } + // The permission set is replaced whole, so saving it again is the update. + const perms = await call('PUT', `/admin/rust/permissions/groups/${id}/permissions`, { + permissions: PERM_GROUP.permissions, + }); + if (!perms.ok) fail(`grant ${PERM_GROUP.name}'s permissions`, perms); + else say('=', ` permissions ${PERM_GROUP.permissions.join(', ')}`); + for (const username of PERM_GROUP.members) { + const added = await call('POST', `/admin/rust/permissions/groups/${id}/members`, { username }); + if (added.ok) say('=', ` member ${username}`); + else if (added.status === 409) say('=', ` member ${username} (already)`); + else fail(`add ${username} to ${PERM_GROUP.name}`, added); + } + } +} + // ── main ─────────────────────────────────────────────────────────────────────────────── -console.log(DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo'); +console.log( + `${DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo'} (${GAME})`, +); await login(); await settings(); -await uoLink(); await users(); -await posts(); -await wiki(); + +// The news and the wiki are written in one game's voice (a patch note about house decay, a +// champion spawn), so they belong to the UO demo. The Rust captures show none of them. +if (GAME === 'rust') { + await rustServers(); + await rustVisibility(); + await npcProfiles(); + await permGroup(); +} else { + await uoLink(); + await posts(); + await wiki(); +} console.log( `\n${DRY ? 'would create' : 'created'} ${created}, already present ${existed}` + (process.exitCode ? ' — with failures above' : ''), ); console.log( - '\nWhat this does NOT seed, on purpose: teams, the marketplace, houses, points boards\n' + - 'and the atlas. Those arrive from the shard over the bridge (D42) — start the sidecar\n' + - 'and the shard, and they populate themselves.\n', + GAME === 'rust' + ? '\nWhat this does NOT seed, on purpose: the feed, the leaderboard, who is online, clans\n' + + 'and the map. Those arrive from the server over the bridge — start the server, and\n' + + 'they populate themselves. PLAN.md §16 phase 18 says how its players were staged.\n' + : '\nWhat this does NOT seed, on purpose: teams, the marketplace, houses, points boards\n' + + 'and the atlas. Those arrive from the shard over the bridge (D42) — start the sidecar\n' + + 'and the shard, and they populate themselves.\n', ); diff --git a/src/components/app/Screenshots.astro b/src/components/app/Screenshots.astro index 87452d9..3bca4ee 100644 --- a/src/components/app/Screenshots.astro +++ b/src/components/app/Screenshots.astro @@ -39,9 +39,10 @@ const shots = screensOf('phone');

What it looks like

- Captured against a real deployment with real content, not mocked up. The app takes its - colours, type and navigation from the site it is connected to, so these show one - community's app rather than a neutral one. + Captured from the app against two demo deployments: one with a real Ultima Online + server behind it, and one with a real Rust server whose players are a demo cast. The + app takes its colours, type and navigation from the site it is connected to, so these + show one community's app rather than a neutral one.

    diff --git a/src/components/home/WhatItLooksLike.astro b/src/components/home/WhatItLooksLike.astro index 110c2d6..7114cfe 100644 --- a/src/components/home/WhatItLooksLike.astro +++ b/src/components/home/WhatItLooksLike.astro @@ -2,10 +2,10 @@ import Screenshot from '../Screenshot.astro'; /** - * The homepage's one screenshot. PLAN.md §13 phase 9, D4 / D44. + * The homepage's screenshots, one per game. PLAN.md §13 phase 9, D4 / D44; §16 phase 18. * * ----------------------------------------------------------------------------------------- - * WHY ONE, AND WHY THIS ONE + * WHY ONE PER GAME, AND WHY THESE * ----------------------------------------------------------------------------------------- * `DataPath` above it draws the claim — a private game server, a sidecar, a public site — * and a diagram of a data path is a promise that the data arrives. This is the page where @@ -16,11 +16,13 @@ import Screenshot from '../Screenshot.astro'; * tagged `uo` in `screens.mjs`, so it prints "Ultima Online" beside it and may keep that * game's words in its alt and caption. A core screen was the alternative and was declined: * the claim here is live game state on a public page, which no core screen shows. Phase 18 - * adds a Rust capture beside it, labelled the same way. + * added a Rust capture beside it, labelled the same way: the live map, because every pixel + * of it is the running server's — the Rust demo's players are a demo cast (D81), and the + * map shows none of them. * * A gallery here would compete with `Capabilities` further down, which is the part of the - * homepage that enumerates. So: one figure, the signature screen, and the rest of the set - * on each game's page under `/modules/`, where each one sits beside the claim it supports. + * homepage that enumerates. So: one figure per game, its signature screen, and the rest of + * the set on each game's page under `/modules/`, where each sits beside the claim it supports. */ --- @@ -28,12 +30,16 @@ import Screenshot from '../Screenshot.astro';

    What it looks like

    The other end of that diagram

    - A demo deployment with a real Ultima Online server behind it. The gold supply, the state - of the link and the player online in Britain are all read from the game server over the - bridge. None of it is typed in, and none of it is a mock-up. + Two demo deployments, each with a real game server behind it. On the first, the gold + supply, the state of the link and the player online are all read from an Ultima Online + server over the bridge. On the second, the map is the running Rust server's own: its + island, its monuments, and the events on it as they happen.

    - +
    + + +
diff --git a/src/content/docs/docs/rust/permissions-and-groups.mdx b/src/content/docs/docs/rust/permissions-and-groups.mdx index fb38103..06f01d1 100644 --- a/src/content/docs/docs/rust/permissions-and-groups.mdx +++ b/src/content/docs/docs/rust/permissions-and-groups.mdx @@ -4,6 +4,7 @@ description: Admin → Rust permissions — the site holds every server's permis --- import { Aside } from '@astrojs/starlight/components'; +import Screenshot from '../../../../components/Screenshot.astro'; **Admin → Rust permissions** (`/admin/rust`) is where the site holds every permission and group on each server: what was there before the site, what is made here, and what is changed in the @@ -66,6 +67,8 @@ new one. Each group has four tabs: same permissions and players on every server it is on — including servers added later, if you tick that — and a change made in one game gives that server its own copy. + + ### Chat styles A group's **Settings** tab has a **Chat style (BetterChat)** section. Give a group a style and diff --git a/src/content/docs/docs/rust/runicnpc/npc-profiles.mdx b/src/content/docs/docs/rust/runicnpc/npc-profiles.mdx index e0568d0..ef78e4c 100644 --- a/src/content/docs/docs/rust/runicnpc/npc-profiles.mdx +++ b/src/content/docs/docs/rust/runicnpc/npc-profiles.mdx @@ -4,6 +4,7 @@ description: Authoring RunicNPC's profiles and faction table on the site — whi --- import { Aside } from '@astrojs/starlight/components'; +import Screenshot from '../../../../../components/Screenshot.astro'; **Admin → Rust NPC profiles** is where a Runic Gateway site authors RunicNPC's profiles. There is no in-game profile editor: on a server a site manages, the profiles and the faction table are the @@ -47,6 +48,8 @@ Each row shows a profile's label and RunicNPC name, the servers it is for, its r a boss or has its own loot, its health, kits and movement, and how its kills count. **New profile** and **Edit** open the editor below the list. + + ### Which servers A profile is for **every server** — including servers added later — or for the ones ticked. Each diff --git a/src/content/docs/docs/rust/the-live-map.mdx b/src/content/docs/docs/rust/the-live-map.mdx index e5482f2..323bdb0 100644 --- a/src/content/docs/docs/rust/the-live-map.mdx +++ b/src/content/docs/docs/rust/the-live-map.mdx @@ -4,11 +4,14 @@ description: The Map tab on each server's page — where the picture comes from, --- import { Aside } from '@astrojs/starlight/components'; +import Screenshot from '../../../../components/Screenshot.astro'; Each server's public page has a **Map** tab: this wipe's map, with the game's own grid, its monuments, and layers for everything that moves. It is drawn in the browser from two things the bridge answers live — the picture, and the positions. + + ## The picture **From Rust+, when it is on.** A server with `app.port` set renders its map at boot and keeps the diff --git a/src/data/games/rust.mjs b/src/data/games/rust.mjs index 38b523d..f710a45 100644 --- a/src/data/games/rust.mjs +++ b/src/data/games/rust.mjs @@ -120,3 +120,11 @@ export const staffFeatures = [ 'titles, map layers, event steps. None is required.', }, ]; + +/** + * The screenshots `/modules/rust/` shows, by id from `screens.mjs`: what players see under that + * list, and the staff screens under theirs. Each is tagged `game: 'rust'` there (D67). Captured + * in phase 18 (D65) from the Oxide rig, with stand-in players named by their role (D80). + */ +export const screens = ['rust-servers', 'rust-feed', 'rust-leaderboard', 'rust-map']; +export const staffScreens = ['admin-rust-permissions', 'admin-rust-npcs']; diff --git a/src/data/screens.mjs b/src/data/screens.mjs index 09c4718..1b431f8 100644 --- a/src/data/screens.mjs +++ b/src/data/screens.mjs @@ -39,7 +39,15 @@ * deployment is branded "Runic Gateway Demo" rather than a real community's name (D43) — * the screenshots show the platform, not somebody's private shard. * - * Nothing here is a mock-up, and nothing here was drawn. + * None of those is a mock-up, and nothing here was drawn. + * + * The Rust captures (phase 18) are a second demo deployment, since one deployment runs one + * game module, wired to the Oxide test server over a real rust-link sidecar. The server, its + * status, its world map and markers, the permission groups its plugins hold and RunicNPC's + * sync state are that server's. Its players are not: the rig has none, so the feed, the + * leaderboard and the linked Steam account were written into the demo's database by + * `scripts/mockRustDemo.mjs`, in the module's own row shapes (D81), with every name a role + * (D80). The captions say what the screen does, never that a player did it. * * --------------------------------------------------------------------------------------- * A SCREENSHOT BELONGS TO ITS GAME (D67) @@ -69,6 +77,8 @@ export const PHONE = { width: 1440, height: 3088 }; * @property {string} route Route on the demo deployment. The capture script's only input. * @property {boolean} admin Capture signed in as an administrator rather than anonymously. * @property {number} [scrollY] Pixels to scroll before the shot, when the subject is below the fold. + * @property {string[]} [click] Visible text of what to click, in order, before the shot — for a + * subject behind a tab or a button the route cannot reach. * @property {string} alt What the screen shows, for somebody who cannot see it. * @property {string} caption The sentence printed under the figure. * @property {'web'|'phone'} family Which geometry the file follows. @@ -131,6 +141,57 @@ export const screens = [ family: 'web', }, + // ── Rust's public pages (PLAN.md §16 phase 18, D65) ───────────────────────────────── + // + // The second demo deployment (see WHAT IS IN THEM above): a real server, demo players. + // The demo makes who is online public, which a real community decides for itself; the + // players it would name are the demo cast. The server list's player count is the cast's + // too, written just before the shot, since the live server reports its own every 30 s. + { + id: 'rust-servers', + game: 'rust', + route: '/rust', + admin: false, + alt: 'The Rust servers page of a Runic Gateway site, with one server card: its map, world size, when it last wiped, its next wipe a week away, and six of forty players online.', + caption: + 'Every server the community runs, as each last reported itself — and the next wipe, worked out from the schedule the operator set.', + family: 'web', + }, + { + id: 'rust-feed', + game: 'rust', + route: '/rust/servers/rust-oxide', + admin: false, + scrollY: 268, + alt: "A Rust server's feed: players killing each other with the weapon, distance and map square, chat lines, a cargo ship docking, a raid base cleared, a guarded crate won, a Bradley destroyed and players connecting and leaving, newest first.", + caption: + 'The feed: kills, chat, comings and goings and the world’s own events, as the bridge reports them. Who sees players named in it is the operator’s choice.', + family: 'web', + }, + { + id: 'rust-leaderboard', + game: 'rust', + route: '/rust/servers/rust-oxide?tab=leaderboard', + admin: false, + scrollY: 520, + alt: "A Rust server's leaderboard for the current wipe, ranking eight players by kills, with deaths, NPC kills, structures built, time played, crates hacked, drops looted and treasures won.", + caption: + 'The wipe’s leaderboard. Any column sorts it, and a column for an optional plugin appears only where that plugin is installed.', + family: 'web', + }, + { + id: 'rust-map', + game: 'rust', + route: '/rust/servers/rust-oxide?tab=map', + admin: false, + scrollY: 470, + click: ['+'], + alt: "A Rust server's live map: the island from the game's own render under a grid, with monuments, airdrops and a PvP zone around the cargo ship marked, above the switches for each layer.", + caption: + 'The live map, rendered from the running server and refreshed every ten seconds while the tab is open. Each layer has its own audience.', + family: 'web', + }, + { id: 'news', route: '/site/news', @@ -196,6 +257,30 @@ export const screens = [ family: 'web', }, + { + id: 'admin-rust-permissions', + game: 'rust', + route: '/admin/rust', + admin: true, + click: ['Groups', 'vip'], + alt: "The Rust permissions screen: the server's groups listed down the left, the VIP group open with its permissions filed under the plugin that registers each one, and Granted or Revoked beside every permission.", + caption: + 'Every permission and group on the server, kept by the site — what was there before it, what is made here, and what is changed in the game — and pushed back after a wipe.', + family: 'web', + }, + { + id: 'admin-rust-npcs', + game: 'rust', + route: '/admin/rust/npcs', + admin: true, + click: ['Edit'], + scrollY: 900, + alt: 'The RunicNPC profile editor for a guard: its names, its Kits kit, prefab and role, how far it notices players before it sleeps, and its health, damage, aim and vision.', + caption: + 'A RunicNPC profile, edited on the site and pushed to every server it is for. RunicNPC checks each field the same way here, in the console and through its API.', + family: 'web', + }, + // ── The Android app, against the same deployment on the same day (D26) ────────────── // // Captured from an emulator pointed at the demo stack through `adb reverse`, signed in as @@ -256,6 +341,37 @@ export const screens = [ "The drawer is the deployment's own navigation, not a fixed menu — a site that renames or reorders its pages renames and reorders them here.", family: 'phone', }, + + // The app's Rust screens (phase 18), against the Rust demo, signed in as `player` (D80). + // From an `Android-app` `edge` debug build: they were not in a release when captured. + { + id: 'app-rust-server', + game: 'rust', + route: '/rust/servers/rust-oxide', + admin: false, + alt: "The app's Rust server screen: the server's map, size, last and next wipe and six players online, tabs for the feed, leaderboard, online list, map and wipes, and the feed of kills, chat and players connecting.", + caption: 'Each server in the app: the same feed as the site, with the same filters.', + family: 'phone', + }, + { + id: 'app-rust-map', + game: 'rust', + route: '/rust/servers/rust-oxide?tab=map', + admin: false, + alt: "The app's live map of a Rust server: the island under a grid with monuments and world events marked, and switches for the grid, monuments and world events, site events and players.", + caption: 'The live map on a phone, refreshed every ten seconds while it is open.', + family: 'phone', + }, + { + id: 'app-rust-account', + game: 'rust', + route: '/account/rust', + admin: false, + alt: "The app's Rust account screen for a signed-in player: a box for the link code the game gives, the linked Steam account, the VIP group it has on the server, its coins, and a button to open its backpack.", + caption: + 'A player’s own side: link a Steam account with a code from the game, then see what it can do, its coins and its backpack on every server.', + family: 'phone', + }, ]; /** One entry by id, or `undefined`. */ diff --git a/src/pages/modules/rust.astro b/src/pages/modules/rust.astro index 6837412..bda83be 100644 --- a/src/pages/modules/rust.astro +++ b/src/pages/modules/rust.astro @@ -2,9 +2,10 @@ import Base from '../../layouts/Base.astro'; import PageHeader from '../../components/PageHeader.astro'; import GameFeatures from '../../components/GameFeatures.astro'; +import Screenshot from '../../components/Screenshot.astro'; import platform from '../../data/platform.json'; -import { features, staffFeatures } from '../../data/games/rust.mjs'; +import { features, staffFeatures, screens, staffScreens } from '../../data/games/rust.mjs'; import { assertGameCoverage } from '../../data/games/index.mjs'; /** @@ -17,8 +18,8 @@ import { assertGameCoverage } from '../../data/games/index.mjs'; * * It carries the RunicNPC card D61 asked for, pointing at the standalone `/runicnpc/` page. * - * No screenshots yet, and no slots for them: D65 brings the Rust captures in phase 18, from - * the rigs, and D4 rules out a placeholder in the meantime. + * The screenshots came in phase 18 (D65), from the Oxide rig: the public ones under what + * players see, the staff screens under what staff run. Each is tagged `rust` in `screens.mjs`. */ assertGameCoverage('rust'); @@ -96,12 +97,25 @@ const description = items={features} /> +
+

+ Captured from a demo site connected to a real Rust test server. The server, its status + and its map are that server's own; the players in the feed and the leaderboard are a + demo cast, named for their roles. +

+ {screens.map((id) => )} +
+ +
+ {staffScreens.map((id) => )} +
+

Also from this project

@@ -134,6 +148,11 @@ const description =