Compare commits
24 Commits
268e1c98fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c3c347d237 | |||
| 4e1ce2316a | |||
| 73f664c38e | |||
| e91e76bfa9 | |||
| 5f58a4cbf2 | |||
| 82b55e3084 | |||
| a0af01026e | |||
| 5c6735bd1d | |||
| 5e987518c6 | |||
| 3574bba4d5 | |||
| d01fd55f43 | |||
| 83c438885e | |||
| 391a4a131b | |||
| c8a293b8f6 | |||
| 916921551f | |||
| 7709b055a4 | |||
| 92a1a33121 | |||
| 283814dbf2 | |||
| 782e3df7e2 | |||
| e0d491d33b | |||
| ebd6ba8dbf | |||
| 289b6b3a8d | |||
| 5ba91717c0 | |||
| 2da014790e |
11
PLAN.md
11
PLAN.md
@@ -1622,6 +1622,17 @@ a mechanism rather than diligence:
|
||||
replaceable by a file copy, and that promise survives exactly as long as nobody types the address
|
||||
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
|
||||
mechanism, diligence is not.
|
||||
|
||||
**All three network checks read a file through Gitea's `contents` endpoint, never `raw`** —
|
||||
`checkFacts.mjs`, `checkQuickstart.mjs`, `checkReference.mjs`. Phase 12b found the reason.
|
||||
`raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea keeps
|
||||
a copy for six hours: on cutover day this check read `website`'s `version.js` from a fortnight
|
||||
earlier and failed the site for saying Module API 1.9.0 when `main` said 1.6.0 — except that
|
||||
`main` said 1.9.0, and nothing anyone could edit here would have made it pass. `contents`
|
||||
answers `private, must-revalidate` and is not cached, at the cost of a base64 decode. Same
|
||||
argument as `checkLinks.mjs` fetching nothing: a check that goes red on someone else's
|
||||
infrastructure is a check people learn to ignore, and one that goes red on a stale copy is
|
||||
worse — it is indistinguishable from the failure it exists to report.
|
||||
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
||||
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
|
||||
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
|
||||
|
||||
@@ -32,6 +32,7 @@ Where the console offers free text about security practices, two things are wort
|
||||
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. |
|
||||
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. |
|
||||
| Messages | Other user-generated content | No | No | Not collected by us. |
|
||||
| Messages | Other in-app messages | No | No | Not collected by us. Stored on the device only. |
|
||||
| Device or other IDs | Device or other IDs | No | No | Not collected. |
|
||||
|
||||
## Each answer, and why it is the truthful one
|
||||
@@ -83,12 +84,23 @@ Push is off until you enable it. When you do, the app mints a random, unguessabl
|
||||
|
||||
**Messages → Other user-generated content.** Not collected by us.
|
||||
|
||||
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives.
|
||||
Forum posts, Team activity, character and shard information, 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 an app with almost no content, which is a limitation and also an accurate description of where the data lives.
|
||||
|
||||
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
|
||||
- **Retention:** Held by the deployment, under its operator’s policy
|
||||
- **Read from:** `PLAN.md §9 section 2`
|
||||
|
||||
### A snapshot of your notifications, so the inbox opens without a signal
|
||||
|
||||
**Messages → Other in-app messages.** Not collected by us. Stored on the device only.
|
||||
|
||||
The app keeps the most recent notifications it has already fetched — at most thirty, and only the first page — on the device, so opening the inbox shows you what you had rather than a spinner. It is a copy of what the deployment already sent you and it is refreshed from there; nothing is written here that was not read from your own account. It is scoped to the account that fetched it, so a second person signing in on the same phone is never shown the first one’s messages.
|
||||
|
||||
- **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere, and no server we operate is on either end of it.
|
||||
- **Retention:** Until you sign out, or the thirty are pushed out by newer ones
|
||||
- **In detail:** Signing out deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
|
||||
- **Read from:** `core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt`
|
||||
|
||||
### No analytics, no crash reporting, no advertising
|
||||
|
||||
**Device or other IDs → Device or other IDs.** Not collected.
|
||||
@@ -116,4 +128,4 @@ Not part of the Data Safety form — that form is about the app — but a review
|
||||
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
|
||||
- **The web server’s access log** — Short-term operational retention, then rotated away.
|
||||
|
||||
Last generated from data dated 2026-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
Last generated from data dated 2026-09-01. Regenerate with `npm run play:datasafety` after any change to what the app stores.
|
||||
|
||||
BIN
public/screens/admin-client-files.webp
Normal file
BIN
public/screens/admin-client-files.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 67 KiB |
@@ -60,8 +60,28 @@ async function api(pathname) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const raw = async (repo, filePath, ref) =>
|
||||
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text();
|
||||
/**
|
||||
* A file's bytes, read through the `contents` endpoint rather than `raw`.
|
||||
*
|
||||
* `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea
|
||||
* serves a copy for six hours and this check can read a blob most of a working day old.
|
||||
* That is not theoretical: on the day of the engagement cutover it reported website's
|
||||
* MODULE_API_VERSION as 1.6.0 -- the value from two weeks earlier -- and failed a site
|
||||
* whose number was right. A check that goes red on stale data is a check people learn to
|
||||
* ignore, which is the one failure mode this file exists to avoid.
|
||||
*
|
||||
* `contents` answers `private, must-revalidate`, which the CDN does not cache, so it is
|
||||
* always the ref's current blob. The cost is a JSON parse and a base64 decode.
|
||||
*/
|
||||
async function raw(repo, filePath, ref) {
|
||||
const meta = await json(`${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`);
|
||||
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||
throw new Error(
|
||||
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||
);
|
||||
}
|
||||
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
const json = async (pathname) => (await api(pathname)).json();
|
||||
|
||||
|
||||
@@ -55,12 +55,18 @@ const checked = [];
|
||||
const ok = (what) => checked.push(what);
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */
|
||||
/** Same file accessor checkFacts.mjs uses, and for the same reason -- including the CDN one. */
|
||||
async function raw(repo, filePath, ref) {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
return res.text();
|
||||
const meta = await res.json();
|
||||
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||
throw new Error(
|
||||
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||
);
|
||||
}
|
||||
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,12 +53,18 @@ const checked = [];
|
||||
const ok = (what) => checked.push(what);
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/** Same raw-file accessor checkFacts.mjs and checkQuickstart.mjs use. */
|
||||
/** Same file accessor checkFacts.mjs and checkQuickstart.mjs use, CDN caveat included. */
|
||||
async function raw(repo, filePath, ref = 'main') {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
return res.text();
|
||||
const meta = await res.json();
|
||||
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
|
||||
throw new Error(
|
||||
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
|
||||
);
|
||||
}
|
||||
return Buffer.from(meta.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard
|
||||
* UOLINK_WS ws://127.0.0.1:8080/ws sidecar WebSocket
|
||||
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
|
||||
* UOLINK_PROTOCOL 4 wire protocol to pin — see the note below
|
||||
* UOLINK_PROTOCOL (platform.json) wire protocol to pin — see the note below
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
|
||||
@@ -45,6 +45,8 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import platform from '../src/data/platform.json' with { type: 'json' };
|
||||
|
||||
const BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const API = `${BASE}/api/v1`;
|
||||
const ADMIN_USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
||||
@@ -53,16 +55,21 @@ const DEMO_PASS = process.env.RG_DEMO_PASS || 'DemoReview!2026';
|
||||
const UOLINK_BASE = process.env.UOLINK_BASE || 'http://127.0.0.1:8080';
|
||||
const UOLINK_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
|
||||
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
|
||||
// The pinned wire protocol has to be STATED, not left to the module's default.
|
||||
// The pinned wire protocol, read from `platform.json` rather than written down here.
|
||||
//
|
||||
// `module-uo`'s schema fragment still carries `protocol INT NOT NULL DEFAULT 3`, from the
|
||||
// protocol-3 cutover; the sidecar on `link` `main` speaks 4. The module handles protocol 4's
|
||||
// frames — `guild.roster` and `guild.leave` ingest landed with the Teams cutover — but a
|
||||
// FRESH install pins 3, and the sidecar answers a 3 with `409 protocol version mismatch` on
|
||||
// every REST call. So a new deployment reads nothing from its shard until somebody edits the
|
||||
// number in Admin → Shard. Raised with the org lead rather than patched from here: the fix
|
||||
// belongs in `module-uo`, not in this repo's screenshot rig (PLAN.md §13 phase 9).
|
||||
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || 4);
|
||||
// It was a literal `4` until the Asset Bridge cutover, with a note explaining that
|
||||
// `module-uo` pinned 3 on a fresh install while the sidecar spoke 4, so a new deployment
|
||||
// read nothing from its shard until somebody edited the number in Admin → Shard. That debt
|
||||
// has since been paid: the module's schema fragment defaults the column to the protocol its
|
||||
// build speaks and carries a one-shot migration per bump, so both a fresh install and an
|
||||
// upgraded one land on the right number by themselves.
|
||||
//
|
||||
// What remains is the rig's own reason to state it: this seed points a demo deployment at a
|
||||
// sidecar, and if it pins the wrong number every REST call comes back `409`. Reading it from
|
||||
// `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 DRY = process.argv.includes('--dry-run');
|
||||
|
||||
|
||||
@@ -36,10 +36,14 @@ export const docsSidebar = [
|
||||
{ label: 'Users and roles', slug: 'docs/administration/users-and-roles' },
|
||||
{ label: 'Authentication', slug: 'docs/administration/authentication' },
|
||||
{ label: 'Teams', slug: 'docs/administration/teams' },
|
||||
{ label: 'Scheduled events', slug: 'docs/administration/events' },
|
||||
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
||||
{ label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' },
|
||||
{ label: 'Engagement rules', slug: 'docs/administration/engagement-rules' },
|
||||
{ label: 'Message templates', slug: 'docs/administration/message-templates' },
|
||||
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' },
|
||||
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
|
||||
{ label: 'Client files', slug: 'docs/administration/client-files' },
|
||||
{ label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
|
||||
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
||||
],
|
||||
@@ -64,6 +68,7 @@ export const docsSidebar = [
|
||||
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
|
||||
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
|
||||
{ label: 'Teams architecture', slug: 'docs/architecture/teams-architecture' },
|
||||
{ label: 'Events architecture', slug: 'docs/architecture/events-architecture' },
|
||||
{ label: 'Protocol versions', slug: 'docs/architecture/protocol-versions' },
|
||||
],
|
||||
},
|
||||
@@ -75,7 +80,7 @@ export const docsSidebar = [
|
||||
{ label: 'sidecar.toml', slug: 'docs/reference/sidecar-toml' },
|
||||
{ label: 'Bridge.cfg', slug: 'docs/reference/bridge-cfg' },
|
||||
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
|
||||
{ label: 'Event catalog', slug: 'docs/reference/event-catalog' },
|
||||
{ label: 'Shard event catalog', slug: 'docs/reference/event-catalog' },
|
||||
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
|
||||
],
|
||||
},
|
||||
@@ -111,10 +116,14 @@ export const plannedSidebar = {
|
||||
'Users and roles',
|
||||
'Authentication',
|
||||
'Teams',
|
||||
'Scheduled events',
|
||||
'Moderation',
|
||||
'Notifications and email',
|
||||
'Engagement rules',
|
||||
'Message templates',
|
||||
'Managing modules',
|
||||
'The shard connection',
|
||||
'Client files',
|
||||
'Maintenance and upgrades',
|
||||
'Troubleshooting',
|
||||
],
|
||||
@@ -133,6 +142,7 @@ export const plannedSidebar = {
|
||||
'The bridge',
|
||||
'Authentication architecture',
|
||||
'Teams architecture',
|
||||
'Events architecture',
|
||||
'Protocol versions',
|
||||
],
|
||||
Reference: [
|
||||
@@ -141,7 +151,7 @@ export const plannedSidebar = {
|
||||
'sidecar.toml',
|
||||
'Bridge.cfg',
|
||||
'HTTP API',
|
||||
'Event catalog',
|
||||
'Shard event catalog',
|
||||
'Canonical documents',
|
||||
],
|
||||
};
|
||||
|
||||
103
src/content/docs/docs/administration/client-files.mdx
Normal file
103
src/content/docs/docs/administration/client-files.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Client files
|
||||
description: Creature portraits, item pictures and the game's own name table — where they come from, the one button that imports them, and why nothing here happens on a restart.
|
||||
---
|
||||
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Most of what a game shows you is not text. Ultima Online keeps its creature artwork, its
|
||||
item graphics and even its item *names* inside the client files, and a site that cannot read
|
||||
them shows a bestiary of words and a marketplace of numbers.
|
||||
|
||||
With the `uo` module installed, **Client files** appears in the admin sidebar at
|
||||
`/admin/uo/files`. It is where those three things arrive.
|
||||
|
||||
<Screenshot id="admin-client-files" />
|
||||
|
||||
## Where they come from
|
||||
|
||||
A ServUO shard cannot boot without a UO client — it resolves one at startup to read the
|
||||
world's own data. So the files were already on the shard host, and the shard reads and
|
||||
decodes them there, handing the results over the bridge like everything else.
|
||||
|
||||
**Nothing is converted on a desktop and nothing is uploaded.** Earlier versions of this
|
||||
platform asked an operator to install a third-party tool, build a converter against it and
|
||||
copy the output onto the web host. That path is gone.
|
||||
|
||||
## Three things, one page
|
||||
|
||||
| Section | Fills | How it arrives |
|
||||
|---|---|---|
|
||||
| **Creature portraits** | The bestiary and the spawn atlas | One picture per creature body, imported as a **set** |
|
||||
| **Item and land pictures** | Marketplace listings and character sheets | **One at a time**, shortly after a page asks for one |
|
||||
| **Item and title names (clilocs)** | Anywhere an item is named | The whole table at once — tens of thousands of names |
|
||||
|
||||
They are one page because they are one job: they come out of one client install, and they
|
||||
all change at the same moment — when you patch it.
|
||||
|
||||
<Aside type="caution" title="Nothing here happens on a restart">
|
||||
Boot deliberately never asks the shard for client files. A client patch is an event **you**
|
||||
know about and the website does not, and a site that re-read hundreds of megabytes on every
|
||||
restart to discover nothing had changed would pay for the rare case forever.
|
||||
|
||||
So after you patch your client, the site keeps serving the old pictures and the old names
|
||||
until somebody presses a button on this page. That is the whole reason the page exists.
|
||||
</Aside>
|
||||
|
||||
## Update, or re-import everything
|
||||
|
||||
Every section offers the same pair, and the difference is worth knowing:
|
||||
|
||||
- **Update** asks the shard what changed first and transfers only that. When nothing has, it
|
||||
costs one small round trip and answers *"unchanged"*.
|
||||
- **Re-import everything** fetches the lot. It is for the case the first cannot see — you
|
||||
restored a backup, or lost the uploads volume, and the database still remembers pictures
|
||||
that are no longer on disk.
|
||||
|
||||
Item and land pictures work differently, because there are tens of thousands of item
|
||||
graphics times every dye colour and importing them as a set would be absurd. They arrive
|
||||
lazily instead. The two buttons there — *Fetch waiting pictures* and *Refresh the ones I
|
||||
have* — exist for the two moments waiting is the wrong answer: you have just linked a shard,
|
||||
or you have just patched a client.
|
||||
|
||||
## When the page says something is wrong
|
||||
|
||||
Every one of these is a reported state with a reason, not an error. The site keeps serving
|
||||
whatever is already imported in all of them.
|
||||
|
||||
| What you see | What it means |
|
||||
|---|---|
|
||||
| **The shard is busy with another client-file request** | Not a fault. The shard serves one of these at a time, and an import — or the item-picture pass refilling itself — is holding it. It frees itself. |
|
||||
| **The shard is not answering for client files** | The ordinary bridge problem: see [The shard connection](/docs/administration/the-shard-connection/). |
|
||||
| **…set `AssetsEnabled` on the shard** | The asset plane is switched off in [`Bridge.cfg`](/docs/reference/bridge-cfg/). It is a separate switch on purpose — turning it on is consenting to the website reading this host's client files. |
|
||||
| **The shard host cannot render images** | A Linux host with no `libgdiplus`. Names are unaffected, because they have no pixels in them. |
|
||||
| **Waiting for you: *n* pictures … no longer offered** | The shard stopped offering artwork this site holds. A deletion is never silent here; it waits for you to approve or dismiss it. |
|
||||
|
||||
<Aside type="note" title="Linux shard hosts need one package">
|
||||
ServUO runs under Mono on Linux, and the library it decodes sprites with is a thin layer
|
||||
over **`libgdiplus`** — in the *decode* path, not merely the encode. Without it the shard
|
||||
cannot read a single sprite.
|
||||
|
||||
`sudo apt-get install libgdiplus`, or `dnf install libgdiplus`. `runicgateway doctor` checks
|
||||
for it, and Windows shard hosts need nothing. See
|
||||
[Requirements](/docs/getting-started/requirements/).
|
||||
</Aside>
|
||||
|
||||
## What it will not do
|
||||
|
||||
- **It never writes to the game.** Everything on this plane is a read.
|
||||
- **It never overwrites your own artwork.** A portrait you drew and named yourself always
|
||||
wins over an imported one.
|
||||
- **Creatures with no artwork stay as text.** That is normal rather than a failure — a stock
|
||||
client has no animation for most ghost and gargoyle bodies, and the shard reports nothing
|
||||
rather than guessing. A wrong picture is worse than no picture.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/v8.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md)
|
||||
is the asset plane's design of record;
|
||||
[`link/SHARD_PREREQS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md)
|
||||
covers what a shard host needs first, and
|
||||
[`website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md)
|
||||
is what to do on a site that was running before this existed.
|
||||
@@ -59,9 +59,10 @@ per-Team settings:
|
||||
## Email
|
||||
|
||||
Configured on the same screen and covered in
|
||||
[Notifications and email](/docs/administration/notifications-and-email/): it is Gmail over
|
||||
OAuth2, it reuses the Google authentication client, and it must be set up on the
|
||||
[Authentication](/docs/administration/authentication/) page first.
|
||||
[Notifications and email](/docs/administration/notifications-and-email/): pick a mail
|
||||
transport, enter its host, port and credentials, and send a test. It depends on nothing
|
||||
else on the site — a relay is the recommended posture, a mailbox provider over SMTP the
|
||||
simplest, and your own MTA needs no credentials at all.
|
||||
|
||||
<Aside type="note" title="Until email is connected, the contact form is a mailto: link">
|
||||
That is a deliberate fallback rather than a failure — but it does mean the *Contact email*
|
||||
|
||||
175
src/content/docs/docs/administration/engagement-rules.mdx
Normal file
175
src/content/docs/docs/administration/engagement-rules.mdx
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Engagement rules
|
||||
description: Decide what your site mails and shows people — the rule editor, saved audiences, the trigger catalog and the send log that answers "did they actually get it".
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
[Notifications and email](/docs/administration/notifications-and-email/) is about where a
|
||||
message goes. [Message templates](/docs/administration/message-templates/) is about what it
|
||||
says. This page is the part in between: **what makes one get sent at all.**
|
||||
|
||||
A **rule** is four decisions — *when* (a trigger), *to whom* (an audience), *by what*
|
||||
(channels), and *how often* (timing). **Admin → Engagement → Rules.**
|
||||
|
||||
## Nothing sends until you turn it on
|
||||
|
||||
Every rule arrives switched **off**. That is true of the ones you make, and it is true of
|
||||
the ones your modules ship with them: install a game module and you get a shelf of ready
|
||||
rules, all dark, none of them mailing anybody. Turning one on is a deliberate, separate
|
||||
act.
|
||||
|
||||
The same caution runs through the rest of the screen. Every rule carries a **hard ceiling
|
||||
on sends per hour** — you cannot save one without a number — because the failure mode of an
|
||||
automated mailer is not a wrong message, it is ten thousand of them at four in the morning.
|
||||
|
||||
<Aside type="caution" title="Upgrading? Your Team emails are here now">
|
||||
Team notification email used to be its own pipeline. It is engagement rules now, and — like
|
||||
every other seeded rule — those four rules arrive **disabled**. If your members were getting
|
||||
Team mail before an upgrade, it stops until you turn them on. The admin dashboard says so
|
||||
while it is true.
|
||||
</Aside>
|
||||
|
||||
## What a rule is made of
|
||||
|
||||
**The trigger** is the event that fires it: a house falling into disrepair, a post being
|
||||
published, a login failing. Pick it from what is registered — see
|
||||
[the catalog](#the-trigger-catalog) below. A rule's trigger is **fixed once the rule
|
||||
exists**: its cooldowns, its pending messages and its whole send history are about one
|
||||
event, so changing it would silently be a different rule wearing the same name. Make a new
|
||||
one instead.
|
||||
|
||||
**The audience** is who hears about it. Some are built in — the person the event is about,
|
||||
everyone subscribed to it, staff. Others come from your modules and are named in their own
|
||||
vocabulary. You can also point a rule at a **saved audience** you composed yourself; see
|
||||
[Audiences](#audiences).
|
||||
|
||||
**The channels** are how it reaches them: on the site, by email, by push. A rule can name
|
||||
more than one, and each channel picks its own template — the same event can be a sentence
|
||||
in the inbox and a properly laid-out letter in the mail.
|
||||
|
||||
**The timing** is the part worth reading twice.
|
||||
|
||||
- A **delay** holds the message before it goes, so a situation that resolves itself never
|
||||
produces a message at all.
|
||||
- **Cancel on** names the events that call it back. A warning that a house is about to
|
||||
collapse waits fifteen minutes and is cancelled outright if the owner turns up and
|
||||
repairs it — nobody is told their house was in danger after it stopped being in danger.
|
||||
- A **cooldown** is the "not again for a while" limit, counted **per person, per subject
|
||||
and per channel**. Per subject, so a cooldown about one house says nothing about another.
|
||||
Per channel, so "one a day about this house" means one email *and* one inbox item, which
|
||||
is what an operator setting that limit means.
|
||||
|
||||
## Audiences
|
||||
|
||||
**Admin → Engagement → Audiences** is where you build a named set of people out of the ones
|
||||
your modules declare — *members of this Team*, *the sitting governors* — and combine them:
|
||||
all of these, any of these, none of these.
|
||||
|
||||
One rule governs the whole screen: **composition narrows and never widens.**
|
||||
|
||||
- The ceiling of a saved audience is **derived** from the tightest thing in it, never
|
||||
chosen. That is true of "any of" too, where the intuitive answer — the widest of the two —
|
||||
is the wrong one. A ceiling says what an expression is *allowed* to reach, not what it
|
||||
happens to resolve to today.
|
||||
- **"None of" is only offered inside an "all of" group.** Alone it would have to mean
|
||||
"everybody except these", which is a broadcast built out of a short list, and it is not
|
||||
offered anywhere it would mean that.
|
||||
- Two audiences with no relationship between them — staff and "the person this is about",
|
||||
say — have no honest combined ceiling, so the save is refused rather than guessing which
|
||||
side to take.
|
||||
|
||||
Before you save a rule, the editor shows you a **reach preview**: a number, never a list of
|
||||
names. It will also tell you when a number is a floor rather than an answer, and when an
|
||||
audience resolves to nobody at all and why.
|
||||
|
||||
## The ceiling, and why a rule will not offer the audience you expected
|
||||
|
||||
Every trigger declares the **widest audience a rule may ever give it**. It is the security
|
||||
boundary of the whole system, and it is set in code by whoever declared the event, not in
|
||||
the admin panel. Staff-only events cannot be widened into public ones by anybody, including
|
||||
you.
|
||||
|
||||
Seven values, and they are a **tree, not a ladder**:
|
||||
|
||||
| Ceiling | Who that is |
|
||||
| --- | --- |
|
||||
| `everyone` | Everyone, including signed-out visitors |
|
||||
| `authenticated` | Any signed-in user |
|
||||
| `subscribers` | Signed-in users subscribed to this event |
|
||||
| `members` | Members of a module-declared list |
|
||||
| `staff` | Staff only — admins, editors and moderators |
|
||||
| `admin` | Administrators only |
|
||||
| `owner` | Only the user the event is about |
|
||||
|
||||
<Aside type="note" title="Fewer people is not less exposure">
|
||||
The tempting reading is a ladder — that a staff-only event could obviously also go to just
|
||||
one person. It cannot, and the example is the whole argument: cheat detection is a
|
||||
staff-only event, and "just one person" would be *the player it was detected on*. The
|
||||
question a ceiling answers is never how many, it is **which**.
|
||||
</Aside>
|
||||
|
||||
So `staff` does not permit `owner`, `members` does not permit `subscribers`, and the editor
|
||||
simply does not offer you the audiences the trigger forbids. The one exception proves the
|
||||
rule: `admin` sits under `staff`, because every administrator really is staff.
|
||||
|
||||
## The trigger catalog
|
||||
|
||||
**Admin → Engagement → Triggers** lists every event a rule can be built on, and it is
|
||||
read-only on purpose — **there is no table behind it**. A trigger is declared in code, by
|
||||
the site or by an installed module, so what you are looking at is whatever registered on
|
||||
this boot. Uninstall a module and its triggers stop appearing; nothing was deleted.
|
||||
|
||||
Two things it shows that are invisible everywhere else:
|
||||
|
||||
- **The variables** each event carries, with an example of each. This is the list a template
|
||||
is allowed to reference — when a message comes out with a hole in it, this is the screen
|
||||
that says why.
|
||||
- **The ceiling**, so when the rule editor offers you a narrower set of audiences than you
|
||||
expected, you can see the number it is obeying.
|
||||
|
||||
### Dormant rules
|
||||
|
||||
A rule can be switched on and still be unable to fire — most often because the module that
|
||||
declared its trigger, or the audience it points at, is no longer installed. Those are
|
||||
badged **dormant** in the list, with the reason, because "this rule cannot fire" is a
|
||||
different fact from "this rule is off" and you need both. The on/off switch keeps working
|
||||
on a dormant rule, deliberately: a rule whose module has gone is exactly the rule you most
|
||||
want to be able to stop.
|
||||
|
||||
## The send log
|
||||
|
||||
**Admin → Engagement → Send Log** answers one question: *did that person get that message,
|
||||
and if not, why not?* Every attempt is a row — when, what fired it, which user, which
|
||||
channel, and the result. Filter by result to go straight to what failed.
|
||||
|
||||
| Result | What it means |
|
||||
| --- | --- |
|
||||
| **Sent** | Handed to the channel successfully |
|
||||
| **Failed** | The attempt errored — the reason is on the row, not hidden in a tooltip |
|
||||
| **Not sent** | Suppressed before it was attempted: unsubscribed, unverified, or on the [suppression list](/docs/administration/troubleshooting/) |
|
||||
| **Bounced** | The receiving server rejected it after the fact |
|
||||
| **Marked as spam** | The recipient reported it |
|
||||
|
||||
Test sends from the template editor land here too, labelled as such, so you can confirm
|
||||
your own test arrived before turning a rule on for real.
|
||||
|
||||
<Aside type="tip" title="Two things it will not show you, on purpose">
|
||||
**The email address.** The log stores a one-way hash of it — enough to tie a bounce back to
|
||||
a delivery, not enough to become a second address book.
|
||||
|
||||
**A name.** It holds the user id, and that is deliberate: joining the account list in would
|
||||
quietly turn a delivery log into a staff-readable directory. Paste the id into Moderation,
|
||||
which is where a person's record belongs.
|
||||
</Aside>
|
||||
|
||||
## What a game module brings
|
||||
|
||||
A module declares its own triggers and its own audiences, in its own vocabulary, and it may
|
||||
ship rules and message bodies to go with them. The Ultima Online module ships a large family
|
||||
of them — houses falling to ruin, vendors running out of gold, a governor being seated, a
|
||||
guild's fortunes — written in the voice of an in-world office rather than a system alert.
|
||||
|
||||
All of them arrive **disabled**, like every other seeded rule. Read the list in
|
||||
**Admin → Engagement → Triggers**, turn on the ones your shard should send, and check the
|
||||
send log the first time each one fires.
|
||||
191
src/content/docs/docs/administration/events.mdx
Normal file
191
src/content/docs/docs/administration/events.mdx
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: Scheduled events
|
||||
description: Author an event as phases and steps, price it against this deployment's caps before it runs, and let it change a live game world unattended — with a ledger that makes the undo automatic.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
An **event** is a scheduled, bounded, audited change to a live game world. You write it once
|
||||
as a sequence of phases, publish a version of it, put it on the calendar, and it runs — at
|
||||
four in the morning if that is when you scheduled it, with nobody watching.
|
||||
|
||||
That last clause is the whole reason this feature is shaped the way it is. Everything below
|
||||
that looks like extra ceremony — the switchboard, the caps, the dry run, the ledger — is
|
||||
there because the thing being automated is somebody's game world, and the person who
|
||||
authored the change is asleep when it happens.
|
||||
|
||||
**Core owns the engine; the installed module owns the meaning.** Core decides whether an
|
||||
action is permitted, when it runs, in what order, how many times, within what budget, what
|
||||
it created and who is told. The module says which verbs exist and performs them. Core never
|
||||
learns a game word: every label you see in the step editor came from the module that
|
||||
registered it.
|
||||
|
||||
## Where it is
|
||||
|
||||
**Admin → Events**, its own group in the sidebar:
|
||||
|
||||
| Row | Who sees it |
|
||||
|---|---|
|
||||
| **Events** — the definitions, and their runs | Admin, editor, moderator |
|
||||
| **Calendar** — month and list view, with series | Admin, editor, moderator |
|
||||
| **Actions** — what this deployment permits, and the caps | **Admin only** |
|
||||
| **My participation** — your own attendance | Everyone |
|
||||
|
||||
Reading is staff-wide on purpose. A moderator's power over this feature is the **run
|
||||
console** — the screen you open when an event is doing something wrong at two in the
|
||||
morning — and hiding it from the one role that exists for incident response would be a
|
||||
strange way to build an incident tool. The narrower gates are on the actions, not the rows:
|
||||
authoring is admin and editor, publishing a version and starting a run are admin only, and
|
||||
all of it is enforced on the server rather than by hiding a button.
|
||||
|
||||
## Authoring
|
||||
|
||||
A **definition** is the thing that gets listed, searched, scheduled and audited: a title, a
|
||||
slug, a storyline, a schedule, and an ordered list of **phases**. Each phase holds **steps**,
|
||||
and a step is one action with its parameters.
|
||||
|
||||
A phase advances on a condition — after a duration, or when something happens in the game a
|
||||
given number of times. The vocabulary of "something that happens" is the trigger catalog the
|
||||
installed module already ships, so a module gains phase conditions by declaring one more
|
||||
entry in a list it already had.
|
||||
|
||||
<Aside type="note" title="A timeline, not a node graph">
|
||||
The phase editor is a vertical list, deliberately. The condition grammar has no branching —
|
||||
it is `and` / `or` / `not` over comparisons and nothing else — and a canvas would advertise
|
||||
power the engine does not have. Phases in order, each with its steps, its advance condition,
|
||||
its budget draw and its failure policy, is exactly what it can do.
|
||||
</Aside>
|
||||
|
||||
### Versions are immutable, and a run pins one
|
||||
|
||||
Publishing takes a snapshot. The run that starts on Saturday holds the version that was
|
||||
published, not the one you edited on Friday — which is what makes a run reproducible and an
|
||||
audit answerable after a change. **A running event cannot be edited**; you edit the
|
||||
definition, publish a new version, and the next run picks it up.
|
||||
|
||||
## Nothing is enabled until you enable it
|
||||
|
||||
**Admin → Events → Actions** lists every action the installed modules registered, and
|
||||
**everything above a notification arrives switched off.** Installing a module must never
|
||||
start doing things to your world.
|
||||
|
||||
Each row has two controls: whether the action is permitted on this deployment at all, and its
|
||||
**per-run caps** — how much of a budget dimension one run may consume. Dimensions are
|
||||
declared by the module (`uo.creatures`, `uo.bosses`, `uo.rewards` and so on), and consumption
|
||||
is counted in the database with a conditional update, not checked in application code.
|
||||
|
||||
That distinction matters more than it sounds. A stolen admin session has already passed every
|
||||
role check there is; it still cannot exceed the cap, because the cap is a condition on the
|
||||
`UPDATE` that spends the budget.
|
||||
|
||||
<Aside type="caution" title="A cap breach is a refusal, not a failure">
|
||||
A step that would exceed a cap does not run, does not retry, and is recorded `refused` with
|
||||
the dimension and both numbers — *"asks for 12 of `uo.creatures`; 0 of 5 is already spent this
|
||||
run"*. That is an authoring mistake being reported to the author, not an outage.
|
||||
</Aside>
|
||||
|
||||
## Dry run before anything unattended
|
||||
|
||||
**Verify** materialises the whole plan without touching the world: every step is dispatched
|
||||
with a verify flag, and you get back what *would* happen and what it *would* cost against the
|
||||
caps, in the module's own words. Refusals show up here, before the calendar entry exists.
|
||||
|
||||
A definition that has never been verified is exactly the one worth not scheduling. Verifying
|
||||
is cheap, and it is the last point a human sees the plan.
|
||||
|
||||
## Running one
|
||||
|
||||
Runs start on the schedule, or by hand. A **series** groups definitions into an arc, so a
|
||||
three-part story reads as one thing on the calendar rather than three unrelated entries.
|
||||
|
||||
The **run console** shows live status, the steps and their attempts, the budget consumed
|
||||
against each cap, any failures, and the cleanup. Its controls are:
|
||||
|
||||
- **Pause** and **resume** — resume carries a run past any settled step, including one that
|
||||
failed or was refused.
|
||||
- **Skip**, **retry** and **confirm** a single step. *Confirm* is how a human-cue step
|
||||
advances: the run posts the instruction, waits, and moves on when somebody says they did it.
|
||||
- **Advance** a phase by hand.
|
||||
- **Cancel**, with or without cleanup.
|
||||
|
||||
Every one of those is logged with the person who did it.
|
||||
|
||||
## What an event does to a world, and how it is undone
|
||||
|
||||
Two different things, and the difference is the whole safety story.
|
||||
|
||||
**What it owns.** Creatures, bosses, oracle NPCs, decoration, a temporary gate — things the
|
||||
run created. Each one is written to a **resource ledger** as it is made, with the run and
|
||||
step that made it.
|
||||
|
||||
**What it borrows.** A spawner's respawn timer, a starting skill cap, a seasonal flag — values
|
||||
that already existed and are being changed for the duration. Those are **leases**: the game
|
||||
keeps the original, the site records both halves, and the lease carries its own deadline.
|
||||
|
||||
<Aside type="tip" title="Cleanup is generated, never authored">
|
||||
There is no undo phase for you to write, and that is on purpose: an operator cannot be relied
|
||||
on to write the undo, and an aborted run never reaches the phase they wrote it in. Teardown
|
||||
steps are derived from the ledger and run on **every** terminal path — completion,
|
||||
cancellation and abort alike.
|
||||
|
||||
A lease is safer still. The game restores the baseline when the deadline passes whether or not
|
||||
it ever hears from the site again, and a lease is never written to disk — so a game-server
|
||||
restart puts every borrowed value back too.
|
||||
</Aside>
|
||||
|
||||
## The game server has its own switches
|
||||
|
||||
They live on the shard host, outside the site's reach, and the site cannot turn them on.
|
||||
|
||||
**`EventsEnabled` is off by default, and it is a different switch from `AdminWriteEnabled`.**
|
||||
Turning the admin plane on is consenting to staff moderation driven from a screen somebody is
|
||||
looking at. Turning this on is consenting to the site changing and watching your world
|
||||
unattended. One switch could not honestly express both.
|
||||
|
||||
Beside it sit the game's own ceilings — how many creatures one call may spawn, how long a gate
|
||||
may stand, how much one run may own in total, how often the world may be saved. **They refuse
|
||||
rather than clamp**, for the same reason the caps do: a quietly shortened request leaves the
|
||||
two halves disagreeing about what actually happened. See
|
||||
[Bridge.cfg](/docs/reference/bridge-cfg/) for every key.
|
||||
|
||||
## What players see
|
||||
|
||||
The public calendar at `/site/events` carries what is scheduled, what is happening now, what
|
||||
finished recently, and published results. A run that was cancelled says so — *"Did not
|
||||
happen"* — rather than quietly disappearing.
|
||||
|
||||
**Listing is separate from publishing.** A definition has its own *listed* switch, because
|
||||
publishing is what makes an event runnable and a surprise invasion should not have to be
|
||||
advertised a fortnight in advance in order to be allowed to happen. Unlisting hides the
|
||||
definition, its runs and its results from the public pages and from a participant's own
|
||||
history; it hides nothing from staff.
|
||||
|
||||
Where a module can tell who took part, a run can keep a **participation ledger** — scores and
|
||||
ranks, published as a results table when the run finishes. Ranks are computed at publication
|
||||
and stored, so somebody added afterwards does not silently renumber a table people have
|
||||
already read. A signed-in person sees their own attendance under their account, and staff see
|
||||
theirs on the same screen.
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
- **`degraded` is not `failed`.** If the game server disappears mid-run, the run degrades,
|
||||
world-changing steps park unattempted, and it recovers when the connection does. The public
|
||||
page does not say so — that is operator information.
|
||||
- **`refused` means a bound said no**, and it is reported with the numbers.
|
||||
- **The run log answers "why did phase 3 not start?"** as a query, not by reading a wall of
|
||||
text. It is kept for 90 days after a run reaches a terminal state — and a run still in
|
||||
flight keeps every line it has, however old, because the question it answers is still open.
|
||||
- **Cleanup can be re-run** from the run console if a teardown was interrupted.
|
||||
|
||||
## Where the record is
|
||||
|
||||
[`website/EVENTS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md)
|
||||
is the design of record — the model, the data, the security argument and what was deliberately
|
||||
left out.
|
||||
[`website/MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
is the contract a module registers its verbs against, and
|
||||
[`link/ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md)
|
||||
is what the site may ask a game to do at all.
|
||||
|
||||
For how the engine is put together, see
|
||||
[Events architecture](/docs/architecture/events-architecture/).
|
||||
@@ -116,6 +116,13 @@ when ServUO needs restarting — it never restarts your shard itself. Because it
|
||||
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
|
||||
protocol.
|
||||
|
||||
<Aside type="caution" title="After you patch the UO client, press one more button">
|
||||
Creature portraits, item pictures and the name table are read from that client, and the
|
||||
site deliberately never re-reads them on its own — a restart does not, and neither does
|
||||
`update`. They keep serving the old artwork until somebody presses *Update* on
|
||||
**Admin → Client files**. It is one round trip when nothing has changed.
|
||||
</Aside>
|
||||
|
||||
<Aside type="note" title="Update the two sides in either order, but verify after each">
|
||||
They are independent deployments joined by a version-checked contract: a mismatch is
|
||||
rejected with a `409` rather than mis-parsed. So the worst case is a bridge that refuses to
|
||||
|
||||
133
src/content/docs/docs/administration/message-templates.mdx
Normal file
133
src/content/docs/docs/administration/message-templates.mdx
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: Message templates
|
||||
description: Edit what your site's email actually says — the block editor, the variable palette, the preview, test sends, and the send log that tells you whether a message arrived.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Every message your site sends — password resets, invitations, notifications — is a
|
||||
**template** you can edit. They ship working, so a fresh site mails correctly before you
|
||||
open this screen at all. You come here when you want it to sound like your shard.
|
||||
|
||||
**Admin → Engagement → Templates.**
|
||||
|
||||
## What is in the list
|
||||
|
||||
Each row is one message. The ones marked **system** are the ones the site itself depends
|
||||
on: the password reset, the invitation, the address-confirmation mail. You can edit every
|
||||
word of those, but you cannot delete them — a site with no password-reset body is a site
|
||||
where nobody can get back in.
|
||||
|
||||
The rest are the general-purpose bodies that rules send. Those you can delete, as long as
|
||||
no rule is currently pointing at one.
|
||||
|
||||
<Aside type="tip" title="Your edits survive upgrades">
|
||||
When you edit a shipped template, the site remembers that a person changed it. Later
|
||||
versions may ship an improved default for the same message — and it will **not** be applied
|
||||
over your words. You will see a note on the row telling you a newer default exists, and it
|
||||
is up to you whether to look at it.
|
||||
</Aside>
|
||||
|
||||
## Editing a message
|
||||
|
||||
The editor has the message on the left and a live preview on the right.
|
||||
|
||||
### The body is blocks, not HTML
|
||||
|
||||
You build a message out of pieces: a heading, a paragraph, a button, a divider, an image,
|
||||
or an item list. Add one from the row of buttons, click it to edit it, and use the arrows
|
||||
to move it. There is no HTML to write, which is deliberate — email HTML is a genuinely
|
||||
horrible format, and the blocks already produce something that survives Outlook.
|
||||
|
||||
### Variables are chosen, never typed
|
||||
|
||||
Under most text fields is a row of small grey names: `siteName`, `resetUrl`, `title`. Those
|
||||
are the **variables** this particular message is given when it is sent. Click one and it is
|
||||
inserted as a token; the message that goes out has the real value in its place.
|
||||
|
||||
You cannot invent a variable. If you type one the message is not given — a typo, or a name
|
||||
you remembered from a different message — the save is refused and the error names the
|
||||
variable. That is on purpose: a variable that does not exist renders as *nothing*, so
|
||||
without the check the mistake would be invisible until it reached somebody's inbox as a
|
||||
sentence with a hole in it.
|
||||
|
||||
To see every variable a given event provides, with an example of each, look at
|
||||
**Admin → Engagement → Triggers**.
|
||||
|
||||
### Both halves of the message
|
||||
|
||||
Every email goes out in two forms: the designed HTML one, and a plain-text one for clients
|
||||
that will not show HTML. The plain-text half is generated from your blocks automatically,
|
||||
and you can see it under the **Plain text** tab.
|
||||
|
||||
If the generated version is not good enough, write your own in **Plain-text part** at the
|
||||
bottom of the editor. Whatever you write there replaces the generated text completely.
|
||||
|
||||
A published message must have *something* in its text part. If every block you used
|
||||
contributes nothing to it — a message made only of dividers and images, say — the save is
|
||||
refused.
|
||||
|
||||
### Draft and published
|
||||
|
||||
A **draft** is not what goes out. While a message is a draft, the site sends the shipped
|
||||
default in its place, so you can leave something half-finished without breaking anything.
|
||||
Switch it to **Published** when you want your version to be the one people receive.
|
||||
|
||||
## The preview
|
||||
|
||||
The preview is rendered by the server using the same code that renders the real message, so
|
||||
what you see is what will arrive — not an approximation drawn by the browser.
|
||||
|
||||
It fills the variables in with example values, so you never need to trigger a real event to
|
||||
see what a message looks like.
|
||||
|
||||
Three controls are worth knowing:
|
||||
|
||||
- **Desktop / Mobile** — the same body at a reading-pane width and a phone width.
|
||||
- **Dark mode** — an approximation of what mail clients that invert light messages will do
|
||||
to yours. Worth a glance: a design that relies on a light background can come out as
|
||||
dark-on-dark for a large minority of readers.
|
||||
- **Plain text** — the other half of the message, as described above.
|
||||
|
||||
## Sending yourself a test
|
||||
|
||||
The **Send a test** box sends the message to any address you type, through whatever mail
|
||||
transport the site is configured with (**Admin → Settings → Email delivery** — see
|
||||
[Notifications and email](/docs/administration/notifications-and-email/)).
|
||||
|
||||
It sends **what is on screen**, saved or not. That is the point of it: try a wording, send
|
||||
it to yourself, look at it in a real inbox, and only then decide whether to save.
|
||||
|
||||
Test sends are recorded in the send log like any other message, including when they fail.
|
||||
|
||||
## Making a new template
|
||||
|
||||
You do not start from a blank page. Pick a message that is close to what you want, press
|
||||
**Duplicate**, and give the copy a key.
|
||||
|
||||
The **key** is how a rule refers to the template — `notify.house-idoc`, say. Lowercase
|
||||
letters, digits, dots and dashes, and it cannot be changed later, so pick one that will
|
||||
still make sense in a year.
|
||||
|
||||
The copy always starts as a draft. Once you are happy with it, publish it and point a rule
|
||||
at it in **Admin → Engagement → Rules**.
|
||||
|
||||
<Aside type="caution" title="A template a rule is using cannot be deleted">
|
||||
If you try, the site tells you which rules are still pointing at it. Repoint or delete
|
||||
those first. The alternative — letting the delete through — would leave a rule that quietly
|
||||
stops producing mail, and nothing on screen would say why.
|
||||
</Aside>
|
||||
|
||||
## Did it arrive?
|
||||
|
||||
**Admin → Engagement → Send Log** lists every message the site tried to deliver, newest
|
||||
first, successes and failures alike. When mail is not arriving, this is the screen that
|
||||
tells you whether the site tried and the relay refused, or whether it never tried at all.
|
||||
|
||||
Failures carry the reason the mail server gave, which is usually the actual answer — a
|
||||
rejected sender address, a bad password, a relay that will not accept your domain.
|
||||
|
||||
The log does not store anybody's email address. It keeps a one-way fingerprint instead, so
|
||||
that a bounce can be matched back to a delivery without the log itself becoming a second
|
||||
copy of your members' addresses. The rest of that screen — and the rules that decide a
|
||||
message is sent at all — is [Engagement rules](/docs/administration/engagement-rules/).
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Notifications and email
|
||||
description: Email over Gmail OAuth2, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
|
||||
description: Email over SMTP, the announcement pipeline and its legs, the Discord bot, and opt-in push to the mobile app.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
@@ -9,21 +9,67 @@ Four separate delivery paths, each optional, each off until you configure it. A
|
||||
configures none of them still works — it just never reaches anyone who is not looking at
|
||||
it.
|
||||
|
||||
This page is about the paths themselves. What decides that a particular message gets sent
|
||||
down one of them is a rule — see [Engagement rules](/docs/administration/engagement-rules/).
|
||||
|
||||
## Email
|
||||
|
||||
**Admin → Settings → Email delivery.** The site sends contact-form messages (and test
|
||||
messages) through **Gmail over OAuth2**, delivered to the *Contact email* setting.
|
||||
**Admin → Settings → Email delivery.** The site sends contact-form messages, invitations,
|
||||
password resets, team notifications and test messages through **SMTP**. Contact-form mail
|
||||
goes to the *Contact email* setting.
|
||||
|
||||
It reuses the **Google authentication client**, so the order is fixed: configure Google on
|
||||
the [Authentication](/docs/administration/authentication/) page first, then press **Connect
|
||||
Gmail** here. Until then the panel reads *Unconfigured* and says exactly that.
|
||||
You pick a mail transport and fill in the fields it asks for. There is no consent flow and
|
||||
no redirect to bounce through — it is a form, and the credentials go straight into the
|
||||
database encrypted at rest, write-only: the panel will tell you a password is *set*, and
|
||||
will never show it to you again.
|
||||
|
||||
The refresh token it stores is encrypted at rest like every other secret.
|
||||
### Three ways to point it somewhere
|
||||
|
||||
<Aside type="note" title="There is no SMTP option">
|
||||
Gmail over OAuth2 is the only supported delivery path today. Until it is connected, the
|
||||
contact form falls back to a `mailto:` link to the contact address — which works, and puts
|
||||
the message in the visitor's own mail client rather than in your logs.
|
||||
Any SMTP server works. Which one you should use depends on how much mail you expect to send.
|
||||
|
||||
**A relay — the recommended one.** Mailgun, SES, Postmark or equivalent: their host, port
|
||||
`587`, *Implicit TLS* **off**, and your API key as the password. Deliverability is the hard
|
||||
part of sending mail — reputation, DKIM, bounce handling — and this is the option where
|
||||
somebody else owns it. Use this for anything with real volume.
|
||||
|
||||
**A mailbox provider over SMTP — the simplest.** For example `smtp.gmail.com`, port `587`,
|
||||
*Implicit TLS* **off**, your address as the username, and an
|
||||
[app password](https://support.google.com/accounts/answer/185833) — not your account
|
||||
password, and it requires 2-Step Verification to be on. Fine for a small site; subject to
|
||||
the provider's daily send caps.
|
||||
|
||||
**Your own MTA.** If you already run mail on the same host: its address, port `25`,
|
||||
*Implicit TLS* **off**, username and password blank. The site treats a username with no
|
||||
password as incomplete, since that authenticates as nobody.
|
||||
|
||||
<Aside type="caution" title="The two fields that cause most failures">
|
||||
**Implicit TLS** belongs *on* only for port **465**. On port `587` leave it **off** — the
|
||||
connection still upgrades to TLS, using STARTTLS. Port 587 with it on does not report an
|
||||
error; it hangs.
|
||||
|
||||
**Send from** must be an address the account is allowed to send as. Unlike a username, this
|
||||
is not verified when you save it — a server that refuses your sender rejects the mail for
|
||||
SPF/DMARC reasons that look like nothing at all from the outside. **Send test** is what
|
||||
proves it, and it names this specifically when it happens.
|
||||
</Aside>
|
||||
|
||||
Until a transport is configured, the contact form falls back to a `mailto:` link to the
|
||||
contact address — which works, and puts the message in the visitor's own mail client rather
|
||||
than in your logs. Invitations surface a copyable accept link instead, and password resets
|
||||
still answer normally.
|
||||
|
||||
<Aside type="note" title="Upgrading from the Gmail connect flow">
|
||||
Earlier versions authorised a mailbox with a **Connect Gmail** consent flow that borrowed
|
||||
the Google authentication client. That flow has been removed.
|
||||
|
||||
If your site used it, mail **stops** on upgrade until you enter SMTP credentials — and
|
||||
nothing errors when it does, because every sender degrades politely. The admin dashboard
|
||||
warns you while it is true. `smtp.gmail.com` port 587 with an app password is the shortest
|
||||
route back.
|
||||
|
||||
Single sign-on is unaffected: the Google provider exists for SSO in its own right, and email
|
||||
merely borrowed its credentials. Removing the borrow also removes a trap — rotating the SSO
|
||||
secret used to break outbound mail silently.
|
||||
</Aside>
|
||||
|
||||
## Announcements
|
||||
@@ -75,14 +121,45 @@ Two properties matter for what you have to trust:
|
||||
Without `NTFY_PUBLIC_URL` / `NTFY_ALLOWED_ORIGINS`, the app simply shows push as
|
||||
unavailable for your instance — nothing breaks.
|
||||
|
||||
A tickle raised by an engagement rule carries a pointer to the matching item in the
|
||||
[on-site inbox](#on-site-notifications) where there is one, so the app opens on the thing
|
||||
that happened rather than on a list. It is still only a pointer: the content is fetched, not
|
||||
delivered.
|
||||
|
||||
## On-site notifications
|
||||
|
||||
The third way to reach somebody, and the only one that needs no relay, no mailbox and no
|
||||
app: an item in their **notification inbox** on the site itself. A bell in the header
|
||||
carries the unread count; the list lives at **Account → Notifications**.
|
||||
|
||||
Two things are worth knowing before you enable a rule that uses it:
|
||||
|
||||
- **It is the one channel that is on by default.** Push and email are opt-in — both reach
|
||||
somebody somewhere else, so both have to be asked for. An inbox item is a row on a page
|
||||
the person chose to open, so it is opt-*out*: they switch it off per notification under
|
||||
Account → Notifications → Settings.
|
||||
- **The body is plain text, always.** The in-app template renders through the same block
|
||||
editor as your mail, but only the text of each block is stored, so nothing an operator
|
||||
writes can become markup on somebody else's page. Links are site-relative or dropped.
|
||||
|
||||
Old, read items are pruned nightly (90 days by default). **Unread items are never pruned** —
|
||||
an inbox that quietly deleted things nobody had seen would make the unread badge meaningless.
|
||||
|
||||
## Who receives what
|
||||
|
||||
The per-person side of this lives in the player portal, not the admin panel: each member
|
||||
chooses which Team and forum notifications they want, and how. Two defaults are worth
|
||||
knowing because they are not symmetrical:
|
||||
The per-person side of this lives in the player portal, not the admin panel: **Account →
|
||||
Notifications → Settings** is a grid of every notification against every channel, and each
|
||||
member sets their own. The defaults are not symmetrical, and the asymmetry is deliberate:
|
||||
|
||||
- **Push is opt-out** once a device is registered.
|
||||
- **Email is opt-in.**
|
||||
- **Push is opt-in.**
|
||||
- **On the site is opt-out** — see above.
|
||||
- **Muting a Team silences all three for that Team**, whatever the grid says, without
|
||||
touching any of their other Teams.
|
||||
|
||||
The operator's side of the same question — which events exist, and how wide an audience each
|
||||
one may ever be given — is [Engagement rules](/docs/administration/engagement-rules/). When
|
||||
a message went nowhere and you want to know why, the send log there is the screen that says.
|
||||
|
||||
<Aside type="caution" title="Nothing here retries">
|
||||
The announcement dispatcher sends once, and the Team notification bridge states plainly that
|
||||
|
||||
@@ -48,6 +48,52 @@ Two rules are structural rather than settings:
|
||||
about a leader has to reach someone above them. See
|
||||
[Moderation](/docs/administration/moderation/).
|
||||
|
||||
## Team notification emails
|
||||
|
||||
**Team emails are sent by the engagement rules, and they arrive switched off.**
|
||||
|
||||
Someone posting in a Team forum used to send mail with no configuration at all. It now goes
|
||||
through the same engine as everything else the site sends: the forum post raises an event,
|
||||
an **engagement rule** decides who is told and through which message template, and the
|
||||
outbox delivers it. Push notifications to the app and the Discord bridge below are
|
||||
unaffected — only the email moved.
|
||||
|
||||
The practical consequence on an existing site: **nobody gets Team email until you turn a
|
||||
rule on.** Open **Admin → Engagement → Rules**. Four rules are waiting there, one per Team
|
||||
event, all switched off, and the screen says so at the top for as long as they all are.
|
||||
Switch on the ones your site wants.
|
||||
|
||||
| Rule | Sends when |
|
||||
|---|---|
|
||||
| **Team forum posts** | someone posts a new thread or reply |
|
||||
| **Team announcements** | a leader posts an announcement |
|
||||
| **Team — new member** | someone joins, at most once an hour per person |
|
||||
| **Team — leadership change** | a new leader is named, at most once an hour per person |
|
||||
|
||||
The first two are the ones most sites want. The last two describe things that already show
|
||||
up on the Team's activity feed and arrive from a sweep rather than from a person doing
|
||||
something — which is why they ship off and with a cooldown.
|
||||
|
||||
<Aside type="note" title="Members still control their own mail">
|
||||
A rule decides whether the site sends at all. Each member still chooses, per Team, between
|
||||
no email, one message per post, and a daily digest — on their own notifications screen or
|
||||
through the unsubscribe link in any Team email. Turning a rule on does not sign anybody up.
|
||||
</Aside>
|
||||
|
||||
**Digests are re-read at the moment they are sent**, not assembled as posts arrive. A site
|
||||
that was down for two days sends one digest rather than two days of backlog, a post a
|
||||
moderator hid is not in it, and somebody who lost access to a forum between the post and the
|
||||
send does not receive it.
|
||||
|
||||
**Unsubscribe links keep working.** A link in mail sent before this change still does what
|
||||
it says. What changed is that it is now precise: it stops the emails it came with and leaves
|
||||
that Team's push notifications alone, where before it silenced both.
|
||||
|
||||
You can change what any of these messages say — see
|
||||
[Message templates](/docs/administration/message-templates/) — decide which of them are sent
|
||||
at all under [Engagement rules](/docs/administration/engagement-rules/), and see who was
|
||||
actually sent what in **Admin → Engagement → Send log**.
|
||||
|
||||
## The Discord bridges
|
||||
|
||||
Two integrations, both optional, both configured from **Admin → Teams**.
|
||||
|
||||
@@ -81,6 +81,13 @@ lines, and a duration in seconds. Re-posting the same id **replaces** that messa
|
||||
The id is the useful part — give a recurring announcement a stable one and you can update or
|
||||
withdraw it without waiting for it to expire.
|
||||
|
||||
## Client files
|
||||
|
||||
The other half of what the bridge carries has its own screen: creature portraits, item
|
||||
pictures and the game's own name table, read from the UO client on the shard host. It is
|
||||
**Client files**, at `/admin/uo/files`, and it is where an operator goes after patching that
|
||||
client — nothing imports on a restart. See [Client files](/docs/administration/client-files/).
|
||||
|
||||
## What reaches the public
|
||||
|
||||
Events from the shard fan out over two separate streams, and the split is a security
|
||||
|
||||
@@ -88,6 +88,23 @@ the tree and never compiles — and ServUO ignores the script build's exit code,
|
||||
looks clean. `doctor` catches it by comparing file hashes against the install record.
|
||||
</Aside>
|
||||
|
||||
## The bestiary has no pictures, or items show numbers
|
||||
|
||||
Those come out of the UO client on the shard host, and **nothing imports them on a
|
||||
restart** — a button on **Admin → Client files** is the only thing that does. Check that
|
||||
page first: it reports why rather than failing.
|
||||
|
||||
| What it says | What to do |
|
||||
|---|---|
|
||||
| Counts are zero and no import is recorded | Press *Update*. On a shard that was linked before this existed, nobody ever has. |
|
||||
| *…set `AssetsEnabled` on the shard* | The asset plane is off in `Bridge.cfg`. It is a separate switch on purpose. |
|
||||
| *The shard host cannot render images* | A Linux host with no `libgdiplus`. Install it and press *Update* again. Names are unaffected either way. |
|
||||
| *The shard is busy with another client-file request* | Not a fault. Something ordinary holds the slot; it frees itself. |
|
||||
| Pictures were fine and went blank | Check the uploads volume before anything else — the database still remembers pictures that are no longer on disk, and *Re-import everything* is the button for exactly that. |
|
||||
|
||||
Items reading as numbers rather than names is the same page, different section: it means the
|
||||
cliloc table has not been imported. See [Client files](/docs/administration/client-files/).
|
||||
|
||||
## Teams are missing
|
||||
|
||||
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
|
||||
@@ -97,14 +114,71 @@ a Team. See [Teams](/docs/administration/teams/).
|
||||
|
||||
## Email and announcements never arrive
|
||||
|
||||
- **The contact form opens a mail client.** Email delivery is not connected; that is the
|
||||
documented fallback. Connect Gmail in **Settings → Email delivery** — after configuring
|
||||
the Google provider, which it reuses.
|
||||
- **The contact form opens a mail client.** Email delivery is not configured; that is the
|
||||
documented fallback. Enter SMTP credentials in **Settings → Email delivery**. If this site
|
||||
used to send mail and stopped, the Gmail connect flow was removed — the admin dashboard
|
||||
says so, and [Notifications and email](/docs/administration/notifications-and-email/) has
|
||||
the migration.
|
||||
- **Mail is configured but nothing arrives, and there is no error.** Two usual causes, both
|
||||
invisible without a test send. *Implicit TLS* left on for port 587 hangs rather than
|
||||
failing; and a **Send from** address the server will not let you send as is rejected for
|
||||
SPF/DMARC reasons. Press **Send test** — its failure message names both cases.
|
||||
- **Email was working and the toggle is still on.** *Enable email sending* now gates every
|
||||
message, not just some of them. If it is off, nothing is sent, including the contact
|
||||
form.
|
||||
- **A published post announced nothing.** The Discord bot is a separate container. If the
|
||||
Discord Bot screen says *bot unreachable*, it is not running.
|
||||
- **A missed announcement does not come back.** Nothing retries; the post itself is still
|
||||
on the site.
|
||||
|
||||
## Nothing is sent for one particular event
|
||||
|
||||
Mail works, other notifications arrive, but this one thing never produces anything. The
|
||||
answer is almost always in **Engagement → Rules**, and it is one of four:
|
||||
|
||||
- **The rule is off.** Every rule ships disabled, including the ones your modules bring
|
||||
with them, so "installed" is not "on".
|
||||
- **The rule is badged *dormant*.** It is switched on but cannot fire — usually because the
|
||||
module that declared its trigger, or the audience it points at, is no longer installed.
|
||||
- **It fired and was held back by its own cooldown**, which is per person, per subject and
|
||||
per channel. The Send Log shows nothing for a message that was never queued.
|
||||
- **The audience resolves to nobody.** The rule editor's reach preview is the fastest way
|
||||
to find that out — it will tell you the count is zero and why.
|
||||
|
||||
[Engagement rules](/docs/administration/engagement-rules/) walks through all four.
|
||||
|
||||
## One person stopped receiving email
|
||||
|
||||
Everyone else is getting mail, so the transport is fine. Check
|
||||
**Engagement → Suppressions**, then **Engagement → Send Log**.
|
||||
|
||||
- **They are on the suppression list.** The site stops mailing an address once the
|
||||
receiving server says the mailbox does not exist. Addresses are stored one way and
|
||||
shown masked (`d***@example.com`), so search by their domain to find the row. If they
|
||||
have since fixed their mailbox, press **Lift a suppression** and type the full
|
||||
address — the screen genuinely does not have it, which is why you are asked.
|
||||
- **Suppression only affects engagement rules.** Password resets, invites and address
|
||||
verification still go out to a suppressed address, because those are things the person
|
||||
asked for themselves. So "they can reset their password but get no notifications" is
|
||||
the expected shape of this problem, not a contradiction.
|
||||
- **The Send Log says *Not sent*.** That is a suppression: nothing was sent to the mail
|
||||
server at all. *Bounced* means it was sent and the mailbox does not exist. *Failed*
|
||||
means the relay refused it for some other reason — that one is about your
|
||||
configuration, not about them.
|
||||
- **The Send Log has no row for them at all.** They were excluded before anything was
|
||||
queued. Either they have not opted in on **Notifications** for that stream, or
|
||||
*Require a verified email address* is on in **Settings** and they have not confirmed
|
||||
theirs. The rule editor's audience preview shows how many people each of those removes.
|
||||
|
||||
## Everyone stopped receiving email at once
|
||||
|
||||
Do **not** start clearing the suppression list — it is almost certainly not the cause.
|
||||
A whole-deployment stop is a transport problem: an expired password, a relay that has
|
||||
started refusing you, or *Enable email sending* switched off. The Send Log will show
|
||||
*Failed* rather than *Bounced* or *Not sent*, and **Settings → Email delivery** shows the
|
||||
last error. A wrong password never suppresses anybody; only the receiving server saying a
|
||||
specific mailbox does not exist does that.
|
||||
|
||||
## Uploads and modules fail with permission errors
|
||||
|
||||
Docker created a bind-mount source that the container user cannot write — usually because
|
||||
|
||||
163
src/content/docs/docs/architecture/events-architecture.mdx
Normal file
163
src/content/docs/docs/architecture/events-architecture.mdx
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Events architecture
|
||||
description: An event does not edit the world — it holds a lease. How a game-agnostic engine schedules changes to a live game world it cannot name.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The Event System is a game-agnostic engine for **scheduled, bounded, audited** changes to a
|
||||
live game world. Core runs it and cannot name a single thing in your game.
|
||||
|
||||
The administrator's view is [Scheduled events](/docs/administration/events/).
|
||||
|
||||
## The two sentences the design turns on
|
||||
|
||||
**An event does not edit the world. It holds a lease.**
|
||||
|
||||
Anything an event changes that already existed is borrowed, not set: the game keeps the
|
||||
baseline, the site records both halves, and the lease carries its own deadline. When the
|
||||
deadline passes the game restores the value — whether or not it ever hears from the site
|
||||
again. A lease is never written to the game's save file either, so a server restart also
|
||||
puts every borrowed value back. That is the difference between automating a change and
|
||||
handing an unattended process a `[set` command.
|
||||
|
||||
**The module declares; core dispatches.** A module says a verb exists, what it costs and what
|
||||
it needs; core decides whether it is permitted, when it runs, in what order, how many times,
|
||||
within what budget, what it created and who is told. Nothing crosses that line as a string
|
||||
core interprets — the browser posts an action *id* and a params object, both validated
|
||||
against the registry before anything is dispatched. There is no passthrough field and no
|
||||
place a request body can name a game command.
|
||||
|
||||
## What is a table, and what deliberately is not
|
||||
|
||||
Eleven core tables, no ORM, and no migration system — which makes every table a permanent
|
||||
commitment. The rule applied was: **a table is for what must be queried, claimed or joined.**
|
||||
|
||||
| Kind | Where it lives |
|
||||
|---|---|
|
||||
| Definitions, series, versions, runs, steps, budget, resources, participants, gates, settings, log | Tables |
|
||||
| Phases | Configuration inside an immutable version snapshot. A phase has no identity a query needs; a step does |
|
||||
| Actions, budget dimensions, conditions | Registry entries a module declares at load. A stored one would outlive the module that can perform it |
|
||||
| A reward catalog | Neither. A reward is an ordinary action, so a granted reward is an ordinary ledger row |
|
||||
|
||||
**The step is the unit of execution, and it is a row** — one action invocation with a due
|
||||
time, a status, an attempt count and a claim. Retries, timeouts, duplicate execution and
|
||||
resumption after a crash are then all properties of that row rather than of a process's
|
||||
memory, which is what lets the runner be killed mid-run and pick up where it stopped.
|
||||
|
||||
**One run per occurrence, guaranteed by a unique index** on the definition, the scope and the
|
||||
scheduled instant — not by the claim. Two application instances cannot both start the same
|
||||
occurrence, because the second insert fails.
|
||||
|
||||
## Budgets are enforced in SQL
|
||||
|
||||
Consumption is spent with a conditional update:
|
||||
|
||||
```sql
|
||||
UPDATE event_run_budget
|
||||
SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND consumed + ? <= cap
|
||||
```
|
||||
|
||||
No transaction, no read-then-write, and no way for two concurrent steps to both squeeze past
|
||||
the same ceiling.
|
||||
|
||||
<Aside type="tip" title="Why that is the strongest control here">
|
||||
A compromised admin session has already passed every role check the application has. It has
|
||||
not passed this one, because this one is not a check — it is a condition on the write. That is
|
||||
the reason per-run quotas were kept after the delegation model was dropped.
|
||||
</Aside>
|
||||
|
||||
## The ledger, and why cleanup is generated
|
||||
|
||||
Every world write appends to a resource ledger before it is confirmed: the run, the step, the
|
||||
owning module, an opaque kind and reference, and — for a borrowed value — the baseline
|
||||
alongside what was applied.
|
||||
|
||||
Teardown is then **derived from the ledger**, never authored, and runs on every terminal path:
|
||||
completion, cancellation and abort alike. An operator cannot be relied on to write the undo,
|
||||
and an aborted run never reaches the phase they wrote it in.
|
||||
|
||||
Two rules make that hold up:
|
||||
|
||||
- **A unique index across non-reverted rows** stops two events leasing the same target. The
|
||||
second one is refused rather than layered on top of the first.
|
||||
- **A restore is a compare-and-set.** If the current value is not what the lease applied,
|
||||
somebody else changed it since; the row is marked `drifted` rather than stamped over. The
|
||||
ledger would rather say "I do not know what happened here" than lie about having undone it.
|
||||
|
||||
## At-most-once, on a wire that can lose an answer
|
||||
|
||||
Every command the site sends the game carries an **idempotency key**, and the game executes a
|
||||
given key at most once — a repeat is answered with the original reply rather than re-run.
|
||||
|
||||
Without it, a lost acknowledgement is indistinguishable from a command that never applied, so
|
||||
every world write has to be declared un-retryable and one has to be *lost* rather than risk
|
||||
*doubling* it. The key is what makes a world-changing step an ordinary retried row like any
|
||||
other.
|
||||
|
||||
<Aside type="caution" title="The rule that pays for it">
|
||||
**Do not answer an error after changing the world.** The store treats a handler that ran and
|
||||
deliberately refused as a transient outcome and releases the key, so the answer is not frozen
|
||||
for ever — the acceptance walk found a refusal ("the last save was 227 seconds ago") replayed
|
||||
identically six times, with a number that could never age. A handler that has already changed
|
||||
something must not take that path.
|
||||
</Aside>
|
||||
|
||||
## Three layers, and the role check is only one of them
|
||||
|
||||
1. **Declaration** — a module says a verb exists. That is code the operator installed; it is
|
||||
not a permission.
|
||||
2. **Enablement** — an admin turns an action on for this deployment and sets its caps.
|
||||
Nothing above a notification is on by default.
|
||||
3. **Invocation** — the role check, then the cap, then the game's own switches. Admin routes
|
||||
are re-validated against the database on every request, so a demotion takes effect on the
|
||||
next click.
|
||||
|
||||
The game's switches are the layer the site cannot reach: `EventsEnabled` and
|
||||
`AdminWriteEnabled` live in a file on the shard host and are off out of the box, and the
|
||||
game's own ceilings **refuse rather than clamp** — because a silently shortened request leaves
|
||||
the two halves disagreeing about what happened.
|
||||
|
||||
<Aside type="note" title="Stated plainly">
|
||||
The module boundary is **not** a security boundary — a module runs in the same process with
|
||||
full access, and the module system's own documentation says so. None of the above defends
|
||||
against a hostile module. It defends against a compromised session and an operator mistake,
|
||||
both of which are made larger by *scheduling*: a change that happens while nobody is watching.
|
||||
That is why the caps and the leases matter more here than the role check does.
|
||||
</Aside>
|
||||
|
||||
## Where it meets everything else
|
||||
|
||||
- **[Engagement](/docs/administration/engagement-rules/)** — core registers `event.` triggers
|
||||
and owns none of the delivery. Who is told about a run is an ordinary rule.
|
||||
- **[The bridge](/docs/architecture/the-bridge/)** — every world verb becomes a command on the
|
||||
same versioned wire the game already speaks, through the same sidecar. Core still holds no
|
||||
game connection.
|
||||
- **[Teams](/docs/architecture/teams-architecture/)** — "this Team's members" is already a
|
||||
registered audience, so a guild-scoped event needs no event-side feature at all.
|
||||
- **News** — an event does not write posts. A core action links an *existing* post to a run and
|
||||
enqueues it through the announcement pipeline, so the in-game town crier and Discord arrive
|
||||
as legs that already exist.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
- **No branching.** The condition grammar is `and` / `or` / `not` over comparisons, and the
|
||||
phase editor is a timeline rather than a canvas, because a canvas would promise power the
|
||||
engine has not got.
|
||||
- **No delegation, grants or proposal queue.** Permissions gate on the admin roles that
|
||||
already exist. The whole authorisation decision lives behind one function, which is what
|
||||
keeps a coordinator model a later option rather than a redesign.
|
||||
- **No event invoking another event.** It already works by composition — a second event's
|
||||
condition can be the first one completing.
|
||||
- **No mutation of game-owned content without a baseline.** If it cannot be restored, it
|
||||
cannot be leased, and it is out.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`website/EVENTS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md)
|
||||
is the design of record;
|
||||
[`website/MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
is the contract a module registers against; and
|
||||
[`link/v7.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v7.md)
|
||||
is the wire protocol the world verbs travel on.
|
||||
@@ -4,12 +4,13 @@ description: One number, declared in three repositories, that decides whether a
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import platform from '../../../../data/platform.json';
|
||||
|
||||
The loopback wire protocol between the game plugin and the sidecar is a **versioned
|
||||
compatibility contract**, not a build dependency. Nothing compiles the three sides together,
|
||||
so the number is what stops a mismatch from being discovered as corrupted data.
|
||||
|
||||
The current protocol is **4**.
|
||||
The current protocol is **{platform.protocol}**.
|
||||
|
||||
## Three declaration sites
|
||||
|
||||
@@ -17,8 +18,8 @@ The same number is written down in three places, and they must move together.
|
||||
|
||||
| Where | What declares it |
|
||||
|---|---|
|
||||
| `link/sidecar/src/main.rs` | `pub const PROTOCOL_VERSION: u32 = 4` — what the sidecar speaks |
|
||||
| `servuo-plugins/overlay.toml` | `protocol = 4` — what the plugin overlay speaks |
|
||||
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.protocol} — what the sidecar speaks |
|
||||
| `servuo-plugins/overlay.toml` | `protocol`, currently {platform.protocol} — what the plugin overlay speaks |
|
||||
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
|
||||
|
||||
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">
|
||||
@@ -44,19 +45,35 @@ allowed to be chosen independently.
|
||||
|
||||
## What a bump obliges
|
||||
|
||||
Changing a message shape means editing every side plus the specification. A protocol-4
|
||||
change touched:
|
||||
Changing a message shape means editing every side plus the specification. The most recent
|
||||
bump — **8**, which taught the bridge to carry a game's own client files — touched four
|
||||
repositories:
|
||||
|
||||
| Repository | What had to change |
|
||||
|---|---|
|
||||
| `servuo-plugins` | The emitters, the config keys, and `overlay.toml` |
|
||||
| `link` | `PROTOCOL_VERSION`, a store migration, and the projections |
|
||||
| `module-uo` | The tables, the ingest, and the kind-to-feature map |
|
||||
| `servuo-plugins` | The extractors and the decoders they call, the switches and caps in `Bridge.cfg`, and `overlay.toml` |
|
||||
| `link` | `PROTOCOL_VERSION`, a cap on how large a line the shard may send, and the endpoints that carry the new commands |
|
||||
| `module-uo` | The importers, the admin screen, and the pages that render a picture |
|
||||
| `docs` | The protocol document and the integration guide |
|
||||
|
||||
Note `link`'s entry: **a protocol bump can require a store migration**, because the sidecar
|
||||
persists what it forwards. That is not automatic, and version 4 was the first bump that
|
||||
needed one.
|
||||
**`website` is not on that list, and its absence is the interesting part.** Core holds no
|
||||
game connection and names no game noun, so most protocol bumps do not reach it at all. The
|
||||
one before this did, because what changed then was not a game *noun* but the shape of a
|
||||
thing core owns the ledger for. This one did not reach core because everything it needed —
|
||||
somewhere to put a picture — core already offered every module. Its entire share of eight
|
||||
phases of work was a **deletion**: a developer tool it no longer needed.
|
||||
|
||||
**A protocol bump can also require a store migration**, because the sidecar persists what it
|
||||
forwards. That is not automatic, and it has happened once: version 4 added a column to a
|
||||
table that already existed. Versions 5, 6, 7 and 8 needed none, because every frame is
|
||||
persisted whole — a bump that only widens a frame, or adds a kind, or adds a guarantee about
|
||||
how a command is executed, asks nothing of a store that defines no schema for a frame's
|
||||
contents. That is the dumb-forwarder property paying for itself.
|
||||
|
||||
Version 8 puts it more sharply still. It is the largest bump this protocol has had, and it
|
||||
moves megabytes of artwork rather than events — and it changed **no line** of the sidecar's
|
||||
store, because the things it carries are answers to requests rather than events to keep. A
|
||||
forwarder that holds no opinion about what it forwards has nothing to migrate.
|
||||
|
||||
## This is not the module API version
|
||||
|
||||
@@ -92,8 +109,10 @@ What is worth inheriting is the **shape**:
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
|
||||
is the protocol-4 record, including its cross-repository obligations;
|
||||
[`link/v8.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md)
|
||||
is the current protocol's record, including its cross-repository obligations, and
|
||||
[`link/v7.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v7.md)
|
||||
the one before it;
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§7 is the wire protocol, and
|
||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||
|
||||
@@ -114,9 +114,10 @@ lifecycle](/docs/modules/module-lifecycle/#failure-is-contained-by-construction)
|
||||
|
||||
### Secrets are encrypted at rest
|
||||
|
||||
OAuth client secrets, the sidecar token and the Gmail refresh token are AES-256-GCM
|
||||
encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token is write-only in the API** — it is
|
||||
never returned to any client.
|
||||
OAuth client secrets, the sidecar token, the Discord bot token and the mail transport's
|
||||
credentials are AES-256-GCM encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token and the
|
||||
mail credentials are write-only in the API** — neither is ever returned to any client; the
|
||||
email panel reports only that a password is *set*.
|
||||
|
||||
<Aside type="caution" title="Rotating that key orphans every stored secret">
|
||||
Nothing re-encrypts. What was stored under the old key can no longer be read, and every
|
||||
|
||||
@@ -68,7 +68,7 @@ responsibility. The sidecar is a **dumb forwarder** — it makes no access-contr
|
||||
and holds no policy. Access control and the admin-toggleable visibility scope live on the
|
||||
**website**, where an administrator can see and change them.
|
||||
|
||||
## Two ways in
|
||||
## Three ways in
|
||||
|
||||
**Live events** arrive over an outbound **WebSocket** and are routed by the module's ingest
|
||||
dispatcher. Kinds are handled differently by nature: state-changing kinds update tables,
|
||||
@@ -77,6 +77,23 @@ than accumulating history.
|
||||
|
||||
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
|
||||
|
||||
**Bulk reads** — a game's own client artwork, its string table, its spawn files — are the
|
||||
newest and the least obvious. They go over the request/reply path in **pages**, with **one
|
||||
request in flight at a time** and a hard cap on how large a single line may be.
|
||||
|
||||
<Aside type="note" title="Why bulk data must not ride the event stream">
|
||||
It is the tempting shortcut, and it is wrong for a structural reason rather than a
|
||||
performance one: the sidecar **persists every event and broadcasts it to every connected
|
||||
client**. That is exactly what you want for "a house went IDOC" and exactly what you do not
|
||||
want for hundreds of megabytes of artwork, which is an *answer to a question somebody
|
||||
asked* rather than news.
|
||||
|
||||
Sending it as replies instead is what let the same bump move megabytes without the sidecar's
|
||||
store changing by a line. The single slot is the other half: it is what keeps the queue
|
||||
between the game and the writer thread shallow, so rule 2 above still holds while a
|
||||
transfer is running.
|
||||
</Aside>
|
||||
|
||||
Every call carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header. **A
|
||||
protocol mismatch fails fast with `409`** rather than being mis-parsed — see [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
@@ -95,7 +112,7 @@ A representative line looks like:
|
||||
"price":75000,"commission":3750}
|
||||
```
|
||||
|
||||
The full catalog is [Event catalog](/docs/reference/event-catalog/).
|
||||
The full catalog is the [Shard event catalog](/docs/reference/event-catalog/).
|
||||
|
||||
## Two design details worth stealing
|
||||
|
||||
|
||||
@@ -36,11 +36,20 @@ the [`uo` module](/docs/getting-started/install-a-game-module/) and the installe
|
||||
|---|---|
|
||||
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
|
||||
| **ServUO {platform.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
|
||||
| **`libgdiplus`** *(Linux shard hosts only)* | Only needed for **artwork**. ServUO runs under Mono on Linux, and the library it decodes sprites with is a thin layer over this one — in the decode path, not merely the encode. Without it creature portraits and item pictures stay empty, and names and the spawn atlas are unaffected because neither touches a pixel. `sudo apt-get install libgdiplus`; `doctor` checks for it. **Windows hosts need nothing.** |
|
||||
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
|
||||
| **Administrator / root** | It writes into system directories and registers a service. |
|
||||
| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. |
|
||||
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
|
||||
|
||||
<Aside type="note" title="You do not need to install a game client for this">
|
||||
You already have one. A ServUO shard cannot boot without a UO client — it resolves one at
|
||||
startup to read the world's own data — so the artwork, the animations and the name table
|
||||
the site shows are already sitting on that host. The shard reads them there and hands the
|
||||
results over the bridge; nothing is converted on a desktop and nothing is uploaded. See
|
||||
[Client files](/docs/administration/client-files/).
|
||||
</Aside>
|
||||
|
||||
<Aside type="caution" title="Back up before the shard install">
|
||||
The overlay overwrites `Scripts/Scripts.csproj`, a stock file, and the optional patch tier
|
||||
edits stock sources. A copy of `Scripts/` and `Config/` costs nothing and is the difference
|
||||
|
||||
@@ -86,11 +86,18 @@ So verify each link in the chain, in order. Each check tells you which one to fi
|
||||
|
||||
<Aside type="note" title="`runicgateway doctor` answers most of this in one command">
|
||||
Run on the shard host, it checks the install record, the ServUO tree, every overlay file
|
||||
hash, the patch tier, the sidecar, its service, `/health`, and that the sidecar and overlay
|
||||
agree on a protocol. Its output is the first thing anyone helping you will ask for. It
|
||||
hash, the patch tier, the sidecar, its service, `/health`, that the sidecar and overlay
|
||||
agree on a protocol, and — on Linux — that the host can decode an image at all. Its output is the first thing anyone helping you will ask for. It
|
||||
exits non-zero when a check failed, so a monitoring system can run it too.
|
||||
</Aside>
|
||||
|
||||
<Aside type="note" title="A bridge can be green and still show no artwork">
|
||||
Creature portraits and item pictures are a separate switch and a separate import, so a
|
||||
perfectly healthy bridge shows a bestiary of text until somebody presses *Update* on
|
||||
**Admin → Client files**. That is the expected first-run state, not a fault — see [Client
|
||||
files](/docs/administration/client-files/).
|
||||
</Aside>
|
||||
|
||||
## What "working" looks like a week later
|
||||
|
||||
- The public shard page shows live status, and the admin dashboard shows events arriving.
|
||||
|
||||
@@ -43,8 +43,9 @@ They meet at four values pasted into the module's shard screen, and at protocol
|
||||
{platform.protocol}, which both sides check before they will pair.
|
||||
|
||||
You can stop after the first one. A site with no game server attached is a complete
|
||||
community website — news, wiki, pages, Teams, forums, accounts and moderation are all core,
|
||||
and none of them knows a game exists. The second install is what fills the game screens.
|
||||
community website — news, wiki, pages, Teams, forums, accounts, moderation and the event
|
||||
calendar are all core, and none of them knows a game exists. The second install is what
|
||||
fills the game screens, and what lets an event reach into a world.
|
||||
|
||||
## Start here
|
||||
|
||||
@@ -59,8 +60,8 @@ you should expect to see before you move on:
|
||||
6. [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — proving it works, rather than assuming
|
||||
|
||||
Then **Administration** covers running it: configuration, branding, content, users,
|
||||
authentication, Teams, moderation, notifications, modules, the shard connection, upgrades,
|
||||
and what to do when something is wrong.
|
||||
authentication, Teams, scheduled events, moderation, notifications, modules, the shard
|
||||
connection, upgrades, and what to do when something is wrong.
|
||||
|
||||
## Where the truth lives
|
||||
|
||||
|
||||
@@ -45,7 +45,11 @@ moved turns this page red rather than leaving a dead link.
|
||||
- **"Why is the module system like this?"** → `MODULE_SYSTEM.md`.
|
||||
- **"What does this API return?"** → your own deployment's `/api/docs`, then
|
||||
`BACKEND_DESIGN.md` §4.
|
||||
- **"What can the shard send?"** → `link/PLAN.md` §5, and `v4.md` for the current protocol.
|
||||
- **"What can the shard send?"** → `link/PLAN.md` §5, and `v8.md` for the current
|
||||
protocol — which is also where the asset plane is specified.
|
||||
- **"What may a scheduled event do to the world?"** → `website/EVENTS.md` for the model,
|
||||
`MODULE_API.md` for the verbs a module may declare, and `link/ADMIN_CONTROLS.md` for what
|
||||
the site may ask a game to do at all.
|
||||
- **"Who may see this?"** → `SHARD_VISIBILITY.md` for the administrator's view,
|
||||
`modules/uo/API.md` §4 for the specification.
|
||||
- **"How do I set a shard up?"** → `installer/INSTALL.md`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Event catalog
|
||||
title: Shard event catalog
|
||||
description: What a game server can tell the website, how those events are grouped, and the five-rung ladder that decides who may see each one.
|
||||
---
|
||||
|
||||
@@ -8,6 +8,12 @@ import { visibilityLadder } from '../../../../data/reference.mjs';
|
||||
|
||||
The events a shard emits, and the mechanism that decides who may see them.
|
||||
|
||||
**Not to be confused with a scheduled event.** This page is about what the game tells the
|
||||
site, unprompted, as things happen in the world. An *event* in the sense of a thing you put
|
||||
on the calendar and run — phases, steps, a boss at eight o'clock — is
|
||||
[Scheduled events](/docs/administration/events/). The two do meet: a kind listed below is
|
||||
exactly what a scheduled event's phase can wait for.
|
||||
|
||||
The exact wire shapes are in the protocol specification and are **not** restated here — a
|
||||
copy of a wire format is a copy that will be wrong after the next bump. This page is the map
|
||||
and the security model.
|
||||
|
||||
@@ -139,9 +139,19 @@ const community = {
|
||||
{
|
||||
label: 'Notifications',
|
||||
detail:
|
||||
'Web, push and email, chosen per stream by each person rather than per person by ' +
|
||||
'you. Push arrives by default and can be switched off; email only ever arrives if ' +
|
||||
'it was asked for.',
|
||||
'On the site, by push and by email, chosen per notification by each person rather ' +
|
||||
'than per person by you. The on-site inbox arrives by default and can be switched ' +
|
||||
'off; push and email only ever arrive if they were asked for.',
|
||||
},
|
||||
{
|
||||
label: 'Event calendar',
|
||||
demoPath: '/site/events',
|
||||
detail:
|
||||
'What is scheduled, what is happening now, what finished recently, and the ' +
|
||||
'results afterwards — with arcs, so a three-part story reads as one thing rather ' +
|
||||
'than three unrelated entries. A run that was cancelled says so instead of ' +
|
||||
'quietly vanishing. Core owns the whole calendar and can run an event on its ' +
|
||||
'own; what an event may do inside a game world comes from the installed module.',
|
||||
},
|
||||
{
|
||||
label: 'Wiki',
|
||||
@@ -222,7 +232,8 @@ const gameIntelligence = {
|
||||
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.",
|
||||
"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.",
|
||||
},
|
||||
{
|
||||
label: 'Houses and IDOC decay',
|
||||
@@ -239,7 +250,8 @@ const gameIntelligence = {
|
||||
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.',
|
||||
'landmarks and champion altars come with it, and each creature is shown as the ' +
|
||||
'artwork your own client draws it with.',
|
||||
},
|
||||
{
|
||||
label: 'Champion boards',
|
||||
@@ -306,6 +318,17 @@ const administration = {
|
||||
'view with an emergency unban, deliberately — it is not somewhere to tune a ' +
|
||||
'threshold at three in the morning.',
|
||||
},
|
||||
{
|
||||
label: 'Scheduled world events',
|
||||
detail:
|
||||
'Author an event as phases and steps, publish a version, put it on the calendar ' +
|
||||
'and let it run unattended — with a dry run first that prices the whole plan ' +
|
||||
'against this deployment’s caps. Every action arrives switched off, every run ' +
|
||||
'has a per-run budget enforced in the database rather than in a role check, and ' +
|
||||
'everything an event creates or borrows is written to a ledger so the undo is ' +
|
||||
'generated rather than authored. Pause, resume, skip a step or cancel with ' +
|
||||
'cleanup, all logged with the person who did it.',
|
||||
},
|
||||
{
|
||||
label: 'Module management',
|
||||
detail:
|
||||
|
||||
@@ -285,9 +285,10 @@ export const collected = [
|
||||
title: 'Everything you read and post in the app',
|
||||
body:
|
||||
'Forum posts, Team activity, character and shard information, notification ' +
|
||||
'preferences: all of it is a live read or write against the deployment. Nothing is ' +
|
||||
'cached for offline use and nothing is duplicated anywhere else — the app with no ' +
|
||||
'signal is an app with no content, which is a limitation and also an accurate ' +
|
||||
'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 ' +
|
||||
'an app with almost no content, which is a limitation and also an accurate ' +
|
||||
'description of where the data lives.',
|
||||
retention: {
|
||||
summary: 'Held by the deployment, under its operator’s policy',
|
||||
@@ -304,6 +305,40 @@ export const collected = [
|
||||
'access and no way to obtain one.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-inbox-cache',
|
||||
scope: 'app',
|
||||
title: 'A snapshot of your notifications, so the inbox opens without a signal',
|
||||
body:
|
||||
'The app keeps the most recent notifications it has already fetched — at most ' +
|
||||
'thirty, and only the first page — on the device, so opening the inbox shows you ' +
|
||||
'what you had rather than a spinner. It is a copy of what the deployment already ' +
|
||||
'sent you and it is refreshed from there; nothing is written here that was not ' +
|
||||
'read from your own account. It is scoped to the account that fetched it, so a ' +
|
||||
'second person signing in on the same phone is never shown the first one’s ' +
|
||||
'messages.',
|
||||
retention: {
|
||||
summary: 'Until you sign out, or the thirty are pushed out by newer ones',
|
||||
detail:
|
||||
'Signing out deletes the snapshot outright. It lives in the app’s ordinary ' +
|
||||
'preference store rather than the encrypted one — sign-in tokens are the thing ' +
|
||||
'that store is for — which is worth stating plainly: on a device where someone ' +
|
||||
'has root, these are readable, and they are notification bodies rather than ' +
|
||||
'credentials.',
|
||||
},
|
||||
source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt',
|
||||
play: {
|
||||
category: 'Messages',
|
||||
type: 'Other in-app messages',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us. Stored on the device only.',
|
||||
because:
|
||||
'The snapshot is written on the phone from data the deployment had already ' +
|
||||
'delivered. It is not uploaded anywhere, and no server we operate is on either ' +
|
||||
'end of it.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-no-analytics',
|
||||
scope: 'app',
|
||||
@@ -401,6 +436,70 @@ export const collected = [
|
||||
},
|
||||
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
|
||||
},
|
||||
{
|
||||
id: 'deploy-engagement',
|
||||
scope: 'deployment',
|
||||
title: 'Notifications, and the record of what was sent',
|
||||
body:
|
||||
'An operator can have the site notify people about things that happen on it — on ' +
|
||||
'the site, by email, by push — so an address is now used for more than getting ' +
|
||||
'into an account. Each member chooses this per notification and per channel, and ' +
|
||||
'email and push are both off until they ask for them. Alongside that the site ' +
|
||||
'keeps a delivery log: what fired, which account, which channel, whether it ' +
|
||||
'arrived, and a one-way hash of the address rather than the address. Addresses ' +
|
||||
'that bounce or are reported as spam go on a suppression list, which stores the ' +
|
||||
'same hash plus a masked form (`d***@example.com`, never the local part) so an ' +
|
||||
'operator can see what was suppressed without the list becoming a second address ' +
|
||||
'book.',
|
||||
retention: {
|
||||
summary:
|
||||
'The delivery log is kept for a period the operator sets (180 days by default) and then ' +
|
||||
'swept; the suppression list does not expire',
|
||||
detail:
|
||||
'A nightly sweep removes delivery-log entries, finished items from the send queue and the ' +
|
||||
'per-person rate-limit rows once they pass the horizon the operator has set for each — ' +
|
||||
'the defaults are 180 days for the log and 30 for the other two. Two things deliberately ' +
|
||||
'do not expire. An item still waiting to be sent is never swept however old it is, ' +
|
||||
'because it is a message the site still intends to deliver. And the suppression list is ' +
|
||||
'permanent by design: it records a standing decision to stop mailing an address, and ' +
|
||||
'ageing an entry out would mean mailing an address that already bounced or asked to be ' +
|
||||
'left alone. An operator can remove an entry from it deliberately, one at a time. ' +
|
||||
'Deleting an account detaches its rows from it rather than deleting them — a delivery ' +
|
||||
'history stops naming a person, and a suppressed address stays suppressed.',
|
||||
},
|
||||
source:
|
||||
'website server/db/schema.sql — engagement_sends, engagement_suppressions, ' +
|
||||
'notification_channel_prefs; server/src/utils/engagementRetentionPrune.js',
|
||||
},
|
||||
{
|
||||
id: 'deploy-events',
|
||||
scope: 'deployment',
|
||||
title: 'Who took part in a scheduled event',
|
||||
body:
|
||||
'Where the operator runs scheduled events, a run can count who took part — kept as ' +
|
||||
'the name the game module knows a participant by, a score, and a rank, linked to a ' +
|
||||
'site account where one is linked and left unlinked where it is not. That is what ' +
|
||||
'the published results table renders, and what a signed-in person sees as their own ' +
|
||||
'event history. Beside it the site records what each run did: which step ran, what ' +
|
||||
'it created or borrowed in the game world, whether the undo succeeded, and which ' +
|
||||
'staff account started, paused or cancelled it.',
|
||||
retention: {
|
||||
summary:
|
||||
"A run's diagnostic log is swept after 90 days; the run itself and its participants " +
|
||||
'are kept until the operator removes them',
|
||||
detail:
|
||||
'The log that answers "why did this run stall" is deleted 90 days after a run ' +
|
||||
'reaches a terminal state, and only then — a run still in flight keeps every line ' +
|
||||
'it has, however old, because the question it answers is still open. The run, its ' +
|
||||
'steps, what it created and its participant list are not swept: they are the ' +
|
||||
'record of what was done to a shared world, and deleting one silently would ' +
|
||||
'unmake an audit. Deleting an account detaches its participation rows rather than ' +
|
||||
'removing them — the result table keeps the score and stops naming a person.',
|
||||
},
|
||||
source:
|
||||
'website server/db/schema.sql — event_runs, event_run_participants, ' +
|
||||
'event_run_resources, event_run_log; server/src/utils/eventRunner.js',
|
||||
},
|
||||
{
|
||||
id: 'deploy-game-data',
|
||||
scope: 'deployment',
|
||||
@@ -411,7 +510,7 @@ export const collected = [
|
||||
'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/v4.md — the visibility framework',
|
||||
source: 'docs/link/v7.md — the visibility framework',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const legal = {
|
||||
* from git: a build timestamp would move on every rebuild and tell a reader nothing,
|
||||
* and a commit date would move when a stylesheet changed.
|
||||
*/
|
||||
lastUpdated: '2026-08-24',
|
||||
lastUpdated: '2026-09-01',
|
||||
|
||||
/**
|
||||
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
||||
|
||||
@@ -12,23 +12,23 @@
|
||||
"wrong protocol number in the first place."
|
||||
],
|
||||
|
||||
"verifiedOn": "2026-08-19",
|
||||
"verifiedOn": "2026-09-15",
|
||||
|
||||
"protocol": 4,
|
||||
"protocol": 8,
|
||||
|
||||
"moduleApi": "1.6.0",
|
||||
"moduleApi": "1.10.0",
|
||||
|
||||
"bundle": {
|
||||
"tag": "2026.08.19",
|
||||
"sidecar": "v2.0.0",
|
||||
"overlay": "v1.0.0",
|
||||
"tag": "2026.09.15",
|
||||
"sidecar": "v2.3.0",
|
||||
"overlay": "v1.3.0",
|
||||
"servuoMin": "57.4"
|
||||
},
|
||||
|
||||
"releases": {
|
||||
"link": "v2.0.0",
|
||||
"installer": "v0.1.1",
|
||||
"Module-uo": "v1.0.2",
|
||||
"link": "v2.3.0",
|
||||
"installer": "v0.2.0",
|
||||
"Module-uo": "v1.3.0",
|
||||
"Android-app": "v0.5.0"
|
||||
},
|
||||
|
||||
|
||||
@@ -151,6 +151,49 @@ export const bridgeCfg = {
|
||||
AdminReasonMaxLength: 'Reason field cap',
|
||||
AdminBanMaxDurationSec: 'Longest ban the site may set',
|
||||
},
|
||||
'Scheduled events': {
|
||||
EventsEnabled:
|
||||
'Whether the website may run scheduled events against this world at all. Off by ' +
|
||||
'default, and deliberately a separate switch from AdminWriteEnabled',
|
||||
EventsSweepSeconds: 'How often expired gates are collected and lost objects pruned',
|
||||
EventsMinSaveIntervalSec:
|
||||
'Shortest gap between world saves, counted from the last save by anyone. A save ' +
|
||||
'asked for too soon is refused rather than queued',
|
||||
},
|
||||
'Event caps': {
|
||||
EventsMaxCreatures: 'Creatures one call may spawn',
|
||||
EventsMaxBosses: 'Enhanced "boss" variants one call may spawn',
|
||||
EventsMaxNpcs: 'Oracle NPCs one call may place',
|
||||
EventsMaxDecor: 'Decoration items one call may place',
|
||||
EventsMaxGateMinutes: 'Longest a temporary gate may stand',
|
||||
EventsMaxOwnedPerRun: 'Objects one run may own across every verb — the runaway bound',
|
||||
EventsMaxSpread: 'How far from the chosen spot things may be scattered',
|
||||
EventsMaxBossMultiplier: 'How much harder than normal a boss may be made',
|
||||
EventsMaxGrantPerRun: 'How many characters one item grant may reach',
|
||||
EventsMaxGrantStack: 'How large one granted stack may be',
|
||||
},
|
||||
'The oracle NPC': {
|
||||
EventsOracleMaxLines: 'Keyword lines it will answer to',
|
||||
EventsOracleGreetRange: 'How close a player must be to be greeted',
|
||||
EventsOracleSpeechRange: 'How close a player must be to be heard',
|
||||
EventsOracleGreetCooldownSec: 'How often it greets the same player',
|
||||
EventsOracleAnswerCooldownSec: 'How often it answers the same player',
|
||||
},
|
||||
Leases: {
|
||||
LeaseMaxDurationSec:
|
||||
'Longest lease this shard will hold, whatever the site asks for. A longer request ' +
|
||||
'is refused rather than shortened',
|
||||
LeaseGraceSec: 'How long a restored lease stays listed, so a late teardown still gets a verdict',
|
||||
},
|
||||
Participation: {
|
||||
ParticipationSweepSeconds: "How often everyone standing in a run's area is credited",
|
||||
ParticipationKillWeight: 'What one kill inside the area is worth against one minute in it',
|
||||
ParticipationMaxRuns: 'Runs counted at once',
|
||||
ParticipationMaxMembers: 'Members counted per run',
|
||||
ParticipationMaxRadius: 'Widest area an event may declare',
|
||||
ParticipationGraceSec: "How long a closed run's tally stays readable",
|
||||
ParticipationSnapshotChunk: "Members resolved per yield of the game's core thread",
|
||||
},
|
||||
Accounts: {
|
||||
SignupMode: 'How game accounts may be created',
|
||||
AccountCreateEnabled: 'Allow creation at all',
|
||||
@@ -158,6 +201,33 @@ export const bridgeCfg = {
|
||||
AccountNameMaxLength: 'Account name cap',
|
||||
AccountPasswordMaxLength: 'Account password cap',
|
||||
},
|
||||
'Client assets': {
|
||||
AssetsEnabled:
|
||||
'Whether the website may read the UO client files on this host \u2014 art, animations, ' +
|
||||
'the string table \u2014 over the link at all. Its own switch, because it is its own consent',
|
||||
AssetBatchBytes:
|
||||
'Byte budget for one reply page, inside the 1 MiB line cap the sidecar accepts',
|
||||
AssetBodyBatch:
|
||||
'Bodies one catalogue request may name. Counted in items rather than bytes, because ' +
|
||||
'what it bounds is building and deleting that many real mobiles on the core thread. ' +
|
||||
'A larger request is refused, never truncated',
|
||||
AssetFetchKeys:
|
||||
'How many keys one fetch may name. The byte budget above still decides where a page is cut',
|
||||
AssetScanMs: 'How long a catalogue scan may run before the page it has is returned',
|
||||
AssetPlayerDirection:
|
||||
'Which of the five directions a player body renders as. 0 is head-on, facing the viewer',
|
||||
AssetCreatureDirection:
|
||||
'The same for everything else. 1 is the front three-quarter \u2014 a wolf seen head-on ' +
|
||||
'is a dark blob',
|
||||
},
|
||||
'Spawn files': {
|
||||
TreeEnabled:
|
||||
'Whether the shard configuration itself \u2014 spawn files, regions, locations, champion ' +
|
||||
'spawns, decoration \u2014 may cross the bridge. A third switch for a third consent: ' +
|
||||
'this is the work of the operator rather than the game client. Off means the spawn ' +
|
||||
'atlas needs a shared filesystem again',
|
||||
TreeChunkBytes: 'How large a slice of one file may be before it is compressed and sent',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -175,12 +245,14 @@ export const canonicalDocs = {
|
||||
'website/MODULE_SYSTEM.md': 'Why the module system is shaped this way',
|
||||
'website/MODULE_API.md': 'Everything a module may do — the contract',
|
||||
'website/TEAMS.md': 'Teams as a platform primitive',
|
||||
'website/EVENTS.md': 'The Event System — leases, the ledger, and the module seam',
|
||||
'website/SHARD_VISIBILITY.md': 'The audience ladder, for administrators',
|
||||
'website/THEMING_AND_NAV.md': 'Admin-configurable theme, assets and navigation',
|
||||
'website/TRUSTED_DEVICES_MFA.md': 'Trusted devices and the second factor',
|
||||
'link/PLAN.md': 'The sidecar design of record, the data catalog and the wire protocol',
|
||||
'link/INTEGRATION.md': 'Integrating with the sidecar',
|
||||
'link/v4.md': 'Protocol 4, and its cross-repository obligations',
|
||||
'link/v8.md': 'The current protocol, and its cross-repository obligations',
|
||||
'link/SHARD_PREREQS.md': 'What a shard host needs before any of this works',
|
||||
'link/ADMIN_CONTROLS.md': 'What the site may command the game to do',
|
||||
'installer/INSTALL.md': 'The operator guide for setting a shard up',
|
||||
'installer/PLAN.md': "The installer's design of record",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* A demo deployment of this platform, wired to a real ServUO shard over a real sidecar
|
||||
* (D42): the marketplace rows are player vendors the game actually holds, the atlas is
|
||||
* parsed from the shard's own spawn files, the guild rosters came over the bridge. The
|
||||
* parsed from the shard's own spawn files, the guild rosters came over the bridge, and
|
||||
* since protocol 8 the artwork in both came off that host's own UO client. The
|
||||
* deployment is branded "Runic Gateway Demo" rather than a real community's name (D43) —
|
||||
* the screenshots show the platform, not somebody's private shard.
|
||||
*
|
||||
@@ -81,19 +82,19 @@ export const screens = [
|
||||
route: '/uo/market',
|
||||
admin: false,
|
||||
scrollY: 470,
|
||||
alt: 'The marketplace page, listing items for sale by player vendors with their prices, shop names and locations, above a search box and price filters.',
|
||||
alt: 'The marketplace page, listing items for sale by player vendors — each row showing the item picture, its name, the shop, the seller, the location and the price — above a search box and price filters.',
|
||||
caption:
|
||||
'Player vendors, searchable from the website — the same index the in-game vendor search reads, honouring the same per-vendor opt-out.',
|
||||
'Player vendors, searchable from the website — the same index the in-game vendor search reads, honouring the same per-vendor opt-out. The names and the pictures both come out of the client on the shard host.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'spawn-atlas',
|
||||
route: '/uo/atlas',
|
||||
admin: false,
|
||||
scrollY: 430,
|
||||
alt: 'The spawn atlas, listing creatures with how many of them spawn and on which facets, above a search box and facet filters.',
|
||||
scrollY: 466,
|
||||
alt: 'The spawn atlas, listing creatures — sea serpent, water elemental, orc, ettin, horse, goat, sheep — each with the artwork from the game client beside how many of them spawn and on which facets.',
|
||||
caption:
|
||||
"The spawn atlas is parsed from the shard's own spawn files, so it stays accurate whether or not the server is up.",
|
||||
"The spawn atlas is parsed from the shard's own spawn files, so it stays accurate whether or not the server is up. Every portrait was decoded on the shard host and came over the same bridge.",
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
@@ -151,6 +152,15 @@ export const screens = [
|
||||
'The shard connection, showing a live sidecar. The token is write-only: it is never sent back to any client, including this screen.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'admin-client-files',
|
||||
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.',
|
||||
caption:
|
||||
'Client files, imported from the UO client on the shard host over the same bridge. Nothing here happens on a restart — these buttons are the only thing that imports.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'admin-modules',
|
||||
route: '/admin/modules',
|
||||
|
||||
Reference in New Issue
Block a user