feat(site): phase 17 - per-game module pages, /runicnpc/, and checkTerms over the built site
All checks were successful
PR checks / checks (pull_request) Successful in 2m37s

PLAN.md §16 phase 17, built on the org lead's answers of 2026-10-22,
recorded as D75-D79 (count of record 79).

- D75: core marketing pages describe what a game module adds in core's
  words. "Game intelligence" became "What a game module adds"; each
  game's own list moved to src/data/games/ and renders only on the new
  /modules/uo/ and /modules/rust/. /modules/ is a card per game.
- The page-side capability check is per game (assertGameCoverage), the
  item phase 13 moved here.
- D76: the homepage keeps its game screenshot, labelled. screens.mjs
  entries may carry `game` (D67); Screenshot.astro and the app grid
  print the game's name and mark the figure data-game.
- D77: /app/ describes the Rust screens (released the same day).
- D78: /runicnpc/, the standalone page, is in the header and footer;
  the narrow header was re-measured so five links keep two rows.
- D79: checkTerms.mjs --built reads every built page's visible text
  after the build; game routes and data-game elements allow their
  game's words. Proven against ten plants. Still the twelfth check.
- Re-read fixes: /architecture/'s "two machines is also the maximum",
  Discord "guild" on /integrations/, and collection.mjs against what
  Steam sign-in and the Rust module keep (PLAY_DATA_SAFETY.md
  regenerated; the Play answers are unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-22 20:10:51 -05:00
parent ec65b2d5aa
commit 56473d4d51
32 changed files with 1924 additions and 451 deletions

View File

@@ -0,0 +1,107 @@
---
import type { Capability } from '../data/capabilities.mjs';
/**
* A game's own feature list, with the argument for each — PLAN.md §16, phase 17.
*
* Rendered only by a game's page under `/modules/` (`GAME_ROUTES` in `checkTerms.mjs`),
* which is what lets the items be written in the game's own words. It is `/features/`'s
* card treatment (D20: the label and the detail, never the label alone), so a reader who has
* seen that page reads this one the same way.
*
* A feature with a `demoPath` gets the per-capability demo link of D25. The markup is the
* exact contract `scripts/checkBrand.mjs` enforces and `applyBrand.mjs` rewrites at boot —
* see the note in `/features/` before touching it. A stock build hides every one.
*/
interface Props {
title: string;
lede?: string;
items: Capability[];
}
const { title, lede, items } = Astro.props;
---
<section class="page section feats">
<h2>{title}</h2>
{lede && <p class="prose feats__lede">{lede}</p>}
<ul class="feats__items">
{
items.map((item) => (
<li class="panel feats__item">
<h3>{item.label}</h3>
<p class="feats__detail">{item.detail}</p>
{item.demoPath && (
<a
class="demo-link"
href="" data-demo-url="" data-demo-path={item.demoPath}
rel="noopener noreferrer"
>
See it running
</a>
)}
</li>
))
}
</ul>
</section>
<style>
.feats h2 {
margin: 0;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.feats__lede {
margin: 0.85rem 0 0;
color: var(--muted);
}
.feats__items {
display: grid;
gap: 1rem;
margin: 1.75rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
}
.feats__item {
display: flex;
flex-direction: column;
}
.feats__item h3 {
margin: 0 0 0.6rem;
color: var(--gold);
font-size: 1.04rem;
}
.feats__detail {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
/* As on /features/: a link rather than a button, and the flex item itself, so the
global rule that hides an empty demo link takes its margin with it. */
.demo-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 1rem;
color: var(--portal);
font-size: 0.88rem;
text-decoration-color: color-mix(in srgb, var(--portal) 40%, transparent);
}
.demo-link:hover {
color: var(--portal-bright);
}
.demo-link::after {
content: '\2197';
}
</style>

View File

@@ -28,6 +28,8 @@ const brand = renderBrand(Astro);
const links = [
{ href: '/features/', label: 'Features' },
{ href: '/docs/', label: 'Docs' },
// D78 (phase 17): the standalone RunicNPC page, for a Rust server owner who runs no site.
{ href: '/runicnpc/', label: 'RunicNPC' },
{ href: '/app/', label: 'App' },
{ href: '/community/', label: 'Community' },
];

View File

@@ -1,5 +1,6 @@
---
import { screenById, WEB, PHONE } from '../data/screens.mjs';
import platform from '../data/platform.json';
/**
* One screenshot, as a figure with its caption. PLAN.md §13 phase 9, D4 / D44.
@@ -44,9 +45,16 @@ const src = `/screens/${shot.id}.webp`;
// taken at one geometry (see screens.mjs), and `checkScreens.mjs` asserts the files really
// are that size, so these attributes cannot drift from the pixels.
const { width, height } = shot.family === 'web' ? WEB : PHONE;
// D67: a game's screen says whose it is. The name comes from `platform.json`, as
// GameExample's does, so the label cannot disagree with the tag the check reads.
const game = shot.game ? platform.games[shot.game] : null;
if (shot.game && !game) {
throw new Error(`Screenshot "${id}" is tagged game "${shot.game}", which platform.json does not have`);
}
---
<figure class="shot">
<figure class="shot" data-game={shot.game}>
<img
src={src}
alt={shot.alt}
@@ -55,7 +63,14 @@ const { width, height } = shot.family === 'web' ? WEB : PHONE;
loading="lazy"
decoding="async"
/>
{!bare && <figcaption>{shot.caption}</figcaption>}
{
(game || !bare) && (
<figcaption>
{game && <span class="shot__game">{game.name}</span>}
{!bare && shot.caption}
</figcaption>
)
}
</figure>
<style>
@@ -78,4 +93,18 @@ const { width, height } = shot.family === 'web' ? WEB : PHONE;
font-size: 0.9rem;
line-height: 1.5;
}
/* The game's name, ahead of the caption (D67). Styled here rather than with the global
chip, because the docs pages render this component without the marketing stylesheet. */
.shot__game {
display: inline-block;
margin-right: 0.5rem;
padding: 0.05rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--muted);
font-size: 0.76rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,63 @@
---
import { games } from '../data/games/index.mjs';
/**
* "Supported games: Ultima Online · Rust" — PLAN.md §16, D63.
*
* The one place a core page names the games, and the way it hands a reader on to them. Core
* pages describe what a game module adds in core's words (D75); this strip is the link to
* the page where each game says it in its own. The names come from `platform.json` through
* `games/index.mjs`, so a third game appears here the day it is added there.
*/
interface Props {
/** Extra class for the caller's spacing. */
class?: string;
}
const { class: className } = Astro.props;
---
<p class:list={['games', className]}>
<span class="games__label">Supported games</span>
{
games.map((game, index) => (
<>
{index > 0 && <span class="games__sep" aria-hidden="true">·</span>}
<a class="games__link" href={game.href}>
{game.name}
</a>
</>
))
}
</p>
<style>
.games {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.65rem;
margin: 0;
font-size: 0.95rem;
}
.games__label {
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.games__label::after {
content: ':';
}
.games__sep {
color: var(--dim);
}
.games__link {
color: var(--gold);
font-weight: 600;
}
</style>

View File

@@ -1,5 +1,6 @@
---
import { screensOf, PHONE } from '../../data/screens.mjs';
import platform from '../../data/platform.json';
/**
* The app's screenshot strip. Reserved in phase 5 (D26), filled in phase 9.
@@ -45,7 +46,7 @@ const shots = screensOf('phone');
<ul class="shots__grid">
{shots.map((shot) => (
<li class="shots__item">
<li class="shots__item" data-game={shot.game}>
<img
src={`/screens/${shot.id}.webp`}
alt={shot.alt}
@@ -54,7 +55,10 @@ const shots = screensOf('phone');
loading="lazy"
decoding="async"
/>
<p class="shots__caption">{shot.caption}</p>
<p class="shots__caption">
{shot.game && <span class="shots__game">{platform.games[shot.game].name}</span>}
{shot.caption}
</p>
</li>
))}
</ul>
@@ -91,6 +95,19 @@ const shots = screensOf('phone');
box-shadow: var(--shadow-card);
}
/* The game's name on a game's screen (D67), as Screenshot.astro prints it. */
.shots__game {
display: inline-block;
margin-right: 0.5rem;
padding: 0.05rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
.shots__caption {
margin: 0.85rem 0 0;
color: var(--dim);

View File

@@ -26,8 +26,8 @@ import platform from '../../data/platform.json';
<p class="eyebrow">Where the game stops</p>
<h2>A seam, with a version on it</h2>
<p class="prose">
The core site does not know what a shard is, what a guild is, or that Ultima Online
exists. Everything that does lives in an installable module on the other side of a
The core site does not know what game it serves, what that game calls a group of
players, or that Ultima Online and Rust exist. Everything that does lives in an installable module on the other side of a
declared interface — which is what makes "put your game on it" a shape rather than a
slogan.
</p>
@@ -123,8 +123,8 @@ import platform from '../../data/platform.json';
<p>
Core owns the Teams primitive — the roster, the forum, the notifications, the voice
channel — and does not own the <em>word</em>. A Team cannot be created in core at
all; it arrives from the module, which is why the UO module calls them guilds and
builds those pages itself. That is the pattern the whole interface is built on: core
all; it arrives from the module, which is why each module calls them by its own
game’s word and builds those pages itself. That is the pattern the whole interface is built on: core
supplies the machinery, the module supplies the meaning.
</p>
</section>

View File

@@ -9,7 +9,10 @@
* doing capacity planning, and "how many machines is this" is the first question they have.
*
* Drawn generically for the same reason the homepage's diagram is (D17) — "your game host",
* not "your ServUO box" — with the prose beside it naming the real components. The boundary
* not a particular game's server — with the prose beside it linking to each game's page,
* which names its real components (D75). Until phase 17 the caption named one game's and
* said two machines was also the maximum, which stopped being true when Rust shipped: one
* sidecar serves one server, so six servers means six sidecars. The boundary
* is the one drawn argument: everything above it is reachable because you published it, and
* everything below it is not reachable at all.
*
@@ -73,8 +76,10 @@
</svg>
<p class="diagram__caption">
Today the game server is a ServUO shard and the sidecar is uo-link. Two machines is
the minimum and also the maximum — nothing here scales by adding a third.
The sidecar and the bridge plugin are each game’s own — see
<a href="/modules/uo/">Ultima Online</a> and <a href="/modules/rust/">Rust</a>. Two
machines is the minimum. A community running several game servers runs a sidecar
beside each one, on whichever host that server already lives on.
</p>
</div>
@@ -92,10 +97,10 @@
<section>
<h3>The game host</h3>
<p>
The machine your game server is already on. One installer binary puts the plugin
into the server's tree, installs the sidecar beside it and registers the service —
then prints four values. It never contacts your website; you paste those four
values into the admin panel yourself, and that is the moment the two halves meet.
The machine your game server is already on. One installer binary puts the bridge
plugin into the server's tree, installs the sidecar beside it and registers the
service — then prints what the site needs. It never contacts your website; you enter
those values in the admin panel yourself, and that is the moment the two halves meet.
</p>
</section>

View File

@@ -1,6 +1,7 @@
---
import platform from '../../data/platform.json';
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
import { capabilityGroups } from '../../data/capabilities.mjs';
import { games, assertGameCoverage } from '../../data/games/index.mjs';
import SupportedGames from '../SupportedGames.astro';
/**
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
@@ -8,8 +9,10 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
* copy to keep true.
*
* The call below is the point of the exercise: it throws, and therefore fails the build, if
* the "Game intelligence" list and the module's own declared capabilities have drifted
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
* either game's own feature list (`src/data/games/`) and its module's declared capabilities
* have drifted apart. The homepage names neither list — its module-supplied group is the
* neutral one, in core's words (D75) — but it is the page a reader meets first, so it is
* the page that should refuse to build when a game page is out of date. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
* this makes the page honest against `platform.json`, which is the half that was missing.
*
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
@@ -17,17 +20,18 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
* what exists while quietly omitting the well-known things that do not is the exact failure
* §1 is written to prevent.
*/
assertCapabilityCoverage(platform.games.uo.module.capabilities);
for (const game of games) assertGameCoverage(game.key);
---
<section class="page section caps">
<p class="eyebrow">What it does</p>
<h2>A community site, and a window into the game</h2>
<p class="prose caps__lede">
The core is game-agnostic: it does not know what a shard is. Everything that does arrives
as an installable <a href="/modules/">module</a>, which is why the same platform can carry
a different game without a fork.
The core is game-agnostic: it does not know what game you run. Everything that does
arrives as an installable <a href="/modules/">module</a>, which is why the same platform
carries two games without a fork.
</p>
<SupportedGames class="caps__games" />
<div class="caps__grid">
{
@@ -69,6 +73,10 @@ assertCapabilityCoverage(platform.games.uo.module.capabilities);
color: var(--muted);
}
.caps__games {
margin-top: 1.25rem;
}
.caps__grid {
display: grid;
gap: 1rem;

View File

@@ -4,7 +4,7 @@ import { brand } from '../../lib/brand.mjs';
/**
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
* "installation path": a "Runic Gateway install" is two independent installs. The installer
* binary sets up the shard side only and never contacts the website; the website is a
* binary sets up the game side only and never contacts the website; the website is a
* separate Docker deployment.
*
* That belongs on the homepage rather than being saved for the docs. It is the single
@@ -13,7 +13,9 @@ import { brand } from '../../lib/brand.mjs';
* project. Saying it here is what makes the docs a confirmation rather than a surprise.
*
* The two halves are ordered site-first because that is the order they must be done in: the
* shard side ends by pasting four values into the site's admin panel, which has to exist.
* game side ends by entering what the installer printed into the site's admin panel, which
* has to exist. Which screen that is belongs to the game module, so this says "the game
* module's screen" rather than naming one game's menu (D63).
*
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
@@ -44,9 +46,10 @@ import { brand } from '../../lib/brand.mjs';
<div class="start__half">
<h3><span class="start__num">2</span> The game side</h3>
<p>
One binary, run on the machine the game server already lives on. It syncs the plugin,
installs the sidecar as a service, and prints four values. You paste those into
Admin → Shard, and the two halves find each other.
One binary, run on the machine the game server already lives on — or, for Rust, a
Pterodactyl egg. It puts the bridge plugin in place, installs the sidecar, and prints
what the site needs. You enter that on the game module’s screen in the admin panel,
and the two halves find each other.
</p>
</div>
</div>

View File

@@ -9,12 +9,18 @@ import Screenshot from '../Screenshot.astro';
* -----------------------------------------------------------------------------------------
* `DataPath` above it draws the claim — a private game server, a sidecar, a public site —
* and a diagram of a data path is a promise that the data arrives. This is the page where
* it arrives, captured from a deployment wired to a running shard, so the section directly
* under the diagram is the diagram's evidence.
* it arrives, captured from a deployment wired to a running game server, so the section
* directly under the diagram is the diagram's evidence.
*
* It is one game's screen, and says so (D76, taken in phase 17 under D67): the screenshot is
* tagged `uo` in `screens.mjs`, so it prints "Ultima Online" beside it and may keep that
* game's words in its alt and caption. A core screen was the alternative and was declined:
* the claim here is live game state on a public page, which no core screen shows. Phase 18
* adds a Rust capture beside it, labelled the same way.
*
* A gallery here would compete with `Capabilities` further down, which is the part of the
* homepage that enumerates. So: one figure, the signature screen, and the rest of the set
* on `/features/` where each one sits beside the claim it supports.
* on each game's page under `/modules/`, where each one sits beside the claim it supports.
*/
---
@@ -22,9 +28,9 @@ import Screenshot from '../Screenshot.astro';
<p class="eyebrow">What it looks like</p>
<h2>The other end of that diagram</h2>
<p class="prose looks__lede">
A demo deployment with a real shard behind it. The gold supply, the state of the link and
the player online in Britain are all read from the game server over the bridge. None of it
is typed in, and none of it is a mock-up.
A demo deployment with a real Ultima Online server behind it. The gold supply, the state
of the link and the player online in Britain are all read from the game server over the
bridge. None of it is typed in, and none of it is a mock-up.
</p>
<Screenshot id="shard-status" />

View File

@@ -4,9 +4,9 @@
* ---------------------------------------------------------------------------------------
* WHY THIS IS NOT capabilities.mjs
* ---------------------------------------------------------------------------------------
* `capabilities.mjs` describes what a DEPLOYMENT does, and it is checked against
* `module-uo`'s manifest because the module declares its capabilities in a machine-readable
* file. The app declares nothing of the kind: what it does is a set of screens in
* `capabilities.mjs` and `games/` describe what a DEPLOYMENT does, and each game's list is
* checked against its module's manifest because a module declares its capabilities in a
* machine-readable file. The app declares nothing of the kind: what it does is a set of screens in
* `Routes.kt`, and there is no manifest to diff against. So these are written from that
* file and carry the route names, which is the closest thing to a citation available — a
* reader who wants to check a claim here has a file to open.
@@ -18,11 +18,19 @@
* ---------------------------------------------------------------------------------------
* "GATED BY WHAT THE DEPLOYMENT PUBLISHES" IS LOAD-BEARING, NOT A DISCLAIMER
* ---------------------------------------------------------------------------------------
* Almost everything here is conditional on the site the app is pointed at: shard screens
* Almost everything here is conditional on the site the app is pointed at: game screens
* appear only when the deployment runs a game module and has that visibility feature
* turned on, staff screens only for a staff account, push only where the operator runs an
* ntfy. A features list that omitted that would be describing an app nobody will see,
* because there is no default deployment — see `requiresDeployment` below.
*
* ---------------------------------------------------------------------------------------
* ONE ITEM PER GAME, NAMED BY THE GAME (phase 17, D75 and D77)
* ---------------------------------------------------------------------------------------
* `/app/` is a core page, so it names the games but not their vocabulary. The game screens
* are one item per game, titled with the game's name and described in core's words. The
* Rust screens (`ui/rust/` on `Android-app`) were on `edge` when this was written; the org
* lead released them the same day (D77), so the page describes them as shipped.
*/
/**
@@ -54,33 +62,29 @@ export const requiresDeployment = {
/** @type {{ heading: string, blurb: string, items: AppFeature[] }[]} */
export const appFeatures = [
{
heading: 'The shard, live',
heading: 'The game, live',
blurb:
'The same feed the website shows, on a phone. Every one of these appears only when ' +
'the deployment runs a game module and the operator has published that surface — ' +
'the visibility settings are per-feature and default to off.',
items: [
{
title: 'Status, and the boards',
title: 'Ultima Online: the server, its boards and its market',
body:
'Whether the game server is up, plus champion spawns, guilds, city governors and ' +
'houses as the shard reports them.',
gate: 'A game module, and the matching visibility feature',
'Whether the game server is up and what it reports — its champions, Teams, ' +
'governors and houses — plus the player marketplace down to a single vendor, the ' +
"published ruleset, the leaderboards and the creature atlas. Item names are " +
"resolved against the game's own string table, so they read the way they read in " +
'the client.',
gate: 'The Ultima Online module, per feature',
},
{
title: 'The player marketplace',
title: 'Rust: every server, live',
body:
'Player-run vendors and what is on their shelves, down to a single vendor. Item ' +
"names arrive as the game's own string ids and are resolved against its string " +
'table, so they read the way they read in the client.',
gate: 'A game module publishing the market',
},
{
title: 'Rules, leaderboards and the spawn atlas',
body:
"The deployment's published ruleset, its points and loyalty boards, and the " +
'creature atlas — what spawns where, and what it drops.',
gate: 'A game module, per feature',
'Every server the community runs, and a page for each with its feed, its ' +
'leaderboard, who is online, its history on the site, and its live map with the ' +
'layers you may see — positions refreshed every ten seconds while the map is open.',
gate: 'The Rust module, per feature',
},
],
},
@@ -117,10 +121,11 @@ export const appFeatures = [
'per device from the app.',
},
{
title: 'Your characters, vendors and houses',
title: 'Your own side of the game',
body:
'Character sheets, the vendors you run and the houses you own, visible to the ' +
'account they belong to and to nobody else.',
'On Ultima Online, your character sheets, the vendors you run and the houses you ' +
'own; on Rust, the Steam accounts you have linked, your coins and your backpack. ' +
'Visible to the account they belong to and to nobody else.',
gate: 'A game module',
},
],

View File

@@ -16,23 +16,17 @@
* the homepage without an argument to go with it.
*
* ---------------------------------------------------------------------------------------
* THE PART THAT IS A CHECK, NOT A LIST
* THE ONE GROUP CORE DOES NOT SUPPLY, IN CORE'S WORDS
* ---------------------------------------------------------------------------------------
* "Game intelligence" is the only group core does not supply — it comes from whichever
* module is installed, and today that is `module-uo`. Its items therefore carry the
* capability slugs the module actually declares in its `module.json`, and
* `assertCapabilityCoverage()` fails the build if the two lists drift apart.
* "What a game module adds" is the only group core does not supply — it comes from
* whichever module is installed. Until phase 17 it was "Game intelligence" and listed the
* Ultima Online module's features in that game's words, on the homepage and `/features/`.
* Two modules exist now, and D63 keeps a game's vocabulary on its own pages, so the group
* says what BOTH modules add, in core's words, and each game's real list lives in
* `src/data/games/` and on its page under `/modules/` (D75).
*
* That closes a real gap. `platform.json` holds `games.uo.module.capabilities` and
* `scripts/checkFacts.mjs` re-reads it from the module's manifest on every build — so the
* day `module-uo` gains a capability, the JSON goes red and someone updates it. Before this
* function, updating the JSON was the end of it and the page kept the old list. Now the
* page is what goes red next.
*
* Note that slugs are NOT one-per-item in either direction: `shard` is the source of four
* separate user-facing capabilities, and the marketplace draws on `market` and `cliloc`
* together (item names arrive as cliloc ids and are resolved against the shard's own
* string table). The check is coverage in both directions, not a bijection.
* The capability check moved with the lists: `assertGameCoverage()` in `games/index.mjs`
* holds each game's features to the capabilities its module declares, in both directions.
*
* ---------------------------------------------------------------------------------------
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
@@ -47,8 +41,8 @@
* the tables, the roster resolver, the forums, the notification streams, the Discord bridge,
* the voice channels, the activity feed and `/admin/teams` — and cannot *originate* a Team.
* They arrive from the installed module, which is exactly the point: core does not own the
* word for a Team, so `module-uo` calls them guilds and builds the pages, and a future
* module can call them something else on the same primitive.
* word for a Team, so each module calls them what its game calls them and builds the pages
* on the same primitive.
*
* So `needsModule` marks an item that is core machinery a module has to populate. On a bare
* core it is present, correct and permanently empty. `/` renders the requalified group
@@ -68,11 +62,13 @@
* character sheets are reachable only by the account they belong to, and a Team forum lives
* behind a Team id no static page can know. Paths are read from the real route tables —
* core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never
* guessed. Note the module's public pages are namespaced under its own id, so a UO route is
* `/uo/…`; a deployment running a different module would deep-link somewhere else, which is
* why these sit beside the `caps` slugs on the module-supplied items.
* guessed. A module's public pages are namespaced under its own id, so a route into the
* Ultima Online module is `/uo/…`: the demo §15 plans runs that game, which is why the
* Teams item points there and the module-supplied items in `games/` carry their own.
*/
import { games } from './games/index.mjs';
/**
* The shape of a capability, written out because TypeScript otherwise infers it per group
* from whichever fields that group's items happen to use — and then `/features/` cannot
@@ -83,7 +79,7 @@
* @typedef {object} Capability
* @property {string} label What it is called, on every page that lists it.
* @property {string} detail The argument for it. `/features/` only; see D20.
* @property {string[]} [caps] Module capability slugs, on module-supplied items only.
* @property {string[]} [caps] Module capability slugs, on a game's own items (`games/`) only.
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
*
@@ -124,8 +120,8 @@ const community = {
'A roster, a leader, a private forum, its own notification streams and a Discord ' +
'voice channel, all hanging off one group. Core owns every part of that machinery ' +
'and deliberately cannot create a Team: they arrive from the installed module, ' +
'which is how a guild inside the game becomes a Team on the site — and why a ' +
'different game can call them something else without core learning a new word.',
'which is how a group the game already has becomes a Team on the site — and why ' +
'each game keeps its own name for one without core learning a new word.',
},
{
label: 'Team forums',
@@ -178,102 +174,66 @@ const community = {
};
/**
* Game intelligence — module-supplied. The `caps` arrays are the contract with
* `platform.json`; see `assertCapabilityCoverage` below.
* What a game module adds — module-supplied, and written in core's words (D63, D75). Each
* item is something BOTH modules supply, each in its own game's way; the game's own list,
* in its own words, is on its page under `/modules/`, which `/features/` and the homepage
* link to. No `caps` here: they belong to a game's own items in `src/data/games/`, where
* `assertGameCoverage()` checks them.
*
* @type {CapabilityGroup}
*/
const gameIntelligence = {
id: 'game-intelligence',
title: 'Game intelligence',
const gameModule = {
id: 'game-module',
title: 'What a game module adds',
moduleSupplied: true,
summary:
'Supplied by the installed game module, not by the core site. Two modules exist, for ' +
'Ultima Online and for Rust; this is what the Ultima Online module publishes from a ' +
'live game server, and the Rust module brings its own set in its place.',
'Ultima Online and for Rust, and each adds its own set from a live game server — these ' +
'are the parts they share.',
items: [
{
label: 'Live server status',
caps: ['shard'],
demoPath: '/uo/shard',
detail:
'Whether the server is up, who is on it, and how long ago the site last heard from ' +
'it. When the game is down this page is the thing that says so — the site does not ' +
'go down with it.',
'Whether each game server is up, who is on it, and how long ago the site last heard ' +
'from it. When the game is down the site is the thing that says so — it does not go ' +
'down with it.',
},
{
label: 'Economy and activity',
caps: ['shard'],
demoPath: '/uo/shard/activity',
label: 'A live feed of the world',
detail:
'A live feed of what is happening in the world, and the economy underneath it. ' +
'Every event passes an allowlist before it can reach a public page; staff read a ' +
'second stream carrying the rest.',
'What is happening in the game, as it happens. Every kind of event passes an ' +
'allowlist the module holds before it can reach a public page; staff read a second ' +
'stream carrying the rest.',
},
{
label: 'Character sheets',
caps: ['shard'],
label: 'Leaderboards',
detail:
'Skills, stats and equipment, drawn from the live world rather than from a form ' +
'somebody filled in. Reachable by the account the character is linked to, and by ' +
'staff — not by the public.',
'The numbers the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday — and kept by the site after the game has moved on.',
},
{
label: 'Points and loyalty boards',
caps: ['shard'],
demoPath: '/uo/leaderboards',
label: 'Maps of the world',
detail:
'The leaderboards the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday.',
'Where things are, drawn from the server rather than from someone else’s idea of the ' +
'game: one module maps what spawns where, the other shows what is moving now.',
},
{
label: 'Player-vendor marketplace',
caps: ['market', 'cliloc'],
demoPath: '/uo/market',
label: 'Teams, from the game',
detail:
'Every player vendor on the server and what is on it, searchable without logging ' +
'in to the game. Item names arrive from the world as numeric ids and are resolved ' +
"against the game's own string table, so they read as names rather than numbers — " +
"beside the item's own picture, in the colour it was dyed.",
'The groups players already form in the game arrive as Teams on the site, with the ' +
'forum, notifications and voice channel core attaches to one.',
},
{
label: 'Houses and IDOC decay',
caps: ['houses'],
demoPath: '/uo/houses',
label: 'Players’ own game accounts',
detail:
'Who owns what and where it stands, including which houses are decaying — ' +
'published while it is still information rather than after it has become a rumour.',
'A player links their game identity to their site account and sees their own side ' +
'of the game on it. Who may see another player at all is the operator’s choice.',
},
{
label: 'Spawn atlas',
caps: ['atlas'],
demoPath: '/uo/atlas',
label: 'Game steps for scheduled events',
detail:
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
"describes your server rather than someone else's idea of the game. Regions, " +
'landmarks and champion altars come with it, and each creature is shown as the ' +
'artwork your own client draws it with.',
},
{
label: 'Champion boards',
caps: ['champs'],
demoPath: '/uo/champs',
detail: 'Which altars are running, how far along they are, and what turned up.',
},
{
label: 'Guilds',
caps: ['guilds'],
demoPath: '/uo/guilds',
detail:
'Guild rosters and standings, kept in step with the game. This is also what fills ' +
'the Teams primitive above: a guild in the world becomes a Team on the site, with ' +
"the forum, the notifications and the voice channel that core attaches to one.",
},
{
label: 'City governors',
caps: ['governors'],
demoPath: '/uo/governors',
detail: 'Who holds which city, and what they did with it.',
'What an event may do inside the world comes from the module: core runs the ' +
'calendar, the budget and the undo, and the module brings the steps.',
},
],
};
@@ -372,7 +332,7 @@ const integration = {
{
label: 'Discord: slash commands, notifications, voice',
detail:
'A bot for the guild you already have. Commands answer from your site, ' +
'A bot for the Discord server you already have. Commands answer from your site, ' +
'notifications bridge into channels, and a Team can be granted a voice channel ' +
'that maintains its own membership.',
},
@@ -429,7 +389,7 @@ const infrastructure = {
export const capabilityGroups = [
community,
gameIntelligence,
gameModule,
administration,
integration,
infrastructure,
@@ -456,46 +416,11 @@ export function capabilityGroup(id) {
);
}
/**
* Fails the build when the module's declared capabilities and this page's list disagree.
*
* Called from the component rather than from a check script on purpose: the failure needs
* to reach whoever is editing the page, and an Astro build error names the component. It
* also means the rule cannot be skipped by running `astro build` without `npm run verify`.
/*
* The capability check that lived here — "Game intelligence" against the module's declared
* capabilities — moved to `assertGameCoverage()` in `games/index.mjs` in phase 17, with the
* lists it checks.
*/
export function assertCapabilityCoverage(declared) {
const claimed = new Set();
for (const item of gameIntelligence.items) {
for (const cap of item.caps || []) claimed.add(cap);
}
const known = new Set(declared);
const unlisted = declared.filter((cap) => !claimed.has(cap));
const invented = [...claimed].filter((cap) => !known.has(cap));
if (!unlisted.length && !invented.length) return;
const lines = [];
if (unlisted.length) {
lines.push(
`the installed module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no ` +
`capability on the homepage claims — the site is under-selling what it can show.`
);
}
if (invented.length) {
lines.push(
`the homepage claims ${invented.map((c) => `"${c}"`).join(', ')}, which the module no ` +
`longer declares — the site is advertising something that is gone (§1).`
);
}
throw new Error(
`src/data/capabilities.mjs disagrees with platform.json's games.uo.module.capabilities:\n` +
lines.map((line) => ` - ${line}`).join('\n') +
`\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n`
);
}
/**
* Fails the build when a capability has no `detail`.
@@ -505,8 +430,9 @@ export function assertCapabilityCoverage(declared) {
* asymmetry between the two renderings is the whole of D20, and this is what keeps the
* thinner one from being the only one anybody notices.
*
* Called from `/features/` for the same reason `assertCapabilityCoverage` is called from
* the homepage: the build error should name the page that would have shipped wrong.
* Called from `/features/` for the same reason `assertGameCoverage` is called from the
* homepage: the build error should name the page that would have shipped wrong. It also
* reads each game's own list, which its page under `/modules/` renders with the detail too.
*/
export function assertDetailCoverage() {
const missing = [];
@@ -515,6 +441,11 @@ export function assertDetailCoverage() {
if (!item.detail?.trim()) missing.push(`${group.title} → ${item.label}`);
}
}
for (const game of games) {
for (const item of game.features) {
if (!item.detail?.trim()) missing.push(`${game.name} → ${item.label}`);
}
}
if (!missing.length) return;

View File

@@ -284,7 +284,7 @@ export const collected = [
scope: 'app',
title: 'Everything you read and post in the app',
body:
'Forum posts, Team activity, character and shard information, notification ' +
'Forum posts, Team activity, what the game module shows you, notification ' +
'preferences: all of it is a live read or write against the deployment. Apart from ' +
'the notification snapshot described in the next entry, nothing is cached for ' +
'offline use and nothing is duplicated anywhere else — the app with no signal is ' +
@@ -376,9 +376,12 @@ export const collected = [
body:
'A username, a password hash, an optional email address, the role, and — where the ' +
'operator has enabled it — a two-factor secret and single-use recovery codes. The ' +
'time and IP address of the last sign-in are stored on the account row.',
'time and IP address of the last sign-in are stored on the account row. A sign-in ' +
'identity linked to the account — Google, Discord, an OIDC provider or Steam — keeps ' +
'that provider’s id for the person; a Steam link also keeps the Steam display name ' +
'and the address of the Steam avatar. Linked identities are deleted with the account.',
retention: { summary: 'Set by the operator; nothing expires on its own' },
source: 'website server/db/schema.sql — users',
source: 'website server/db/schema.sql — users, user_identities',
},
{
id: 'deploy-sessions',
@@ -505,12 +508,23 @@ export const collected = [
scope: 'deployment',
title: 'Game data from the connected server',
body:
'Where a game module is installed, information about characters, guilds, houses and ' +
'the in-game economy flows from the game server to the site through the bridge. ' +
'Which of it is visible to the public is the operator’s decision, made in the ' +
'admin panel — the bridge itself forwards, and the site decides.',
retention: { summary: 'Operator-configured' },
source: 'docs/link/v7.md — the visibility framework',
'Where a game module is installed, what happens on the game server flows to the site ' +
'through the bridge, and the module keeps it. With Ultima Online that is characters ' +
'and their accounts, Teams, houses and the in-game economy. With Rust it is each ' +
'player’s Steam id and in-game name, their statistics, who is online, and the ' +
'server’s event record — which includes in-game chat, player reports and the IP ' +
'addresses on sign-in attempts and bans, readable by staff only. A linked Steam ' +
'account is kept against the site account and deleted with it. Which of any of this ' +
'the public sees is the operator’s decision, made in the admin panel — the bridge ' +
'forwards, and the site decides.',
retention: {
summary:
'Operator-configured. The Rust module keeps its event record 30 days, and its ' +
'sidecar keeps its own copy 14 days by default; statistics until the operator removes them',
},
source:
'docs/link/v7.md — the visibility framework; Module-Rust server/db/schema.sql, ' +
'server/catalogue.js, server/boot.js (EVENT_RETENTION_DAYS); Rust-Link sidecar/src/config.rs',
},
];

View File

@@ -30,6 +30,7 @@ export function footerColumns(brand) {
{ href: '/features/', label: 'Features' },
{ href: '/architecture/', label: 'Architecture' },
{ href: '/modules/', label: 'Modules' },
{ href: '/runicnpc/', label: 'RunicNPC' },
{ href: '/app/', label: 'Android app' },
],
},

104
src/data/games/index.mjs Normal file
View File

@@ -0,0 +1,104 @@
/**
* games/index.mjs — the games the platform supports, and the check that holds each one's
* feature list to its module's declared capabilities.
*
* PLAN.md §16, phase 17. Each game has a file of its own (`uo.mjs`, `rust.mjs`) written in
* that game's words, which only that game's pages render. This file is what the core pages
* read instead: the game's name and where its page is, which D63 lets any page say.
*
* The order is the order the games were supported in, and every list of games on the site
* follows it.
*/
import platform from '../platform.json' with { type: 'json' };
import * as uo from './uo.mjs';
import * as rust from './rust.mjs';
/**
* @typedef {object} Game
* @property {'uo'|'rust'} key A key of `platform.json`'s `games`.
* @property {string} name The game's own name, from `platform.json`.
* @property {string} href Its marketing page.
* @property {string} docs Its docs group.
* @property {string} release Its module's release, from `platform.json`.
* @property {string} blurb One sentence in core's words, for the core pages' game cards.
* @property {import('../capabilities.mjs').Capability[]} features
*/
/** @type {Game[]} */
export const games = [
{
key: 'uo',
name: platform.games.uo.name,
href: '/modules/uo/',
docs: '/docs/ultima-online/',
release: platform.releases['Module-uo'],
blurb:
'The first module, and the one the seam was cut around: the server’s live state, its ' +
'economy and its players’ own groups, extracted from the site without changing a URL.',
features: uo.features,
},
{
key: 'rust',
name: platform.games.rust.name,
href: '/modules/rust/',
docs: '/docs/rust/',
release: platform.releases['Module-Rust'],
blurb:
'Any number of servers, each with its own sidecar: a live feed, leaderboards that ' +
'outlast a reset, a live map, and permissions and events run from the site.',
features: [...rust.features, ...rust.staffFeatures],
},
];
for (const key of Object.keys(platform.games)) {
if (!games.some((game) => game.key === key)) {
throw new Error(`src/data/games/index.mjs has no entry for "${key}", which platform.json's games declares.`);
}
}
/** One game by key, or a build failure naming it. @param {string} key */
export function gameByKey(key) {
const game = games.find((candidate) => candidate.key === key);
if (game) return game;
throw new Error(`src/data/games/index.mjs has no game "${key}". Known: ${games.map((g) => g.key).join(', ')}.`);
}
/**
* Fails the build when a game's feature list and its module's declared capabilities
* disagree, in either direction.
*
* Called from the pages rather than a check script, for the reason phase 3 gave: the failure
* should reach whoever is editing the page, and an Astro build error names the component. It
* also means `astro build` alone cannot skip it.
*
* @param {string} key
*/
export function assertGameCoverage(key) {
const game = gameByKey(key);
const declared = platform.games[key].module.capabilities;
const claimed = new Set(game.features.flatMap((item) => item.caps || []));
const unlisted = declared.filter((cap) => !claimed.has(cap));
const invented = [...claimed].filter((cap) => !declared.includes(cap));
if (!unlisted.length && !invented.length) return;
const lines = [];
if (unlisted.length) {
lines.push(
`the module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no feature on ` +
`its page claims — the site is under-selling what it can show.`,
);
}
if (invented.length) {
lines.push(
`the page claims ${invented.map((c) => `"${c}"`).join(', ')}, which the module no ` +
`longer declares — the site is advertising something that is gone (§1).`,
);
}
throw new Error(
`src/data/games/${key}.mjs disagrees with platform.json's games.${key}.module.capabilities:\n` +
lines.map((line) => ` - ${line}`).join('\n') +
`\n\nUpdate the features in src/data/games/${key}.mjs, or the JSON if the module itself changed.\n`,
);
}

122
src/data/games/rust.mjs Normal file
View File

@@ -0,0 +1,122 @@
/**
* games/rust.mjs — what the Rust module adds, in Rust's own words.
*
* PLAN.md §16, phase 17. `/modules/rust/` renders this; the core pages describe what a game
* module adds in core's words and link there (D75). It is the Rust half of the coverage
* assertion phase 13 moved here: `caps` is the contract with `platform.json`'s
* `games.rust.module.capabilities`, re-read by `checkFacts.mjs` from `Module-Rust`'s own
* `module.json`, and `assertGameCoverage()` in `index.mjs` holds the two together.
*
* Every item was written from the Rust docs group, which phase 15 walked on the walk site
* and checked against the module's source (D36) — this file says less than those pages and
* nothing they do not. `rust` names the module itself rather than a surface (Module-Rust's
* README: "the single gate a whole navigation group hangs on"), so it rides on the server
* list, which is the page that gate opens.
*
* The staff items carry no `caps`: a capability is what a client feature-detects on, and
* the admin screens are not something any client detects.
*
* No `demoPath` anywhere: the demo instance planned in §15 runs Ultima Online.
*/
/** @typedef {import('../capabilities.mjs').Capability} Capability */
/** For visitors and players. @type {Capability[]} */
export const features = [
{
label: 'Every server, live',
caps: ['rust', 'servers'],
detail:
'Every Rust server the community runs, as it last reported itself: up or down, who is ' +
'on, which map, and when the next wipe is. One sidecar serves one server, so a ' +
'community with six servers sees six — and a server that is down says so while the ' +
'site carries on.',
},
{
label: 'The kill feed',
caps: ['killfeed'],
detail:
'Kills, chat, comings and goings, RunicNPC bosses, and the world itself — cargo, ' +
'helicopters, airdrops — as they happen. Every kind passes a default-deny allowlist ' +
'before it reaches a public page; staff read the rest, connection records and player ' +
'reports included.',
},
{
label: 'Leaderboards, per wipe',
caps: ['leaderboard'],
detail:
'Kills, deaths, NPC kills, structures and time played, for one wipe or all-time — with ' +
'the chat titles players have earned. The record survives the wipe: the game forgets, ' +
'and the site does not.',
},
{
label: 'Who is online',
caps: ['presence'],
detail:
'Who is on right now, and who is asleep. Nothing names a player to the public by ' +
'default: how far a name reaches — staff, signed-in players, everyone — is the ' +
'operator’s choice, made per surface.',
},
{
label: 'Wipes',
caps: ['wipes'],
detail:
'Every wipe the site has seen, and the next one once the server has a wipe schedule — ' +
'the monthly forced wipe or a rule of your own.',
},
{
label: 'The live map',
caps: ['map'],
detail:
'The server’s own map picture with layers on it: monuments and world events, the ' +
'zones and NPCs your events placed, players, bases as positions only, and a player’s ' +
'own clan. Each layer has its own audience, and positions are never stored.',
},
{
label: 'Steam accounts, linked',
caps: ['identity'],
detail:
'A player joins their Steam account to their site account with a code from /link in ' +
'game, or by signing in with Steam. Staff can undo a wrong link. Rewards go to Steam ' +
'ids, so a player earns them before they ever link.',
},
{
label: 'Clans are Teams',
detail:
'Each clan on a server is a Team on the site: a page, a feed for its members and ' +
'core’s Team notifications. Who may see a clan’s roster is set alongside who may see ' +
'a name at all.',
},
];
/** For staff, one per admin screen the module adds. @type {Capability[]} */
export const staffFeatures = [
{
label: 'Permissions and groups, from the site',
detail:
'The site holds every server’s Oxide or Carbon groups and permissions and pushes them ' +
'in. One vip on the site is one vip in game, on the servers it covers, and a change ' +
'made in game is noticed rather than silently kept.',
},
{
label: 'Mod config, from the site',
detail:
'Edit your plugins’ config files on the game host from the admin panel, with a backup ' +
'first, a watched reload, and an automatic undo if the plugin does not come back.',
},
{
label: 'Events, zones and rewards',
detail:
'Rust steps for core’s scheduled events: open a ZoneManager zone with a dome, place ' +
'NPCs, count who is present, reward a kit, pay coins, start a raid base, or run ' +
'console commands. Every step arrives switched off, on the server as well as on the ' +
'site.',
},
{
label: 'Optional plugins, recognised',
detail:
'BetterChat, Economics, Backpacks, TruePVE, Dynamic PVP, Dangerous Treasures, Guarded ' +
'Crate, RaidableBases and more each add something where they are installed: coins, ' +
'titles, map layers, event steps. None is required.',
},
];

117
src/data/games/uo.mjs Normal file
View File

@@ -0,0 +1,117 @@
/**
* games/uo.mjs — what the Ultima Online module adds, in Ultima Online's own words.
*
* PLAN.md §16, phase 17. Until this phase these items were the "Game intelligence" group of
* `capabilities.mjs`, which the homepage and `/features/` render — core pages, which D63
* says may name the games but not use their vocabulary. They moved here, to a file that
* belongs to one game, and `/modules/uo/` renders them. The core pages now describe what a
* game module adds in core's words and link here (D75).
*
* `caps` is the contract with `platform.json`'s `games.uo.module.capabilities`, which
* `checkFacts.mjs` re-reads from the module's own `module.json`. `assertGameCoverage()` in
* `index.mjs` fails the build when the two drift apart, in either direction — so the day the
* module gains a capability, the JSON goes red, and the day the JSON moves, this page does.
*
* Slugs are NOT one-per-item: `shard` is the source of four separate user-facing features,
* and the marketplace draws on `market` and `cliloc` together (item names arrive as cliloc
* ids and are resolved against the shard's own string table). Coverage, not a bijection.
*
* `demoPath` is a stable public route on the module's own pages, deep-linked into a demo
* when one is mounted (D25). Paths are read from `module-uo`'s `client/src/entry.jsx`.
*/
/** @typedef {import('../capabilities.mjs').Capability} Capability */
/** @type {Capability[]} */
export const features = [
{
label: 'Live shard status',
caps: ['shard'],
demoPath: '/uo/shard',
detail:
'Whether the shard is up, who is on it, and how long ago the site last heard from it. ' +
'When the game is down this page is the thing that says so — the site does not go ' +
'down with it.',
},
{
label: 'Economy and activity',
caps: ['shard'],
demoPath: '/uo/shard/activity',
detail:
'A live feed of what is happening in the world, and the economy underneath it. Every ' +
'event passes an allowlist before it can reach a public page; staff read a second ' +
'stream carrying the rest.',
},
{
label: 'Character sheets',
caps: ['shard'],
detail:
'Skills, stats and equipment, drawn from the live world rather than from a form ' +
'somebody filled in. Reachable by the account the character is linked to, and by ' +
'staff — not by the public.',
},
{
label: 'Points and loyalty boards',
caps: ['shard'],
demoPath: '/uo/leaderboards',
detail:
'The leaderboards the shard already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday.',
},
{
label: 'Player-vendor marketplace',
caps: ['market', 'cliloc'],
demoPath: '/uo/market',
detail:
'Every player vendor on the shard and what is on it, searchable without logging in to ' +
'the game. Item names arrive from the world as cliloc ids and are resolved against the ' +
"shard's own string table, so they read as names rather than numbers — beside the " +
"item's own picture, in the colour it was dyed.",
},
{
label: 'Houses and IDOC decay',
caps: ['houses'],
demoPath: '/uo/houses',
detail:
'Who owns what and where it stands, including which houses are decaying — published ' +
'while it is still information rather than after it has become a rumour.',
},
{
label: 'Spawn atlas',
caps: ['atlas'],
demoPath: '/uo/atlas',
detail:
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
"describes your server rather than someone else's idea of the game. Regions, " +
'landmarks and champion altars come with it, and each creature is shown as the ' +
'artwork your own client draws it with.',
},
{
label: 'Champion boards',
caps: ['champs'],
demoPath: '/uo/champs',
detail: 'Which altars are running, how far along they are, and what turned up.',
},
{
label: 'Guilds',
caps: ['guilds'],
demoPath: '/uo/guilds',
detail:
'Guild rosters and standings, kept in step with the shard. Each guild is also a Team ' +
'on the site, with the forum, the notifications and the voice channel that core ' +
'attaches to one.',
},
{
label: 'City governors',
caps: ['governors'],
demoPath: '/uo/governors',
detail: 'Who holds which city, and what they did with it.',
},
];
/**
* The screenshots `/modules/uo/` shows beside the list, by id from `screens.mjs`. Every one
* is tagged `game: 'uo'` there, so it prints the game's name beside it (D67) wherever it is
* shown. They lived on `/features/` until phase 17.
*/
export const screens = ['shard-status', 'marketplace', 'spawn-atlas', 'guilds', 'houses'];

View File

@@ -40,6 +40,15 @@
* the screenshots show the platform, not somebody's private shard.
*
* Nothing here is a mock-up, and nothing here was drawn.
*
* ---------------------------------------------------------------------------------------
* A SCREENSHOT BELONGS TO ITS GAME (D67)
* ---------------------------------------------------------------------------------------
* A capture of one game's screen shows that game's words in its pixels, and its alt text has
* to describe the image honestly. So an entry may carry `game`: its alt and caption may then
* use that game's words, and `Screenshot.astro` prints the game's name beside it wherever it
* appears, marking the figure `data-game` for `checkTerms.mjs --built`. A core screen — the
* news, the dashboard, the modules list — carries no tag.
*/
/** Web capture geometry. The capture script and the check both read these. */
@@ -63,6 +72,7 @@ export const PHONE = { width: 1440, height: 3088 };
* @property {string} alt What the screen shows, for somebody who cannot see it.
* @property {string} caption The sentence printed under the figure.
* @property {'web'|'phone'} family Which geometry the file follows.
* @property {'uo'|'rust'} [game] The game whose screen this is (D67), a key of `platform.json`'s `games`.
*/
/** @type {Screen[]} */
@@ -70,6 +80,7 @@ export const screens = [
// ── The product's public surfaces ───────────────────────────────────────────────────
{
id: 'shard-status',
game: 'uo',
route: '/uo/shard',
admin: false,
alt: 'The shard page of a Runic Gateway site, showing the shard online, its gold supply, the state of the shard link, and one player online in Britain.',
@@ -79,6 +90,7 @@ export const screens = [
},
{
id: 'marketplace',
game: 'uo',
route: '/uo/market',
admin: false,
scrollY: 470,
@@ -89,6 +101,7 @@ export const screens = [
},
{
id: 'spawn-atlas',
game: 'uo',
route: '/uo/atlas',
admin: false,
scrollY: 466,
@@ -99,6 +112,7 @@ export const screens = [
},
{
id: 'guilds',
game: 'uo',
route: '/uo/guilds',
admin: false,
alt: 'The guilds page, listing each guild on the shard with its abbreviation, how many members are online, and its leader.',
@@ -108,6 +122,7 @@ export const screens = [
},
{
id: 'houses',
game: 'uo',
route: '/uo/houses',
admin: false,
alt: 'The houses page, listing homes that have entered their final decay stage with their owner and location.',
@@ -145,6 +160,7 @@ export const screens = [
},
{
id: 'admin-shard',
game: 'uo',
route: '/admin/uo/link',
admin: true,
alt: 'The shard connection screen, showing the sidecar connected, the websocket ingest live, and fields for the sidecar URL, token and protocol version.',
@@ -154,6 +170,7 @@ export const screens = [
},
{
id: 'admin-client-files',
game: 'uo',
route: '/admin/uo/files',
admin: true,
alt: 'The client files screen, showing the creature portraits section: 1,095 pictures held of 1,095 catalogued, 746 of 800 creatures matched, the last import and the extractor version, above Update and Re-import everything buttons.',
@@ -195,6 +212,7 @@ export const screens = [
},
{
id: 'app-shard',
game: 'uo',
route: '/shard',
admin: false,
alt: "The app's shard screen, showing the shard online with one player and its gold supply, links to champion spawns, guilds, governors and falling houses, and a live activity feed.",
@@ -204,6 +222,7 @@ export const screens = [
},
{
id: 'app-market',
game: 'uo',
route: '/market',
admin: false,
alt: "The app's marketplace, listing weapons for sale with their price, the shop selling them and where it stands.",

View File

@@ -54,8 +54,8 @@ import { playPolicy } from '../data/beta.mjs';
*/
const title = 'The Android app';
const description =
'A native Android client for a Runic Gateway deployment: the shard, the site and your ' +
'account, themed by whichever community you point it at.';
'A native Android client for a Runic Gateway deployment: the game, the site and your ' +
'account, themed by whichever community you point it at — Ultima Online or Rust.';
const apk = platform.androidApk;
const release = platform.releases['Android-app'];

View File

@@ -3,13 +3,10 @@ import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import Screenshot from '../components/Screenshot.astro';
import SupportedGames from '../components/SupportedGames.astro';
import platform from '../data/platform.json';
import {
capabilityGroups,
assertCapabilityCoverage,
assertDetailCoverage,
} from '../data/capabilities.mjs';
import { capabilityGroups, assertDetailCoverage } from '../data/capabilities.mjs';
import { games, assertGameCoverage } from '../data/games/index.mjs';
/**
* `/features/` — PLAN.md §13 phase 4, built to D20.
@@ -24,8 +21,8 @@ import {
* a thing somebody has to notice.
*
* Both assertions below run at build time and both name this page in their message.
* `assertCapabilityCoverage` is the module contract the homepage also runs — repeated here
* deliberately, since either page can be built alone and each should fail on its own.
* `assertGameCoverage` is the module contract the homepage and each game page also run —
* repeated here deliberately, since any page can be built alone and each should fail on its own.
* `assertDetailCoverage` is this page's own: a capability with no detail renders as a
* heading with nothing under it, and nothing else in the repo would notice.
*
@@ -53,7 +50,7 @@ import {
* matches bytes, and a stock build hides every one of these links, so a mistake here is
* invisible until the day somebody configures a demo.
*/
assertCapabilityCoverage(platform.games.uo.module.capabilities);
for (const game of games) assertGameCoverage(game.key);
assertDetailCoverage();
/**
@@ -66,19 +63,21 @@ assertDetailCoverage();
* the container runs and who owns the database, and a picture of a web page says nothing
* true about either.
*
* The module-supplied group has none since phase 17: its screenshots were of one game's
* screens, and they moved with that game's list to its page under `/modules/` (D75).
*
* `Screenshot` throws on an id that `screens.mjs` does not declare, so a typo here fails the
* build rather than rendering a broken image.
*/
const groupScreens: Record<string, string[]> = {
community: ['news'],
'game-intelligence': ['marketplace', 'spawn-atlas', 'guilds', 'houses'],
administration: ['admin-dashboard', 'admin-users'],
integration: ['admin-modules'],
};
const title = 'Features';
const description =
'What a Runic Gateway deployment does — core, and what the installed game module adds.';
'What a Runic Gateway deployment does — core, and what the installed game module adds, for Ultima Online or Rust.';
---
<Base title={title} description={description}>
@@ -86,16 +85,17 @@ const description =
<p>
Grouped the way the software is actually divided, because that division is the thing
most worth understanding before you install it: the core site is game-agnostic and does
not know what a shard is, and everything that does arrives as an <a href="/modules/"
not know what game you run, and everything that does arrives as an <a href="/modules/"
>installable module</a
>.
</p>
<p>
Two modules exist today, for Ultima Online and for Rust, and a deployment runs one of
them. The second group below is what the Ultima Online module adds; the Rust module
brings its own set in its place. On a deployment with no module, that group is simply
absent and the other four are unchanged.
them. The second group below is what both of them add, each in its own game’s way;
each game’s own list is on its page. On a deployment with no module, that group is
simply absent and the other four are unchanged.
</p>
<SupportedGames class="features__games" />
</PageHeader>
{
@@ -110,6 +110,16 @@ const description =
<p class="prose group__summary">{group.summary}</p>
{group.moduleSupplied && (
<div class="group__games">
{games.map((game) => (
<a class="btn btn--ghost" href={game.href}>
What the {game.name} module adds
</a>
))}
</div>
)}
<ul class="group__items">
{group.items.map((item) => (
<li class="panel group__item">
@@ -175,6 +185,17 @@ const description =
color: var(--muted);
}
.group__games {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.25rem;
}
.features__games {
margin-top: 1.5rem;
}
.group__items {
display: grid;
gap: 1rem;

View File

@@ -43,7 +43,7 @@ const integrations = [
{
title: 'Slash commands',
body:
'Commands registered with your guild that answer from your site — so the thing ' +
'Commands registered with your Discord server that answer from your site — so the thing ' +
'somebody wants to look up is available where the conversation is happening, ' +
'rather than one tab away.',
},
@@ -60,13 +60,13 @@ const integrations = [
body:
'A Team can be granted its own voice channel, with membership maintained by the ' +
'bot rather than by whoever is online. The bot creates the category, and the ' +
'panel reports how many roles your guild has left before Discord’s own limit.',
'panel reports how many roles your Discord server has left before Discord’s own limit.',
},
],
caveat:
'Voice access is granted with a Discord role, and roles are visible on a member’s ' +
'profile — so a Team with a voice channel is a Team anyone in your guild can see the ' +
'membership of. That was a deliberate trade for a limit that counts per guild rather ' +
'profile — so a Team with a voice channel is a Team anyone in your Discord server can ' +
'see the membership of. That was a deliberate trade for a limit that counts per server rather ' +
'than per channel, and it is the right one for most communities, but it is not private.',
},
{

View File

@@ -2,26 +2,27 @@
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import SupportedGames from '../components/SupportedGames.astro';
import platform from '../data/platform.json';
import { capabilityGroup } from '../data/capabilities.mjs';
import { games } from '../data/games/index.mjs';
/**
* `/modules/` — PLAN.md §13 phase 4.
* `/modules/` — PLAN.md §13 phase 4, restructured in §16 phase 17.
*
* §10 gives this page four jobs: what a module is, `module-uo` as the worked example,
* writing your own, and the Integration Kit with its draft badge (D8). They are in that
* order because they are increasing commitment — a reader deciding whether to install one,
* a reader wondering what they get, a reader considering building one.
* §10 gave this page four jobs: what a module is, a worked example, writing your own, and
* the Integration Kit with its draft badge (D8). They are in that order because they are
* increasing commitment — a reader deciding whether to install one, a reader wondering what
* they get, a reader considering building one.
*
* ---------------------------------------------------------------------------------------
* THE WORKED EXAMPLE READS ITS OWN CAPABILITIES
* TWO GAMES, EACH ON ITS OWN PAGE
* ---------------------------------------------------------------------------------------
* The `module-uo` section lists what the module publishes, and it takes that list from
* `capabilities.mjs` rather than retyping it — the same list the homepage names and
* `/features/` expands, which is already checked against the module's own manifest through
* `platform.json` (§12). A third hand-maintained copy on this page is exactly the failure
* that machinery exists to prevent, and this is the page where it would be least visible.
* The worked example was the Ultima Online module, listed in that game's words. Since phase
* 17 there are two modules, and D63 keeps a game's vocabulary on its own pages, so this page
* is core: a card per game, in core's words, from `games/index.mjs`, each linking to
* `/modules/uo/` or `/modules/rust/`, where the game's real feature list is (D75). What the
* modules share — a sidecar, their own tables, their own releases — stays here, said once.
*
* ---------------------------------------------------------------------------------------
* THE DRAFT CHIP IS A DECISION, NOT A DISCLAIMER
@@ -34,14 +35,12 @@ import { capabilityGroup } from '../data/capabilities.mjs';
*/
const title = 'Modules';
const description =
'What a module is, what the Ultima Online module publishes, and what it takes to write ' +
'one for another game.';
'What a module is, the two game modules that exist — Ultima Online and Rust — and what it ' +
'takes to write one for another game.';
const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
const docs = `${gitea}/docs/src/branch/main`;
const gameIntelligence = capabilityGroup('game-intelligence');
/** The three ways a module reaches a running deployment. None of them is a build. */
const installPaths = [
{
@@ -81,8 +80,10 @@ const installPaths = [
<p>
That division is not an aspiration bolted on afterwards. The Ultima Online support was
extracted out of the site into a module, and every URL it had before the move it still
has — which is the only version of this claim worth making.
has — which is the only version of this claim worth making. Rust followed, built
against the same seam.
</p>
<SupportedGames class="mod__supported" />
</PageHeader>
<section class="page section mod" id="what">
@@ -146,60 +147,65 @@ const installPaths = [
</ol>
</section>
<section class="page section mod" id="module-uo">
<div class="mod__head">
<p class="eyebrow">The worked example</p>
<h2>module-uo</h2>
<span class="chip chip--version">{platform.releases['Module-uo']}</span>
</div>
<section class="page section mod" id="games">
<p class="eyebrow">The game modules</p>
<h2>Two games, one module each</h2>
<p class="prose mod__lede">
The Ultima Online module, and the reference every module that follows is measured
against. It is what turns a general-purpose community site into something that knows
what a shard is — and it is the proof that the seam described on
<a href="/architecture/">the architecture page</a> is real, because the code on the far
side of it was moved there rather than designed there.
Both were built inside this project, and a deployment runs one of them — the site
refuses to start a second. Each has a page of its own, in its own game's words, and a
documentation group that starts from installing it.
</p>
<div class="mod__example">
<div class="panel mod__caps">
<h3>What it publishes</h3>
<ul>
{gameIntelligence.items.map((item) => <li>{item.label}</li>)}
</ul>
<p class="mod__caps-note">
The same list <a href="/features/">features</a> expands, read from one file that is
checked against the module's own manifest on every build.
</p>
</div>
<ul class="mod__games">
{
games.map((game) => (
<li class="panel mod__game">
<div class="mod__head">
<h3>{game.name}</h3>
<span class="chip chip--version">
{game.release}
</span>
</div>
<p>{game.blurb}</p>
<div class="mod__game-links">
<a class="btn btn--primary" href={game.href}>
What the {game.name} module adds
</a>
<a class="btn btn--ghost" href={game.docs}>
Its documentation
</a>
</div>
</li>
))
}
</ul>
<div class="mod__facts">
<section>
<h3>It connects to a real server</h3>
<p>
The module talks to the sidecar beside your game server, not to the game. You
deploy that side with the installer and paste four values into the admin panel;
nothing here requires the game to exist, and with no server configured the site
renders normally and shows it offline.
</p>
</section>
<section>
<h3>It owns its own tables</h3>
<p>
Its schema is applied by the site on every boot and its data is its own. The
module declares which versions of the core interface it speaks — the site runs
{' '}{platform.moduleApi} — and refuses to load against one it does not.
</p>
</section>
<section>
<h3>It is a separate release</h3>
<p>
Versioned, tagged and published on its own cadence, independently of the site.
Upgrading one does not mean upgrading the other, as long as the declared interface
range still holds.
</p>
</section>
</div>
<div class="mod__facts mod__facts--shared">
<section>
<h3>Each connects to a real server</h3>
<p>
A module talks to the sidecar beside your game server, not to the game. You deploy
that side with the installer and give the admin panel its address and token;
nothing here requires the game to exist, and with no server configured the site
renders normally and shows it offline.
</p>
</section>
<section>
<h3>Each owns its own tables</h3>
<p>
Its schema is applied by the site on every boot and its data is its own. A module
declares which versions of the core interface it speaks — the site runs
{' '}{platform.moduleApi} — and refuses to load against one it does not.
</p>
</section>
<section>
<h3>Each is a separate release</h3>
<p>
Versioned, tagged and published on its own cadence, independently of the site.
Upgrading one does not mean upgrading the other, as long as the declared interface
range still holds.
</p>
</section>
</div>
</section>
@@ -239,9 +245,6 @@ const installPaths = [
<a class="btn btn--ghost" href={`${docs}/website/MODULE_API.md`} rel="noopener noreferrer">
The module contract
</a>
<a class="btn btn--ghost" href={`${docs}/modules/uo/README.md`} rel="noopener noreferrer">
module-uo in depth
</a>
</div>
</section>
@@ -287,7 +290,6 @@ const installPaths = [
.mod__grid h3,
.mod__paths h3,
.mod__caps h3,
.mod__facts h3,
.mod__draft h3 {
margin: 0 0 0.5rem;
@@ -317,55 +319,53 @@ const installPaths = [
font-style: italic;
}
.mod__example {
.mod__games {
display: grid;
gap: 1rem;
margin-top: 2rem;
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
align-items: start;
}
.mod__caps ul {
margin: 0;
margin: 2rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
}
.mod__game {
display: flex;
flex-direction: column;
}
.mod__game h3 {
margin: 0;
color: var(--gold);
font-size: 1.25rem;
}
.mod__game p {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: 0.94rem;
}
.mod__caps li {
position: relative;
padding-left: 1.1rem;
color: var(--text);
.mod__game-links {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: auto;
padding-top: 1.25rem;
}
.mod__caps li + li {
margin-top: 0.3rem;
.mod__supported {
margin-top: 1.5rem;
}
/* The same drawn marker the homepage's capability lists use, so a reader who has
seen this list once recognises it as the same list. */
.mod__caps li::before {
content: '';
position: absolute;
left: 0;
top: 0.62em;
width: 5px;
height: 5px;
border-radius: var(--radius-pill);
background: var(--portal);
opacity: 0.75;
.mod__facts--shared {
display: grid;
gap: 1.4rem;
margin-top: 2.25rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
.mod__caps-note {
margin: 1rem 0 0;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.85rem;
}
.mod__facts section + section {
margin-top: 1.4rem;
.mod__facts--shared section + section {
margin-top: 0;
}
.mod__facts p {
@@ -395,10 +395,4 @@ const installPaths = [
gap: 0.7rem;
margin-top: 1.75rem;
}
@media (max-width: 860px) {
.mod__example {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,199 @@
---
import Base from '../../layouts/Base.astro';
import PageHeader from '../../components/PageHeader.astro';
import GameFeatures from '../../components/GameFeatures.astro';
import platform from '../../data/platform.json';
import { features, staffFeatures } from '../../data/games/rust.mjs';
import { assertGameCoverage } from '../../data/games/index.mjs';
/**
* `/modules/rust/` — PLAN.md §16, phase 17.
*
* The Rust module's own page, in Rust's words (D63; `GAME_ROUTES` in `checkTerms.mjs`). The
* lists are `games/rust.mjs`, written from the Rust docs group that phase 15 walked, and the
* call below holds them to the capabilities `Module-Rust` declares — the page-side half of
* the coverage assertion phase 13 moved here.
*
* It carries the RunicNPC card D61 asked for, pointing at the standalone `/runicnpc/` page.
*
* No screenshots yet, and no slots for them: D65 brings the Rust captures in phase 18, from
* the rigs, and D4 rules out a placeholder in the meantime.
*/
assertGameCoverage('rust');
const rust = platform.games.rust;
const pieces = [
{
role: 'The module',
name: rust.module.repo,
version: platform.releases['Module-Rust'],
what: 'Every Rust screen on the site, installed from Admin → Modules.',
},
{
role: 'The sidecar',
name: 'rust-link',
version: rust.bundle.sidecar,
what: 'One beside each server, and the only part of the bridge that listens on a network.',
},
{
role: 'The bridge plugin',
name: 'RunicGateway.cs',
version: rust.bundle.plugin,
what: 'One file that runs unchanged on Oxide and Carbon, plus its optional helpers.',
},
{
role: 'NPCs',
name: 'RunicNPC',
version: rust.bundle.runicnpc,
what: 'Our NPC plugin, which the bridge drives for every NPC an event places.',
},
];
const title = 'The Rust module';
const description =
'What the Rust module adds to a Runic Gateway site: every server live, the kill feed, ' +
'leaderboards per wipe, the live map, Steam linking, and permissions, mod config and events run from the site.';
---
<Base title={title} description={description}>
<PageHeader eyebrow="Game module" title="Rust">
<p>
The <code>rust</code> module turns a Runic Gateway site into a Rust community’s site. It
follows any number of servers, each running Oxide or Carbon with a sidecar of its own,
and keeps what each one tells it after the wipe has taken it from the game.
</p>
<p>
The server side is installed as one bundle — <strong>{rust.bundle.tag}</strong>,
speaking protocol {rust.protocol} — with the installer, from a Pterodactyl egg, or by
hand. It needs Oxide {rust.bundle.oxideMin} or Carbon {rust.bundle.carbonMin} or newer,
and the uMod plugins {rust.bundle.requiresPlugins.join(' and ')}.
</p>
</PageHeader>
<section class="page section pieces" aria-labelledby="pieces-heading">
<h2 id="pieces-heading">The pieces</h2>
<ul class="pieces__list">
{
pieces.map((piece) => (
<li class="panel">
<p class="pieces__role">{piece.role}</p>
<p class="pieces__name">
<code>{piece.name}</code>
<span class="chip chip--version">{piece.version}</span>
</p>
<p class="pieces__what">{piece.what}</p>
</li>
))
}
</ul>
</section>
<GameFeatures
title="What players see"
lede="Every public surface is the operator's to widen: nothing names a player to the public by default."
items={features}
/>
<GameFeatures
title="What staff run from the site"
lede="Seven admin screens, and a Rust section on each user's page."
items={staffFeatures}
/>
<section class="page section npc">
<div class="panel npc__card">
<p class="eyebrow">Also from this project</p>
<h2>RunicNPC</h2>
<p>
Our own NPC plugin for Rust: NPCs where you want them, with the kit, health, aim and
behaviour you give them — roamers, sentries, guards, quest-givers and bosses with
phases. On a Runic Gateway server it is required and the site authors its profiles;
on any other Rust server it runs on its own, with <code>/rnpc</code> commands and an
API for other plugins.
</p>
<div class="npc__links">
<a class="btn btn--primary" href="/runicnpc/">About RunicNPC</a>
<a class="btn btn--ghost" href="/docs/rust/runicnpc/">Its documentation</a>
</div>
</div>
</section>
<section class="page section next">
<h2>Setting it up</h2>
<p class="prose">
The documentation’s Rust group starts from installing the bridge and covers every admin
screen the module adds, with the reference for each file it reads.
</p>
<div class="next__links">
<a class="btn btn--primary" href="/docs/rust/">The Rust docs</a>
<a class="btn btn--ghost" href="/modules/">All modules</a>
</div>
</section>
</Base>
<style>
.pieces h2,
.next h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.pieces__list {
display: grid;
gap: 1rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
.pieces__role {
margin: 0;
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pieces__name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin: 0.5rem 0 0;
color: var(--gold);
}
.pieces__what {
margin: 0.6rem 0 0;
color: var(--muted);
font-size: 0.94rem;
}
.npc__card h2 {
margin: 0;
color: var(--gold);
font-size: clamp(1.4rem, 2.8vw, 1.8rem);
}
.npc__card p:not(.eyebrow) {
margin: 0.75rem 0 0;
max-width: var(--measure);
color: var(--muted);
}
.npc__links,
.next__links {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 1.5rem;
}
.next p {
margin: 0;
color: var(--muted);
}
</style>

165
src/pages/modules/uo.astro Normal file
View File

@@ -0,0 +1,165 @@
---
import Base from '../../layouts/Base.astro';
import PageHeader from '../../components/PageHeader.astro';
import GameFeatures from '../../components/GameFeatures.astro';
import Screenshot from '../../components/Screenshot.astro';
import platform from '../../data/platform.json';
import { features, screens } from '../../data/games/uo.mjs';
import { assertGameCoverage } from '../../data/games/index.mjs';
/**
* `/modules/uo/` — PLAN.md §16, phase 17.
*
* The Ultima Online module's own page, and so one of the few marketing pages that may use
* that game's words (D63; `GAME_ROUTES` in `checkTerms.mjs`). The feature list is
* `games/uo.mjs`, held to the module's declared capabilities by the call below, and the
* screenshots are the ones `/features/` showed until this phase — every one tagged with its
* game in `screens.mjs` (D67).
*
* Versions are read from `platform.json`, which `checkFacts.mjs` holds to the module's
* release, the installer's bundle and both halves of the bridge.
*/
assertGameCoverage('uo');
const uo = platform.games.uo;
const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
const docs = `${gitea}/docs/src/branch/main`;
const pieces = [
{
role: 'The module',
name: uo.module.repo,
version: platform.releases['Module-uo'],
what: 'Every Ultima Online screen on the site, installed from Admin → Modules.',
},
{
role: 'The sidecar',
name: 'uo-link',
version: uo.bundle.sidecar,
what: 'Beside the shard, and the only part of the bridge that listens on a network.',
},
{
role: 'The bridge plugin',
name: uo.pluginRepo,
version: uo.bundle.overlay,
what: `An overlay compiled by ServUO at boot, on ServUO ${uo.bundle.servuoMin} or newer.`,
},
];
const title = 'The Ultima Online module';
const description =
'What the Ultima Online module adds to a Runic Gateway site: live shard status, the ' +
'player-vendor marketplace, the spawn atlas, guilds, houses and more, from a running ServUO shard.';
---
<Base title={title} description={description}>
<PageHeader eyebrow="Game module" title="Ultima Online">
<p>
The <code>uo</code> module turns a Runic Gateway site into an Ultima Online shard’s
site, fed live from a running ServUO shard. It was the first module, and the one the
seam was cut around: this support was extracted out of the site, and every URL it had
before the move it still has.
</p>
<p>
The shard side is installed with the installer as one bundle —
{' '}<strong>{uo.bundle.tag}</strong>, speaking protocol {uo.protocol} — and the site
side is a paste in the admin panel.
</p>
</PageHeader>
<section class="page section pieces" aria-labelledby="pieces-heading">
<h2 id="pieces-heading">The pieces</h2>
<ul class="pieces__list">
{
pieces.map((piece) => (
<li class="panel">
<p class="pieces__role">{piece.role}</p>
<p class="pieces__name">
<code>{piece.name}</code>
<span class="chip chip--version">{piece.version}</span>
</p>
<p class="pieces__what">{piece.what}</p>
</li>
))
}
</ul>
</section>
<GameFeatures
title="What it adds"
lede="Everything here is published from the live shard, and every public surface passes the visibility settings first — each one is the operator's to switch on."
items={features}
/>
<section class="page section shots" aria-label="Screenshots">
{screens.map((id) => <Screenshot id={id} />)}
</section>
<section class="page section next">
<h2>Setting it up</h2>
<p class="prose">
The documentation’s Ultima Online group starts from connecting a ServUO shard and ends
at the reference for every file the bridge reads.
</p>
<div class="next__links">
<a class="btn btn--primary" href="/docs/ultima-online/">The Ultima Online docs</a>
<a class="btn btn--ghost" href={`${docs}/modules/uo/README.md`} rel="noopener noreferrer">
The module in depth
</a>
<a class="btn btn--ghost" href="/modules/">All modules</a>
</div>
</section>
</Base>
<style>
.pieces h2,
.next h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.pieces__list {
display: grid;
gap: 1rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
.pieces__role {
margin: 0;
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pieces__name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin: 0.5rem 0 0;
color: var(--gold);
}
.pieces__what {
margin: 0.6rem 0 0;
color: var(--muted);
font-size: 0.94rem;
}
.next p {
margin: 0;
color: var(--muted);
}
.next__links {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 1.5rem;
}
</style>

238
src/pages/runicnpc.astro Normal file
View File

@@ -0,0 +1,238 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import platform from '../data/platform.json';
import { runicnpcCalls, runicnpcHooks } from '../data/runicnpc.mjs';
/**
* `/runicnpc/` — PLAN.md §16, D61, built in phase 17.
*
* The standalone page for RunicNPC, written for the owner of a Rust server who runs no
* Runic Gateway site at all and should be able to find and use the plugin without first
* learning what the platform is. It is in the header (D78) for the same reason. It may use
* Rust's words: it is one of the Rust routes in `checkTerms.mjs`.
*
* It says less than the docs subgroup (`/docs/rust/runicnpc/`), which phase 16 walked and
* drift-checked, and nothing that subgroup does not. Every number is read: the release and
* the API version and framework floors from `platform.json`, which `checkFacts.mjs` holds to
* `runicnpc-rust`'s own `plugin.toml`, and the counts of calls and hooks from
* `runicnpc.mjs`, which `checkReference.mjs` holds to `docs/runicnpc/API.md`.
*/
const npc = platform.runicnpc;
const release = platform.releases['runicnpc-rust'];
const repo = `${platform.gitea.base}/${platform.gitea.org}/runicnpc-rust`;
const calls = Object.keys(runicnpcCalls).length;
const hooks = Object.keys(runicnpcHooks).length;
const roles = [
{ name: 'Roamer', does: 'Moves, chases and fights.' },
{ name: 'Sentry', does: 'Stands still and shoots.' },
{ name: 'Guard', does: 'Holds its spot, chases within its leash, and walks back.' },
{
name: 'Passive',
does: 'Never fights. Pressing E says a line or opens a window — a quest-giver, a quartermaster.',
},
];
const title = 'RunicNPC';
const description =
'An NPC plugin for Rust on Oxide and Carbon: roamers, sentries, guards, quest-givers and ' +
'bosses with phases, placed in game or from a site, with an API for other plugins. Runs on any server.';
---
<Base title={title} description={description}>
<PageHeader eyebrow="A Rust plugin from this project" title="RunicNPC">
<p>
NPCs where you want them, with the kit, health, aim, ranges and behaviour you give them,
brought back after they die. One file, <code>RunicNPC.cs</code>, compiled by Oxide or
Carbon from source like any uMod plugin — and it runs on any Rust server, whether or not
a Runic Gateway site is anywhere near it.
</p>
</PageHeader>
<section class="page section facts" aria-label="At a glance">
<dl class="facts__list">
<div class="panel">
<dt>Release</dt>
<dd><span class="chip chip--version">{release}</span></dd>
</div>
<div class="panel">
<dt>Runs on</dt>
<dd>Oxide {npc.oxideMin} or Carbon {npc.carbonMin}, or newer</dd>
</div>
<div class="panel">
<dt>Needs</dt>
<dd>{npc.requiresPlugins.join(' and ')}. ZoneManager is optional.</dd>
</div>
<div class="panel">
<dt>API version</dt>
<dd>{npc.api} — {calls} calls and {hooks} hooks</dd>
</div>
</dl>
</section>
<section class="page section npc">
<h2>What it does</h2>
<ul class="npc__roles">
{
roles.map((role) => (
<li class="panel">
<h3>{role.name}</h3>
<p>{role.does}</p>
</li>
))
}
</ul>
<div class="npc__grid">
<section>
<h3>Bosses</h3>
<p>
Any role but passive can be a boss: a health bar at the top of the screen, phases as
its health falls, lines said in game, and loot of its own.
</p>
</section>
<section>
<h3>Placements and routes</h3>
<p>
A placement is a spot where a profile’s NPCs stand and come back after they die. It
survives a restart; the NPCs themselves are never saved, and each boot spawns fresh ones. A
route is a line of points you record in game for an NPC to walk.
</p>
</section>
<section>
<h3>Factions</h3>
<p>
With no settings an NPC fights players only. Profiles can be put on sides, turned on
Rust’s scientists or its animals, or allied to a clan, a team or one player.
</p>
</section>
<section>
<h3>A cost it tells you</h3>
<p>
What NPCs cost a server was measured on both frameworks, not guessed, and the
warning saying so appears wherever NPCs are added. Caps are an operator’s setting.
</p>
</section>
</div>
</section>
<section class="page section ways">
<h2>Two ways to run it</h2>
<div class="ways__grid">
<div class="panel">
<h3>On its own</h3>
<p>
Place NPCs in game with <code>/rnpc</code>, edit their profiles at the console, and
let other plugins drive it through its API. Nothing else is needed. It makes no
network call, downloads nothing and scans nothing at boot.
</p>
</div>
<div class="panel">
<h3>On a Runic Gateway server</h3>
<p>
It is required there, and installed with the bridge from the same bundle. The site
authors the profiles and the faction table and pushes them to each server, places
NPCs from its live map, and runs events that place them and wait on them dying
or on a boss’s health.
</p>
</div>
</div>
</section>
<section class="page section next">
<h2>Get it</h2>
<p class="prose">
Releases carry the plugin file. The documentation covers installing it by hand, every
command, every profile field, and the API in full for plugin authors.
</p>
<div class="next__links">
<a class="btn btn--primary" href={`${repo}/releases`} rel="noopener noreferrer">
Download {release}
</a>
<a class="btn btn--ghost" href="/docs/rust/runicnpc/install/">Install it</a>
<a class="btn btn--ghost" href="/docs/rust/runicnpc/commands/">Commands</a>
<a class="btn btn--ghost" href="/docs/rust/runicnpc/api/">The API</a>
<a class="btn btn--ghost" href="/modules/rust/">The Rust module</a>
</div>
</section>
</Base>
<style>
.npc h2,
.ways h2,
.next h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.facts__list {
display: grid;
gap: 1rem;
margin: 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
.facts__list dt {
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.facts__list dd {
margin: 0.5rem 0 0;
color: var(--text);
}
.npc__roles,
.ways__grid {
display: grid;
gap: 1rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
.ways__grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
}
.npc__roles h3,
.ways__grid h3,
.npc__grid h3 {
margin: 0 0 0.5rem;
color: var(--gold);
font-size: 1.02rem;
}
.npc__roles p,
.ways__grid p,
.npc__grid p {
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
.npc__grid {
display: grid;
gap: 1.4rem;
margin-top: 2rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
}
.next p {
margin: 0;
color: var(--muted);
}
.next__links {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 1.5rem;
}
</style>

View File

@@ -226,6 +226,7 @@ svg {
flex-wrap: wrap;
justify-content: flex-start;
row-gap: 0.15rem;
column-gap: 0.5rem;
padding-block: 0.55rem;
min-height: 0;
}
@@ -236,12 +237,16 @@ svg {
gap: 0;
/* Pull the first link's own padding back to the gutter so the row of links
lines up with the lockup above it rather than sitting indented. */
margin-left: -0.45rem;
margin-left: -0.3rem;
}
/* Five links since phase 17 (D78, RunicNPC). Measured at 390px, the fifth pushed Search
onto a third row: the links ended at 326px, and the row's 24px gap plus the 32px
button needed 382 of the 366 available. A tighter gap, padding and type put all of it
back on the second row with room to spare, and every link still clears a 24px target. */
.site-nav a {
padding-inline: 0.45rem;
font-size: 0.86rem;
padding-inline: 0.3rem;
font-size: 0.84rem;
}
/* Search (D47) shares the second row with the links rather than taking a third. `order`