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

@@ -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>