feat(marketing): phase 4 — the marketing pages
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
PLAN.md §13 phase 4: /features/, /architecture/, /modules/, /integrations/, and /community/ — plus the two scope items the phase table never assigned to anyone. Six decisions taken by the org lead before coding, recorded in PLAN.md §10 as D20-D25: - D20 /features/ is the homepage's list with a `detail` line, not a second list. One data file, two renderings, so they cannot disagree about what exists. - D21 /architecture/ draws reasons, not reference: three new inline SVGs, one per boundary. No endpoint tables, no config keys — those are phase 8's and stay canonical in docs/. - D22 The deliberate absences of §2 become one tagged data file, rendered on the three pages that promise them. - D23 Phase 4 absorbs /community/ (specified in §10 and §14 N3, linked from the header since phase 1, built by no phase) and checkLinks.mjs. - D24 `needsModule`: writing the Teams detail exposed a false claim phase 3 shipped. Teams are module-sourced only — teams.module_id is NOT NULL, there is no create route, sync is gated on providerModuleId() — so the Community group no longer says a bare core does all of it. - D25 The per-capability demo affordance brand.json had promised since phase 2 is a deep link, filled at boot from data-demo-path. checkLinks.mjs reads the built HTML rather than src/, because half these links are assembled from data files and template literals. Its PLANNED_ROUTES list is checked in both directions, so it cannot rot into a permanent exemption. applyBrand.mjs gained a pass that recomputes deep links from their immutable path, making it idempotent and reversible; checkBrand.mjs lifts that pattern out and runs it against the stock markup so the two cannot drift. Both proved against a real mount, in both directions. Fixes a cascade bug the checks could not see: [data-demo-url=''] and a scoped component class are both specificity 0,1,0, so .demo-link's `display` beat the hide rule and twelve links to a nonexistent demo rendered, each resolving to the current page. The rule is now !important. The four diagrams' shared SVG vocabulary moved to src/styles/diagram.css. Verified from a clean checkout: npm ci, all five checks, astro check (0 errors), production build, and a live browser pass at desktop and 390px. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
132
src/components/NotBuilt.astro
Normal file
132
src/components/NotBuilt.astro
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
import { notBuiltFor, assertScopeNonEmpty } from '../data/notBuilt.mjs';
|
||||
|
||||
/**
|
||||
* The deliberate absences (PLAN.md §2, D22), rendered for one page's scope.
|
||||
*
|
||||
* §2 describes its absent-features list as "as load-bearing as the rest", and this is the
|
||||
* component that makes that true on a page rather than in a plan. It reads the shared list
|
||||
* so `/features/`, `/integrations/` and `/modules/` cannot drift into telling three
|
||||
* different stories about the same six things.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT LOOKS LIKE THE REST OF THE PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Not a warning box, not a muted footnote, not an accordion. D8's "understated honesty" is
|
||||
* a house style with a specific consequence here: a section that is visually apologetic
|
||||
* teaches a reader that absences are embarrassing, and a section that is visually hidden
|
||||
* teaches them to go looking for the ones you did not mention. These are decisions with
|
||||
* reasons, so they are set as decisions with reasons — the same panels as everything else,
|
||||
* in the same place in the rhythm.
|
||||
*
|
||||
* The one visual difference is the `resolvedBy` line, which every entry carries. An absence
|
||||
* with an exit condition is a position; an absence without one is a hole. D8 gives the
|
||||
* Integration Kit's draft status a defined removal condition and this generalises it.
|
||||
*/
|
||||
interface Props {
|
||||
/** Which page is asking: `features`, `integrations` or `modules`. */
|
||||
scope: string;
|
||||
/** Section heading. Each page frames the same list for its own reader. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { scope, title } = Astro.props;
|
||||
|
||||
assertScopeNonEmpty(scope);
|
||||
const entries = notBuiltFor(scope);
|
||||
---
|
||||
|
||||
<section class="page section notbuilt">
|
||||
<p class="eyebrow">Not built</p>
|
||||
<h2>{title}</h2>
|
||||
<p class="prose notbuilt__lede">
|
||||
Every one of these is a decision rather than a backlog item, so each says why. Where the
|
||||
reasoning was written down in the open, it is linked.
|
||||
</p>
|
||||
|
||||
<ul class="notbuilt__grid">
|
||||
{
|
||||
entries.map((entry) => (
|
||||
<li class="panel notbuilt__item">
|
||||
<h3>{entry.title}</h3>
|
||||
<p class="notbuilt__body">{entry.body}</p>
|
||||
<p class="notbuilt__resolved">
|
||||
<span class="notbuilt__resolved-label">What would change it</span>
|
||||
{entry.resolvedBy}
|
||||
</p>
|
||||
{entry.link && (
|
||||
<p class="notbuilt__link">
|
||||
<a href={entry.link.href} rel="noopener noreferrer">
|
||||
{entry.link.label}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.notbuilt h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.notbuilt__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notbuilt__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.notbuilt__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notbuilt__item h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
/* Takes the slack, so the exit condition sits at the foot of every card in a row
|
||||
rather than immediately under a body of whatever length — the same kind of
|
||||
statement in the same place on each, which is what makes them readable as a row. */
|
||||
.notbuilt__body {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.notbuilt__resolved {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.notbuilt__resolved-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.notbuilt__link {
|
||||
margin: 0.85rem 0 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
55
src/components/PageHeader.astro
Normal file
55
src/components/PageHeader.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
/**
|
||||
* The opening of every marketing page except the homepage — eyebrow, `<h1>`, lede.
|
||||
*
|
||||
* A component rather than four copies of the same three elements, because phase 4 writes
|
||||
* five pages and phases 5 and 6 write four more. The homepage is deliberately not one of
|
||||
* them: its `<h1>` is the tagline inside the hero, set against the emblem, and pulling that
|
||||
* into a shared header would either flatten the hero or push its layout in here (D19).
|
||||
*
|
||||
* The `<h1>` is the page's own name, not the product's, and `Base` appends the site name to
|
||||
* the document title — so a page sets a short `title` and gets "Features — Runic Gateway"
|
||||
* in the tab and "Features" on the page.
|
||||
*/
|
||||
interface Props {
|
||||
/** Small uppercase line above the title. What kind of page this is. */
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { eyebrow, title } = Astro.props;
|
||||
---
|
||||
|
||||
<header class="page section pagehead">
|
||||
<p class="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
<div class="prose pagehead__lede">
|
||||
<slot />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* The section rhythm gives generous space below; the header wants less, because the
|
||||
first section under it is part of the same thought. */
|
||||
.pagehead {
|
||||
padding-bottom: clamp(1rem, 2.5vw, 1.75rem);
|
||||
}
|
||||
|
||||
.pagehead h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: clamp(2rem, 5vw, 2.9rem);
|
||||
}
|
||||
|
||||
.pagehead__lede {
|
||||
color: var(--muted);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.pagehead__lede :global(p) {
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.pagehead__lede :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
142
src/components/architecture/Allowlist.astro
Normal file
142
src/components/architecture/Allowlist.astro
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
/**
|
||||
* "What reaches the public" — the second of `/architecture/`'s three diagrams (D21).
|
||||
*
|
||||
* The homepage states the split in one sentence inside the data-path walk ("a public one
|
||||
* carrying an allowlist of safe events, and a staff-only one carrying the rest… that split
|
||||
* is a security boundary, not a preference"). This is the page where that sentence has to
|
||||
* become a picture, because it is the single design decision a technical evaluator is most
|
||||
* entitled to be suspicious of: a live feed of a game world contains things that must never
|
||||
* be published, and "we filter it" is a claim, not a mechanism.
|
||||
*
|
||||
* So the diagram draws the shape of the mechanism — one stream in, one decision, two streams
|
||||
* out — and the notes say where the decision lives and what happens when it is wrong in
|
||||
* either direction. What it deliberately does NOT do is enumerate event kinds: that is the
|
||||
* catalog's job in the docs, it changes with the protocol, and a marketing page holding a
|
||||
* copy of it would be a copy that goes stale (§1).
|
||||
*
|
||||
* The rings sit behind the filter rather than behind the whole picture, on the phase-3
|
||||
* principle that they mark the one place the argument actually happens.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="allowlist">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">What reaches the public</p>
|
||||
<h2>One feed in, two feeds out</h2>
|
||||
<p class="prose">
|
||||
A live game world emits things that are fine on a front page and things that are not:
|
||||
who logged in from which address, what the cheat detector flagged, what a staff member
|
||||
did to whom. Both arrive on the same connection, so something has to divide them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 470" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- Centred on the filter: the one place in the picture where the argument is. -->
|
||||
<g class="rings">
|
||||
<circle cx="190" cy="178" r="96" />
|
||||
<circle cx="190" cy="178" r="136" />
|
||||
<circle cx="190" cy="178" r="176" />
|
||||
</g>
|
||||
|
||||
<rect class="node" x="20" y="12" width="340" height="60" rx="10" />
|
||||
<text class="node-title" x="40" y="38">Everything the game emits</text>
|
||||
<text class="node-sub" x="40" y="58">one authenticated stream, from the sidecar</text>
|
||||
|
||||
<path class="spine spine--live" d="M190 80 V132" />
|
||||
<path class="arrow arrow--live" d="M190 140 l-6 -10 h12 Z" />
|
||||
|
||||
<rect class="node node--self" x="20" y="142" width="340" height="72" rx="10" />
|
||||
<text class="node-title" x="40" y="172">Your site decides</text>
|
||||
<text class="node-sub" x="40" y="192">one allowlist, in one place, on your server</text>
|
||||
|
||||
<!-- Diverging: the public leg in cyan because it is still a live feed; the staff
|
||||
leg in gold because it is the privileged one. -->
|
||||
<path class="spine spine--live" d="M120 222 C120 268 96 268 96 306" />
|
||||
<path class="arrow arrow--live" d="M96 314 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="spine" d="M260 222 C260 268 284 268 284 306" />
|
||||
<path class="arrow" d="M284 314 l-6 -10 h12 Z" />
|
||||
|
||||
<rect class="node" x="8" y="316" width="176" height="128" rx="10" />
|
||||
<text class="node-title" x="26" y="344">Public pages</text>
|
||||
<text class="node-sub" x="26" y="366">an allowlist of event</text>
|
||||
<text class="node-sub" x="26" y="382">kinds, and nothing</text>
|
||||
<text class="node-sub" x="26" y="398">outside it</text>
|
||||
<text class="node-audience" x="26" y="424">anyone at all</text>
|
||||
|
||||
<rect class="node" x="196" y="316" width="176" height="128" rx="10" />
|
||||
<text class="node-title" x="214" y="344">Staff console</text>
|
||||
<text class="node-sub" x="214" y="366">the rest: audit trail,</text>
|
||||
<text class="node-sub" x="214" y="382">login attempts,</text>
|
||||
<text class="node-sub" x="214" y="398">addresses, cheat flags</text>
|
||||
<text class="node-audience" x="214" y="424">signed-in staff only</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
The allowlist is the security boundary. A new kind of event is invisible to the public
|
||||
until somebody adds it, which is the safe direction to fail in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>It is an allowlist, not a blocklist</h3>
|
||||
<p>
|
||||
The public stream carries the kinds of event that are named as safe; everything else
|
||||
goes to the staff stream by default. That ordering is the whole point. A blocklist
|
||||
fails open — the day the game emits something new, it is already published — and an
|
||||
allowlist fails closed, so the worst case is a page that is missing something rather
|
||||
than a page that has published an address.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>The decision lives on your server</h3>
|
||||
<p>
|
||||
Not in the sidecar and not in the game. The bridge is a deliberately dumb forwarder:
|
||||
it moves what the game emits and makes no judgements about audience. Everything
|
||||
about who may see what is decided by the site you run, in one place, where you can
|
||||
read it — and where changing it does not mean redeploying anything on the game host.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>More than two audiences, in practice</h3>
|
||||
<p>
|
||||
Two streams is the transport. Above it sits a configurable audience model — logged
|
||||
out, signed in, linked to a game account, staff — that decides how much of a given
|
||||
surface each of those sees. The public stream is the floor of that, and it is the
|
||||
one that is a boundary rather than a setting.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>When the game is down</h3>
|
||||
<p>
|
||||
Nothing arrives, and the site carries on. Live surfaces say the server is offline
|
||||
and everything that does not depend on it — the wiki, the news, accounts, the forums
|
||||
— is unaffected. A site that goes down with the game it reports on is not much of a
|
||||
status page.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Each outcome node ends with a line naming its audience, set apart from the
|
||||
description above it rather than reading as another line of it.
|
||||
|
||||
Its own class, not `:nth-last-of-type`: an index into a list of `<text>`
|
||||
siblings is correct only until somebody adds a label, and it fails by
|
||||
styling the wrong words rather than by failing. */
|
||||
.node-audience {
|
||||
fill: var(--muted);
|
||||
font-family: var(--sans);
|
||||
font-size: 11.5px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
166
src/components/architecture/ModuleSeam.astro
Normal file
166
src/components/architecture/ModuleSeam.astro
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
import platform from '../../data/platform.json';
|
||||
|
||||
/**
|
||||
* "Where the game stops and the platform starts" — the third of `/architecture/`'s diagrams
|
||||
* (D21).
|
||||
*
|
||||
* The other two draw runtime shapes. This one draws a code boundary, and it is here because
|
||||
* it is the claim the whole project rests on: that a community platform can be built once
|
||||
* and pointed at any game. An evaluator has every reason to read that as marketing, so the
|
||||
* page draws the seam and then says plainly what does and does not prove it — one module
|
||||
* exists, the second is a paper exercise, and the exit criterion for calling the contract
|
||||
* proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`).
|
||||
*
|
||||
* The Module API version is read from `platform.json` like every other number on this site
|
||||
* (§12). It is the one place a version genuinely belongs in this diagram: the seam is
|
||||
* literally a version check, and a module whose declared range does not match refuses to
|
||||
* load rather than half-loading.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="module-seam">
|
||||
<div class="diagram__head">
|
||||
<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
|
||||
declared interface — which is what makes "put your game on it" a shape rather than a
|
||||
slogan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 500" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- Core: what ships in the image, on every deployment, module or not. -->
|
||||
<rect class="host" x="8" y="8" width="364" height="186" rx="14" />
|
||||
<text class="host-title" x="28" y="42">Runic Gateway core</text>
|
||||
<text class="host-sub" x="28" y="62">game-agnostic; the same image everywhere</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="80" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="108">Accounts</text>
|
||||
|
||||
<rect class="node node--self" x="196" y="80" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="108">Teams</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="134" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="162">Wiki and posts</text>
|
||||
|
||||
<rect class="node node--self" x="196" y="134" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="162">Admin and API</text>
|
||||
|
||||
<!-- The seam. Both boundary lines and the label between them: this is the one
|
||||
thing in the picture that is neither core nor module. -->
|
||||
<path class="boundary" d="M8 224 H372" />
|
||||
<text class="seam-label" x="190" y="252" text-anchor="middle">
|
||||
Module API {platform.moduleApi}
|
||||
</text>
|
||||
<path class="boundary" d="M8 272 H372" />
|
||||
|
||||
<!-- Registers upward; is asked downward. Two arrows, opposite directions, because
|
||||
the traffic across a seam is not one-way and drawing it as one-way is what
|
||||
makes people think a module is a plugin that only listens. -->
|
||||
<path class="spine" d="M120 300 V206" />
|
||||
<path class="arrow" d="M120 198 l-6 10 h12 Z" />
|
||||
<text class="seam-arrow" x="136" y="216">registers</text>
|
||||
|
||||
<path class="spine" d="M260 200 V294" />
|
||||
<path class="arrow" d="M260 302 l-6 -10 h12 Z" />
|
||||
<text class="seam-arrow" x="244" y="290" text-anchor="end">calls</text>
|
||||
|
||||
<!-- The module: everything that knows a game exists. -->
|
||||
<rect class="host" x="8" y="306" width="364" height="186" rx="14" />
|
||||
<text class="host-title" x="28" y="340">Game module</text>
|
||||
<text class="host-sub" x="28" y="360">one per deployment; UO today</text>
|
||||
|
||||
<rect class="node" x="28" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="406">Routes</text>
|
||||
|
||||
<rect class="node" x="196" y="378" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="406">Screens</text>
|
||||
|
||||
<rect class="node" x="28" y="432" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="46" y="460">Its own tables</text>
|
||||
|
||||
<rect class="node" x="196" y="432" width="156" height="46" rx="10" />
|
||||
<text class="node-title" x="214" y="460">Nav rows</text>
|
||||
</svg>
|
||||
|
||||
<p class="diagram__caption">
|
||||
A module declares which versions of the interface it speaks. If that does not match
|
||||
what the site offers, it refuses to load and the site comes up without it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>The module brings its own everything</h3>
|
||||
<p>
|
||||
Not just screens: its routes, its database tables, its navigation rows, its slice of
|
||||
the OpenAPI spec and its own prebuilt client bundle. Installing it is a paste in the
|
||||
admin panel or a line in your environment — never a build step, because production
|
||||
runs an image you pulled, and an operator who has to compile something has been
|
||||
handed a maintenance job rather than a feature.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Failure is contained by design</h3>
|
||||
<p>
|
||||
A module that will not load is marked as failed and the site starts without it.
|
||||
Disabling one is a kill switch, not a visibility flag — its routes stop answering
|
||||
and its live connections close. Uninstalling keeps the data, and destroying the data
|
||||
is a separate, deliberate choice made in its own dialog.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Teams is the shape of the contract</h3>
|
||||
<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
|
||||
supplies the machinery, the module supplies the meaning.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>What this does not yet prove</h3>
|
||||
<p>
|
||||
One module exists and it is Ultima Online. A second, for a different game, is a
|
||||
written dry-run that was deliberately never implemented — it exists to test whether
|
||||
the contract generalises on paper. Until somebody builds the second one, the seam is
|
||||
a well-argued design rather than a demonstrated one, and this site says so wherever
|
||||
it comes up.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* The seam label sits between the two boundary rules rather than beside them: it is
|
||||
the name of the gap, not an annotation on either side of it. Gold, because it is
|
||||
the one contract in the picture. */
|
||||
.seam-label {
|
||||
fill: var(--gold);
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Two words, because two arrows crossing a boundary in opposite directions is
|
||||
ambiguous without them — and the ambiguity is the exact misreading this diagram
|
||||
exists to prevent, that a module is something core talks at. */
|
||||
.seam-arrow {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
122
src/components/architecture/TwoHosts.astro
Normal file
122
src/components/architecture/TwoHosts.astro
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
/**
|
||||
* "What you actually deploy" — the first of `/architecture/`'s three diagrams (D21).
|
||||
*
|
||||
* This one exists because of a specific, repeated misunderstanding that §10 names and the
|
||||
* homepage's CTA already spends two sentences on: a Runic Gateway install is two
|
||||
* independent installs, on two machines, and neither installs the other. The homepage says
|
||||
* it; this page draws it, because an evaluator deciding whether to run the software is
|
||||
* 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
|
||||
* is the one drawn argument: everything above it is reachable because you published it, and
|
||||
* everything below it is not reachable at all.
|
||||
*
|
||||
* The vocabulary and the layout are `src/styles/diagram.css`; only the geometry is here.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section diagram" id="two-hosts">
|
||||
<div class="diagram__head">
|
||||
<p class="eyebrow">What you deploy</p>
|
||||
<h2>Two hosts, two installs</h2>
|
||||
<p class="prose">
|
||||
Almost everyone gets this wrong once. The website and the game-side bridge are separate
|
||||
deployments on separate machines, and neither one installs the other — so a "Runic
|
||||
Gateway install" is really two, done in that order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__body">
|
||||
<div class="diagram__figure">
|
||||
<svg viewBox="0 0 380 546" class="flow" aria-hidden="true" focusable="false">
|
||||
<!-- The web host, and everything that runs on it. -->
|
||||
<rect class="host" x="8" y="8" width="364" height="232" rx="14" />
|
||||
<text class="host-title" x="28" y="42">Your web host</text>
|
||||
<text class="host-sub" x="28" y="62">a VPS, a home server, anything running Docker</text>
|
||||
|
||||
<rect class="node node--self" x="28" y="80" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="106">Runic Gateway</text>
|
||||
<text class="node-sub" x="46" y="126">one container, pulled not built</text>
|
||||
|
||||
<rect class="node" x="28" y="150" width="156" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="176">Game module</text>
|
||||
<text class="node-sub" x="46" y="196">installed, not built</text>
|
||||
|
||||
<rect class="node" x="196" y="150" width="156" height="60" rx="10" />
|
||||
<text class="node-title" x="214" y="176">Database</text>
|
||||
<text class="node-sub" x="214" y="196">your data, your disk</text>
|
||||
|
||||
<!-- The one hop between them, and the only one. Two arrowheads because the traffic
|
||||
genuinely goes both ways: the site calls the sidecar for point-in-time reads,
|
||||
and the sidecar pushes the live feed back up. -->
|
||||
<path class="spine spine--live" d="M190 248 V312" />
|
||||
<path class="arrow arrow--live" d="M190 240 l-6 10 h12 Z" />
|
||||
<path class="arrow arrow--live" d="M190 320 l-6 -10 h12 Z" />
|
||||
|
||||
<path class="boundary" d="M8 280 H372" />
|
||||
<text class="boundary-label" x="372" y="273" text-anchor="end">the network</text>
|
||||
|
||||
<!-- The game host. Nothing here is reachable from outside except the sidecar. -->
|
||||
<rect class="host" x="8" y="320" width="364" height="214" rx="14" />
|
||||
<text class="host-title" x="28" y="354">Your game host</text>
|
||||
<text class="host-sub" x="28" y="374">where the game server already runs</text>
|
||||
|
||||
<rect class="node" x="28" y="392" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="418">Sidecar</text>
|
||||
<text class="node-sub" x="46" y="438">the only part of this with a port open</text>
|
||||
|
||||
<rect class="node" x="28" y="462" width="324" height="60" rx="10" />
|
||||
<text class="node-title" x="46" y="488">Game server</text>
|
||||
<text class="node-sub" x="46" y="508">dials out over loopback; listens for nothing</text>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="diagram__notes">
|
||||
<section>
|
||||
<h3>The web host</h3>
|
||||
<p>
|
||||
A Docker Compose deployment: the site, its database, and whichever game module you
|
||||
installed. Images are pulled rather than built, so nothing compiles here and an
|
||||
upgrade is a pull and a restart. This is the only machine anybody points a browser
|
||||
at, and the only one that needs a certificate.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Why they share a host</h3>
|
||||
<p>
|
||||
The game talks to the sidecar over loopback, on the same machine, and dials
|
||||
<em>out</em> to do it. That is what lets the game server open no port at all — and it
|
||||
is also why there is no macOS installer build. The pair has to sit together, and no
|
||||
game server anybody runs is on one.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>What crosses between them</h3>
|
||||
<p>
|
||||
One authenticated connection, in both directions: a WebSocket carrying the live feed
|
||||
up, and REST calls going down for point-in-time questions. Nothing else on either
|
||||
machine talks to the other, and the sidecar answers your site and nobody else.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -159,12 +159,6 @@ import platform from '../../data/platform.json';
|
||||
top: calc(var(--header-h) + 1.5rem);
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.datapath__caption {
|
||||
margin: 1rem 0 0;
|
||||
max-width: 380px;
|
||||
@@ -172,75 +166,14 @@ import platform from '../../data/platform.json';
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---- The drawing ------------------------------------------------------
|
||||
SVG presentation attributes cannot take a var(), so every colour here is
|
||||
set as a CSS property on a class instead. That is also what keeps
|
||||
checkTokens.mjs satisfied: no literal reaches the markup. */
|
||||
.node {
|
||||
fill: var(--panel-b);
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
/* The SVG vocabulary this diagram draws with -- .node, .spine, .arrow,
|
||||
.boundary, .rings -- now lives in src/styles/diagram.css, shared with
|
||||
/architecture/'s three. It was duplicated in four files the moment the
|
||||
second diagram existed, and the rules it holds are decisions about what a
|
||||
diagram on this site looks like rather than about this one.
|
||||
|
||||
.node--self {
|
||||
fill: var(--panel-a);
|
||||
stroke: var(--gold-deep);
|
||||
}
|
||||
|
||||
.node-title {
|
||||
fill: var(--head);
|
||||
font-family: var(--sans);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-sub {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.spine {
|
||||
fill: none;
|
||||
stroke: var(--gold-deep);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.spine--live {
|
||||
stroke: var(--portal);
|
||||
filter: drop-shadow(0 0 6px var(--portal-deep));
|
||||
}
|
||||
|
||||
.arrow {
|
||||
fill: var(--gold-deep);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.arrow--live {
|
||||
fill: var(--portal);
|
||||
}
|
||||
|
||||
.boundary {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 5;
|
||||
}
|
||||
|
||||
.boundary-label {
|
||||
fill: var(--dim);
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rings {
|
||||
fill: none;
|
||||
stroke: var(--gold-deep);
|
||||
stroke-width: 1;
|
||||
opacity: 0.16;
|
||||
}
|
||||
The layout below stays here: the right-hand column is a numbered walk,
|
||||
not the notes column .diagram__body assumes. */
|
||||
|
||||
/* ---- The list ---------------------------------------------------------- */
|
||||
.datapath__steps {
|
||||
|
||||
Reference in New Issue
Block a user