feat(site): phase 18 - the Rust screenshots, web and app
All checks were successful
PR checks / checks (pull_request) Successful in 2m55s

Nine captures tagged rust (D65): the server list, feed, leaderboard,
live map, permissions and the RunicNPC profile editor on the web, and a
server's feed, its map and the player's Rust account in the app, from
an Android-app edge debug build.

D80: every name in a capture is a role (admin, moderator, player...),
from this phase on. D81: the Rust demo's players are mock data written
by scripts/mockRustDemo.mjs; the server, its status, map, permission
groups and RunicNPC sync state are the real Oxide test server's. The
captions and /modules/rust/ and /app/ say which part is a demo cast.

seedDemo.mjs gains --game rust (server row with a weekly wipe, public
presence for the demo, two NPC profiles, a VIP group). captureScreens.mjs
gains a click list per screen. The homepage shows the Rust live map beside
the UO capture (D76); three Rust docs pages carry their screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-22 22:05:06 -05:00
parent 4f38e29ad7
commit c47e9c1aa4
21 changed files with 745 additions and 29 deletions

View File

@@ -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);
}

205
scripts/mockRustDemo.mjs Normal file
View File

@@ -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 <db container> mariadb -u<user> -p<password> <rust demo db> < 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'));

View File

@@ -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',
);