#!/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 4 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'; 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 has to be STATED, not left to the module's default. // // `module-uo`'s schema fragment still carries `protocol INT NOT NULL DEFAULT 3`, from the // protocol-3 cutover; the sidecar on `link` `main` speaks 4. The module handles protocol 4's // frames — `guild.roster` and `guild.leave` ingest landed with the Teams cutover — but a // FRESH install pins 3, and the sidecar answers a 3 with `409 protocol version mismatch` on // every REST call. So a new deployment reads nothing from its shard until somebody edits the // number in Admin → Shard. Raised with the org lead rather than patched from here: the fix // belongs in `module-uo`, not in this repo's screenshot rig (PLAN.md §13 phase 9). const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || 4); 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:

  1. Sable, for ' + 'restocking the free reagent stall three times without being asked.
  2. Tobin, ' + 'for guiding two new players through their first dungeon.
  3. Mirena, for the ' + 'map corrections on the wiki.
  4. Brannwen, for writing up the champion ' + 'rotation.
  5. Aldric, for handling a difficult report quietly and well.
  6. ' + '
', 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: '

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: '

Before you connect

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.

The first hour

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: '

Hiring a vendor

Any house you own or co-own can hold vendors. Hire one ' + 'from an innkeeper and place it inside.

Being findable

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: '

Placement

Houses can be placed anywhere the client allows, with the ' + 'usual clearance rules. There is no lottery.

Decay

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: '

The rules

  1. No harassment, in game or on the site.
  2. No ' + 'exploiting bugs — report them instead, and you will usually be thanked in ' + 'public.
  3. One account per person for events with prizes.
' + '

Appeals

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= 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(SETTINGS)) say('~', `${k} = ${v}`); return; } const res = await call('PUT', '/admin/settings', SETTINGS); if (!res.ok) return fail('settings', res); for (const [k, v] of Object.entries(SETTINGS)) 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 USERS) { 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}`); } } // ── main ─────────────────────────────────────────────────────────────────────────────── console.log(DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo'); await login(); await settings(); await uoLink(); await users(); 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', );