Files
runicgateway.com/scripts/seedDemo.mjs
wtclaude c47e9c1aa4
All checks were successful
PR checks / checks (pull_request) Successful in 2m55s
feat(site): phase 18 - the Rust screenshots, web and app
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
2026-10-22 22:05:06 -05:00

718 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* seedDemo.mjs — the deployment the screenshots are taken of. PLAN.md §13 phase 9, D45.
*
* 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 (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
* ---------------------------------------------------------------------------------------
* Every row this creates could have been an INSERT, and every INSERT would have been a
* second implementation of a rule the website already owns: how a body is sanitized, what
* a slug may contain, which excerpt is derived when none is given, how a password is
* hashed. A seed that writes SQL directly produces a database the product could not have
* produced, and screenshots of that database show a product that does not exist.
*
* So this speaks HTTP to a running site, as an admin, through the same endpoints the admin
* panel calls. The cost is that the site has to be up; the benefit is that the content is
* real, and that this script keeps working when a column moves.
*
* ---------------------------------------------------------------------------------------
* WHY IT IS IDEMPOTENT RATHER THAN DESTRUCTIVE
* ---------------------------------------------------------------------------------------
* Re-running must not double the news list, and must not erase a screenshot rig somebody
* has been adjusting by hand. Every step therefore looks before it writes and reports
* `= exists` rather than failing. That also makes the script usable as a repair: point it
* at a stack that has drifted and it puts back only what is missing.
*
* What it deliberately does NOT create: anything the shard owns. Teams arrive from the
* guild board over the bridge, the marketplace from player vendors, the atlas from real
* spawners (PLAN.md §13 phase 9, D42). Seeding those would be inventing game state that
* the product is supposed to be showing, which is exactly what D4 forbids.
*/
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 || (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';
const UOLINK_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
// The pinned wire protocol, read from `platform.json` rather than written down here.
//
// It was a literal `4` until the Asset Bridge cutover, with a note explaining that
// `module-uo` pinned 3 on a fresh install while the sidecar spoke 4, so a new deployment
// read nothing from its shard until somebody edited the number in Admin → Shard. That debt
// has since been paid: the module's schema fragment defaults the column to the protocol its
// build speaks and carries a one-shot migration per bump, so both a fresh install and an
// upgraded one land on the right number by themselves.
//
// What remains is the rig's own reason to state it: this seed points a demo deployment at a
// sidecar, and if it pins the wrong number every REST call comes back `409`. Reading it from
// `platform.json` means the number is the one `checkFacts.mjs` verified against `link`'s
// `main` — so the rig cannot quietly drift two protocols behind the platform again, which is
// exactly what the literal did.
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || platform.games.uo.protocol);
const DRY = process.argv.includes('--dry-run');
// ── The demo deployment's identity (D43) ───────────────────────────────────────────────
//
// A neutral demo brand rather than UOMysticmoon: the screenshots show the platform, not a
// private shard, and §15's demo VM can wear the same identity so the imagery stays true the
// day it exists. The name is deliberately "… Demo" rather than an invented community —
// nobody should have to wonder whether they are looking at a real server they could join.
// The published contact address lives in exactly one file in this repository (D13), and
// `checkFacts.mjs` fails the build if a literal address appears anywhere else — including
// here. So the demo wears the same address the site publishes, read from the same place.
const brandDefault = JSON.parse(
readFileSync(new URL('../brand-default/brand.json', import.meta.url), 'utf8'),
);
const SETTINGS = {
site_title: 'Runic Gateway Demo',
site_mode: 'live',
status_message: 'Live — the demo shard is up.',
homepage_teaser:
'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' +
'live game server. Everything on this site is real data from the shard behind it.',
contact_email: brandDefault.contactEmail,
};
// ── The cast ───────────────────────────────────────────────────────────────────────────
//
// Five accounts, one per role the admin screens distinguish, so a screenshot of the users
// table shows the role column doing something. Names are ordinary fantasy given names and
// belong to nobody.
const USERS = [
{ username: 'aldricmoss', role: 'moderator' },
{ username: 'brannwen', role: 'editor' },
{ username: 'sablequill', role: 'player' },
{ username: 'tobinreed', role: 'player' },
{ 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
// maintenance notice and a Friday post. Bodies are short HTML because that is what the
// editor stores, and the list screens show the excerpt anyway.
const POSTS = [
{
category: 'news',
title: 'Autumn patch: vendor search, and a fix for house decay',
excerpt:
'Player-vendor listings are now searchable from the site, and the decay timer no ' +
'longer resets when a co-owner logs in.',
body:
'<p>The autumn patch is live. The headline change is that <strong>every player ' +
'vendor on the shard is now searchable from this site</strong> — the marketplace ' +
'page reads the same live feed the game does, so a listing appears within a minute ' +
'of being priced.</p><p>We also fixed the house decay timer resetting when a ' +
'co-owner logged in. That bug had been quietly keeping condemned houses alive since ' +
'spring.</p><p>Full notes are on the wiki.</p>',
published: true,
},
{
category: 'news',
title: 'The Harvest Moon festival opens this weekend',
excerpt:
'Three days of gatherings at the crossroads, with a champion spawn on the last ' +
'night. Everyone is welcome, no signup needed.',
body:
'<p>The Harvest Moon festival runs from Friday evening to Sunday night at the ' +
'crossroads north of town. There is no signup and no entry fee — turn up.</p>' +
'<p>Saturday is the market day; bring anything you want to sell and we will set out ' +
'extra vendor stalls. Sunday night closes with a champion spawn, which will be ' +
'announced in game and on the shard status page here.</p>',
published: true,
},
{
category: 'news',
title: 'Scheduled maintenance, Tuesday 03:00 UTC',
excerpt:
'About twenty minutes of downtime for a server restart and a world save. The site ' +
'stays up throughout.',
body:
'<p>We are restarting the shard on Tuesday at 03:00 UTC for a world save and a ' +
'server update. Expect about twenty minutes of downtime.</p><p>This site stays up ' +
'while the shard is down — the status panel will simply show the shard as offline, ' +
'and the marketplace and atlas will show their last known state.</p>',
published: true,
},
{
category: 'five-on-friday',
title: 'Five on Friday: the ones who keep the roads clear',
excerpt:
'Five players who spent the week doing unglamorous work, and what they were up to.',
body:
'<p>Five people who made the week better for everybody else:</p><ol><li>Sable, for ' +
'restocking the free reagent stall three times without being asked.</li><li>Tobin, ' +
'for guiding two new players through their first dungeon.</li><li>Mirena, for the ' +
'map corrections on the wiki.</li><li>Brannwen, for writing up the champion ' +
'rotation.</li><li>Aldric, for handling a difficult report quietly and well.</li>' +
'</ol>',
published: true,
},
{
category: 'newsletter',
title: 'Monthly notes — what changed, and what is next',
excerpt:
'A month of changes in one place: the vendor search, the new guides, and what we ' +
'are working on next.',
body:
'<p>A quiet, productive month. The vendor search shipped, the wiki gained four ' +
'guides, and the guild boards now update on the site within a minute of a change in ' +
'game.</p><p>Next month we are looking at the champion boards and at making the ' +
'atlas easier to read on a phone.</p>',
published: true,
},
];
// ── The wiki ───────────────────────────────────────────────────────────────────────────
//
// One category and four pages, because the wiki index screenshot needs a category with
// enough in it to look like a wiki rather than a placeholder.
const WIKI_CATEGORY = {
slug: 'guides',
title: 'Guides',
description: 'How things work here, written by the people who play here.',
};
const WIKI_PAGES = [
{
slug: 'getting-started',
title: 'Getting started',
excerpt: 'What to install, how to connect, and the first hour.',
body:
'<h2>Before you connect</h2><p>You need a game client and an account. Make the ' +
'account on this site — the shard accepts accounts created here, and it saves you ' +
'typing your password into a chat window.</p><h2>The first hour</h2><p>Start in ' +
'town, take the newcomer quest, and do not sell your starting tools. If you get ' +
'stuck, ask in Discord: somebody is usually around.</p>',
},
{
slug: 'player-vendors',
title: 'Player vendors',
excerpt: 'How to hire one, how to price, and how the site search finds you.',
body:
'<h2>Hiring a vendor</h2><p>Any house you own or co-own can hold vendors. Hire one ' +
'from an innkeeper and place it inside.</p><h2>Being findable</h2><p>Everything a ' +
'vendor holds is published to the marketplace on this site within about a minute, ' +
'including the price and the house it stands in. If a listing looks stale, the ' +
'shard was probably down when you priced it — it will correct itself on the next ' +
'sweep.</p>',
},
{
slug: 'housing-and-decay',
title: 'Housing and decay',
excerpt: 'Placement rules, the decay timer, and what IDOC actually means here.',
body:
'<h2>Placement</h2><p>Houses can be placed anywhere the client allows, with the ' +
'usual clearance rules. There is no lottery.</p><h2>Decay</h2><p>A house decays if ' +
'nobody with access logs in for long enough. The site lists houses approaching ' +
'collapse on the housing page, which is the same data the game uses — not a ' +
'prediction.</p>',
},
{
slug: 'community-rules',
title: 'Community rules',
excerpt: 'The short version: do not be the reason somebody stops playing.',
body:
'<h2>The rules</h2><ol><li>No harassment, in game or on the site.</li><li>No ' +
'exploiting bugs — report them instead, and you will usually be thanked in ' +
'public.</li><li>One account per person for events with prizes.</li></ol>' +
'<h2>Appeals</h2><p>Every moderation action can be appealed from your account page. ' +
'An appeal is read by somebody who was not involved in the original action.</p>',
},
];
// ── HTTP plumbing ──────────────────────────────────────────────────────────────────────
//
// One cookie jar, because the session is a cookie and `fetch` has no jar of its own. Only
// the value of the auth cookie matters, so this keeps exactly that.
let cookie = '';
let created = 0;
let existed = 0;
function keepCookies(res) {
const raw = res.headers.getSetCookie?.() ?? [];
for (const line of raw) {
const [pair] = line.split(';');
if (pair.trim()) cookie = pair.trim();
}
}
async function call(method, path, body) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
keepCookies(res);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return { ok: res.ok, status: res.status, data };
}
function say(mark, what) {
console.log(` ${mark} ${what}`);
if (mark === '+') created += 1;
if (mark === '=') existed += 1;
}
function fail(what, res) {
console.error(`\n ! ${what} failed — HTTP ${res.status}`);
console.error(` ${JSON.stringify(res.data)?.slice(0, 400)}`);
process.exitCode = 1;
}
// ── The steps ──────────────────────────────────────────────────────────────────────────
async function login() {
const res = await call('POST', '/auth/login', { username: ADMIN_USER, password: ADMIN_PASS });
if (!res.ok) {
console.error(
`\nCould not log in as "${ADMIN_USER}". Create the admin first, from the website repo:\n` +
` cd website/server && DB_NAME=<demo db> ADMIN_USERNAME=${ADMIN_USER} ` +
`ADMIN_PASSWORD='…' node db/seed.js\n`,
);
fail('login', res);
process.exit(1);
}
console.log(`\nsigned in as ${ADMIN_USER} at ${BASE}`);
}
async function settings() {
console.log('\nsite settings (D43 — the neutral demo identity)');
if (DRY) {
for (const [k, v] of Object.entries(siteSettings)) say('~', `${k} = ${v}`);
return;
}
const res = await call('PUT', '/admin/settings', siteSettings);
if (!res.ok) return fail('settings', res);
for (const [k, v] of Object.entries(siteSettings)) say('+', `${k} = ${String(v).slice(0, 60)}`);
}
async function uoLink() {
console.log('\nshard connection (Admin → Shard)');
if (!UOLINK_TOKEN) {
say('~', 'UOLINK_TOKEN unset — leaving the sidecar config alone');
return;
}
const now = await call('GET', '/admin/uo-link/config');
if (now.status === 404) {
say('~', 'no /admin/uo-link route — the uo module is not installed');
return;
}
if (
now.ok &&
now.data?.config?.baseUrl === UOLINK_BASE &&
now.data?.config?.protocol === UOLINK_PROTOCOL &&
now.data?.config?.enabled
) {
say('=', `already pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
return;
}
if (DRY) return say('~', `would point the site at ${UOLINK_BASE}`);
const res = await call('PUT', '/admin/uo-link/config', {
baseUrl: UOLINK_BASE,
wsUrl: UOLINK_WS,
token: UOLINK_TOKEN,
protocol: UOLINK_PROTOCOL,
enabled: true,
});
if (!res.ok) return fail('uo-link config', res);
say('+', `pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
}
async function users() {
console.log('\naccounts');
const list = await call('GET', '/admin/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 cast) {
if (have.has(user.username)) {
say('=', `${user.username} (${user.role})`);
continue;
}
if (DRY) {
say('~', `${user.username} (${user.role})`);
continue;
}
const res = await call('POST', '/admin/users', {
username: user.username,
password: DEMO_PASS,
role: user.role,
});
if (!res.ok) {
fail(`create ${user.username}`, res);
continue;
}
say('+', `${user.username} (${user.role})`);
}
}
async function posts() {
console.log('\nposts');
const list = await call('GET', '/admin/posts');
if (!list.ok) return fail('list posts', list);
const rows = Array.isArray(list.data) ? list.data : (list.data?.posts ?? []);
const have = new Set(rows.map((p) => p.title));
for (const post of POSTS) {
if (have.has(post.title)) {
say('=', `${post.category}: ${post.title}`);
continue;
}
if (DRY) {
say('~', `${post.category}: ${post.title}`);
continue;
}
const res = await call('POST', '/admin/posts', post);
if (!res.ok) {
fail(`create post "${post.title}"`, res);
continue;
}
say('+', `${post.category}: ${post.title}`);
}
}
async function wiki() {
console.log('\nwiki');
const cats = await call('GET', '/admin/wiki/categories');
if (!cats.ok) return fail('list wiki categories', cats);
const catRows = Array.isArray(cats.data) ? cats.data : (cats.data?.categories ?? []);
let category = catRows.find((c) => c.slug === WIKI_CATEGORY.slug);
if (category) {
say('=', `category ${WIKI_CATEGORY.slug}`);
} else if (DRY) {
say('~', `category ${WIKI_CATEGORY.slug}`);
} else {
const res = await call('POST', '/admin/wiki/categories', WIKI_CATEGORY);
if (!res.ok) return fail('create wiki category', res);
category = res.data?.category ?? res.data;
say('+', `category ${WIKI_CATEGORY.slug}`);
}
const pages = await call('GET', '/admin/wiki');
if (!pages.ok) return fail('list wiki pages', pages);
const pageRows = Array.isArray(pages.data) ? pages.data : (pages.data?.pages ?? []);
const have = new Set(pageRows.map((p) => p.slug));
for (const page of WIKI_PAGES) {
if (have.has(page.slug)) {
say('=', `page ${page.slug}`);
continue;
}
if (DRY) {
say('~', `page ${page.slug}`);
continue;
}
const res = await call('POST', '/admin/wiki', {
...page,
category_id: category?.id ?? null,
published: true,
});
if (!res.ok) {
fail(`create wiki page "${page.slug}"`, res);
continue;
}
say('+', `page ${page.slug}`);
}
}
// ── 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'} (${GAME})`,
);
await login();
await settings();
await users();
// 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(
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',
);