feat(site): phase 18 - the Rust screenshots, web and app
All checks were successful
PR checks / checks (pull_request) Successful in 2m55s
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:
@@ -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',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user