#!/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 * * 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_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 * * --------------------------------------------------------------------------------------- * 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 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_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.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' }, ]; // ── 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: '
The autumn patch is live. The headline change is that every player ' + 'vendor on the shard is now searchable from this site — the marketplace ' + 'page reads the same live feed the game does, so a listing appears within a minute ' + 'of being priced.
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.
Full notes are on the wiki.
', 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: '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.
' + '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.
', 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: '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.
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.
', 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: 'Five people who made the week better for everybody else:
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.
Next month we are looking at the champion boards and at making the ' + 'atlas easier to read on a phone.
', 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: '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.
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.
', }, { slug: 'player-vendors', title: 'Player vendors', excerpt: 'How to hire one, how to price, and how the site search finds you.', body: 'Any house you own or co-own can hold vendors. Hire one ' + 'from an innkeeper and place it inside.
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.
', }, { slug: 'housing-and-decay', title: 'Housing and decay', excerpt: 'Placement rules, the decay timer, and what IDOC actually means here.', body: 'Houses can be placed anywhere the client allows, with the ' + 'usual clearance rules. There is no lottery.
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.
', }, { slug: 'community-rules', title: 'Community rules', excerpt: 'The short version: do not be the reason somebody stops playing.', body: 'Every moderation action can be appealed from your account page. ' + 'An appeal is read by somebody who was not involved in the original action.
', }, ]; // ── 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=