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
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:
107
src/components/GameFeatures.astro
Normal file
107
src/components/GameFeatures.astro
Normal 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>
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
63
src/components/SupportedGames.astro
Normal file
63
src/components/SupportedGames.astro
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
104
src/data/games/index.mjs
Normal 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
122
src/data/games/rust.mjs
Normal 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
117
src/data/games/uo.mjs
Normal 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'];
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
199
src/pages/modules/rust.astro
Normal file
199
src/pages/modules/rust.astro
Normal 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
165
src/pages/modules/uo.astro
Normal 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
238
src/pages/runicnpc.astro
Normal 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>
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user