feat(screens): phase 9 — real screenshots, from a real shard
All checks were successful
PR checks / checks (pull_request) Successful in 1m25s
All checks were successful
PR checks / checks (pull_request) Successful in 1m25s
D4 asked for screenshots of the review stack rather than placeholders. Seventeen
of them: eleven of the site in a browser, six of the app on a phone, all from one
demo deployment wired to a running ServUO shard over a real sidecar, captured on
one day (D42).
The deployment is branded "Runic Gateway Demo" rather than a real community (D43),
and the captures sit beside the claims they support — the homepage, /features/, and
five of the administration pages phase 7 could describe but not show (D44).
The rig is committed rather than remembered (D45):
scripts/seedDemo.mjs content, by driving the site's own API — never SQL,
because a row the product could not have produced is
a screenshot of a product that does not exist
src/data/screens.mjs every capture: route, viewport, scroll, alt, caption
scripts/captureScreens.mjs npm run screens:capture
scripts/checkScreens.mjs the ninth check script, in CI
Shard-side dressing is servuo-plugins' scaffolding (D46), never deployed.
The rig found five things nothing else had. One is fixed upstream — a fresh
module-uo install pinned wire protocol 3 against a sidecar speaking 4, released as
v1.0.2, which this repo's own facts check then caught in platform.json. Four are
raised as product observations and worked around in the rig: a renamed guild
member never reaches the site, a guild deleted while the shard is down is a ghost
row forever, "Houses in danger" cannot show a house that was already collapsing,
and the app's news list prints raw ISO timestamps.
Players online reads 0. Logging a character in needs a UO client driven by hand,
and that is where this stopped — PLAN.md §10 says exactly why, and how to retake
the two frames that would change.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
189
scripts/captureScreens.mjs
Normal file
189
scripts/captureScreens.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* captureScreens.mjs — retakes the screenshots in `src/data/screens.mjs`.
|
||||
*
|
||||
* PLAN.md §13 phase 9, D4 / D45.
|
||||
*
|
||||
* node scripts/captureScreens.mjs # every web screen
|
||||
* node scripts/captureScreens.mjs shard-status admin-users
|
||||
* RG_DEMO=http://localhost:3000 node scripts/captureScreens.mjs
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* AN AUTHORING TOOL, LIKE buildBrandAssets.mjs — NOT A CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* This never runs in CI and CI never needs it: its output is committed, because the site
|
||||
* must build from a clean checkout with no game server, no database and no browser. What
|
||||
* CI runs is `checkScreens.mjs`, which only reads the files this produced.
|
||||
*
|
||||
* It exists because D4 asks for real screenshots of a real deployment, and the way real
|
||||
* screenshots rot is that the recipe for taking them lives in somebody's memory. The rig
|
||||
* is written down in PLAN.md §13; the framing — route, viewport, scroll offset, whether to
|
||||
* sign in — is written down in `screens.mjs`; and this turns the two into files.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY puppeteer-core AND NOT puppeteer
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `puppeteer` downloads its own Chromium — a hundred-odd megabytes fetched on every clean
|
||||
* install of a repository that needs a browser once per redesign. `puppeteer-core` drives
|
||||
* a Chrome that is already on the machine, which every machine that can look at this site
|
||||
* has. Point `RG_CHROME` at it if it is somewhere unusual.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT SIGNS IN THROUGH THE API RATHER THAN THE LOGIN FORM
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The administration screens need a session, and typing into the login form is the part of
|
||||
* a browser script most likely to break on a redesign — a moved field, a renamed button, a
|
||||
* React input that ignores synthetic typing. The session cookie is the only thing actually
|
||||
* wanted, so this asks the API for one from inside the page and lets the browser store it.
|
||||
* If that call stops returning 200 the script says so and stops, rather than quietly
|
||||
* screenshotting a login screen twelve times.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { screens, screensOf, WEB } from '../src/data/screens.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = path.join(HERE, '..', 'public', 'screens');
|
||||
|
||||
const BASE = (process.env.RG_DEMO || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
||||
const PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
|
||||
|
||||
/** Where Chrome usually is, per platform. First hit wins; `RG_CHROME` beats all of them. */
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.RG_CHROME,
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
|
||||
const todo = screensOf('web').filter((shot) => wanted.length === 0 || wanted.includes(shot.id));
|
||||
|
||||
if (todo.length === 0) {
|
||||
const known = screens.map((shot) => shot.id).join(', ');
|
||||
console.error(`Nothing to capture. Known ids: ${known}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!chrome) {
|
||||
console.error(
|
||||
'No Chrome found. Set RG_CHROME to the browser executable — this script drives an\n' +
|
||||
'installed Chrome rather than downloading one (see the header).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: 'new',
|
||||
defaultViewport: { ...WEB.viewport, deviceScaleFactor: WEB.scale },
|
||||
// Scrollbars are the browser's furniture, not the product's, and a colour profile that
|
||||
// is not sRGB makes the palette in a screenshot disagree with the palette on the page.
|
||||
args: ['--hide-scrollbars', '--force-color-profile=srgb'],
|
||||
});
|
||||
|
||||
/**
|
||||
* One page per privilege level rather than signing in and out around each shot: signing
|
||||
* out is the step that gets forgotten, and a public page captured with an admin session
|
||||
* shows a navigation bar the public never sees.
|
||||
*/
|
||||
const anon = await browser.newPage();
|
||||
const admin = await browser.newPage();
|
||||
|
||||
await admin.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const status = await admin.evaluate(
|
||||
async (username, password) => {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status;
|
||||
},
|
||||
USER,
|
||||
PASS,
|
||||
);
|
||||
|
||||
if (status !== 200) {
|
||||
console.error(
|
||||
`Could not sign in as "${USER}" at ${BASE} (HTTP ${status}).\n` +
|
||||
'Seed the demo first — see PLAN.md §13 phase 9 and scripts/seedDemo.mjs.',
|
||||
);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
for (const shot of todo) {
|
||||
const page = shot.admin ? admin : anon;
|
||||
const url = BASE + shot.route;
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
|
||||
|
||||
if (shot.scrollY) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY);
|
||||
}
|
||||
|
||||
// Live pages settle after their first paint: a shard panel fills in from an event
|
||||
// stream, a list re-sorts once its data lands. A second is cheap and the difference
|
||||
// between a screenshot of the product and a screenshot of its loading state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
|
||||
const png = await page.screenshot({ type: 'png' });
|
||||
const file = path.join(OUT, `${shot.id}.webp`);
|
||||
|
||||
// Quality 82 is where UI text stops visibly softening; the files land near 150 KB,
|
||||
// which is what makes a page with five of them still a page and not a download.
|
||||
await sharp(png).webp({ quality: 82 }).toFile(file);
|
||||
|
||||
const meta = await sharp(file).metadata();
|
||||
|
||||
if (meta.width !== WEB.width || meta.height !== WEB.height) {
|
||||
console.error(
|
||||
` ! ${shot.id}: got ${meta.width}x${meta.height}, expected ${WEB.width}x${WEB.height}`,
|
||||
);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` + ${shot.id.padEnd(18)} ${shot.route.padEnd(20)} ${meta.width}x${meta.height}`);
|
||||
} catch (err) {
|
||||
console.error(` ! ${shot.id}: ${err.message}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// A file left behind by a screen that has since been renamed or dropped is a file the
|
||||
// site still ships and nothing points at. Say so; do not delete somebody's work silently.
|
||||
if (wanted.length === 0) {
|
||||
const declared = new Set(screensOf('web').map((shot) => `${shot.id}.webp`));
|
||||
const phones = new Set(screensOf('phone').map((shot) => `${shot.id}.webp`));
|
||||
const orphans = readdirSync(OUT).filter((name) => !declared.has(name) && !phones.has(name));
|
||||
|
||||
if (orphans.length > 0) {
|
||||
console.log(`\nNot declared in screens.mjs, left alone: ${orphans.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${todo.length - failures} captured, ${failures} failed.`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
171
scripts/checkScreens.mjs
Normal file
171
scripts/checkScreens.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkScreens.mjs — the screenshots agree with what the pages say about them.
|
||||
*
|
||||
* PLAN.md §12, §13 phase 9, D45.
|
||||
*
|
||||
* node scripts/checkScreens.mjs
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IT PROVES, AND WHY EACH ONE IS WORTH A CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 1. EVERY DECLARED SCREEN HAS A FILE. A missing image is invisible in review — the page
|
||||
* still builds, still lays out, and only a reader sees the broken frame.
|
||||
*
|
||||
* 2. EVERY FILE IS THE DECLARED SIZE. `width` and `height` reach the markup as intrinsic
|
||||
* attributes, and an attribute that disagrees with the file is a page that jumps as the
|
||||
* image decodes. It also catches a re-capture taken at the wrong viewport, which looks
|
||||
* fine on its own and wrong beside the others.
|
||||
*
|
||||
* 3. NOTHING IN public/screens IS ORPHANED. A capture that stopped being referenced is a
|
||||
* file the container still ships and nobody looks at — and, worse, one that never gets
|
||||
* retaken, so it silently becomes the oldest thing in the repository.
|
||||
*
|
||||
* 4. EVERY DECLARED SCREEN IS ACTUALLY USED. The mirror of 3: an entry in `screens.mjs`
|
||||
* that no page renders is a capture being maintained for nothing. Usage is a literal
|
||||
* search for the id across `src/`, which is how both readers of the data refer to one —
|
||||
* `<Screenshot id="admin-users" />` and the `groupScreens` map on `/features/`.
|
||||
*
|
||||
* 5. THE ALT TEXT AND CAPTION SAY SOMETHING. An empty alt on an editorial image is an
|
||||
* accessibility failure the build cannot otherwise see, and a caption is the sentence
|
||||
* that makes a screenshot evidence rather than decoration.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS THE PNG HEADER ITSELF
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* It does not: it reads the WebP header, and it does it with twenty lines rather than a
|
||||
* dependency. `sharp` is already here for the brand assets and could answer this, but this
|
||||
* check runs in CI on every pull request and a check that needs a native image library to
|
||||
* tell you a file is 1920 pixels wide is a check that will one day fail for a reason that
|
||||
* has nothing to do with screenshots.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { screens, WEB, PHONE } from '../src/data/screens.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(HERE, '..');
|
||||
const DIR = path.join(ROOT, 'public', 'screens');
|
||||
const SRC = path.join(ROOT, 'src');
|
||||
|
||||
const problems = [];
|
||||
|
||||
/**
|
||||
* The pixel size of a WebP file, from its header.
|
||||
*
|
||||
* A RIFF container: "RIFF" size "WEBP" then one of three chunk types. Lossy ("VP8 ") and
|
||||
* lossless ("VP8L") pack the dimensions differently, and an animated or extended file
|
||||
* ("VP8X") states them outright. `cwebp` at quality 82 writes VP8 , but a future change of
|
||||
* encoder should not turn this check into a mystery, so all three are handled.
|
||||
*/
|
||||
function webpSize(file) {
|
||||
const buf = readFileSync(file);
|
||||
|
||||
if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunk = buf.toString('ascii', 12, 16);
|
||||
|
||||
if (chunk === 'VP8X') {
|
||||
return {
|
||||
width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
|
||||
height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
|
||||
};
|
||||
}
|
||||
|
||||
if (chunk === 'VP8L') {
|
||||
const bits = buf[21] | (buf[22] << 8) | (buf[23] << 16) | (buf[24] << 24);
|
||||
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
||||
}
|
||||
|
||||
if (chunk === 'VP8 ') {
|
||||
return {
|
||||
width: buf.readUInt16LE(26) & 0x3fff,
|
||||
height: buf.readUInt16LE(28) & 0x3fff,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every file under `src/`, read once, so usage is a search rather than a guess. */
|
||||
function sourceText() {
|
||||
const out = [];
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (/\.(astro|mdx?|mjs|js|ts|tsx)$/.test(entry.name)) out.push(readFileSync(full, 'utf8'));
|
||||
}
|
||||
};
|
||||
|
||||
walk(SRC);
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = sourceText();
|
||||
const declared = new Set();
|
||||
|
||||
for (const shot of screens) {
|
||||
const name = `${shot.id}.webp`;
|
||||
const file = path.join(DIR, name);
|
||||
declared.add(name);
|
||||
|
||||
if (!existsSync(file)) {
|
||||
problems.push(
|
||||
`${shot.id}: no file at public/screens/${name}. ` +
|
||||
`Retake it: node scripts/captureScreens.mjs ${shot.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const want = shot.family === 'web' ? WEB : PHONE;
|
||||
const size = webpSize(file);
|
||||
|
||||
if (!size) {
|
||||
problems.push(`${shot.id}: public/screens/${name} is not a WebP this check can read.`);
|
||||
} else if (size.width !== want.width || size.height !== want.height) {
|
||||
problems.push(
|
||||
`${shot.id}: file is ${size.width}x${size.height}, ` +
|
||||
`declared ${want.width}x${want.height} for the "${shot.family}" family.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!shot.alt || shot.alt.length < 20) {
|
||||
problems.push(`${shot.id}: alt text is missing or too short to describe the screen.`);
|
||||
}
|
||||
|
||||
if (!shot.caption) {
|
||||
problems.push(`${shot.id}: no caption.`);
|
||||
}
|
||||
|
||||
const used = sources.some((text) => text.includes(`'${shot.id}'`) || text.includes(`"${shot.id}"`));
|
||||
|
||||
if (!used) {
|
||||
problems.push(
|
||||
`${shot.id}: declared but no page renders it. Use it, or delete the entry and its file.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(DIR)) {
|
||||
for (const name of readdirSync(DIR)) {
|
||||
if (!declared.has(name)) {
|
||||
problems.push(`public/screens/${name}: not declared in src/data/screens.mjs.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error(`\ncheckScreens: ${problems.length} problem(s)\n`);
|
||||
for (const problem of problems) console.error(` - ${problem}`);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`checkScreens: ${screens.length} screens, all present, sized and used.`);
|
||||
462
scripts/seedDemo.mjs
Normal file
462
scripts/seedDemo.mjs
Normal file
@@ -0,0 +1,462 @@
|
||||
#!/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:
|
||||
'<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(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',
|
||||
);
|
||||
Reference in New Issue
Block a user