feat(facts): phase 13 - per-game facts, Rust and RunicNPC checked, main green again
All checks were successful
PR checks / checks (pull_request) Successful in 8m25s

platform.json drops the single protocol/bundle/capability list for a
`games` object (uo, rust) plus a `runicnpc` block. checkFacts.mjs loops
the protocol, plugin-protocol, capability and bundle checks over both
games, reads the schema-2 bundles at v2/<game>/current.json, checks each
module's coreApi, and reads RunicNPC's plugin.toml. 41 facts, up from 19;
clears the two that were red on main (Module API 1.17.0, installer
v0.8.0).

D66: the hero, footer and structured data show the Module API only; each
game's protocol and bundle go on that game's pages, and the docs landing
table splits into shared versions plus one column per game.

The re-read section 1 requires found, and this fixes:
- three hard-coded "1.6.0" Module API versions in the module docs
- module-lifecycle saying modules load alphabetically (the loader now
  refuses a second module, W19), and the install pages never mentioning
  the one-module rule
- "there is one module" in five places, now two modules both built
  inside this project; the Integration Kit keeps its draft banner
- the homepage diagram's ServUO/uo-link sub-labels (D63 wording now)

notBuilt drops `second-module` (resolved by Module-Rust's release) and
rewords `multi-module`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-22 08:09:22 -05:00
parent 2971b84d51
commit 9938e71e07
29 changed files with 384 additions and 154 deletions

View File

@@ -92,33 +92,42 @@ function extract(text, re, what, authority) {
return m[1];
}
// Every game the site quotes facts for (PLAN.md §16). Checks 1, 2, 4 and 5 run once per
// game, and a row's label carries the game's key so a failure names which one moved.
const GAMES = Object.entries(platform.games);
// ---------------------------------------------------------------------------
// 1. The wire protocol, from the sidecar itself
// 1. Each game's wire protocol, from its sidecar
// ---------------------------------------------------------------------------
async function checkProtocol() {
const authority = 'link main:sidecar/src/main.rs';
const src = await raw('link', 'sidecar/src/main.rs', 'main');
const value = extract(
src,
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
'PROTOCOL_VERSION',
authority
);
record('protocol (sidecar)', platform.protocol, Number(value), authority);
for (const [key, game] of GAMES) {
const authority = `${game.sidecarRepo} main:sidecar/src/main.rs`;
const src = await raw(game.sidecarRepo, 'sidecar/src/main.rs', 'main');
const value = extract(
src,
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
'PROTOCOL_VERSION',
authority
);
record(`${key} protocol (sidecar)`, game.protocol, Number(value), authority);
}
}
// ---------------------------------------------------------------------------
// 2. The overlay's declared protocol — the third declaration site
// 2. Each game plugin's declared protocol — the third declaration site
//
// CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters,
// or the installer refuses to pair the sidecar with the overlay. If these two ever
// disagree, the site must not print either number.
// or the installer refuses to pair the sidecar with the plugin. If these two ever
// disagree, the site must not print either number. Both games keep the file under the
// same name at the root of their plugin repository.
// ---------------------------------------------------------------------------
async function checkOverlayProtocol() {
const authority = 'servuo-plugins main:overlay.toml';
const toml = await raw('servuo-plugins', 'overlay.toml', 'main');
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
record('protocol (overlay)', platform.protocol, Number(value), authority);
for (const [key, game] of GAMES) {
const authority = `${game.pluginRepo} main:overlay.toml`;
const toml = await raw(game.pluginRepo, 'overlay.toml', 'main');
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
record(`${key} protocol (plugin)`, game.protocol, Number(value), authority);
}
}
// ---------------------------------------------------------------------------
@@ -137,7 +146,7 @@ async function checkModuleApi() {
}
// ---------------------------------------------------------------------------
// 4. The capabilities the installed module actually declares
// 4. The capabilities each game's module actually declares
//
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
@@ -150,36 +159,109 @@ async function checkModuleApi() {
// array is not a changed capability set. A slug appearing or disappearing is.
// ---------------------------------------------------------------------------
async function checkModuleCapabilities() {
const authority = 'Module-uo main:module.json';
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
const declared = [...(manifest.capabilities || [])].sort();
const expected = [...platform.moduleUoCapabilities].sort();
for (const [key, game] of GAMES) {
const authority = `${game.module.repo} main:module.json`;
const manifest = JSON.parse(await raw(game.module.repo, 'module.json', 'main'));
const declared = [...(manifest.capabilities || [])].sort();
const expected = [...game.module.capabilities].sort();
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
record(`${key} module capabilities`, expected.join(' '), declared.join(' '), authority);
// The range the module declares against core. The manifest docs use it as the worked
// example of a caret range resolving across minor bumps, so it is a quoted fact.
record(`${key} module coreApi`, game.module.coreApi, manifest.coreApi, authority);
}
}
// ---------------------------------------------------------------------------
// 5. The current bundle
// 5. Each game's current bundle
//
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
// One manifest per game on the installer's `bundles` branch, at
// `v2/<game>/current.json` (schema 2). The ROOT `current.json` is the schema-1 manifest
// from before the installer learned a second game; it is frozen and no longer read here,
// because a check against a file that stopped moving passes forever.
//
// Each bundle is protocol-checked by CI when it is published, so its protocol is a third
// independent read of the same number rather than a duplicate of checks 1 and 2.
// ---------------------------------------------------------------------------
async function checkBundle() {
const authority = 'installer bundles:current.json';
const current = JSON.parse(await raw('installer', 'current.json', 'bundles'));
async function readBundle(game) {
const file = `v2/${game.bundleGame}/current.json`;
const authority = `installer bundles:${file}`;
const current = JSON.parse(await raw('installer', file, 'bundles'));
if (current.schema !== 2) {
throw new Error(`${authority} is schema ${current.schema}, and this check reads schema 2.`);
}
return { current, authority };
}
record('bundle.tag', platform.bundle.tag, current.bundle, authority);
record('bundle.sidecar', platform.bundle.sidecar, current.link?.tag, authority);
record('bundle.overlay', platform.bundle.overlay, current.overlay?.tag, authority);
async function checkBundle() {
const { uo, rust } = platform.games;
{
const { current, authority } = await readBundle(uo);
record('uo bundle.tag', uo.bundle.tag, current.bundle, authority);
record('uo bundle.sidecar', uo.bundle.sidecar, current.sidecar?.tag, authority);
record('uo bundle.overlay', uo.bundle.overlay, current.payload?.tag, authority);
record('uo bundle.servuoMin', uo.bundle.servuoMin, current.payload?.compat?.min_version, authority);
record('uo protocol (bundle)', uo.protocol, current.protocol, authority);
}
{
const { current, authority } = await readBundle(rust);
const compat = current.payload?.compat ?? {};
record('rust bundle.tag', rust.bundle.tag, current.bundle, authority);
record('rust bundle.sidecar', rust.bundle.sidecar, current.sidecar?.tag, authority);
record('rust bundle.plugin', rust.bundle.plugin, current.payload?.tag, authority);
record('rust bundle.oxideMin', rust.bundle.oxideMin, compat.frameworks?.oxide?.min_version, authority);
record('rust bundle.carbonMin', rust.bundle.carbonMin, compat.frameworks?.carbon?.min_version, authority);
record(
'rust bundle.requiresPlugins',
[...rust.bundle.requiresPlugins].sort().join(' '),
[...(compat.requires_plugins ?? [])].sort().join(' '),
authority
);
record('rust bundle.runicnpc', rust.bundle.runicnpc, current.npc?.tag, authority);
record('rust protocol (bundle)', rust.protocol, current.protocol, authority);
// The bundle carries RunicNPC's API too; check 5b reads it from the plugin's own repo.
record('runicnpc api (bundle)', platform.runicnpc.api, current.npc?.api, authority);
}
}
// ---------------------------------------------------------------------------
// 5b. RunicNPC's own declarations
//
// RunicNPC stands alone as well as shipping in the Rust bundle (D61), so its facts are
// read from its own `plugin.toml` too — the file its release workflow folds into the
// manifest the installer and the egg read. The API version is the number a plugin author
// calling `RunicNpc_*` relies on, and the site quotes it beside the API reference (D62).
// ---------------------------------------------------------------------------
async function checkRunicNpc() {
const authority = 'runicnpc-rust main:plugin.toml';
const toml = await raw('runicnpc-rust', 'plugin.toml', 'main');
const npc = platform.runicnpc;
record('runicnpc api', npc.api, Number(extract(toml, /^\s*api\s*=\s*(\d+)\s*$/m, 'api', authority)), authority);
record(
'bundle.servuoMin',
platform.bundle.servuoMin,
current.overlay?.servuo?.min_version,
'runicnpc oxideMin',
npc.oxideMin,
extract(toml, /^\s*min_oxide_version\s*=\s*"([^"]+)"/m, 'min_oxide_version', authority),
authority
);
record(
'runicnpc carbonMin',
npc.carbonMin,
extract(toml, /^\s*min_carbon_version\s*=\s*"([^"]+)"/m, 'min_carbon_version', authority),
authority
);
const required = extract(toml, /^\s*requires_plugins\s*=\s*\[([^\]]*)\]/m, 'requires_plugins', authority)
.split(',')
.map((name) => name.trim().replace(/^"|"$/g, ''))
.filter(Boolean);
record(
'runicnpc requiresPlugins',
[...npc.requiresPlugins].sort().join(' '),
required.sort().join(' '),
authority
);
// The bundle is protocol-checked by CI when it is published, so this is a third
// independent read of the same number rather than a duplicate of check 1.
record('protocol (bundle)', platform.protocol, current.protocol, authority);
}
// ---------------------------------------------------------------------------
@@ -349,6 +431,7 @@ async function main() {
checkModuleApi,
checkModuleCapabilities,
checkBundle,
checkRunicNpc,
checkReleases,
checkWebsiteHasNoReleases,
checkAndroidApk,

View File

@@ -69,7 +69,7 @@ const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
// `platform.json` means the number is the one `checkFacts.mjs` verified against `link`'s
// `main` — so the rig cannot quietly drift two protocols behind the platform again, which is
// exactly what the literal did.
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || platform.protocol);
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || platform.games.uo.protocol);
const DRY = process.argv.includes('--dry-run');