Compare commits
35 Commits
433a3b920b
...
docs/modul
| Author | SHA1 | Date | |
|---|---|---|---|
| f3a6231084 | |||
| f402395fa5 | |||
| 7548c20820 | |||
| 749233d378 | |||
| 12e4eaad10 | |||
| 8a7b099c2d | |||
| d66ee832c5 | |||
| f740530eb8 | |||
| b43b40e71b | |||
| 17d8608a7f | |||
| fdede79909 | |||
| 6a39ef63c6 | |||
| e733ac0c9c | |||
| 004e217806 | |||
| 30589f1fa5 | |||
| 58b435be70 | |||
| bcd3a750e7 | |||
| 63e6c2b5d1 | |||
| 510d10b297 | |||
| d7054fd1f2 | |||
| 9355f2aec4 | |||
| bb0e6a02fe | |||
| b5277d827c | |||
| 3510c2ecf1 | |||
| 27bfdc9152 | |||
| 037f4abad2 | |||
| 8db52c3d14 | |||
| 2c2a5ccd57 | |||
| 07d8390a17 | |||
| 315574d846 | |||
| ab6699dad7 | |||
| 53b9a2347a | |||
| 0cc561334c | |||
|
|
7d452494a4 | ||
| f665f75bbb |
@@ -24,6 +24,8 @@ sidecar as a service, and hands you the values the website needs.
|
||||
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
|
||||
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
|
||||
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
|
||||
| [MODULE_SYSTEM.md](website/MODULE_SYSTEM.md) | Making the site game-agnostic: game logic becomes an installable module — design of record |
|
||||
| [MODULE_API.md](website/MODULE_API.md) | The module ↔ core contract: `ctx`, the `register*` calls, the client registry and the loader's obligations |
|
||||
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
|
||||
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
|
||||
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
|
||||
|
||||
@@ -20,7 +20,14 @@ android-app/
|
||||
│ └── sync-project-tree.yml
|
||||
├── app/
|
||||
│ ├── licenses/
|
||||
│ │ └── Cinzel-OFL.txt
|
||||
│ │ ├── Cinzel-OFL.txt
|
||||
│ │ ├── EBGaramond-OFL.txt
|
||||
│ │ ├── IMFellEnglish-OFL.txt
|
||||
│ │ ├── Inter-OFL.txt
|
||||
│ │ ├── Merriweather-OFL.txt
|
||||
│ │ ├── PlayfairDisplay-OFL.txt
|
||||
│ │ ├── SourceSans3-OFL.txt
|
||||
│ │ └── WorkSans-OFL.txt
|
||||
│ ├── src/
|
||||
│ │ ├── debug/
|
||||
│ │ │ └── res/
|
||||
@@ -97,6 +104,9 @@ android-app/
|
||||
│ │ │ │ │ │ ├── PlayerShardApi.kt
|
||||
│ │ │ │ │ │ ├── PublicApi.kt
|
||||
│ │ │ │ │ │ └── SsoApi.kt
|
||||
│ │ │ │ │ ├── appearance/
|
||||
│ │ │ │ │ │ ├── SettingsJson.kt
|
||||
│ │ │ │ │ │ └── SiteAppearance.kt
|
||||
│ │ │ │ │ └── repository/
|
||||
│ │ │ │ │ ├── AccountRepository.kt
|
||||
│ │ │ │ │ ├── AdminRepository.kt
|
||||
@@ -134,6 +144,7 @@ android-app/
|
||||
│ │ │ │ │ │ ├── TrustedDevicesScreen.kt
|
||||
│ │ │ │ │ │ └── TrustedDevicesViewModel.kt
|
||||
│ │ │ │ │ ├── components/
|
||||
│ │ │ │ │ │ ├── BrandAssets.kt
|
||||
│ │ │ │ │ │ ├── HtmlText.kt
|
||||
│ │ │ │ │ │ ├── StateViews.kt
|
||||
│ │ │ │ │ │ └── ThemeComponents.kt
|
||||
@@ -148,6 +159,9 @@ android-app/
|
||||
│ │ │ │ │ │ └── HomeViewModel.kt
|
||||
│ │ │ │ │ ├── navigation/
|
||||
│ │ │ │ │ │ ├── Menu.kt
|
||||
│ │ │ │ │ │ ├── NavOverrides.kt
|
||||
│ │ │ │ │ │ ├── NavPaths.kt
|
||||
│ │ │ │ │ │ ├── NavTree.kt
|
||||
│ │ │ │ │ │ └── Routes.kt
|
||||
│ │ │ │ │ ├── news/
|
||||
│ │ │ │ │ │ ├── NewsScreen.kt
|
||||
@@ -199,6 +213,9 @@ android-app/
|
||||
│ │ │ │ │ │ ├── BrandColor.kt
|
||||
│ │ │ │ │ │ ├── Color.kt
|
||||
│ │ │ │ │ │ ├── Font.kt
|
||||
│ │ │ │ │ │ ├── ShardPalette.kt
|
||||
│ │ │ │ │ │ ├── ShardStructure.kt
|
||||
│ │ │ │ │ │ ├── ShardTypeface.kt
|
||||
│ │ │ │ │ │ ├── Theme.kt
|
||||
│ │ │ │ │ │ └── Type.kt
|
||||
│ │ │ │ │ ├── wiki/
|
||||
@@ -226,7 +243,18 @@ android-app/
|
||||
│ │ │ │ ├── drawable-xxhdpi/
|
||||
│ │ │ │ │ └── ic_stat_name.png
|
||||
│ │ │ │ ├── font/
|
||||
│ │ │ │ │ └── cinzel_variable.ttf
|
||||
│ │ │ │ │ ├── cinzel_variable.ttf
|
||||
│ │ │ │ │ ├── eb_garamond_italic.ttf
|
||||
│ │ │ │ │ ├── eb_garamond_variable.ttf
|
||||
│ │ │ │ │ ├── im_fell_english_italic.ttf
|
||||
│ │ │ │ │ ├── im_fell_english_regular.ttf
|
||||
│ │ │ │ │ ├── inter_variable.ttf
|
||||
│ │ │ │ │ ├── merriweather_italic.ttf
|
||||
│ │ │ │ │ ├── merriweather_variable.ttf
|
||||
│ │ │ │ │ ├── playfair_display_italic.ttf
|
||||
│ │ │ │ │ ├── playfair_display_variable.ttf
|
||||
│ │ │ │ │ ├── source_sans_3_variable.ttf
|
||||
│ │ │ │ │ └── work_sans_variable.ttf
|
||||
│ │ │ │ ├── mipmap-anydpi-v26/
|
||||
│ │ │ │ │ ├── ic_launcher.xml
|
||||
│ │ │ │ │ └── ic_launcher_round.xml
|
||||
@@ -303,6 +331,9 @@ android-app/
|
||||
│ │ │ │ ├── FakePlayerShardApi.kt
|
||||
│ │ │ │ ├── FakePublicApi.kt
|
||||
│ │ │ │ └── FakeShardStream.kt
|
||||
│ │ │ ├── appearance/
|
||||
│ │ │ │ ├── SettingsJsonTest.kt
|
||||
│ │ │ │ └── SiteAppearanceTest.kt
|
||||
│ │ │ └── repository/
|
||||
│ │ │ ├── AccountTrustedDevicesTest.kt
|
||||
│ │ │ ├── ConnectionVersionGuardTest.kt
|
||||
@@ -313,11 +344,16 @@ android-app/
|
||||
│ │ │ │ ├── AdminDashboardViewModelTest.kt
|
||||
│ │ │ │ ├── AdminModerationViewModelTest.kt
|
||||
│ │ │ │ └── AdminSupportViewModelTest.kt
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ └── BrandAssetsTest.kt
|
||||
│ │ │ ├── contact/
|
||||
│ │ │ │ └── ContactViewModelTest.kt
|
||||
│ │ │ ├── navigation/
|
||||
│ │ │ │ ├── MenuAccessTest.kt
|
||||
│ │ │ │ └── MenuFeatureGatingTest.kt
|
||||
│ │ │ │ ├── MenuFeatureGatingTest.kt
|
||||
│ │ │ │ ├── NavOverridesTest.kt
|
||||
│ │ │ │ ├── NavPathsTest.kt
|
||||
│ │ │ │ └── NavTreeTest.kt
|
||||
│ │ │ ├── notifications/
|
||||
│ │ │ │ └── NotificationRoutingTest.kt
|
||||
│ │ │ ├── player/
|
||||
@@ -332,7 +368,11 @@ android-app/
|
||||
│ │ │ │ ├── ShardContentViewModelTest.kt
|
||||
│ │ │ │ └── ShardEventTextTest.kt
|
||||
│ │ │ ├── theme/
|
||||
│ │ │ │ └── BrandColorTest.kt
|
||||
│ │ │ │ ├── BrandColorTest.kt
|
||||
│ │ │ │ ├── ShardColorSchemeTest.kt
|
||||
│ │ │ │ ├── ShardPaletteTest.kt
|
||||
│ │ │ │ ├── ShardStructureTest.kt
|
||||
│ │ │ │ └── ShardTypefaceTest.kt
|
||||
│ │ │ ├── ContentViewModelTest.kt
|
||||
│ │ │ └── UiStateTest.kt
|
||||
│ │ ├── util/
|
||||
|
||||
@@ -105,6 +105,12 @@ server/
|
||||
NOT under /shard: nothing here
|
||||
touches the sidecar, and unlike
|
||||
/shard it IS site-mode gated
|
||||
modules.router.js (1) /public/modules — the installed-module
|
||||
list a client feature-detects against.
|
||||
A real prefix and not a fifth singleton
|
||||
below, so the module loader's
|
||||
collision probe (which skips
|
||||
root-mounted layers) sees it
|
||||
site.router.js (4) /settings /status /version /contact —
|
||||
the group-root singletons; declares no
|
||||
router-level middleware
|
||||
@@ -365,7 +371,7 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
|
||||
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`modules/registries.js` — core's plus every installed module's), validated on write |
|
||||
| created_at | DATETIME | |
|
||||
|
||||
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
||||
@@ -665,6 +671,42 @@ JSON. `resolveMany()` returns only ids that resolved to something displayable
|
||||
`~1_val~` are stripped, since the bridge sends the id and never the property packet that carries the
|
||||
arguments — and it never throws, because a cliloc lookup is decoration on a character sheet.
|
||||
|
||||
### installed_modules — what is installed, and what happened to it (module system)
|
||||
|
||||
One row per installed module, keyed by the `id` from its `module.json` — the same id that names its
|
||||
directory on the modules volume and its URL segment.
|
||||
|
||||
| Column | Shape |
|
||||
|---|---|
|
||||
| `id` | VARCHAR(32) PK — the module id |
|
||||
| `name`, `version` | the manifest's label and semver, for the admin Modules screen |
|
||||
| `state` | ENUM `installed` / `enabled` / `disabled` / `started` / `startup_failed` |
|
||||
| `failure_stage`, `failure_reason` | the stage a failure happened at (`manifest`, `core_api`, `mounts`, `extensions`, `schema`, `require`, `register`, `boot`) and its recorded reason |
|
||||
| `source`, `sha256` | the release the bundle came from and the digest verified before unpacking; both NULL for a directory placed on the volume by hand |
|
||||
| `installed_at`, `started_at`, `updated_at` | `started_at` is the last **successful** start |
|
||||
|
||||
**This table never decides which routes exist.** The module loader scans the filesystem at require
|
||||
time, before the database is reachable, so the URL surface is a property of the volume — which is what
|
||||
lets `routes.manifest.json` be generated against a dead database. A disabled module stays mounted and
|
||||
is guarded; the row decides whether it *answers*, not whether it is there.
|
||||
|
||||
**Every boot resets each non-disabled row to `enabled`** and clears its recorded failure, then the load
|
||||
writes that boot's outcome. So a `startup_failed` module is retried on the next restart (an operator
|
||||
who fixes the cause needs no admin-panel visit), a running module can never display a stale reason,
|
||||
and `disabled` — the one operator *decision* rather than outcome — survives untouched. A re-install or
|
||||
upgrade refreshes the metadata and leaves `state` alone.
|
||||
|
||||
The write happens in one place, `src/modules/lifecycle.js`, on the boot path after `ensureSchema()`
|
||||
and before the listener binds: it resets the last boot's outcomes, writes a row for every module found
|
||||
on the volume (with NULL provenance for a hand-placed directory), marks any row whose directory is
|
||||
**gone** `startup_failed`, and then runs each surviving module's `onBoot` and records what happened. A
|
||||
`disabled` row is guarded, not booted, and never has its failure re-recorded — an outcome must not
|
||||
overwrite the operator's decision. Every one of those writes is individually caught: a row that will
|
||||
not update is worse reporting, never a failed boot.
|
||||
|
||||
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
|
||||
[`MODULE_API.md`](MODULE_API.md) Part 4.
|
||||
|
||||
---
|
||||
|
||||
## 4. API contract
|
||||
@@ -679,7 +721,7 @@ are authoritative, and they answer different questions:
|
||||
|
||||
| Artifact | Source of truth for | Generated by |
|
||||
|---|---|---|
|
||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 226 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 228 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
||||
|
||||
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
|
||||
@@ -693,9 +735,16 @@ slashes from generated path keys — see *Regenerating the spec* in the website
|
||||
domain split makes that necessary.
|
||||
|
||||
Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the
|
||||
internal listener. The SPA catch-all, `/uploads` and `/brand` are filesystem-conditional static
|
||||
mounts — not API contract, and including them would make the output depend on whether CI had built
|
||||
the client.
|
||||
internal listener. The SPA catch-all, `/uploads`, `/brand` and `/modules` are filesystem-conditional
|
||||
static mounts — not API contract, and including them would make the output depend on whether CI had
|
||||
built the client, or on which modules happened to be on the volume of the machine that generated it.
|
||||
|
||||
`/modules/<id>/` is the last of those and the newest: an installed module's prebuilt client chunk,
|
||||
served from the directory its `client.entry` sits in and never from the module root, behind the
|
||||
module's own state guard (`503` when it failed to start, `404` when disabled) and with
|
||||
`Cache-Control: no-cache`, because Vite's library build emits an unhashed `entry.js`. Anything else
|
||||
under `/modules` is a `404` rather than the SPA shell. The full contract is
|
||||
[`MODULE_API.md`](MODULE_API.md) §3.1.
|
||||
|
||||
A third generated file, `server/routes.guards.json`, is a **review aid and not a contract**: per route,
|
||||
the middleware handler count plus the *named* middleware on its mount chain. It exists because a
|
||||
@@ -765,14 +814,18 @@ registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscr
|
||||
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
|
||||
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
|
||||
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
|
||||
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
|
||||
`config/notificationStreams.js`. Security invariants:
|
||||
create/publish-post path for `news.post`. The catalog is assembled at boot by
|
||||
`modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus
|
||||
each installed module's; the shard streams and their event→stream mapping are
|
||||
`config/shardStreams.js`, which belongs to module-uo and moves out with it
|
||||
(MODULE_SYSTEM.md §1.8). Security invariants:
|
||||
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
|
||||
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
|
||||
push.
|
||||
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
|
||||
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
|
||||
`/player/shard/*`).
|
||||
`/player/shard/*`) in `utils/shardPush.js`. `utils/pushDispatch.js` itself only publishes to a
|
||||
stream id someone else resolved — it has no idea what a shard event is.
|
||||
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
|
||||
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
|
||||
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
|
||||
@@ -858,6 +911,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
||||
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL); these are **effective** values, so an admin theme (`theme_visual`) beats `BRAND_ACCENT_COLOR` and an uploaded `brand_assets` asset beats its `BRAND_*` path — an optional **`theme`** block, the resolved CSS custom properties for that admin theme (absent when the instance was never themed, which is what makes it render from the shipped stylesheet unchanged) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
|
||||
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
|
||||
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
|
||||
| GET | `/modules` | `{ modules: [{ id, name, version, capabilities }] }` — the modules this backend is currently **serving**, in scan order (module system, `MODULE_API.md` §2.9). A module that is disabled or failed to load is **absent**, not listed with a state: its routes and nav are absent too, so the client renders a site without that capability rather than advertising one that 503s. The recorded failure stage and reason are admin-panel detail and are never published here. `capabilities` are opaque strings the module declares — feature-detect against them and treat an unknown one as absent. Like `/status` and `/version` it is **DB-free and not site-mode gated**, so a client can still feature-detect during maintenance. It is *not* how a module's client chunk loads — `htmlShell` injects a `<script type="module">` per started module. |
|
||||
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
|
||||
| GET | `/posts/:category/:idOrSlug` | single published post |
|
||||
| GET | `/wiki` | list of pages (slug + title) |
|
||||
@@ -1102,7 +1156,7 @@ it picks, it fails open on one side.)
|
||||
| Nav | `GET /public/shard/features` returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only. |
|
||||
|
||||
Config reads are cached ~5s, so admin changes take effect within seconds **including on already-open
|
||||
streams**. `PUBLIC_KINDS` still exists and is still exported (`notificationStreams.js`) but is now
|
||||
streams**. `PUBLIC_KINDS` still exists and is still exported (`utils/shardBroadcast.js`) but is now
|
||||
**derived** from the kind map rather than hand-maintained, so the two cannot drift.
|
||||
|
||||
**`PUBLIC_KINDS` is a module-load constant and must not be used to answer "may this caller read this
|
||||
|
||||
1191
website/MODULE_API.md
Normal file
1191
website/MODULE_API.md
Normal file
File diff suppressed because it is too large
Load Diff
1088
website/MODULE_SYSTEM.md
Normal file
1088
website/MODULE_SYSTEM.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -536,10 +536,20 @@ Three existing behaviors the merge must not disturb:
|
||||
no menu. `pruneNav` applies the gate inside a section and then drops one it
|
||||
leaves empty.
|
||||
|
||||
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
|
||||
redirects them out of anything else. Overrides apply before that filter, so a
|
||||
moderator can still end up with a legitimately short sidebar — but the redirect
|
||||
effect must keep working untouched.
|
||||
- **Moderator confinement.** `AdminLayout` restricts moderators to the rows their
|
||||
role carries and redirects them out of anything else. Overrides apply before
|
||||
that filter, so a moderator can still end up with a legitimately short sidebar
|
||||
— but the redirect effect must keep working untouched.
|
||||
|
||||
Amended by the module system's Phase 2 PR 8: this used to be a hardcoded
|
||||
`MOD_PATHS` allowlist plus a second, differently-worded prefix check in the
|
||||
redirect, and the two had drifted — `/admin/houses` was on the sidebar and not
|
||||
in the redirect, so a moderator who clicked Houses was bounced to Moderation.
|
||||
Both are now derived from each row's own `roles`
|
||||
([`adminNav.js`](../../website/client/src/lib/adminNav.js)), and the redirect
|
||||
derives from the **base** nav rather than the merged one, which is what keeps
|
||||
an override from moving the boundary in either direction. See
|
||||
[`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §1.4.
|
||||
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
|
||||
override that hides every item in a group must produce no orphaned header.
|
||||
|
||||
|
||||
@@ -781,6 +781,10 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token"
|
||||
|
||||
@@ -482,7 +482,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
|
||||
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_job_legs` for due/retry legs (whichever are registered — Discord is core's, the town crier is module-uo's) |
|
||||
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user