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
718 lines
30 KiB
JavaScript
718 lines
30 KiB
JavaScript
#!/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',
|
||
);
|