feat(events): the UO half of the Event System, and the core pin comes home (Phase 16b cutover, 3 of 6) #34
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
|
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time.",
|
||||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
"ref": "66bb3b9a3fad01112c06f32d931c9bae56d22de6",
|
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
|
||||||
"refName": "main @ MODULE_API 1.9.0, the engagement cutover (website#180)"
|
"refName": "main @ MODULE_API 1.10.0, the Event System cutover (website#199)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "uo",
|
"id": "uo",
|
||||||
"name": "Ultima Online",
|
"name": "Ultima Online",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"coreApi": "^1.9.0",
|
"coreApi": "^1.10.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" },
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
"schema": "server/db/schema.sql",
|
"schema": "server/db/schema.sql",
|
||||||
|
|||||||
@@ -467,6 +467,38 @@ const TEMPLATES = [
|
|||||||
'{{champsUrl}}',
|
'{{champsUrl}}',
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── The champion falls (Protocol 6) ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The other half of the pair above, and the half the wire could not report
|
||||||
|
// until protocol 6 gave the shard a kind for it. Written as the crier's own
|
||||||
|
// follow-up: the same voice that announced the champion walking is the one
|
||||||
|
// that reports it did not walk far.
|
||||||
|
//
|
||||||
|
// `{{damagerNote}}` is a single-token block, so an unattributed kill renders
|
||||||
|
// the paragraph without it rather than as a sentence with a hole in it.
|
||||||
|
email(
|
||||||
|
'uo.champ.boss-killed',
|
||||||
|
'Champion spawn — the champion falls (town crier)',
|
||||||
|
'uo.champ.boss_killed',
|
||||||
|
'Hear ye — {{bossName}} has fallen',
|
||||||
|
[
|
||||||
|
heading('h', 'Hear ye, hear ye'),
|
||||||
|
text('p1',
|
||||||
|
'{{bossName}} has fallen{{atPlace}}.{{damagerNote}} The altar is quiet again, and it '
|
||||||
|
+ 'will not stay quiet.'),
|
||||||
|
button('cta', 'See the altars', '{{champsUrl}}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
inapp(
|
||||||
|
'uo.champ.boss-killed-inapp',
|
||||||
|
'Champion spawn — the champion falls (in-app)',
|
||||||
|
'uo.champ.boss_killed',
|
||||||
|
'{{bossName}} has fallen',
|
||||||
|
'{{bossName}} has fallen{{atPlace}}.{{damagerNote}}',
|
||||||
|
'See the altars',
|
||||||
|
'{{champsUrl}}',
|
||||||
|
),
|
||||||
|
|
||||||
// ── A guildmaster of the craft ──────────────────────────────────────────
|
// ── A guildmaster of the craft ──────────────────────────────────────────
|
||||||
email(
|
email(
|
||||||
'uo.skill.capped',
|
'uo.skill.capped',
|
||||||
@@ -624,6 +656,14 @@ const TEMPLATES = [
|
|||||||
const CHANNELS_OWNER = ['email', 'inapp']
|
const CHANNELS_OWNER = ['email', 'inapp']
|
||||||
const CHANNELS_BROADCAST = ['email', 'inapp', 'push']
|
const CHANNELS_BROADCAST = ['email', 'inapp', 'push']
|
||||||
|
|
||||||
|
// The same two channels as CHANNELS_OWNER and a different reason for them: a
|
||||||
|
// rule that goes to every subscriber but cannot be PUSHED, because push is
|
||||||
|
// keyed on a subscription id and no trigger in this module is also a registered
|
||||||
|
// stream. Same value, different fact — folding them into one constant would lose
|
||||||
|
// the distinction the moment somebody added push to whichever one they read as
|
||||||
|
// "the broadcast-ish list". See `uo.champ.boss_killed`.
|
||||||
|
const CHANNELS_CONTENT = ['email', 'inapp']
|
||||||
|
|
||||||
/** In-universe: both bodies are this module's, the digest is core's. */
|
/** In-universe: both bodies are this module's, the digest is core's. */
|
||||||
const bodies = (key) => ({
|
const bodies = (key) => ({
|
||||||
email: `uo.${key}`,
|
email: `uo.${key}`,
|
||||||
@@ -863,6 +903,32 @@ const RULES = [
|
|||||||
cooldown_seconds: 1800,
|
cooldown_seconds: 1800,
|
||||||
max_sends_per_hour: 1000,
|
max_sends_per_hour: 1000,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
trigger_id: 'uo.champ.boss_killed',
|
||||||
|
name: 'Champion spawn — the champion falls',
|
||||||
|
audience: 'subscribers',
|
||||||
|
// **`CHANNELS_CONTENT`, not `CHANNELS_BROADCAST`** — this is the one rule in
|
||||||
|
// the file that leaves push out, and it is not an oversight.
|
||||||
|
//
|
||||||
|
// Push delivery is keyed on the SUBSCRIPTION id, and a subscription row only
|
||||||
|
// ever exists for an id the preferences screen offered a push toggle for —
|
||||||
|
// which core's catalog grants to registered STREAMS and nothing else. This
|
||||||
|
// module's stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids
|
||||||
|
// (`uo.champ.started`, …) are disjoint sets, so no trigger here can be pushed
|
||||||
|
// through the engagement path at all: the tickle resolves to zero endpoints
|
||||||
|
// while the send log records it delivered.
|
||||||
|
//
|
||||||
|
// That is true of every sibling rule above and is a pre-existing defect, not
|
||||||
|
// one this rule introduces. What this rule declines to do is add a
|
||||||
|
// twenty-first instance of it. See EVENTS_PLAN.md Phase 11a.
|
||||||
|
channels: CHANNELS_CONTENT,
|
||||||
|
template_keys: bodies('champ.boss-killed'),
|
||||||
|
// The same half-hour as its `boss_up` twin, and on the SAME subject — the
|
||||||
|
// spawn — so an altar that pops and is cleared inside the window produces the
|
||||||
|
// walk or the fall, not both.
|
||||||
|
cooldown_seconds: 1800,
|
||||||
|
max_sends_per_hour: 1000,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
trigger_id: 'uo.server.up',
|
trigger_id: 'uo.server.up',
|
||||||
name: 'Shard — came online',
|
name: 'Shard — came online',
|
||||||
@@ -960,10 +1026,29 @@ const RULES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const RULE_GROUPS = [{
|
// A group is seeded ONCE, under its own settings guard. So a rule appended to an
|
||||||
|
// existing group reaches fresh installs and nothing else: every deployment that
|
||||||
|
// has already stamped `triggers-v1` is done with it forever, and the new rule
|
||||||
|
// would silently never arrive. That is Engagement Phase 11's seed-key finding,
|
||||||
|
// and core applied the same remedy in Events Phase 10 — a NEW key per addition,
|
||||||
|
// never an edit to an old one.
|
||||||
|
//
|
||||||
|
// So protocol 6's `uo.champ.boss_killed` rule ships as its own group rather than
|
||||||
|
// as a twenty-seventh entry above. `RULES` remains the whole declared set, which
|
||||||
|
// is what the "every declared trigger has exactly one rule" invariant reads.
|
||||||
|
const BOSS_KILLED = RULES.filter((r) => r.trigger_id === 'uo.champ.boss_killed')
|
||||||
|
|
||||||
|
const RULE_GROUPS = [
|
||||||
|
{
|
||||||
key: 'triggers-v1',
|
key: 'triggers-v1',
|
||||||
note: 'UO notifications stay off until an operator enables one',
|
note: 'UO notifications stay off until an operator enables one',
|
||||||
rules: RULES,
|
rules: RULES.filter((r) => !BOSS_KILLED.includes(r)),
|
||||||
}]
|
},
|
||||||
|
{
|
||||||
|
key: 'champ-boss-killed-v1',
|
||||||
|
note: 'The champion-falls notice, added with protocol 6; off like every other',
|
||||||
|
rules: BOSS_KILLED,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
module.exports = { TEMPLATES, RULES, RULE_GROUPS }
|
module.exports = { TEMPLATES, RULES, RULE_GROUPS }
|
||||||
|
|||||||
@@ -600,6 +600,42 @@ const COME_ONLINE = [
|
|||||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Protocol 6, and the reason the kind exists at all. Its first consumer is not
|
||||||
|
// a mail rule but an EVENT PHASE CONDITION: `{ on: 'uo.champ.boss_killed',
|
||||||
|
// where: [...], count: 1 }` is how an author says "move to the next phase when
|
||||||
|
// the boss falls", and a condition is expressed over a trigger firing. That is
|
||||||
|
// also why it is declared here rather than only ingested — a kind nothing
|
||||||
|
// declares is a kind no event can wait on.
|
||||||
|
id: 'uo.champ.boss_killed',
|
||||||
|
label: 'A champion boss was defeated',
|
||||||
|
description: 'Players brought down a champion spawn boss.',
|
||||||
|
kind: 'event',
|
||||||
|
subjectKey: 'spawnSerial',
|
||||||
|
audience: 'subscribers',
|
||||||
|
ceiling: 'authenticated',
|
||||||
|
version: V1,
|
||||||
|
variables: [
|
||||||
|
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||||||
|
description: 'The spawn controller, or the boss itself where the shard could not name an altar. Also the cooldown subject.' },
|
||||||
|
{ name: 'bossName', type: 'string', required: true, example: 'Semidar',
|
||||||
|
description: 'The boss that fell.' },
|
||||||
|
{ name: 'category', type: 'string', required: false, example: 'champion',
|
||||||
|
description: 'champion or sea.' },
|
||||||
|
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570 (Destard)',
|
||||||
|
description: 'Where, already formatted for reading.' },
|
||||||
|
{ name: 'killerName', type: 'string', required: false, example: 'Aldric',
|
||||||
|
description: 'Who struck the last blow, when the shard names one.' },
|
||||||
|
{ name: 'damagerCount', type: 'int', required: false, example: 14,
|
||||||
|
description: 'How many players did damage to it. The names themselves are staff-only and are deliberately not offered here.' },
|
||||||
|
{ name: 'damagerNote', type: 'string', required: false, example: ' 14 players fought it.',
|
||||||
|
description: 'A trailing sentence, LEADING SPACE included, or empty when nobody is credited.' },
|
||||||
|
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||||||
|
description: 'Site-relative path to the champions page.' },
|
||||||
|
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||||
|
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'uo.server.up',
|
id: 'uo.server.up',
|
||||||
label: 'The shard came online',
|
label: 'The shard came online',
|
||||||
|
|||||||
2269
server/config/uoEventActions.js
Normal file
2269
server/config/uoEventActions.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,16 @@ module.exports = {
|
|||||||
// do with a storage failure of core's. `inbox.push` additionally does not report
|
// do with a storage failure of core's. `inbox.push` additionally does not report
|
||||||
// "the user has this switched off", because a module that could see that would
|
// "the user has this switched off", because a module that could see that would
|
||||||
// be a module that could enumerate people's preferences one write at a time.
|
// be a module that could enumerate people's preferences one write at a time.
|
||||||
events: { emit: (...args) => need().events.emit(...args) },
|
events: {
|
||||||
|
emit: (...args) => need().events.emit(...args),
|
||||||
|
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). "Ask every action of mine which
|
||||||
|
// of its ledgered resources the game still has." Core cannot know when to
|
||||||
|
// ask -- it has no concept of the game being up -- so the module says when,
|
||||||
|
// and `shardIngest` says it on a changed `bootId`. Fire-and-forget like
|
||||||
|
// `emit`, and for the same reason: core owns what happens next and there is
|
||||||
|
// nothing a game-event handler could correctly do with the answer.
|
||||||
|
reconcile: (...args) => need().events.reconcile(...args),
|
||||||
|
},
|
||||||
inbox: { push: (...args) => need().inbox.push(...args) },
|
inbox: { push: (...args) => need().inbox.push(...args) },
|
||||||
secretBox: {
|
secretBox: {
|
||||||
encrypt: (...args) => need().secretBox.encrypt(...args),
|
encrypt: (...args) => need().secretBox.encrypt(...args),
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
|||||||
base_url VARCHAR(255) NULL,
|
base_url VARCHAR(255) NULL,
|
||||||
ws_url VARCHAR(255) NULL,
|
ws_url VARCHAR(255) NULL,
|
||||||
auth_token_enc TEXT NULL,
|
auth_token_enc TEXT NULL,
|
||||||
protocol INT NOT NULL DEFAULT 5,
|
protocol INT NOT NULL DEFAULT 7,
|
||||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||||
status_detail VARCHAR(500) NULL,
|
status_detail VARCHAR(500) NULL,
|
||||||
@@ -485,6 +485,13 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
facet VARCHAR(40) NOT NULL,
|
facet VARCHAR(40) NOT NULL,
|
||||||
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||||
|
-- `XmlSpawner.UniqueId` (Phase 12b): the only name for one particular spawner
|
||||||
|
-- that exists OFF the shard. A property lease is targeted by it, because a
|
||||||
|
-- serial is assigned when the world is built and nothing here could know one --
|
||||||
|
-- so without this column the lease's target field could have no dropdown at
|
||||||
|
-- all. NULLable: a shard's own spawners, added in-world rather than from the
|
||||||
|
-- spawn files, carry none, and they are addressed by serial instead.
|
||||||
|
unique_id VARCHAR(64) NULL,
|
||||||
x INT NOT NULL,
|
x INT NOT NULL,
|
||||||
y INT NOT NULL,
|
y INT NOT NULL,
|
||||||
width INT NOT NULL DEFAULT 0,
|
width INT NOT NULL DEFAULT 0,
|
||||||
@@ -500,7 +507,10 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
|||||||
landmark VARCHAR(120) NULL,
|
landmark VARCHAR(120) NULL,
|
||||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||||
INDEX idx_shard_spawn_points_facet (facet),
|
INDEX idx_shard_spawn_points_facet (facet),
|
||||||
INDEX idx_shard_spawn_points_label (label)
|
INDEX idx_shard_spawn_points_label (label),
|
||||||
|
-- The spawner target's dropdown searches by name, and 6,707 rows is more than
|
||||||
|
-- a dropdown holds, so the search is the read rather than a filter over one.
|
||||||
|
INDEX idx_shard_spawn_points_name (name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- The many-to-many between the two above: one spawner commonly carries several
|
-- The many-to-many between the two above: one spawner commonly carries several
|
||||||
@@ -544,6 +554,26 @@ CREATE TABLE IF NOT EXISTS shard_landmarks (
|
|||||||
INDEX idx_shard_landmarks_name (name)
|
INDEX idx_shard_landmarks_name (name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Item types this shard uses as decoration, from Data/Decoration/**/*.cfg.
|
||||||
|
--
|
||||||
|
-- Import-owned like every other shard_* atlas table. It exists so the events
|
||||||
|
-- decoration verb can offer an author a dropdown of what THIS shard already
|
||||||
|
-- calls scenery, rather than a list of item types curated by us: a shard with
|
||||||
|
-- custom decoration gets its own, and the list resolves with the shard offline
|
||||||
|
-- because it came out of the tree at import time.
|
||||||
|
--
|
||||||
|
-- `item_id` is a preview, not an identity. A type appears under as many item
|
||||||
|
-- ids as it has facings or variants (a BarredMetalDoor under eight), and the
|
||||||
|
-- first one seen is kept; the plugin constructs from the TYPE NAME and picks
|
||||||
|
-- its own graphic. `uses` is how many times the shard's own decoration reaches
|
||||||
|
-- for the type, which is the only ordering signal available that means anything.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_decor_types (
|
||||||
|
type VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||||
|
item_id INT NOT NULL DEFAULT 0,
|
||||||
|
uses INT NOT NULL DEFAULT 0,
|
||||||
|
INDEX idx_shard_decor_types_uses (uses)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||||
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||||
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||||
@@ -786,3 +816,24 @@ UPDATE uo_link_config SET protocol = 5
|
|||||||
WHERE id = 1 AND protocol < 5
|
WHERE id = 1 AND protocol < 5
|
||||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
|
||||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
|
||||||
|
|
||||||
|
-- 4. The protocol pin again, at 7 -- and this block is a FIX to already-merged
|
||||||
|
-- code rather than ordinary Phase 12b work.
|
||||||
|
--
|
||||||
|
-- Phase 11a took the wire to 6 and Phase 12a took it to 7, and neither moved
|
||||||
|
-- this. `uoLinkClient` sends `X-UOLink-Version: <this column>` on every call and
|
||||||
|
-- the sidecar answers an exact mismatch with a 409, so a deployment that installed
|
||||||
|
-- this module at any point since Phase 10 would have had EVERY sidecar call
|
||||||
|
-- refused against a protocol-7 sidecar -- the whole event plane dead, loudly but
|
||||||
|
-- for a reason nobody would look here for.
|
||||||
|
--
|
||||||
|
-- It survived two phases because both live walks set the column by hand while
|
||||||
|
-- standing the rig up, which is exactly the shape of a migration nobody runs.
|
||||||
|
-- One block carries an install the whole way rather than one per missed version:
|
||||||
|
-- `protocol < 7` is deliberate, and it is why the 4 and 5 blocks above wrote
|
||||||
|
-- `< n` rather than `= n-1`.
|
||||||
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 7;
|
||||||
|
UPDATE uo_link_config SET protocol = 7
|
||||||
|
WHERE id = 1 AND protocol < 7
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_7_migrated');
|
||||||
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_7_migrated', '1');
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ module.exports = function register(ctx, api) {
|
|||||||
const shardTriggers = require('./config/shardTriggers')
|
const shardTriggers = require('./config/shardTriggers')
|
||||||
const shardAudiences = require('./config/shardAudiences')
|
const shardAudiences = require('./config/shardAudiences')
|
||||||
const engagementSeeds = require('./config/engagementSeeds')
|
const engagementSeeds = require('./config/engagementSeeds')
|
||||||
|
const uoEventActions = require('./config/uoEventActions')
|
||||||
const townCrierLeg = require('./utils/shardAnnounce')
|
const townCrierLeg = require('./utils/shardAnnounce')
|
||||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||||
const guildCommand = require('./commands/guild.command')
|
const guildCommand = require('./commands/guild.command')
|
||||||
@@ -157,6 +158,29 @@ const engagementSeeds = require('./config/engagementSeeds')
|
|||||||
// either.
|
// either.
|
||||||
api.registerSlashCommands([guildCommand])
|
api.registerSlashCommands([guildCommand])
|
||||||
|
|
||||||
|
// The event contract (MODULE_API 1.10.0, EVENTS.md F, EVENTS_PLAN.md Phase 9).
|
||||||
|
// Three verbs an event author can put in a step, the one budget dimension that
|
||||||
|
// bounds a broadcast, and the three option sources the spawn atlas answers.
|
||||||
|
//
|
||||||
|
// **All of it is optional, by the contract's own posture.** A deployment
|
||||||
|
// without this module still has an event engine that can announce, wait, cue a
|
||||||
|
// human and publish results; what these add is the ability for an event to
|
||||||
|
// reach the GAME. Nothing here is a precondition for anything of core's.
|
||||||
|
//
|
||||||
|
// The wave is deliberately the verbs that need no protocol change: the write
|
||||||
|
// plane they use has existed since protocol 2.1 and the admin screens have
|
||||||
|
// driven it by hand for months. The world verbs -- creatures, gates, leases --
|
||||||
|
// wait for Phase 11 to put an idempotency key and a lease deadline on the wire,
|
||||||
|
// because a world write core cannot prove ran exactly once is not one this
|
||||||
|
// module is willing to make unattended.
|
||||||
|
api.registerEventBudgets(uoEventActions.BUDGETS)
|
||||||
|
api.registerEventActions(uoEventActions.ACTIONS)
|
||||||
|
// Phase 11b. One live-read config key, and the module never writes it: an author
|
||||||
|
// puts `core.lease` in a step and core owns the duration bound, the
|
||||||
|
// two-events-one-target check and the teardown restore.
|
||||||
|
api.registerEventLeases(uoEventActions.LEASES)
|
||||||
|
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
|
||||||
|
|
||||||
api.onBoot(boot.onBoot)
|
api.onBoot(boot.onBoot)
|
||||||
api.onShutdown(boot.onShutdown)
|
api.onShutdown(boot.onShutdown)
|
||||||
|
|
||||||
@@ -166,5 +190,6 @@ const engagementSeeds = require('./config/engagementSeeds')
|
|||||||
streams: shardStreams.STREAMS.length,
|
streams: shardStreams.STREAMS.length,
|
||||||
triggers: shardTriggers.TRIGGERS.length,
|
triggers: shardTriggers.TRIGGERS.length,
|
||||||
audiences: shardAudiences.AUDIENCES.length,
|
audiences: shardAudiences.AUDIENCES.length,
|
||||||
|
eventActions: uoEventActions.ACTIONS.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ATLAS_TABLES = [
|
|||||||
'shard_regions',
|
'shard_regions',
|
||||||
'shard_landmarks',
|
'shard_landmarks',
|
||||||
'shard_champion_spawns',
|
'shard_champion_spawns',
|
||||||
|
'shard_decor_types',
|
||||||
]
|
]
|
||||||
|
|
||||||
async function insertBatched(conn, sql, rows) {
|
async function insertBatched(conn, sql, rows) {
|
||||||
@@ -103,6 +104,16 @@ async function replaceAtlas(atlas, art = {}) {
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Optional: a tree with no Data/Decoration leaves this empty rather than
|
||||||
|
// failing the import, and the decoration verb then simply has nothing to
|
||||||
|
// offer. `?? []` rather than a guard, so an atlas built by an older parser
|
||||||
|
// (no `decor` key at all) reloads cleanly instead of throwing here.
|
||||||
|
counts.decor = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_decor_types (type, item_id, uses) VALUES (?,?,?)',
|
||||||
|
(atlas.decor ?? []).map((d) => [d.type, d.itemId ?? 0, d.uses ?? 0]),
|
||||||
|
)
|
||||||
|
|
||||||
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
|
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
|
||||||
// join rows need to know them and `conn.batch()` reports no usable insertId
|
// join rows need to know them and `conn.batch()` reports no usable insertId
|
||||||
// for a multi-row insert. Safe because this transaction just emptied the
|
// for a multi-row insert. Safe because this transaction just emptied the
|
||||||
@@ -110,13 +121,14 @@ async function replaceAtlas(atlas, art = {}) {
|
|||||||
counts.points = await insertBatched(
|
counts.points = await insertBatched(
|
||||||
conn,
|
conn,
|
||||||
'INSERT INTO shard_spawn_points ' +
|
'INSERT INTO shard_spawn_points ' +
|
||||||
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
'(id, facet, name, unique_id, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||||
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
atlas.points.map((p, i) => [
|
atlas.points.map((p, i) => [
|
||||||
i + 1,
|
i + 1,
|
||||||
p.facet,
|
p.facet,
|
||||||
p.name,
|
p.name,
|
||||||
|
p.uniqueId || null,
|
||||||
p.x,
|
p.x,
|
||||||
p.y,
|
p.y,
|
||||||
p.width ?? 0,
|
p.width ?? 0,
|
||||||
@@ -357,6 +369,73 @@ function listLandmarks({ facet = '', q = '' } = {}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every decoration type this shard uses, most-used first.
|
||||||
|
*
|
||||||
|
* Ordered by `uses` because a dropdown of 313 types needs the ones the shard
|
||||||
|
* actually reaches for at the top; the alphabetical tiebreak keeps the order
|
||||||
|
* stable across imports, which matters for a form an author scrolls.
|
||||||
|
*/
|
||||||
|
function listDecorTypes({ q = '' } = {}) {
|
||||||
|
const where = []
|
||||||
|
const params = []
|
||||||
|
if (q) {
|
||||||
|
where.push('type LIKE ?')
|
||||||
|
params.push(`%${q}%`)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT type, item_id, uses
|
||||||
|
FROM shard_decor_types
|
||||||
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||||
|
ORDER BY uses DESC, type ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawners an author can name, searched by name and bounded (Phase 12b).
|
||||||
|
*
|
||||||
|
* **A search rather than a list, and the numbers are why.** This tree has 6,707
|
||||||
|
* spawn points against a 2,000-entry dropdown bound, so a flat read would drop
|
||||||
|
* two thirds of the world and say nothing about which two thirds — the failure
|
||||||
|
* Phase 12a named for decoration, arriving for real. `resolveOptionSource` grew
|
||||||
|
* a `q` for this.
|
||||||
|
*
|
||||||
|
* Only rows with a `unique_id` are offered: that is the only name for a spawner
|
||||||
|
* that exists off the shard, and a row without one cannot be targeted from a
|
||||||
|
* form however it is labelled. A shard's own in-world spawners have none and are
|
||||||
|
* addressed by serial, which an author types rather than picks.
|
||||||
|
*
|
||||||
|
* Ordered by `max_count DESC` so the spawners worth an event's attention come
|
||||||
|
* first, with a stable alphabetical tiebreak for a form somebody scrolls.
|
||||||
|
*/
|
||||||
|
function listSpawners({ q = '', limit = 200 } = {}) {
|
||||||
|
const where = ['unique_id IS NOT NULL', "unique_id <> ''"]
|
||||||
|
const params = []
|
||||||
|
if (q) {
|
||||||
|
where.push('(name LIKE ? OR region LIKE ? OR landmark LIKE ?)')
|
||||||
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`)
|
||||||
|
}
|
||||||
|
params.push(Number(limit) || 200)
|
||||||
|
return query(
|
||||||
|
`SELECT unique_id, name, facet, region, landmark, max_count
|
||||||
|
FROM shard_spawn_points
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY max_count DESC, name ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One decoration type, or nothing when this shard's files never name it. */
|
||||||
|
async function getDecorType(type) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT type, item_id, uses FROM shard_decor_types WHERE type = ?',
|
||||||
|
[type],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
function listChampions({ facet = '' } = {}) {
|
function listChampions({ facet = '' } = {}) {
|
||||||
const params = []
|
const params = []
|
||||||
let where = ''
|
let where = ''
|
||||||
@@ -388,5 +467,8 @@ module.exports = {
|
|||||||
listCreatureCompanions,
|
listCreatureCompanions,
|
||||||
listRegions,
|
listRegions,
|
||||||
listLandmarks,
|
listLandmarks,
|
||||||
|
listDecorTypes,
|
||||||
|
listSpawners,
|
||||||
|
getDecorType,
|
||||||
listChampions,
|
listChampions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,6 +403,59 @@ async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoration types, shaped for a dropdown.
|
||||||
|
*
|
||||||
|
* `type` is both the value and the label: it is the ServUO class name and it is
|
||||||
|
* what the plugin constructs from, so showing the author anything else would
|
||||||
|
* put a name on the screen that does not appear in the refusal if the shard
|
||||||
|
* declines it.
|
||||||
|
*/
|
||||||
|
async function listDecorTypes(opts = {}) {
|
||||||
|
const rows = await db.listDecorTypes(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
type: r.type,
|
||||||
|
itemId: Number(r.item_id) || 0,
|
||||||
|
uses: Number(r.uses) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawners an author can name, searched (Phase 12b).
|
||||||
|
*
|
||||||
|
* The value is the `UniqueId` because that is what the shard resolves a target
|
||||||
|
* by; the label is the spawner's own name, which is what an author recognises
|
||||||
|
* ("fel bulbous putrification" is a place they know). A row with no name still
|
||||||
|
* answers, labelled by its id, rather than being dropped: a nameless spawner is
|
||||||
|
* still a spawner somebody may need to turn down.
|
||||||
|
*/
|
||||||
|
async function listSpawners(opts = {}) {
|
||||||
|
const rows = await db.listSpawners(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
uniqueId: r.unique_id,
|
||||||
|
name: r.name || null,
|
||||||
|
facet: r.facet,
|
||||||
|
region: r.region || null,
|
||||||
|
landmark: r.landmark || null,
|
||||||
|
maxCount: Number(r.max_count) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One decoration type, or null.
|
||||||
|
*
|
||||||
|
* The events decoration verb resolves through this rather than passing a type
|
||||||
|
* name straight through, which does two things at once: it fetches the item id
|
||||||
|
* the graphic-holder classes need, and it keeps the verb to the vocabulary this
|
||||||
|
* shard's own decoration files use. A type the atlas has never seen is refused
|
||||||
|
* here rather than constructed there.
|
||||||
|
*/
|
||||||
|
async function getDecorType(type) {
|
||||||
|
const row = await db.getDecorType(String(type == null ? '' : type).trim())
|
||||||
|
if (!row) return null
|
||||||
|
return { type: row.type, itemId: Number(row.item_id) || 0, uses: Number(row.uses) || 0 }
|
||||||
|
}
|
||||||
|
|
||||||
async function listRegions(opts = {}) {
|
async function listRegions(opts = {}) {
|
||||||
const rows = await db.listRegions(opts)
|
const rows = await db.listRegions(opts)
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
@@ -485,6 +538,9 @@ module.exports = {
|
|||||||
getCreature,
|
getCreature,
|
||||||
listRegions,
|
listRegions,
|
||||||
listLandmarks,
|
listLandmarks,
|
||||||
|
listDecorTypes,
|
||||||
|
listSpawners,
|
||||||
|
getDecorType,
|
||||||
listChampions,
|
listChampions,
|
||||||
listFacets,
|
listFacets,
|
||||||
publicMeta,
|
publicMeta,
|
||||||
|
|||||||
@@ -11,16 +11,28 @@ const { secretBox } = require('../../core')
|
|||||||
// Only used before an admin has saved anything — the stored row wins once it exists,
|
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||||
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||||
//
|
//
|
||||||
// This says 5 because this build handles protocol 5's frames: house.decay's `schedule`,
|
// This says 7 because this build speaks protocol 7: the idempotency key and the
|
||||||
// vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind.
|
// participation ledger (6), and the world verbs plus the targeted lease planes (7).
|
||||||
//
|
//
|
||||||
// It said 4 before that, and 3 for a while after protocol 4 shipped — which is the bug
|
// It said 4 before 5, and 3 for a while after protocol 4 shipped — which is the bug this
|
||||||
// this constant is now the fix for. A FRESH install pinned 3, the sidecar answered
|
// constant was introduced to fix. A FRESH install pinned 3, the sidecar answered
|
||||||
// `409 protocol version mismatch` to every REST call, and a new deployment read nothing
|
// `409 protocol version mismatch` to every REST call, and a new deployment read nothing
|
||||||
// from its shard until an admin edited the number by hand in Admin → Shard. Bumping it
|
// from its shard until an admin edited the number by hand in Admin → Shard.
|
||||||
// in the SAME change as the emitters is the discipline that prevents a repeat; see the
|
//
|
||||||
// matching cutover in db/schema.sql.
|
// **And it happened again, twice, in Phases 11a and 12a** — this constant and the two in
|
||||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5
|
// `db/schema.sql` all sat at 5 while the wire went to 6 and then 7, so every sidecar call
|
||||||
|
// on a real deployment would have been refused. Both live walks set the column by hand
|
||||||
|
// while standing the rig up, which is exactly what makes a migration nobody runs
|
||||||
|
// invisible. Phase 12b carries all three to 7.
|
||||||
|
//
|
||||||
|
// **Nothing in this repo can check this against the wire**, and that is worth knowing
|
||||||
|
// before trusting the test that guards it: `schemaFragment.test.js` asserts the three
|
||||||
|
// declarations agree WITH EACH OTHER, which is a real check — they drifted apart once —
|
||||||
|
// but all three being equally stale passes it. The wire's version lives in `link`
|
||||||
|
// (`PROTOCOL_VERSION`) and the overlay's in `servuo-plugins/overlay.toml`; the thing that
|
||||||
|
// actually pairs them is the installer's bundle check, at deploy time. So bumping this in
|
||||||
|
// the same change as the emitters is still the discipline, and no test here replaces it.
|
||||||
|
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 7
|
||||||
|
|
||||||
function toSafe(row) {
|
function toSafe(row) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function fakeCtx(overrides = {}) {
|
|||||||
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
|
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
|
||||||
// contract — a module gets no delivery answer back, deliberately — so the
|
// contract — a module gets no delivery answer back, deliberately — so the
|
||||||
// spies return undefined rather than a promise, which is what core does.
|
// spies return undefined rather than a promise, which is what core does.
|
||||||
events: { emit: spy(undefined) },
|
events: { emit: spy(undefined), reconcile: spy(undefined) },
|
||||||
inbox: { push: spy(undefined) },
|
inbox: { push: spy(undefined) },
|
||||||
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
||||||
middleware: {
|
middleware: {
|
||||||
@@ -104,6 +104,9 @@ function fakeApi() {
|
|||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
triggers: null,
|
triggers: null,
|
||||||
audiences: null,
|
audiences: null,
|
||||||
|
eventActions: null,
|
||||||
|
eventBudgets: null,
|
||||||
|
eventOptionSources: null,
|
||||||
hooks: {},
|
hooks: {},
|
||||||
}
|
}
|
||||||
const called = new Set()
|
const called = new Set()
|
||||||
@@ -134,6 +137,16 @@ function fakeApi() {
|
|||||||
// and merging two calls would make "which group is this rule in" — the
|
// and merging two calls would make "which group is this rule in" — the
|
||||||
// question the one-shot seed guard answers — unanswerable.
|
// question the one-shot seed guard answers — unanswerable.
|
||||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||||
|
// MODULE_API 1.10.0 (EVENTS.md F, EVENTS_PLAN.md Phases 7 and 9). `once` on
|
||||||
|
// all three, matching core: it stages a registrant's whole batch and applies
|
||||||
|
// it as one, so a second call is a module changing its mind mid-register().
|
||||||
|
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||||
|
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||||
|
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||||
|
// And the fourth, from Phase 11b. `once` for the same reason, and present here
|
||||||
|
// for a second one: a verb this module calls and this fake does not have is a
|
||||||
|
// TypeError in `entry.test.js` rather than a surprise at somebody's boot.
|
||||||
|
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ test('the seventeen in-universe families have both channels; the nine plain ones
|
|||||||
// letter from anybody.
|
// letter from anybody.
|
||||||
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
||||||
}
|
}
|
||||||
assert.equal(bespoke, 17)
|
// Eighteen since protocol 6: the champion FALLS, in the same crier's voice as
|
||||||
assert.equal(seeds.TEMPLATES.length, 34)
|
// the champion walking, because they are one story told in two mails.
|
||||||
|
assert.equal(bespoke, 18)
|
||||||
|
assert.equal(seeds.TEMPLATES.length, 36)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
||||||
@@ -222,7 +224,7 @@ test('every declared fragment carries an example that shows its own shape', () =
|
|||||||
// The `example` is what the template editor previews and test-sends with, so a
|
// The `example` is what the template editor previews and test-sends with, so a
|
||||||
// trailing fragment whose example omits the leading space teaches an author the
|
// trailing fragment whose example omits the leading space teaches an author the
|
||||||
// wrong thing about where to put one.
|
// wrong thing about where to put one.
|
||||||
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote']
|
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote', 'damagerNote']
|
||||||
for (const t of TRIGGERS) {
|
for (const t of TRIGGERS) {
|
||||||
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
|
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
|
||||||
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
|
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
|
||||||
@@ -236,7 +238,17 @@ test('one rule group, and appending to it later would reach fresh installs only'
|
|||||||
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
|
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
|
||||||
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
|
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
|
||||||
// rule has to edit a test whose name says what appending costs.
|
// rule has to edit a test whose name says what appending costs.
|
||||||
assert.equal(seeds.RULE_GROUPS.length, 1)
|
// TWO groups since protocol 6, and the second one is this test's whole point
|
||||||
|
// made concrete: `uo.champ.boss_killed` could not be appended to `triggers-v1`,
|
||||||
|
// because a deployment that has already stamped that key would never have
|
||||||
|
// received it. A new rule gets a new key.
|
||||||
|
assert.equal(seeds.RULE_GROUPS.length, 2)
|
||||||
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
|
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
|
||||||
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
|
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
|
||||||
|
assert.equal(seeds.RULE_GROUPS[1].key, 'champ-boss-killed-v1')
|
||||||
|
assert.deepEqual(seeds.RULE_GROUPS[1].rules.map((r) => r.trigger_id), ['uo.champ.boss_killed'])
|
||||||
|
// No rule belongs to two groups, and between them they are the whole set.
|
||||||
|
const grouped = seeds.RULE_GROUPS.flatMap((g) => g.rules.map((r) => r.trigger_id))
|
||||||
|
assert.equal(new Set(grouped).size, grouped.length)
|
||||||
|
assert.deepEqual([...grouped].sort(), seeds.RULES.map((r) => r.trigger_id).sort())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,6 +53,81 @@ test('registers exactly what module.json declares', () => {
|
|||||||
|
|
||||||
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
|
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
|
||||||
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
|
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
|
||||||
|
|
||||||
|
// The event contract (MODULE_API 1.10.0, EVENTS_PLAN.md Phase 9). Asserted
|
||||||
|
// here rather than only in the actions' own suite because registration is the
|
||||||
|
// half that can silently not happen: a declaration file nothing calls is a
|
||||||
|
// deployment whose event authors simply never see the verbs, with no error
|
||||||
|
// anywhere.
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
api.record.eventActions.map((a) => a.id).sort(),
|
||||||
|
[
|
||||||
|
'uo.boss.spawn',
|
||||||
|
'uo.broadcast',
|
||||||
|
'uo.creature.spawn',
|
||||||
|
'uo.decor.place',
|
||||||
|
'uo.gate.open',
|
||||||
|
'uo.item.grant',
|
||||||
|
'uo.news.post',
|
||||||
|
'uo.npc.place',
|
||||||
|
'uo.participation.collect',
|
||||||
|
'uo.participation.open',
|
||||||
|
'uo.towncrier.post',
|
||||||
|
'uo.world.save',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
// Phase 12a's five are all the MODULE's dimensions, never core's (org lead,
|
||||||
|
// 2026-09-07): core meters whatever a module declares and knows nothing about
|
||||||
|
// Ultima Online. Asserted as an ordered list because the order is the order
|
||||||
|
// an author meets them in a cap meter.
|
||||||
|
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), [
|
||||||
|
'uo.broadcasts',
|
||||||
|
'uo.creatures',
|
||||||
|
'uo.bosses',
|
||||||
|
'uo.npcs',
|
||||||
|
'uo.decor',
|
||||||
|
'uo.gate.minutes',
|
||||||
|
'uo.rewards',
|
||||||
|
])
|
||||||
|
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
|
||||||
|
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
|
||||||
|
// on any of the rest applies cleanly and does nothing.
|
||||||
|
// Phase 12b adds five TARGETED leases beside it -- a key that names a capability
|
||||||
|
// over many things, with the target supplied per step. Four spawner properties
|
||||||
|
// (`MaxCount`, not the `Amount` EVENTS_PLAN.md named: there is no such property
|
||||||
|
// on ServUO 57.4) and the seasonal status, which is a three-value enum over eight
|
||||||
|
// events rather than the nine-value one section G described.
|
||||||
|
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), [
|
||||||
|
'uo.playercaps.skillcap',
|
||||||
|
'uo.spawner.maxcount',
|
||||||
|
'uo.spawner.mindelay',
|
||||||
|
'uo.spawner.maxdelay',
|
||||||
|
'uo.spawner.running',
|
||||||
|
'uo.seasonal.status',
|
||||||
|
])
|
||||||
|
// Only the targeted ones declare a target, and every one of them names a source:
|
||||||
|
// a target field with no list behind it is the free-text box the option-source
|
||||||
|
// contract exists to replace.
|
||||||
|
for (const lease of api.record.eventLeases) {
|
||||||
|
if (lease.id === 'uo.playercaps.skillcap') {
|
||||||
|
assert.strictEqual(lease.target, undefined, 'a config lease has no target')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.ok(lease.target && lease.target.label, `${lease.id} has no target label`)
|
||||||
|
assert.ok(lease.target.source, `${lease.id} has no target source`)
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
api.record.eventOptionSources.map((s) => s.id).sort(),
|
||||||
|
[
|
||||||
|
'uo.options.creatures',
|
||||||
|
'uo.options.decor',
|
||||||
|
'uo.options.items',
|
||||||
|
'uo.options.landmarks',
|
||||||
|
'uo.options.regions',
|
||||||
|
'uo.options.seasonal',
|
||||||
|
'uo.options.spawners',
|
||||||
|
],
|
||||||
|
)
|
||||||
assert.ok(api.record.streams.length > 0)
|
assert.ok(api.record.streams.length > 0)
|
||||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ const one = (event) => {
|
|||||||
// ── The catalogue itself ───────────────────────────────────────────────────
|
// ── The catalogue itself ───────────────────────────────────────────────────
|
||||||
|
|
||||||
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
|
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
|
||||||
assert.equal(TRIGGERS.length, 26)
|
// 27 since protocol 6: `uo.champ.boss_killed` joins the twenty-six §8.6 named.
|
||||||
|
// It is not one of the four carve-outs below being reinstated — it is a row the
|
||||||
|
// catalogue could not have, because until protocol 6 the wire had no kind for a
|
||||||
|
// boss defeat and the inference from `champ.update` was not good enough to mail.
|
||||||
|
assert.equal(TRIGGERS.length, 27)
|
||||||
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
|
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
|
||||||
// assertion is the guard on the carve-outs: adding one back is a decision, and
|
// assertion is the guard on the carve-outs: adding one back is a decision, and
|
||||||
// a decision should have to edit a test that says so.
|
// a decision should have to edit a test that says so.
|
||||||
@@ -113,6 +117,10 @@ test('every url variable a body can interpolate is actually SUPPLIED', () => {
|
|||||||
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
|
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
|
||||||
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
|
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
|
||||||
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
|
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
|
||||||
|
// Protocol 6. A single frame, unlike its two neighbours: a defeat is an
|
||||||
|
// EVENT on the wire rather than a change spotted between two snapshots, which
|
||||||
|
// is the whole reason the kind was worth a protocol bump.
|
||||||
|
'uo.champ.boss_killed': bossKilled(),
|
||||||
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
|
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
|
||||||
'uo.server.down': { kind: 'server.shutdown' },
|
'uo.server.down': { kind: 'server.shutdown' },
|
||||||
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
|
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
|
||||||
@@ -320,6 +328,21 @@ test('the pre-decision attempt kind is not mapped at all', () => {
|
|||||||
|
|
||||||
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
|
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
|
||||||
|
|
||||||
|
// Protocol 6. The spawn serial matches `champ`'s, so the pair can be walked as
|
||||||
|
// one altar's story: the boss goes up, then it comes down.
|
||||||
|
const bossKilled = (over) => ({
|
||||||
|
kind: 'champ.boss.killed',
|
||||||
|
serial: '0x40012345',
|
||||||
|
bossSerial: '0x901', category: 'champion', boss: 'Semidar', bossType: 'Semidar',
|
||||||
|
map: 'Felucca', x: 5187, y: 570, region: 'Destard',
|
||||||
|
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
|
||||||
|
damagers: [
|
||||||
|
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true, damage: 900 },
|
||||||
|
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => {
|
test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => {
|
||||||
assert.deepEqual(ids(champ({ active: true })), [])
|
assert.deepEqual(ids(champ({ active: true })), [])
|
||||||
assert.deepEqual(ids(champ({ active: true })), []) // still no change
|
assert.deepEqual(ids(champ({ active: true })), []) // still no change
|
||||||
@@ -339,6 +362,64 @@ test('champ.remove forgets the spawn, so its next appearance is a first sighting
|
|||||||
assert.deepEqual(ids(champ({ active: true })), [])
|
assert.deepEqual(ids(champ({ active: true })), [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── champ.boss.killed (Protocol 6) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a defeat fires on the frame itself, with no baseline to compare against', () => {
|
||||||
|
// Unlike its two neighbours above. `champ.update` is a SNAPSHOT, so a first
|
||||||
|
// sighting can never be a transition; a defeat is an event, so a first sighting
|
||||||
|
// is exactly the thing being reported.
|
||||||
|
const hit = one(bossKilled())
|
||||||
|
assert.equal(hit.triggerId, 'uo.champ.boss_killed')
|
||||||
|
assert.equal(hit.data.bossName, 'Semidar')
|
||||||
|
assert.equal(hit.data.killerName, 'Aldric')
|
||||||
|
assert.equal(hit.data.damagerCount, 2)
|
||||||
|
assert.equal(hit.data.damagerNote, ' 2 players fought it.')
|
||||||
|
assert.equal(hit.data.location, 'Felucca 5187, 570 (Destard)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the subject is the SPAWN, so boss_up and boss_killed share one cooldown subject', () => {
|
||||||
|
map(champ({ active: true, bossUp: false }))
|
||||||
|
const up = one(champ({ active: true, bossUp: true }))
|
||||||
|
const down = one(bossKilled())
|
||||||
|
assert.equal(up.triggerId, 'uo.champ.boss_up')
|
||||||
|
assert.equal(down.data.spawnSerial, up.data.spawnSerial)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a defeat the shard could not attribute to an altar stands on the boss itself', () => {
|
||||||
|
// The sweep learns which altar a champion belongs to; a boss that popped and
|
||||||
|
// died between two sweeps arrives with no `serial`. A subject that exists once
|
||||||
|
// is all a cooldown needs, so the boss's own serial stands in rather than the
|
||||||
|
// firing being dropped.
|
||||||
|
const hit = one(bossKilled({ serial: undefined }))
|
||||||
|
assert.equal(hit.data.spawnSerial, '0x901')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a defeat clears the tracker, so the next boss on that altar is a transition again', () => {
|
||||||
|
map(champ({ active: true, bossUp: false }))
|
||||||
|
map(champ({ active: true, bossUp: true })) // fires boss_up
|
||||||
|
map(bossKilled())
|
||||||
|
// Without the tracker reset this would emit nothing: the tracker would still
|
||||||
|
// believe a boss is up, so the next one would not look like a change.
|
||||||
|
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the damage TABLE never becomes trigger data, only its size', () => {
|
||||||
|
// `damagers` is `staff` in the visibility config. A trigger variable is
|
||||||
|
// interpolated into mail an operator may address to every subscriber, so a
|
||||||
|
// damager name reaching `data` would undo that field rule one layer up.
|
||||||
|
const hit = one(bossKilled())
|
||||||
|
const rendered = JSON.stringify(hit.data)
|
||||||
|
assert.equal(rendered.includes('Bran'), false, 'no damager name reaches the data')
|
||||||
|
assert.equal(rendered.includes('seed_003'), false, 'no damager account reaches the data')
|
||||||
|
assert.equal(hit.data.damagers, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unattributed kill renders no damager sentence rather than an empty one', () => {
|
||||||
|
const hit = one(bossKilled({ damagers: [] }))
|
||||||
|
assert.equal(hit.data.damagerCount, undefined)
|
||||||
|
assert.equal(hit.data.damagerNote, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
|
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
|
||||||
|
|
||||||
test('a governor change is a transition, and never on first sight', () => {
|
test('a governor change is a transition, and never on first sight', () => {
|
||||||
|
|||||||
126
server/test/shardIngest.eventReconcile.test.js
Normal file
126
server/test/shardIngest.eventReconcile.test.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// A shard restart makes the event resource ledger a claim about a world that no
|
||||||
|
// longer exists (EVENTS.md §F, EVENTS_PLAN.md Phases 8 and 9).
|
||||||
|
//
|
||||||
|
// Core cannot notice that on its own — it has no concept of the game being up —
|
||||||
|
// so the module says when, and `server.hello` carrying a *changed* `bootId` is
|
||||||
|
// the only signal that distinguishes a shard restart from a sidecar reconnect.
|
||||||
|
// Getting that wrong in either direction is a real failure: never asking leaves
|
||||||
|
// core believing a ledger of things that are gone, and asking on every reconnect
|
||||||
|
// makes core orphan rows that are perfectly alive.
|
||||||
|
|
||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const shardIngest = require('../utils/shardIngest')
|
||||||
|
|
||||||
|
function makeDeps() {
|
||||||
|
const order = []
|
||||||
|
const noop = async () => {}
|
||||||
|
return {
|
||||||
|
order,
|
||||||
|
shardEvents: { append: noop },
|
||||||
|
shardState: { clearOnline: async () => { order.push('clearOnline') }, upsertOnline: noop, setOffline: noop },
|
||||||
|
shardLinks: {},
|
||||||
|
shardMarket: {},
|
||||||
|
uoLinkConfig: { recordStatus: async (row) => { order.push(`recordStatus:${row.bootId}`) } },
|
||||||
|
settings: { getInstanceName: async () => 'Rig' },
|
||||||
|
broadcast: () => {},
|
||||||
|
pushDispatch: () => {},
|
||||||
|
engagement: () => {},
|
||||||
|
eventsReconcile: () => { order.push('reconcile') },
|
||||||
|
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hello = (bootId) => ({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig', bootId })
|
||||||
|
|
||||||
|
beforeEach(() => shardIngest.reset())
|
||||||
|
|
||||||
|
test('the first hello of a process is not a restart', async () => {
|
||||||
|
// The website has just come up and the shard has not moved. Everything in the
|
||||||
|
// ledger is still in force, and asking would be core spending a round trip per
|
||||||
|
// module to be told so.
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
assert.ok(!deps.order.includes('reconcile'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a sidecar reconnect is not a restart either', async () => {
|
||||||
|
// `server.hello` is sent on EVERY reconnect, and the sidecar dropping its
|
||||||
|
// socket changes nothing in the game. Reconciling here would orphan every live
|
||||||
|
// row — the ledger would still be right and core would stop believing it.
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
assert.ok(!deps.order.includes('reconcile'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a changed bootId asks every module to reconcile its ledger', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
await shardIngest.ingest(hello('boot-2'), deps)
|
||||||
|
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the reconcile happens AFTER the new bootId is recorded', async () => {
|
||||||
|
// The ordering is load-bearing rather than tidy. Every action decides what is
|
||||||
|
// still in force by comparing its stamp against the CURRENT boot id, which it
|
||||||
|
// reads back out of the row `recordStatus` writes. Asking first would compare
|
||||||
|
// every resource against the boot that has just ended — and every one of them
|
||||||
|
// would look live, which is the exact opposite of what a restart means.
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
await shardIngest.ingest(hello('boot-2'), deps)
|
||||||
|
|
||||||
|
const recordedAt = deps.order.lastIndexOf('recordStatus:boot-2')
|
||||||
|
const askedAt = deps.order.indexOf('reconcile')
|
||||||
|
assert.ok(recordedAt >= 0 && askedAt >= 0)
|
||||||
|
assert.ok(askedAt > recordedAt, 'reconcile must not run before the new boot id is stored')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a hello with no bootId at all changes nothing', async () => {
|
||||||
|
// An older plugin, or a frame that lost the field. Not knowing which boot this
|
||||||
|
// is cannot be allowed to read as "a new one".
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
await shardIngest.ingest({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig' }, deps)
|
||||||
|
assert.ok(!deps.order.includes('reconcile'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a backfill replay never reconciles, however many boots it walks through', async () => {
|
||||||
|
// **The defect the live rig found, and nothing else could.** A WS reconnect
|
||||||
|
// replays the last several `server.hello` frames in order — this rig saw three,
|
||||||
|
// each with a different `bootId` — so every replayed frame looks like a
|
||||||
|
// restart. Acting on the intermediate ones would compare a resource stamped
|
||||||
|
// with the CURRENT boot against a boot that ended hours ago and mark it
|
||||||
|
// `orphaned`: a live crier line core will never take down again, lost to
|
||||||
|
// nothing worse than the website reconnecting.
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
for (const boot of ['boot-2', 'boot-3', 'boot-4']) {
|
||||||
|
await shardIngest.ingest(hello(boot), { ...deps, fromBackfill: true })
|
||||||
|
}
|
||||||
|
assert.ok(!deps.order.includes('reconcile'))
|
||||||
|
// The replay still moves the tracked boot on, so the NEXT live hello is
|
||||||
|
// measured against where the replay left off rather than against boot-1.
|
||||||
|
assert.ok(deps.order.includes('recordStatus:boot-4'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a live hello after a replay is still a restart', async () => {
|
||||||
|
// The gate is about the frame, not about the module going quiet: skipping the
|
||||||
|
// replay must not make the next genuine restart invisible.
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
await shardIngest.ingest(hello('boot-2'), { ...deps, fromBackfill: true })
|
||||||
|
await shardIngest.ingest(hello('boot-3'), deps)
|
||||||
|
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a reconcile that throws does not take the ingest down with it', async () => {
|
||||||
|
// Fire-and-forget by the contract, and the feed must survive one bad module:
|
||||||
|
// `ingest()` never throws, because a single event may not kill the socket.
|
||||||
|
const deps = makeDeps()
|
||||||
|
deps.eventsReconcile = () => { throw new Error('registry exploded') }
|
||||||
|
await shardIngest.ingest(hello('boot-1'), deps)
|
||||||
|
await assert.doesNotReject(() => shardIngest.ingest(hello('boot-2'), deps))
|
||||||
|
})
|
||||||
@@ -95,6 +95,58 @@ test('an unknown viewer level cannot see a gated kind or a locked field', async
|
|||||||
assert.equal('webId' in out.leader, false)
|
assert.equal('webId' in out.leader, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Protocol 6: the champion defeat ──────────────────────────────────
|
||||||
|
|
||||||
|
const KILL = {
|
||||||
|
kind: 'champ.boss.killed',
|
||||||
|
serial: '0x40012345',
|
||||||
|
boss: 'Semidar',
|
||||||
|
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
|
||||||
|
damagers: [
|
||||||
|
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', webId: '7', player: true, damage: 900 },
|
||||||
|
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the kill is public and its damage table is not', () => {
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
// The whole shape of this addition in one assertion: a champion falling is
|
||||||
|
// content the public board is FOR, and a ranked roll of who was strong enough
|
||||||
|
// to fell it is a performance record nobody published on purpose.
|
||||||
|
assert.equal(visibility.kindVisibleTo('champ.boss.killed', 'anonymous', config), true)
|
||||||
|
for (const level of ['anonymous', 'logged_in', 'player']) {
|
||||||
|
const out = visibility.projectFeature('champs', KILL, level, config)
|
||||||
|
assert.equal(out.boss, 'Semidar', `${level} sees which boss fell`)
|
||||||
|
assert.equal('damagers' in out, false, `${level} must not see the damage table`)
|
||||||
|
}
|
||||||
|
assert.equal(visibility.projectFeature('champs', KILL, 'staff', config).damagers.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the killer rides the frame the way mob.killed already publishes one', () => {
|
||||||
|
// Deliberately NOT a configurable field. It is one actor, announced in-game to
|
||||||
|
// everyone present, and the same disclosure the public activity feed has made
|
||||||
|
// through `mob.killed` since before this framework existed.
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
|
||||||
|
assert.equal(out.killer.name, 'Aldric')
|
||||||
|
assert.equal('acct' in out.killer, false, 'rule 1 still applies inside it')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an admin who lowers the damager rule still cannot see an account inside it', () => {
|
||||||
|
// Rule 1 beats a field rule wherever the two meet, and a damager entry is an
|
||||||
|
// actor object like any other. An admin who opens the table to everyone has
|
||||||
|
// published character names, which is what they chose; they have not published
|
||||||
|
// account names, which is not theirs to choose.
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
config.champs.fields = { ...config.champs.fields, damagers: 'anonymous' }
|
||||||
|
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
|
||||||
|
assert.equal(out.damagers.length, 2)
|
||||||
|
assert.equal(out.damagers[0].name, 'Aldric')
|
||||||
|
assert.equal(out.damagers[0].damage, 900)
|
||||||
|
assert.equal('acct' in out.damagers[0], false)
|
||||||
|
assert.equal('webId' in out.damagers[0], false)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Rule 1: locked fields ──────────────────────────────────────────────────
|
// ── Rule 1: locked fields ──────────────────────────────────────────────────
|
||||||
|
|
||||||
test('acct and webId are stripped below admin regardless of feature config', () => {
|
test('acct and webId are stripped below admin regardless of feature config', () => {
|
||||||
@@ -360,10 +412,21 @@ const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
|
|||||||
// inside the roster's member array (see the roster test above).
|
// inside the roster's member array (see the roster test above).
|
||||||
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
|
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
|
||||||
|
|
||||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
|
// v6 adds the champion defeat. It rides the existing `champs` feature, which is
|
||||||
|
// already anonymous, so the KIND is public — while the `damagers` table on it is
|
||||||
|
// `staff` by field rule. That split is the point: a shard announces that its
|
||||||
|
// champion fell without publishing a roll of who was strong enough to fell it.
|
||||||
|
const V6_ADDED_PUBLIC_KINDS = ['champ.boss.killed']
|
||||||
|
|
||||||
|
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3, v4 and v6 additions', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[...visibility.PUBLIC_KINDS].sort(),
|
[...visibility.PUBLIC_KINDS].sort(),
|
||||||
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(),
|
[
|
||||||
|
...PRE_V3_PUBLIC_KINDS,
|
||||||
|
...V3_ADDED_PUBLIC_KINDS,
|
||||||
|
...V4_ADDED_PUBLIC_KINDS,
|
||||||
|
...V6_ADDED_PUBLIC_KINDS,
|
||||||
|
].sort(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const {
|
|||||||
buildFacetIndex,
|
buildFacetIndex,
|
||||||
resolveFacetName,
|
resolveFacetName,
|
||||||
slugify,
|
slugify,
|
||||||
|
parseDecoration,
|
||||||
decodeEntities,
|
decodeEntities,
|
||||||
} = require('../utils/spawnAtlasParse')
|
} = require('../utils/spawnAtlasParse')
|
||||||
|
|
||||||
@@ -144,9 +145,14 @@ test('parsePoints: reads the kept fields and drops the rest', () => {
|
|||||||
assert.equal(covetous.minDelay, 300)
|
assert.equal(covetous.minDelay, 300)
|
||||||
assert.equal(covetous.maxDelay, 600)
|
assert.equal(covetous.maxDelay, 600)
|
||||||
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||||
// Dropped fields must not survive into the artifact — this is what keeps it
|
// **The UniqueId is KEPT from Phase 12b**, having been dropped since the atlas
|
||||||
// under 1 MB.
|
// shipped. It is `XmlSpawner.UniqueId` — carried in the spawn files and on the
|
||||||
assert.equal(covetous.uniqueId, undefined)
|
// live spawner — so it is the only name for one particular spawner that exists
|
||||||
|
// off the shard, and a property lease targets by it. A serial cannot do that
|
||||||
|
// job: serials are assigned when the world is built and nothing here knows one.
|
||||||
|
assert.equal(covetous.uniqueId, '001a34e5-0efa-46de-9c93-b6a163d96370')
|
||||||
|
// The rest of the dropped fields still are. Triggering, refractory windows,
|
||||||
|
// proximity and sounds are what the site has no use for.
|
||||||
assert.equal(covetous.proximityTriggerSound, undefined)
|
assert.equal(covetous.proximityTriggerSound, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -599,3 +605,49 @@ test('parsePoints: DelayInSec decides the unit, and both come out in seconds', (
|
|||||||
assert.equal(seconds.minDelay, 5)
|
assert.equal(seconds.minDelay, 5)
|
||||||
assert.equal(seconds.maxDelay, 10)
|
assert.equal(seconds.maxDelay, 10)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── parseDecoration (Phase 12a) ───────────────────────────────
|
||||||
|
|
||||||
|
test('parseDecoration: reads the type off each header and ignores the placements', () => {
|
||||||
|
const rows = parseDecoration(`# switch
|
||||||
|
Static 0x108F
|
||||||
|
5552 1864 11
|
||||||
|
5399 1875 17
|
||||||
|
|
||||||
|
# crate
|
||||||
|
LargeCrate 0x0E3C
|
||||||
|
5408 607 45
|
||||||
|
`)
|
||||||
|
assert.deepEqual(rows, [
|
||||||
|
{ type: 'Static', itemId: 0x108f },
|
||||||
|
{ type: 'LargeCrate', itemId: 0x0e3c },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseDecoration: a parenthesised property list is not part of the type', () => {
|
||||||
|
// These are the shard's own decoration details — which way a door faces, what
|
||||||
|
// hue a banner is — and an event author is choosing neither. Only the class
|
||||||
|
// name is, because that is what the plugin constructs from.
|
||||||
|
assert.deepEqual(parseDecoration('AnkhNorth 0x0004 (Hue=0x47E)'), [
|
||||||
|
{ type: 'AnkhNorth', itemId: 4 },
|
||||||
|
])
|
||||||
|
assert.deepEqual(parseDecoration('ArmsAndWeaponsPrimer 0x0FEF (Name=a life of travel)'), [
|
||||||
|
{ type: 'ArmsAndWeaponsPrimer', itemId: 0x0fef },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseDecoration: a negative z on a placement line is not mistaken for a type', () => {
|
||||||
|
// The real trap in this format: a coordinate line starts with a digit OR a
|
||||||
|
// minus, so "not a comment" is not the test. A z of -12 is ordinary in every
|
||||||
|
// dungeon file in the tree.
|
||||||
|
assert.deepEqual(parseDecoration(`Static 0x07A4
|
||||||
|
5558 1826 -12
|
||||||
|
-5 -5 -5
|
||||||
|
`), [{ type: 'Static', itemId: 0x07a4 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseDecoration: empty, comment-only and absent input all yield nothing', () => {
|
||||||
|
assert.deepEqual(parseDecoration(''), [])
|
||||||
|
assert.deepEqual(parseDecoration(null), [])
|
||||||
|
assert.deepEqual(parseDecoration('# nothing but a comment\n\n'), [])
|
||||||
|
})
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {})
|
|||||||
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||||||
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||||||
|
|
||||||
|
// Decoration, NESTED, because the real tree nests two deep in places
|
||||||
|
// (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`) and a flat read would index a
|
||||||
|
// fraction of it while looking like it worked.
|
||||||
|
fs.mkdirSync(path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper'), { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Data', 'Decoration', 'top.cfg'),
|
||||||
|
'# a brazier\nBrazier 0x0E31\n100 100 0\n200 200 -5\n\nStatic 0x108F\n300 300 0\n',
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper', 'nested.cfg'),
|
||||||
|
'Brazier 0x0E31\n400 400 0\nLargeCrate 0x0E3C\n500 500 0\n',
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
for (const facet of facets) {
|
for (const facet of facets) {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(root, 'Spawns', `${facet}.xml`),
|
path.join(root, 'Spawns', `${facet}.xml`),
|
||||||
@@ -397,3 +412,81 @@ test('refresh: an explicit path overrides the configured one', async () => {
|
|||||||
assert.equal(result.status, 'imported')
|
assert.equal(result.status, 'imported')
|
||||||
assert.deepEqual(result.addedFacets, ['Override'])
|
assert.deepEqual(result.addedFacets, ['Override'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── The decoration index (Phase 12a) ────────────────────────
|
||||||
|
|
||||||
|
test('decoration is read recursively and rolled up per type', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decor-'))
|
||||||
|
try {
|
||||||
|
writeTree(root)
|
||||||
|
const atlas = buildAtlas(root)
|
||||||
|
|
||||||
|
// Sorted by type, and `uses` counts every header line across the whole tree
|
||||||
|
// — the nested file's Brazier is the second use of the same type, not a
|
||||||
|
// second type.
|
||||||
|
assert.deepEqual(atlas.decor, [
|
||||||
|
{ type: 'Brazier', itemId: 0x0e31, uses: 2 },
|
||||||
|
{ type: 'LargeCrate', itemId: 0x0e3c, uses: 1 },
|
||||||
|
{ type: 'Static', itemId: 0x108f, uses: 1 },
|
||||||
|
])
|
||||||
|
assert.equal(atlas.meta.counts.decor, 3)
|
||||||
|
|
||||||
|
// Every decoration file is fingerprinted like every other source, so an
|
||||||
|
// operator editing one is a tree change the boot path notices.
|
||||||
|
const labels = Object.keys(atlas.meta.source).filter((l) => l.startsWith('Data/Decoration/'))
|
||||||
|
assert.deepEqual(labels.sort(), ['Data/Decoration/Deep/Deeper/nested.cfg', 'Data/Decoration/top.cfg'])
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two spellings of one decoration type fold into one row', () => {
|
||||||
|
// The Phase 16 acceptance walk's blocking finding. Stock ServUO 57.4's own
|
||||||
|
// `Data/Decoration/` names four types under two casings each —
|
||||||
|
// CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
|
||||||
|
// SpinningWheelEastAddon/SpinningwheelEastAddon — and in every pair exactly one
|
||||||
|
// is a real class; the other is a mis-cased line the shard's own loader resolves
|
||||||
|
// anyway.
|
||||||
|
//
|
||||||
|
// A case-SENSITIVE Map keeps both. `shard_decor_types.type` is a PRIMARY KEY
|
||||||
|
// under MariaDB's default `..._ai_ci` collation, which folds case, so the second
|
||||||
|
// row raised `1062 Duplicate entry` and took the WHOLE atlas import transaction
|
||||||
|
// down with it. The blast radius is not decoration: with no atlas, EVERY option
|
||||||
|
// source answers empty and no world verb can be authored at all.
|
||||||
|
//
|
||||||
|
// Asserted on the count as well as the row, because the failure mode was two
|
||||||
|
// rows that a database — not this function — would later refuse.
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decorcase-'))
|
||||||
|
try {
|
||||||
|
writeTree(root)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Data', 'Decoration', 'miscased.cfg'),
|
||||||
|
'checkerboard 0x0FA6\n600 600 0\nCheckerBoard 0x0FA6\n700 700 0\n',
|
||||||
|
)
|
||||||
|
const atlas = buildAtlas(root)
|
||||||
|
|
||||||
|
const boards = atlas.decor.filter((d) => d.type.toLowerCase() === 'checkerboard')
|
||||||
|
assert.equal(boards.length, 1, 'two casings of one type must not be two rows')
|
||||||
|
// First spelling seen wins, exactly as the first item id does. Which one
|
||||||
|
// survives is cosmetic — the shard resolves either.
|
||||||
|
assert.equal(boards[0].type, 'checkerboard')
|
||||||
|
assert.equal(boards[0].uses, 2, 'both lines still count as uses of the one type')
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a tree with no decoration at all still builds', () => {
|
||||||
|
// Optional, like the champion file. A shard that has stripped its decoration
|
||||||
|
// has a perfectly good atlas; the decoration verb simply has nothing to offer.
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-nodecor-'))
|
||||||
|
try {
|
||||||
|
writeTree(root)
|
||||||
|
fs.rmSync(path.join(root, 'Data', 'Decoration'), { recursive: true, force: true })
|
||||||
|
const atlas = buildAtlas(root)
|
||||||
|
assert.deepEqual(atlas.decor, [])
|
||||||
|
assert.equal(atlas.meta.counts.decor, 0)
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
885
server/test/uoEventActions.test.js
Normal file
885
server/test/uoEventActions.test.js
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
// module-uo's event verbs, wave 1 (EVENTS_PLAN.md Phase 9).
|
||||||
|
//
|
||||||
|
// The declarations are data plus three `perform()`s, so most of this suite is
|
||||||
|
// about the *shapes* core will check and the failure paths a live rig cannot be
|
||||||
|
// made to produce on demand — a sidecar that answers 409, a shard that restarts
|
||||||
|
// between two steps, a crier line one character over the cap.
|
||||||
|
//
|
||||||
|
// **The first test is the one the whole phase rests on.** Every other property
|
||||||
|
// here — "a broadcast is sent once", "a failed post is retried" — is a claim
|
||||||
|
// about what the MODULE decided, and the module only gets to decide when its
|
||||||
|
// client answers before core's dispatch deadline. Assert the relationship, not
|
||||||
|
// the numbers, or the day someone tunes one of them the suite stays green while
|
||||||
|
// the behaviour inverts.
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const uoLinkClient = require('../utils/uoLinkClient')
|
||||||
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||||
|
require('./_setup')
|
||||||
|
const actions = require('../config/uoEventActions')
|
||||||
|
|
||||||
|
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||||
|
|
||||||
|
let calls
|
||||||
|
const saved = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = {
|
||||||
|
broadcast: [], crier: [], crierDel: [], news: [], newsDel: [],
|
||||||
|
spawn: [], despawn: [], owned: [],
|
||||||
|
}
|
||||||
|
for (const name of [
|
||||||
|
'adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews',
|
||||||
|
'spawnWorld', 'ownedWorld', 'despawnWorld',
|
||||||
|
]) {
|
||||||
|
saved[name] = uoLinkClient[name]
|
||||||
|
}
|
||||||
|
saved.getSafe = uoLinkConfig.getSafe
|
||||||
|
saved.listRegions = shardAtlas.listRegions
|
||||||
|
saved.listLandmarks = shardAtlas.listLandmarks
|
||||||
|
saved.searchCreatures = shardAtlas.searchCreatures
|
||||||
|
saved.listDecorTypes = shardAtlas.listDecorTypes
|
||||||
|
saved.getDecorType = shardAtlas.getDecorType
|
||||||
|
|
||||||
|
uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } }
|
||||||
|
uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } }
|
||||||
|
uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } }
|
||||||
|
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
|
||||||
|
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
|
||||||
|
// Phase 12a. Two serials back by default, so a spawn produces a resource list
|
||||||
|
// longer than one and the per-serial ledger shape is what the suite exercises.
|
||||||
|
uoLinkClient.spawnWorld = async (b) => {
|
||||||
|
calls.spawn.push(b)
|
||||||
|
const n = b.count || 1
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { serials: Array.from({ length: n }, (_, i) => `0x4000000${i}`) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uoLinkClient.ownedWorld = async (b) => {
|
||||||
|
calls.owned.push(b)
|
||||||
|
return { ok: true, status: 200, data: { owned: [{ serial: '0x40000000', what: 'creature' }] } }
|
||||||
|
}
|
||||||
|
uoLinkClient.despawnWorld = async (b) => {
|
||||||
|
calls.despawn.push(b)
|
||||||
|
return { ok: true, status: 200, data: { removed: b.serials || [], gone: [], refused: [] } }
|
||||||
|
}
|
||||||
|
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
|
||||||
|
// Phase 11b. `uo.participation.open` resolves its `place` param against the
|
||||||
|
// atlas, so the dry-run sweep below reaches this rather than the database.
|
||||||
|
// Two landmarks, because Phase 12a's gate verb resolves a SECOND place: its
|
||||||
|
// destination. One would make the dry-run sweep below pass for the wrong
|
||||||
|
// reason, by never exercising the leg that can name a different point.
|
||||||
|
shardAtlas.listLandmarks = async () => [
|
||||||
|
{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 },
|
||||||
|
{ facet: 'Felucca', name: 'Yew', x: 542, y: 982, z: 0 },
|
||||||
|
]
|
||||||
|
shardAtlas.listDecorTypes = async () => [{ type: 'Brazier', itemId: 0x0E31, uses: 42 }]
|
||||||
|
shardAtlas.getDecorType = async (type) =>
|
||||||
|
type === 'Brazier' ? { type: 'Brazier', itemId: 0x0E31, uses: 42 } : null
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
|
||||||
|
uoLinkClient[name] = saved[name]
|
||||||
|
}
|
||||||
|
uoLinkConfig.getSafe = saved.getSafe
|
||||||
|
shardAtlas.listRegions = saved.listRegions
|
||||||
|
shardAtlas.listLandmarks = saved.listLandmarks
|
||||||
|
shardAtlas.searchCreatures = saved.searchCreatures
|
||||||
|
shardAtlas.listDecorTypes = saved.listDecorTypes
|
||||||
|
shardAtlas.getDecorType = saved.getDecorType
|
||||||
|
for (const name of ['spawnWorld', 'ownedWorld', 'despawnWorld']) {
|
||||||
|
uoLinkClient[name] = saved[name]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The rule everything else depends on ────────────────────────────────────
|
||||||
|
|
||||||
|
test('every action outlives the sidecar client, so the module classifies its own failures', () => {
|
||||||
|
// `dispatch.classify()` answers `retry` for a budget timeout unconditionally
|
||||||
|
// and never asks the action. If core's deadline can fire before the client
|
||||||
|
// gives up, `retry: false` below is unreachable and a broadcast is retried.
|
||||||
|
for (const action of actions.ACTIONS) {
|
||||||
|
assert.ok(
|
||||||
|
action.budgetMs > uoLinkClient.TIMEOUT_MS,
|
||||||
|
`${action.id} budgetMs (${action.budgetMs}) must exceed uoLinkClient.TIMEOUT_MS (${uoLinkClient.TIMEOUT_MS})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The declarations, against the checks core will run ─────────────────────
|
||||||
|
|
||||||
|
test('the declarations satisfy the shape core validates them with', () => {
|
||||||
|
const RISKS = ['notify', 'inspect', 'change', 'irreversible']
|
||||||
|
const REVERSIBLE = ['none', 'self', 'ledger', 'override']
|
||||||
|
const PARAM_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
|
||||||
|
|
||||||
|
for (const a of actions.ACTIONS) {
|
||||||
|
assert.ok(a.id.startsWith('uo.'), `${a.id} must be namespaced to this module`)
|
||||||
|
assert.ok(a.label && a.description, `${a.id} needs a label and a description`)
|
||||||
|
assert.ok(RISKS.includes(a.risk), `${a.id} has an unknown risk class`)
|
||||||
|
assert.ok(REVERSIBLE.includes(a.reversible), `${a.id} has an unknown reversible class`)
|
||||||
|
assert.equal(typeof a.perform, 'function')
|
||||||
|
|
||||||
|
// `revert` is required iff ledger, and forbidden otherwise — a revert on a
|
||||||
|
// non-ledgering action is an undo core will never call.
|
||||||
|
assert.equal(
|
||||||
|
typeof a.revert === 'function',
|
||||||
|
a.reversible === 'ledger',
|
||||||
|
`${a.id} revert() must be present exactly when reversible is 'ledger'`,
|
||||||
|
)
|
||||||
|
// `reconcile` is optional, but only meaningful where something is ledgered.
|
||||||
|
if (a.reconcile !== undefined) {
|
||||||
|
assert.equal(typeof a.reconcile, 'function')
|
||||||
|
assert.ok(a.reversible === 'ledger' || a.reversible === 'override', `${a.id} reconciles but ledgers nothing`)
|
||||||
|
}
|
||||||
|
if (a.cost !== undefined) assert.equal(typeof a.cost, 'function')
|
||||||
|
|
||||||
|
const names = new Set()
|
||||||
|
for (const p of a.params) {
|
||||||
|
assert.ok(!names.has(p.name), `${a.id} declares ${p.name} twice`)
|
||||||
|
names.add(p.name)
|
||||||
|
assert.ok(PARAM_TYPES.includes(p.type), `${a.id}.${p.name} has an unsupported type "${p.type}"`)
|
||||||
|
// Required on every param including the optional ones: it is the authoring
|
||||||
|
// placeholder, and an unattended world write typed into a blank box is how
|
||||||
|
// a typo gets scheduled.
|
||||||
|
assert.ok(
|
||||||
|
p.example !== undefined && p.example !== null && p.example !== '',
|
||||||
|
`${a.id}.${p.name} needs an example`,
|
||||||
|
)
|
||||||
|
assert.ok(p.description, `${a.id}.${p.name} needs a description`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every dimension a cost names is one this module declares', () => {
|
||||||
|
const declared = new Set(actions.BUDGETS.map((b) => b.id))
|
||||||
|
// Phase 12a's six and Phase 12b's seventh are all the MODULE's (org lead,
|
||||||
|
// 2026-09-07): core meters what a module declares and holds no UO knowledge, so
|
||||||
|
// a `uo.` dimension core knew about would be a leak of this game into the engine.
|
||||||
|
//
|
||||||
|
// `uo.rewards` counts ITEMS rather than grants: a step giving 500 gold to forty
|
||||||
|
// people and one giving a candle to forty people are not the same imposition, and
|
||||||
|
// a count of grants would price them identically.
|
||||||
|
assert.deepEqual(
|
||||||
|
[...declared],
|
||||||
|
[
|
||||||
|
'uo.broadcasts',
|
||||||
|
'uo.creatures',
|
||||||
|
'uo.bosses',
|
||||||
|
'uo.npcs',
|
||||||
|
'uo.decor',
|
||||||
|
'uo.gate.minutes',
|
||||||
|
'uo.rewards',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for (const b of actions.BUDGETS) {
|
||||||
|
assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced')
|
||||||
|
assert.ok(b.label && b.unit, 'a dimension is rendered as a label and a unit beside a number')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every dimension a cost names must be one the module declared, or core is
|
||||||
|
// asked to bound something nothing defines.
|
||||||
|
const cost = byId('uo.broadcast').cost({})
|
||||||
|
assert.deepEqual(cost, { 'uo.broadcasts': 1 })
|
||||||
|
for (const id of Object.keys(cost)) assert.ok(declared.has(id), `${id} is spent but never declared`)
|
||||||
|
|
||||||
|
// The keyed verbs deliberately spend nothing: a repeat REPLACES under the same
|
||||||
|
// id, so there is no runaway for a cap to bound.
|
||||||
|
assert.equal(byId('uo.towncrier.post').cost, undefined)
|
||||||
|
assert.equal(byId('uo.news.post').cost, undefined)
|
||||||
|
|
||||||
|
// Phase 12a. Asserted across EVERY action rather than one at a time, because
|
||||||
|
// the failure this catches is a typo in one dimension name out of six, which
|
||||||
|
// core answers by refusing the whole registration at load.
|
||||||
|
for (const action of actions.ACTIONS) {
|
||||||
|
if (typeof action.cost !== 'function') continue
|
||||||
|
const params = {}
|
||||||
|
for (const p of action.params) params[p.name] = p.example
|
||||||
|
for (const id of Object.keys(action.cost(params))) {
|
||||||
|
assert.ok(declared.has(id), `${action.id} spends "${id}", which nothing declares`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gate is priced in MINUTES, not in gates. One standing all day and twelve
|
||||||
|
// standing five minutes each are not the same imposition on a world, and a
|
||||||
|
// count would price them identically.
|
||||||
|
assert.deepEqual(byId('uo.gate.open').cost({ durationMinutes: 120 }), { 'uo.gate.minutes': 120 })
|
||||||
|
assert.deepEqual(byId('uo.creature.spawn').cost({ count: 8 }), { 'uo.creatures': 8 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── uo.broadcast: retried, because protocol 6 made that safe ───────────────
|
||||||
|
|
||||||
|
test('a broadcast is retried on a transient failure and never on a permanent one', async () => {
|
||||||
|
const broadcast = byId('uo.broadcast')
|
||||||
|
// Wave 1 asserted the opposite of this — every failure terminal, including the
|
||||||
|
// two that are plainly transient — because nothing on the wire could stop a
|
||||||
|
// retry announcing to everyone twice. Protocol 6 puts an idempotency key on the
|
||||||
|
// command and the shard refuses the repeat, so the trade that test recorded is
|
||||||
|
// no longer one that has to be made.
|
||||||
|
//
|
||||||
|
// 425 is the new status in this list: `bridge.busy`, the shard saying a command
|
||||||
|
// under this key is still in flight. Transient by construction.
|
||||||
|
const TRANSIENT = new Set([0, 425, 503, 504])
|
||||||
|
for (const status of [0, 400, 401, 403, 409, 425, 503, 504]) {
|
||||||
|
uoLinkClient.adminBroadcast = async () => ({ ok: false, status, error: `status ${status}` })
|
||||||
|
const result = await broadcast.perform({ runId: 7, params: { text: 'hear ye' }, verify: false })
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.retry, TRANSIENT.has(status), `a ${status} retries iff it is transient`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every write carries the step idempotency key, unchanged', async () => {
|
||||||
|
// The key is what makes the retry above safe, so a verb that dropped it would
|
||||||
|
// silently restore the wave-1 hazard while every other assertion still passed.
|
||||||
|
// Asserted per verb rather than once, because each builds its own body.
|
||||||
|
const KEY = 'a'.repeat(40)
|
||||||
|
const seen = {}
|
||||||
|
|
||||||
|
uoLinkClient.adminBroadcast = async (body) => { seen.broadcast = body; return { ok: true } }
|
||||||
|
uoLinkClient.postTownCrier = async (body) => { seen.crier = body; return { ok: true } }
|
||||||
|
uoLinkClient.postNews = async (body) => { seen.news = body; return { ok: true } }
|
||||||
|
|
||||||
|
await byId('uo.broadcast').perform({
|
||||||
|
runId: 7, idempotencyKey: KEY, params: { text: 'hear ye' }, verify: false,
|
||||||
|
})
|
||||||
|
await byId('uo.towncrier.post').perform({
|
||||||
|
runId: 7, idempotencyKey: KEY, params: { lines: 'hear ye' }, verify: false,
|
||||||
|
})
|
||||||
|
await byId('uo.news.post').perform({
|
||||||
|
runId: 7, idempotencyKey: KEY, params: { title: 'A thing', body: 'happened' }, verify: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(seen.broadcast.idempotencyKey, KEY)
|
||||||
|
assert.equal(seen.crier.idempotencyKey, KEY)
|
||||||
|
assert.equal(seen.news.idempotencyKey, KEY)
|
||||||
|
// The two keyed verbs post under an id DERIVED from the key. Both travel: the
|
||||||
|
// id is what makes a repeat replace, the key is what stops it re-announcing.
|
||||||
|
assert.equal(seen.crier.id, `evt-${KEY}`)
|
||||||
|
assert.equal(seen.news.id, `evt-${KEY}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the shard's own words reach the run log, not just a status code", async () => {
|
||||||
|
// **The rig found this.** The sidecar refuses a broadcast with
|
||||||
|
// `{"reason":"admin write plane disabled"}` and `legError` looks for
|
||||||
|
// `data.message`, so the run console read "sidecar responded 403" for a cause
|
||||||
|
// the shard had already explained in a sentence. A staff member clicking a
|
||||||
|
// button knows what they switched off; an event that ran at four in the morning
|
||||||
|
// leaves the run log as the only place anyone will learn why.
|
||||||
|
uoLinkClient.adminBroadcast = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
data: { kind: 'admin.error', reason: 'admin write plane disabled' },
|
||||||
|
error: 'sidecar responded 403',
|
||||||
|
})
|
||||||
|
const result = await byId('uo.broadcast').perform({ runId: 1, params: { text: 'hear ye' }, verify: false })
|
||||||
|
assert.match(result.error, /admin write plane disabled/)
|
||||||
|
// And NOT the double-announce clause: a 403 will not succeed on any attempt, so
|
||||||
|
// pointing an operator at a policy decision misdirects them away from the
|
||||||
|
// switch they actually have to flip.
|
||||||
|
assert.doesNotMatch(result.error, /announce twice/)
|
||||||
|
assert.equal(result.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a permanent refusal of a keyed verb is not retried either', async () => {
|
||||||
|
// Same distinction on the other side: the keyed verbs DO retry a transient, and
|
||||||
|
// must not burn three attempts on a refusal that cannot change.
|
||||||
|
uoLinkClient.postTownCrier = async () => ({ ok: false, status: 403, data: { reason: 'admin write plane disabled' } })
|
||||||
|
const result = await byId('uo.towncrier.post').perform({
|
||||||
|
runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines: 'hear ye' }, verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(result.retry, false)
|
||||||
|
assert.match(result.error, /admin write plane disabled/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a broadcast names its run in the shard audit, not a staff member', async () => {
|
||||||
|
await byId('uo.broadcast').perform({ runId: 42, params: { text: 'hear ye', hue: 1153 }, verify: false })
|
||||||
|
assert.equal(calls.broadcast.length, 1)
|
||||||
|
assert.equal(calls.broadcast[0].actor, 'event:42')
|
||||||
|
assert.equal(calls.broadcast[0].hue, 1153)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an over-long broadcast is refused by the DRY RUN, before anything is sent', async () => {
|
||||||
|
const broadcast = byId('uo.broadcast')
|
||||||
|
const text = 'x'.repeat(actions.MAX_BROADCAST_LEN + 1)
|
||||||
|
|
||||||
|
const dry = await broadcast.perform({ runId: 1, params: { text }, verify: true })
|
||||||
|
assert.equal(dry.ok, false)
|
||||||
|
assert.equal(dry.retry, false)
|
||||||
|
assert.match(dry.error, new RegExp(String(actions.MAX_BROADCAST_LEN)))
|
||||||
|
|
||||||
|
const live = await broadcast.perform({ runId: 1, params: { text }, verify: false })
|
||||||
|
assert.equal(live.ok, false)
|
||||||
|
assert.deepEqual(calls.broadcast, [], 'nothing may reach the shard once the cap is breached')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dry run sends nothing at all', async () => {
|
||||||
|
for (const action of actions.ACTIONS) {
|
||||||
|
const params = {}
|
||||||
|
for (const p of action.params) if (p.required) params[p.name] = p.example
|
||||||
|
const result = await action.perform({ runId: 1, stepId: 1, idempotencyKey: 'k'.repeat(40), params, verify: true })
|
||||||
|
assert.equal(result.ok, true, `${action.id} refused its own example params`)
|
||||||
|
assert.equal(result.resources, undefined, `${action.id} reported a resource it never created`)
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
[calls.broadcast.length, calls.crier.length, calls.news.length],
|
||||||
|
[0, 0, 0],
|
||||||
|
'a dry run reached the shard',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The keyed verbs: one id, stable across a retry ─────────────────────────
|
||||||
|
|
||||||
|
test('the crier and the news gump post under a run-stable id a retry replaces', async () => {
|
||||||
|
const key = 'a1b2c3'.padEnd(40, '0')
|
||||||
|
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
|
||||||
|
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
|
||||||
|
|
||||||
|
assert.equal(calls.crier.length, 2)
|
||||||
|
assert.equal(calls.crier[0].id, calls.crier[1].id, 'a retry must replace, not stack')
|
||||||
|
assert.equal(calls.crier[0].id, `evt-${key}`)
|
||||||
|
// The sidecar's own cap on the id column.
|
||||||
|
assert.ok(calls.crier[0].id.length <= 64)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an event article cannot collide with a website post in the news gump', async () => {
|
||||||
|
// `newsGump.js` posts site articles under the bare post id and re-pushes that
|
||||||
|
// whole set on every reconnect. An event article numbered into the same space
|
||||||
|
// would silently be a collision with a post, in whichever direction wrote last.
|
||||||
|
await byId('uo.news.post').perform({
|
||||||
|
runId: 9,
|
||||||
|
idempotencyKey: 'f'.repeat(40),
|
||||||
|
params: { title: 'The Fair', body: 'Merchants gather.' },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(calls.news.length, 1)
|
||||||
|
assert.doesNotMatch(calls.news[0].id, /^\d+$/, 'an event article must not be numbered like a post')
|
||||||
|
assert.match(calls.news[0].id, /^evt-/)
|
||||||
|
assert.match(calls.news[0].body, /<CENTER>The Fair<\/CENTER>/)
|
||||||
|
assert.equal(calls.news[0].announce, true, 'announce defaults on, as the gump does')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the keyed verbs DO retry, because a repeat replaces', async () => {
|
||||||
|
for (const [id, stub] of [['uo.towncrier.post', 'postTownCrier'], ['uo.news.post', 'postNews']]) {
|
||||||
|
const params = { lines: 'hear ye', title: 'The Fair', body: 'Merchants gather.' }
|
||||||
|
// The announce leg's own classification of this transport, reused rather
|
||||||
|
// than re-decided: a config or data problem is terminal, the rest transient.
|
||||||
|
for (const [status, retry] of [[400, false], [401, false], [403, false], [409, false], [503, true], [504, true], [0, true]]) {
|
||||||
|
uoLinkClient[stub] = async () => ({ ok: false, status, error: `status ${status}` })
|
||||||
|
const result = await byId(id).perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params, verify: false })
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.retry, retry, `${id} misclassified a ${status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a crier post is refused before it is sent when it is not eight short lines', async () => {
|
||||||
|
const crier = byId('uo.towncrier.post')
|
||||||
|
const cases = [
|
||||||
|
['', /empty/],
|
||||||
|
[' \n ', /empty/],
|
||||||
|
[Array.from({ length: actions.MAX_CRIER_LINES + 1 }, (_, i) => `line ${i}`).join('\n'), /criers carry/],
|
||||||
|
['x'.repeat(actions.MAX_CRIER_LINE_LEN + 1), /capped at/],
|
||||||
|
]
|
||||||
|
for (const [lines, expected] of cases) {
|
||||||
|
const result = await crier.perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines }, verify: false })
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.retry, false, 'a badly shaped message is just as badly shaped next minute')
|
||||||
|
assert.match(result.error, expected)
|
||||||
|
}
|
||||||
|
assert.deepEqual(calls.crier, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blank lines are dropped rather than counted against the cap', () => {
|
||||||
|
// A textarea an operator has pressed enter in twice still holds two lines.
|
||||||
|
const parsed = actions.crierLines('hear ye\n\n \nseek the herald\n')
|
||||||
|
assert.equal(parsed.ok, true)
|
||||||
|
assert.deepEqual(parsed.lines, ['hear ye', 'seek the herald'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a crier duration is taken in minutes and bounded at the sidecar cap', async () => {
|
||||||
|
const crier = byId('uo.towncrier.post')
|
||||||
|
const base = { runId: 1, idempotencyKey: 'k'.repeat(40), verify: false }
|
||||||
|
|
||||||
|
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 90 } })
|
||||||
|
assert.equal(calls.crier[0].durationSec, 5400)
|
||||||
|
|
||||||
|
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 60 * 48 } })
|
||||||
|
assert.equal(calls.crier[1].durationSec, 86400, 'a duration past the sidecar cap is clamped, not refused')
|
||||||
|
|
||||||
|
// Left out entirely, so the sidecar applies its own default rather than the
|
||||||
|
// module inventing one.
|
||||||
|
await crier.perform({ ...base, params: { lines: 'hear ye' } })
|
||||||
|
assert.equal(calls.crier[2].durationSec, undefined)
|
||||||
|
|
||||||
|
const bad = await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 'soon' } })
|
||||||
|
assert.equal(bad.ok, false)
|
||||||
|
assert.equal(bad.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Giving it back ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a resource that is already gone is a successful revert', async () => {
|
||||||
|
// §L: "gone, and that is fine". A crier line whose duration ran out is a 404,
|
||||||
|
// and it is the outcome teardown wanted.
|
||||||
|
uoLinkClient.deleteTownCrier = async () => ({ ok: false, status: 404 })
|
||||||
|
uoLinkClient.deleteNews = async () => ({ ok: false, status: 404 })
|
||||||
|
|
||||||
|
for (const id of ['uo.towncrier.post', 'uo.news.post']) {
|
||||||
|
const result = await byId(id).revert({ runId: 1, resources: [{ kind: 'x', ref: 'evt-1' }] })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.ok(!result.failed || !result.failed.length)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a revert names the resources that did not come back', async () => {
|
||||||
|
uoLinkClient.deleteTownCrier = async (id) => {
|
||||||
|
calls.crierDel.push(id)
|
||||||
|
return id === 'evt-bad' ? { ok: false, status: 503 } : { ok: true, status: 200 }
|
||||||
|
}
|
||||||
|
const result = await byId('uo.towncrier.post').revert({
|
||||||
|
runId: 1,
|
||||||
|
resources: [{ ref: 'evt-ok' }, { ref: 'evt-bad' }],
|
||||||
|
})
|
||||||
|
// `ok: true` with a `failed` list, not `ok: false`: the group was worked, and
|
||||||
|
// one member of it is outstanding. Core keeps the row and tries it again.
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.deepEqual(result.failed, ['evt-bad'])
|
||||||
|
assert.deepEqual(calls.crierDel, ['evt-ok', 'evt-bad'], 'one failure must not stop the group')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── reconcile: the boot stamp ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a resource stamped with the current boot is still in force', async () => {
|
||||||
|
const resources = [
|
||||||
|
{ kind: 'towncrier', ref: 'evt-a', payload: { bootId: 'boot-1' } },
|
||||||
|
{ kind: 'towncrier', ref: 'evt-b', payload: { bootId: 'boot-0' } },
|
||||||
|
]
|
||||||
|
const result = await actions.reconcileByBootId({ resources })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
// Only the row from the boot that is still running. Core orphans the other —
|
||||||
|
// which is the honest sentence: it vanished while nobody was looking, rather
|
||||||
|
// than core having put it back.
|
||||||
|
assert.deepEqual(result.inForce, ['evt-a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a resource with no stamp is reported in force, because "I do not know" is not "it is gone"', async () => {
|
||||||
|
const result = await actions.reconcileByBootId({
|
||||||
|
resources: [{ ref: 'evt-old', payload: null }, { ref: 'evt-older', payload: {} }],
|
||||||
|
})
|
||||||
|
assert.deepEqual(result.inForce, ['evt-old', 'evt-older'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('with no shard boot to compare against, reconcile declines rather than orphaning everything', async () => {
|
||||||
|
uoLinkConfig.getSafe = async () => ({ bootId: null })
|
||||||
|
const result = await actions.reconcileByBootId({ resources: [{ ref: 'evt-a', payload: { bootId: 'boot-1' } }] })
|
||||||
|
// Core treats anything that is not an explicit answer as unanswered and leaves
|
||||||
|
// the ledger alone. An `ok: true, inForce: []` here would abandon every live row
|
||||||
|
// on a website that came up before its sidecar did.
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a write with an unreadable config still happens, and simply carries no stamp', async () => {
|
||||||
|
uoLinkConfig.getSafe = async () => { throw new Error('pool is down') }
|
||||||
|
const result = await byId('uo.towncrier.post').perform({
|
||||||
|
runId: 1,
|
||||||
|
idempotencyKey: 'k'.repeat(40),
|
||||||
|
params: { lines: 'hear ye' },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, true, 'a config read must not fail a world write')
|
||||||
|
assert.equal(result.resources[0].payload.bootId, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Option sources ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const source = (id) => actions.OPTION_SOURCES.find((s) => s.id === id)
|
||||||
|
|
||||||
|
test('every option source is namespaced and answers', () => {
|
||||||
|
for (const s of actions.OPTION_SOURCES) {
|
||||||
|
assert.ok(s.id.startsWith('uo.options.'), `${s.id} must be namespaced`)
|
||||||
|
assert.ok(s.label && s.description)
|
||||||
|
assert.equal(typeof s.resolve, 'function')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a place is named by its facet, because two facets both have a Britain', async () => {
|
||||||
|
shardAtlas.listRegions = async () => [
|
||||||
|
{ facet: 'Felucca', name: 'Britain' },
|
||||||
|
{ facet: 'Trammel', name: 'Britain' },
|
||||||
|
]
|
||||||
|
const options = await source('uo.options.regions').resolve()
|
||||||
|
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'two different places must not share a value')
|
||||||
|
assert.deepEqual(options[0], { value: 'Felucca/Britain', label: 'Britain', group: 'Felucca' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a landmark groups by the atlas grouping where it has one, the facet otherwise', async () => {
|
||||||
|
shardAtlas.listLandmarks = async () => [
|
||||||
|
{ facet: 'Felucca', name: 'Despise', group: 'Dungeons' },
|
||||||
|
{ facet: 'Felucca', name: 'Cove', group: null },
|
||||||
|
]
|
||||||
|
const options = await source('uo.options.landmarks').resolve()
|
||||||
|
assert.deepEqual(options.map((o) => o.group), ['Dungeons', 'Felucca'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a creature option carries the type the shard can build, not the atlas slug', async () => {
|
||||||
|
// Changed in Phase 12a, and the reason is the point of the source existing.
|
||||||
|
// Wave 1 declared it before anything consumed it and used the slug — unique,
|
||||||
|
// stable, and unusable: the shard constructs from a ServUO class name, and
|
||||||
|
// `orc-brute` is not one. The atlas's `name` IS the raw type token from the
|
||||||
|
// spawn files, so the fix was to stop discarding the half that works.
|
||||||
|
shardAtlas.searchCreatures = async ({ limit }) => {
|
||||||
|
assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for')
|
||||||
|
return { creatures: [{ slug: 'orcbrute', name: 'OrcBrute' }] }
|
||||||
|
}
|
||||||
|
assert.deepEqual(await source('uo.options.creatures').resolve(), [
|
||||||
|
{ value: 'OrcBrute', label: 'OrcBrute' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decoration options come from the shard\'s own decoration files', async () => {
|
||||||
|
const options = await source('uo.options.decor').resolve()
|
||||||
|
assert.deepEqual(options, [{ value: 'Brazier', label: 'Brazier' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an atlas larger than the dropdown bound is truncated and said so', async () => {
|
||||||
|
const { ctx } = require('./_setup')
|
||||||
|
shardAtlas.listRegions = async () =>
|
||||||
|
Array.from({ length: actions.MAX_OPTIONS + 5 }, (_, i) => ({ facet: 'Felucca', name: `Region ${i}` }))
|
||||||
|
const options = await source('uo.options.regions').resolve()
|
||||||
|
assert.equal(options.length, actions.MAX_OPTIONS)
|
||||||
|
// Silently serving 2000 of 2005 is the defect the bound would otherwise
|
||||||
|
// introduce: an author cannot find the landmark they are looking for and
|
||||||
|
// nothing anywhere says why.
|
||||||
|
const warned = ctx.logs
|
||||||
|
.filter((l) => l.namespace === 'uo-events')
|
||||||
|
.flatMap((l) => l.log.warn.calls)
|
||||||
|
.some(([message]) => /truncated/.test(message))
|
||||||
|
assert.ok(warned, 'a truncated source must leave a log line naming itself')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The world verbs (Phase 12a) ───────────────────────────────
|
||||||
|
|
||||||
|
test('a spawn files one ledger row per serial, not one per call', async () => {
|
||||||
|
// Per serial, because a group half of which a player killed has to reconcile
|
||||||
|
// per creature. One row per call would make teardown all-or-nothing over eight
|
||||||
|
// orcs of which six are gone, which is neither true nor useful.
|
||||||
|
const result = await byId('uo.creature.spawn').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'c'.repeat(40),
|
||||||
|
params: { place: 'Felucca/Britain', creature: 'Orc', count: 3 },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.resources.length, 3)
|
||||||
|
for (const resource of result.resources) {
|
||||||
|
assert.equal(resource.kind, actions.OWNED_KIND)
|
||||||
|
assert.equal(resource.payload.runId, '7')
|
||||||
|
assert.equal(resource.payload.what, 'creature')
|
||||||
|
assert.equal(resource.payload.type, 'Orc')
|
||||||
|
}
|
||||||
|
|
||||||
|
// The place is resolved to a point HERE, so the shard is never handed a
|
||||||
|
// facet/name it would have to know how to read.
|
||||||
|
assert.equal(calls.spawn.length, 1)
|
||||||
|
assert.deepEqual(
|
||||||
|
{ map: calls.spawn[0].map, x: calls.spawn[0].x, y: calls.spawn[0].y },
|
||||||
|
{ map: 'Felucca', x: 1496, y: 1628 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a boss is a creature plus multipliers, and is refused above the ceiling', async () => {
|
||||||
|
const boss = byId('uo.boss.spawn')
|
||||||
|
const params = {
|
||||||
|
place: 'Felucca/Britain',
|
||||||
|
creature: 'OrcCaptain',
|
||||||
|
name: 'Gruk the Unbroken',
|
||||||
|
hitsMultiplier: 3,
|
||||||
|
damageMultiplier: 1.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal((await boss.perform({ runId: 7, idempotencyKey: 'b'.repeat(40), params, verify: false })).ok, true)
|
||||||
|
assert.equal(calls.spawn[0].what, 'boss')
|
||||||
|
assert.equal(calls.spawn[0].hitsMultiplier, 3)
|
||||||
|
assert.equal(calls.spawn[0].damageMultiplier, 1.5)
|
||||||
|
// Absent, not zero: a multiplier nobody set must not arrive as a number the
|
||||||
|
// shard would then apply.
|
||||||
|
assert.equal(calls.spawn[0].statMultiplier, undefined)
|
||||||
|
|
||||||
|
const tooMuch = await boss.perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'b'.repeat(40),
|
||||||
|
params: { ...params, hitsMultiplier: actions.MAX_BOSS_MULTIPLIER + 1 },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(tooMuch.ok, false)
|
||||||
|
assert.equal(tooMuch.retry, false, 'a ceiling will not move on a retry')
|
||||||
|
assert.equal(calls.spawn.length, 1, 'nothing may reach the shard once it is refused here')
|
||||||
|
|
||||||
|
// Named, because an unnamed boss is just a hard orc — and because the name is
|
||||||
|
// what an operator reads in the ledger afterwards.
|
||||||
|
const unnamed = await boss.perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'b'.repeat(40),
|
||||||
|
params: { ...params, name: ' ' },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(unnamed.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an oracle\'s dialogue is parsed from one textarea, and a bad row is named', async () => {
|
||||||
|
const parsed = actions.oracleLines('fire, flame = It burns beneath the keep.\n gate = At dusk. ')
|
||||||
|
assert.deepEqual(parsed, {
|
||||||
|
ok: true,
|
||||||
|
rows: [
|
||||||
|
{ keywords: 'fire,flame', text: 'It burns beneath the keep.' },
|
||||||
|
{ keywords: 'gate', text: 'At dusk.' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Split on the FIRST `=`, so an answer may contain one.
|
||||||
|
assert.deepEqual(actions.oracleLines('sum = 2 = 2 is four').rows, [
|
||||||
|
{ keywords: 'sum', text: '2 = 2 is four' },
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(actions.oracleLines('just some prose').ok, false)
|
||||||
|
assert.equal(actions.oracleLines('fire =').ok, false, 'a keyword with nothing to say is a mistake')
|
||||||
|
assert.equal(actions.oracleLines('= something').ok, false, 'something to say with no keyword is too')
|
||||||
|
|
||||||
|
const tooMany = actions.oracleLines(
|
||||||
|
Array.from({ length: actions.MAX_ORACLE_LINES + 1 }, (_, i) => `w${i} = t${i}`).join('\n'),
|
||||||
|
)
|
||||||
|
assert.equal(tooMany.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an oracle with nothing to say is refused before it is stood up', async () => {
|
||||||
|
// `required: true` on the greeting catches an ABSENT field, at the edge, and
|
||||||
|
// this catches the one holding nothing but spaces — which reaches `perform`
|
||||||
|
// looking exactly like a filled-in form.
|
||||||
|
const result = await byId('uo.npc.place').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'n'.repeat(40),
|
||||||
|
params: { place: 'Felucca/Britain', name: 'Marisa', greeting: ' ' },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.retry, false)
|
||||||
|
assert.match(result.error, /silence/)
|
||||||
|
assert.deepEqual(calls.spawn, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a keyword line reaches the shard as keywords and text, and nothing executable', async () => {
|
||||||
|
// The whole argument for not building this on `XmlSpawner2.XmlDialog`, which
|
||||||
|
// implements exactly this vocabulary and one field more: an `Action` string
|
||||||
|
// that runs commands. What crosses here is what an oracle SAYS.
|
||||||
|
const result = await byId('uo.npc.place').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'n'.repeat(40),
|
||||||
|
params: {
|
||||||
|
place: 'Felucca/Britain',
|
||||||
|
name: 'Marisa',
|
||||||
|
greeting: 'You have questions.',
|
||||||
|
lines: 'fire, flame = It burns beneath the keep.',
|
||||||
|
sex: 'female',
|
||||||
|
},
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.deepEqual(calls.spawn[0].lines, [
|
||||||
|
{ keywords: 'fire,flame', text: 'It burns beneath the keep.' },
|
||||||
|
])
|
||||||
|
assert.equal(calls.spawn[0].sex, 'female')
|
||||||
|
for (const key of Object.keys(calls.spawn[0])) {
|
||||||
|
assert.notEqual(key, 'action', 'nothing executable may cross to the shard')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a gate crosses as a DURATION, and names both ends as points', async () => {
|
||||||
|
const result = await byId('uo.gate.open').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'g'.repeat(40),
|
||||||
|
params: { place: 'Felucca/Britain', destination: 'Felucca/Yew', durationMinutes: 120 },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
const sent = calls.spawn[0]
|
||||||
|
// A duration, never an absolute time: an absolute deadline computed here and
|
||||||
|
// honoured there is measured against two clocks, and a shard ten minutes fast
|
||||||
|
// would collect the gate the instant it opened.
|
||||||
|
assert.equal(sent.holdMs, 120 * 60_000)
|
||||||
|
assert.equal(sent.untilMs, undefined, 'an absolute deadline must not cross')
|
||||||
|
assert.deepEqual(sent.target, { map: 'Felucca', x: 542, y: 982 })
|
||||||
|
|
||||||
|
const tooLong = await byId('uo.gate.open').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'g'.repeat(40),
|
||||||
|
params: {
|
||||||
|
place: 'Felucca/Britain',
|
||||||
|
destination: 'Felucca/Yew',
|
||||||
|
durationMinutes: actions.MAX_GATE_MINUTES + 1,
|
||||||
|
},
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(tooLong.ok, false)
|
||||||
|
assert.equal(tooLong.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('teardown reports a refused serial as failed, and a killed creature as done', async () => {
|
||||||
|
const resources = [
|
||||||
|
{ kind: 'world', ref: '0x40000000', payload: {} },
|
||||||
|
{ kind: 'world', ref: '0x40000001', payload: {} },
|
||||||
|
]
|
||||||
|
|
||||||
|
// `gone` is not a failure. A creature a player killed is the point of having
|
||||||
|
// spawned it, and §L already says "gone, and that is fine" is a successful
|
||||||
|
// revert — so a run does not end `incomplete` because its event worked.
|
||||||
|
uoLinkClient.despawnWorld = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { removed: ['0x40000000'], gone: ['0x40000001'], refused: [] },
|
||||||
|
})
|
||||||
|
assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), { ok: true })
|
||||||
|
|
||||||
|
// `refused` IS. The shard denies this run ever owned it, so nothing will ever
|
||||||
|
// delete it through this path: the row must land unresolved with a reason
|
||||||
|
// rather than be quietly marked reverted.
|
||||||
|
uoLinkClient.despawnWorld = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { removed: ['0x40000000'], gone: [], refused: ['0x40000001'] },
|
||||||
|
})
|
||||||
|
assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), {
|
||||||
|
ok: true,
|
||||||
|
failed: ['0x40000001'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// An unreachable shard has not said anything about anything.
|
||||||
|
uoLinkClient.despawnWorld = async () => ({ ok: false, status: 503, data: null })
|
||||||
|
assert.equal((await actions.revertOwned({ runId: 7, resources })).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the despawn carries NO idempotency key, whatever core hands revert()', async () => {
|
||||||
|
// The Phase 16 acceptance walk's critical finding, as the test that would have
|
||||||
|
// caught it. `revertOwned` used to forward core's `idempotencyKey` onto the
|
||||||
|
// despawn — and core's key is the STEP's, the one `placeOwned` spawned under.
|
||||||
|
// The shard's at-most-once store is keyed on the key ALONE
|
||||||
|
// (`BridgeIdempotency.Intercept` does `_byKey.TryGetValue(key, …)`, with no
|
||||||
|
// reference to which command carried it), so the despawn was taken for a repeat
|
||||||
|
// and answered with the SPAWN's stored reply. `OnDespawn` never ran. Core read
|
||||||
|
// `ok` with no `refused` and marked every row `reverted` while the shard still
|
||||||
|
// held every object — teardown of all five world verbs was a no-op that
|
||||||
|
// reported success.
|
||||||
|
//
|
||||||
|
// Every other stub in this file ignores the body, which is why the suite was
|
||||||
|
// green throughout. This one asserts on the body, and it asserts ABSENCE — the
|
||||||
|
// property that matters — rather than pinning the rest of the shape.
|
||||||
|
let sent = null
|
||||||
|
uoLinkClient.despawnWorld = async (body) => {
|
||||||
|
sent = body
|
||||||
|
return { ok: true, status: 200, data: { removed: ['0x40000000'], gone: [], refused: [] } }
|
||||||
|
}
|
||||||
|
|
||||||
|
await actions.revertOwned({
|
||||||
|
runId: 7,
|
||||||
|
resources: [{ kind: 'world', ref: '0x40000000', payload: {} }],
|
||||||
|
// Core passes this on every call (MODULE_API.md), and it must not reach the wire.
|
||||||
|
idempotencyKey: 'the-step-key-the-spawn-went-out-under',
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ok(sent, 'despawnWorld was not called')
|
||||||
|
assert.equal(
|
||||||
|
Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'),
|
||||||
|
false,
|
||||||
|
'the despawn must not carry an idempotency key — the shard would replay the spawn',
|
||||||
|
)
|
||||||
|
|
||||||
|
// MODULE_API.md: revert is sometimes called with the key and an EMPTY list,
|
||||||
|
// meaning "a command went out under this key and core never learned what it
|
||||||
|
// did". No serials is the shard's own idiom for "everything this run owns",
|
||||||
|
// which is the correct sweep for exactly that case.
|
||||||
|
sent = null
|
||||||
|
await actions.revertOwned({ runId: 7, resources: [], idempotencyKey: 'lost-dispatch' })
|
||||||
|
assert.deepEqual(sent.serials, [])
|
||||||
|
assert.equal(Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reconcile ASKS the shard, because these resources survive a restart', async () => {
|
||||||
|
// The one property that separates this from every other resource in the file.
|
||||||
|
// A crier line lives in shard memory, so a changed `bootId` IS proof it is
|
||||||
|
// gone; a spawned creature is in the world SAVE and survives the restart the
|
||||||
|
// boot stamp would report it lost by.
|
||||||
|
const resources = [
|
||||||
|
{ kind: 'world', ref: '0x40000000', payload: {} },
|
||||||
|
{ kind: 'world', ref: '0x40000001', payload: {} },
|
||||||
|
]
|
||||||
|
|
||||||
|
assert.deepEqual(await actions.reconcileOwned({ runId: 7, resources }), {
|
||||||
|
ok: true,
|
||||||
|
inForce: ['0x40000000'],
|
||||||
|
})
|
||||||
|
assert.deepEqual(calls.owned, [{ runId: '7' }])
|
||||||
|
|
||||||
|
// "I could not ask" must never be read as "it is gone": an unanswered group
|
||||||
|
// leaves every row alone rather than orphaning the lot.
|
||||||
|
uoLinkClient.ownedWorld = async () => ({ ok: false, status: 504, data: null })
|
||||||
|
assert.equal((await actions.reconcileOwned({ runId: 7, resources })).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every world verb declares the same undo contract', async () => {
|
||||||
|
// Five declarations sharing one spread object, asserted rather than assumed:
|
||||||
|
// a verb that quietly lost its `reconcile` would leave its rows unanswered for
|
||||||
|
// the life of the run, and nothing would report it — which is exactly the hole
|
||||||
|
// Phase 11b found in `core.lease`.
|
||||||
|
for (const id of ['uo.creature.spawn', 'uo.boss.spawn', 'uo.npc.place', 'uo.gate.open', 'uo.decor.place']) {
|
||||||
|
const action = byId(id)
|
||||||
|
assert.equal(action.risk, 'change', `${id} must be a world change`)
|
||||||
|
assert.equal(action.reversible, 'ledger', `${id} owns what it made`)
|
||||||
|
assert.equal(typeof action.revert, 'function', `${id} has no undo`)
|
||||||
|
assert.equal(typeof action.reconcile, 'function', `${id} can never be asked what it still holds`)
|
||||||
|
assert.ok(action.budgetMs > 12000, `${id} must outlast the client's own timeout`)
|
||||||
|
assert.equal(typeof action.cost, 'function', `${id} is capped by nothing`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decoration carries the graphic, and a type this shard never decorates with is refused', async () => {
|
||||||
|
const decor = byId('uo.decor.place')
|
||||||
|
|
||||||
|
const ok = await decor.perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'd'.repeat(40),
|
||||||
|
params: { place: 'Felucca/Britain', item: 'Brazier', count: 2 },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(ok.ok, true)
|
||||||
|
assert.equal(ok.resources.length, 2)
|
||||||
|
|
||||||
|
// **The item id crosses, and it has to.** Measured on ServUO 57.4, `Static`
|
||||||
|
// accounts for 5031 decoration placements under 1992 DIFFERENT graphics,
|
||||||
|
// because for that class the graphic is the identity: a bare `new Static()`
|
||||||
|
// is never the paving stone the author picked. 131 of 313 types carry more
|
||||||
|
// than one id.
|
||||||
|
assert.equal(calls.spawn[0].type, 'Brazier')
|
||||||
|
assert.equal(calls.spawn[0].itemId, 0x0e31)
|
||||||
|
|
||||||
|
// Resolving through the atlas is also the boundary: the verb places what this
|
||||||
|
// shard's own decoration files name, which is tighter than "any item that is
|
||||||
|
// not a container" and is the rule the decision actually took.
|
||||||
|
const unknown = await decor.perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'd'.repeat(40),
|
||||||
|
params: { place: 'Felucca/Britain', item: 'BlackrockCrate', count: 1 },
|
||||||
|
verify: false,
|
||||||
|
})
|
||||||
|
assert.equal(unknown.ok, false)
|
||||||
|
assert.equal(unknown.retry, false)
|
||||||
|
assert.match(unknown.error, /never mention/)
|
||||||
|
assert.equal(calls.spawn.length, 1)
|
||||||
|
})
|
||||||
349
server/test/uoEventBorrowed.test.js
Normal file
349
server/test/uoEventBorrowed.test.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
// module-uo's half of protocol 7 part b (EVENTS_PLAN.md Phase 12b).
|
||||||
|
//
|
||||||
|
// What an event BORROWS — five targeted leases over two planes — and the two
|
||||||
|
// one-shots that are neither borrowed nor owned.
|
||||||
|
//
|
||||||
|
// The tests below are the places where the obvious implementation is subtly the
|
||||||
|
// wrong one and nothing would fail if it were written the other way:
|
||||||
|
//
|
||||||
|
// • every callable of a targeted lease must PASS THE TARGET ON. A read that
|
||||||
|
// dropped it would answer about the wrong spawner, and a restore that
|
||||||
|
// dropped it would write a baseline onto one
|
||||||
|
// • a target the shard can no longer read is a REFUSAL at apply time, never a
|
||||||
|
// value: taking the lease anyway records a fictional baseline and later
|
||||||
|
// writes it onto whatever next holds that id
|
||||||
|
// • a target that vanished mid-run is a SUCCESSFUL restore, not a failure —
|
||||||
|
// there is nothing to give back, and reporting it failed leaves a ledger row
|
||||||
|
// unresolved for ever over an object that is gone
|
||||||
|
// • `inForce()` reads the frame's `holds`, which is the only thing that can
|
||||||
|
// answer for a targeted key: there is no list of spawners to walk
|
||||||
|
// • a grant that reached NOBODY is a success, because an event nobody attended
|
||||||
|
// still happened — while a run the shard was never told to count is a 404
|
||||||
|
// • a non-stackable granted in quantity is refused at BOTH ends
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const uoLinkClient = require('../utils/uoLinkClient')
|
||||||
|
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||||
|
require('./_setup')
|
||||||
|
const actions = require('../config/uoEventActions')
|
||||||
|
|
||||||
|
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||||
|
const leaseById = (id) => actions.LEASES.find((l) => l.id === id)
|
||||||
|
|
||||||
|
const STUBBED = ['getLeases', 'applyLease', 'releaseLease', 'grantItem', 'saveWorld']
|
||||||
|
|
||||||
|
let calls
|
||||||
|
let frame
|
||||||
|
const saved = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = { leases: [], apply: [], release: [], grant: [], save: [] }
|
||||||
|
frame = {
|
||||||
|
leases: [{ key: 'Spawner.MaxCount', kind: 'property', current: '3', held: false }],
|
||||||
|
holds: [],
|
||||||
|
}
|
||||||
|
for (const name of STUBBED) saved[name] = uoLinkClient[name]
|
||||||
|
saved.listSpawners = shardAtlas.listSpawners
|
||||||
|
|
||||||
|
uoLinkClient.getLeases = async (q) => {
|
||||||
|
calls.leases.push(q)
|
||||||
|
return { ok: true, status: 200, data: frame }
|
||||||
|
}
|
||||||
|
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.grantItem = async (b) => {
|
||||||
|
calls.grant.push(b)
|
||||||
|
return { ok: true, status: 200, data: { granted: 2, missed: [] } }
|
||||||
|
}
|
||||||
|
uoLinkClient.saveWorld = async (b) => { calls.save.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
shardAtlas.listSpawners = async (opts) => {
|
||||||
|
calls.spawners = opts
|
||||||
|
return [
|
||||||
|
{ uniqueId: 'uid-1', name: 'fel orc fort', facet: 'Felucca', region: 'Britain', maxCount: 9 },
|
||||||
|
{ uniqueId: 'uid-2', name: null, facet: 'Trammel', region: null, landmark: null, maxCount: 1 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const name of STUBBED) uoLinkClient[name] = saved[name]
|
||||||
|
shardAtlas.listSpawners = saved.listSpawners
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The targeted leases ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('every callable carries the target through to the shard', async () => {
|
||||||
|
// The one thing that cannot be got wrong quietly. Core composes the ledger ref
|
||||||
|
// as `<lease id>#<target>` and hands the target back on every call; a callable
|
||||||
|
// that ignored it would read, apply to and restore whichever spawner the shard
|
||||||
|
// happened to answer about, and nothing here or there would report an error.
|
||||||
|
const lease = leaseById('uo.spawner.maxcount')
|
||||||
|
const target = '003f11b8-9bfa-4587-991e-ca263004efe6'
|
||||||
|
|
||||||
|
const read = await lease.read({ target })
|
||||||
|
assert.deepEqual(read, { ok: true, value: '3' })
|
||||||
|
assert.deepEqual(calls.leases[0], { key: 'Spawner.MaxCount', target })
|
||||||
|
|
||||||
|
await lease.apply('30', new Date(Date.now() + 600_000), { target })
|
||||||
|
assert.equal(calls.apply[0].key, 'Spawner.MaxCount')
|
||||||
|
assert.equal(calls.apply[0].target, target)
|
||||||
|
// A DURATION, not the deadline — 11b's rule, unchanged by targeting. A shard
|
||||||
|
// whose clock runs fast would restore an absolute deadline the instant it
|
||||||
|
// took it.
|
||||||
|
assert.ok(calls.apply[0].holdMs > 0 && calls.apply[0].holdMs <= 600_000)
|
||||||
|
|
||||||
|
await lease.restore('3', { expected: '30', target })
|
||||||
|
assert.deepEqual(calls.release[0], {
|
||||||
|
key: 'Spawner.MaxCount',
|
||||||
|
target,
|
||||||
|
expected: '30',
|
||||||
|
baseline: '3',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a target the shard cannot read refuses the lease rather than defaulting', async () => {
|
||||||
|
// The failure this guards is silent and permanent: a lease taken over a
|
||||||
|
// spawner that is not there records whatever came back as the baseline, and
|
||||||
|
// teardown then WRITES that baseline onto whatever next holds the id.
|
||||||
|
frame.leases = [{ key: 'Spawner.MaxCount', unreadable: "nothing on this shard has serial 0x99" }]
|
||||||
|
const refused = await leaseById('uo.spawner.maxcount').read({ target: '0x99' })
|
||||||
|
assert.equal(refused.ok, false)
|
||||||
|
assert.match(refused.error, /nothing on this shard has serial/)
|
||||||
|
|
||||||
|
// A row with neither a value nor a reason is refused too. The shard should
|
||||||
|
// always send one of them, and "it sent neither" must not read as zero.
|
||||||
|
frame.leases = [{ key: 'Spawner.MaxCount' }]
|
||||||
|
const empty = await leaseById('uo.spawner.maxcount').read({ target: 'uid-1' })
|
||||||
|
assert.equal(empty.ok, false)
|
||||||
|
assert.match(empty.error, /could not read/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a target that vanished mid-run is a successful restore, not a failure', async () => {
|
||||||
|
// 12a's `gone` in the lease plane's vocabulary. Somebody deleted the spawner
|
||||||
|
// while the run held it: there is nothing to give back and nothing is owed.
|
||||||
|
// Reported as a failure it would sit in the ledger unresolved for ever, over
|
||||||
|
// an object that no longer exists — and every sweep would try again.
|
||||||
|
uoLinkClient.releaseLease = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { kind: 'lease.ok', released: true, targetGone: true, reason: 'that object has been deleted' },
|
||||||
|
})
|
||||||
|
const done = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
|
||||||
|
assert.deepEqual(done, { ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drift is still drift, and is still not an error', async () => {
|
||||||
|
// Unchanged from 11b and asserted again because targeting rewrote the whole
|
||||||
|
// callable: core records drift as a distinct SUCCESSFUL outcome, so an error
|
||||||
|
// here would put the row on the retry ladder and eventually report the lease
|
||||||
|
// as vanished rather than as somebody having moved it.
|
||||||
|
uoLinkClient.releaseLease = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { kind: 'lease.drifted', current: '12' },
|
||||||
|
})
|
||||||
|
const drifted = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
|
||||||
|
assert.deepEqual(drifted, { ok: false, drifted: true, current: '12' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inForce reads the holds list, which is the only thing that can answer', async () => {
|
||||||
|
// A catalog walk can enumerate the KEYS but never the holds on a targeted one
|
||||||
|
// — there is no list of spawners to walk — so the frame carries every hold the
|
||||||
|
// shard has, and this is what reads it.
|
||||||
|
const lease = leaseById('uo.spawner.maxcount')
|
||||||
|
|
||||||
|
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: false })
|
||||||
|
|
||||||
|
frame.holds = [{ key: 'Spawner.MaxCount', target: 'uid-1', runId: '7' }]
|
||||||
|
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: true })
|
||||||
|
// ...and it is the hold on THIS target, not any hold on the key. A run holding
|
||||||
|
// one spawner must not make every other spawner look leased.
|
||||||
|
assert.deepEqual(await lease.inForce({ target: 'uid-2' }), { ok: true, held: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a shard that cannot answer is never read as "the lease is gone"', async () => {
|
||||||
|
// Core's posture everywhere: "I could not ask" must not be recorded as "it is
|
||||||
|
// gone", because the second orphans the row and stops teardown ever trying.
|
||||||
|
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, data: null })
|
||||||
|
const answer = await leaseById('uo.spawner.maxcount').inForce({ target: 'uid-1' })
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the seasonal lease is a three-value enum over eight events', () => {
|
||||||
|
// §G called `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and
|
||||||
|
// had it backwards: `EventStatus` has three values, `EventType` has nine
|
||||||
|
// entries — and one of those nine is excluded, so it is eight.
|
||||||
|
const lease = leaseById('uo.seasonal.status')
|
||||||
|
assert.equal(lease.type, 'string')
|
||||||
|
assert.deepEqual(lease.values, ['Inactive', 'Active', 'Seasonal'])
|
||||||
|
assert.equal(actions.SEASONAL_EVENTS.length, 8)
|
||||||
|
// TreasuresOfTokuno reads its own era rather than this status, so leasing it
|
||||||
|
// would apply cleanly and change nothing — §N10's "a capability that lies",
|
||||||
|
// and the one instance no runtime probe can catch.
|
||||||
|
assert.ok(!actions.SEASONAL_EVENTS.includes('TreasuresOfTokuno'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every targeted lease bounds what it can hold', () => {
|
||||||
|
// §F requires a range on the numeric types because, unlike a cap, a bad lease
|
||||||
|
// value is in force the moment it is applied. Restated over the five because
|
||||||
|
// they are built by a shared factory: one missing bound would be missing in a
|
||||||
|
// way no single declaration shows.
|
||||||
|
for (const lease of actions.LEASES) {
|
||||||
|
if (lease.id === 'uo.playercaps.skillcap') continue
|
||||||
|
assert.ok(lease.maxDurationMs > 0, `${lease.id} has no duration bound`)
|
||||||
|
if (lease.type === 'int' || lease.type === 'float') {
|
||||||
|
assert.ok(Number.isFinite(lease.min) && Number.isFinite(lease.max), `${lease.id} has no range`)
|
||||||
|
assert.ok(lease.min <= lease.max, `${lease.id} has min above max`)
|
||||||
|
}
|
||||||
|
if (lease.type === 'string') {
|
||||||
|
assert.ok(Array.isArray(lease.values) && lease.values.length, `${lease.id} has no value set`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The spawner source ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the spawner source searches, and says so', async () => {
|
||||||
|
// The first source with more entries than a dropdown holds: 6,707 spawn points
|
||||||
|
// against MAX_OPTIONS' 2,000. A flat list would drop two thirds of the world
|
||||||
|
// and say nothing about which two thirds.
|
||||||
|
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.spawners')
|
||||||
|
assert.equal(source.searchable, true)
|
||||||
|
|
||||||
|
const rows = await source.resolve({ q: 'orc' })
|
||||||
|
assert.equal(calls.spawners.q, 'orc')
|
||||||
|
assert.equal(calls.spawners.limit, actions.SPAWNER_OPTIONS)
|
||||||
|
|
||||||
|
// The value is the UniqueId, because it is the only name for one particular
|
||||||
|
// spawner that exists off the shard.
|
||||||
|
assert.deepEqual(rows[0], { value: 'uid-1', label: 'fel orc fort', group: 'Britain' })
|
||||||
|
// A nameless spawner still answers, labelled by its id. It is still a spawner
|
||||||
|
// somebody may need to turn down, and dropping it would be a dropdown quietly
|
||||||
|
// missing rows again.
|
||||||
|
assert.deepEqual(rows[1], { value: 'uid-2', label: 'uid-2', group: 'Trammel' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The one-shots ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a grant sends a run and never a recipient list', async () => {
|
||||||
|
// The shard has held this run's participation ledger since it opened, keyed by
|
||||||
|
// the same serials core stores as `member_key`. Sending a list would put it on
|
||||||
|
// the wire twice with a window in which the two disagree — and would have
|
||||||
|
// needed a core surface handing a module core's own participants.
|
||||||
|
const out = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'gold', amount: 500, where: 'bank' },
|
||||||
|
})
|
||||||
|
assert.equal(out.ok, true)
|
||||||
|
assert.deepEqual(calls.grant[0], {
|
||||||
|
runId: 7,
|
||||||
|
item: 'gold',
|
||||||
|
amount: 500,
|
||||||
|
hue: undefined,
|
||||||
|
name: undefined,
|
||||||
|
where: 'bank',
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
})
|
||||||
|
assert.equal(out.detail.granted, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a grant that reached nobody is a success', async () => {
|
||||||
|
// An event nobody attended still happened. Reported as a failure the run would
|
||||||
|
// retry against a ledger that will be just as empty next time, and pause. The
|
||||||
|
// shard draws the distinction that matters: a run it was never told to count
|
||||||
|
// is a 404, which fails below.
|
||||||
|
uoLinkClient.grantItem = async () => ({ ok: true, status: 200, data: { granted: 0, missed: [] } })
|
||||||
|
const out = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'gold', amount: 1 },
|
||||||
|
})
|
||||||
|
assert.equal(out.ok, true)
|
||||||
|
assert.equal(out.detail.granted, 0)
|
||||||
|
|
||||||
|
uoLinkClient.grantItem = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
data: { reason: 'run 7 has no participation ledger open on this shard' },
|
||||||
|
})
|
||||||
|
const missing = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'gold', amount: 1 },
|
||||||
|
})
|
||||||
|
assert.equal(missing.ok, false)
|
||||||
|
// 404 is permanent: the ledger will not appear because we asked again.
|
||||||
|
assert.equal(missing.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a non-stackable granted in quantity is refused before the wire', async () => {
|
||||||
|
// Five cloaks would be five items — five chances to overflow a backpack
|
||||||
|
// halfway through with no way to say which half landed. Refused here so the
|
||||||
|
// author sees it on the form, and refused again on the shard because this copy
|
||||||
|
// of the allowlist is the one that can be wrong.
|
||||||
|
const out = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'cloak', amount: 3 },
|
||||||
|
})
|
||||||
|
assert.equal(out.ok, false)
|
||||||
|
assert.equal(out.retry, false)
|
||||||
|
assert.match(out.error, /does not stack/)
|
||||||
|
assert.equal(calls.grant.length, 0)
|
||||||
|
|
||||||
|
const unknown = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'castle', amount: 1 },
|
||||||
|
})
|
||||||
|
assert.equal(unknown.ok, false)
|
||||||
|
assert.equal(unknown.retry, false)
|
||||||
|
assert.equal(calls.grant.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a grant is retryable, and protocol 6 is the reason', async () => {
|
||||||
|
// §G called a grant un-retryable because a lost acknowledgement and a grant
|
||||||
|
// that never applied were the same event — the argument that made
|
||||||
|
// `uo.broadcast` answer `retry: false` in Phase 9. An idempotency key closes
|
||||||
|
// it: a repeat is answered by the original reply, so a retried grant cannot be
|
||||||
|
// one winner receiving two.
|
||||||
|
uoLinkClient.grantItem = async () => ({ ok: false, status: 503, data: null })
|
||||||
|
const out = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'gold', amount: 1 },
|
||||||
|
})
|
||||||
|
assert.equal(out.ok, false)
|
||||||
|
assert.notEqual(out.retry, false)
|
||||||
|
// And the action declares itself irreversible, which is the honest class: the
|
||||||
|
// world is altered and cannot be put back.
|
||||||
|
assert.equal(byId('uo.item.grant').risk, 'irreversible')
|
||||||
|
assert.equal(byId('uo.item.grant').reversible, 'none')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a save refused for coming too soon is retried, not abandoned', async () => {
|
||||||
|
// 429 is the shard's rate limit and is the one refusal on this plane that
|
||||||
|
// waiting fixes. It is deliberately not in PERMANENT_STATUSES, so a phase
|
||||||
|
// boundary is retried rather than dropped.
|
||||||
|
assert.ok(!actions.PERMANENT_STATUSES.has(429))
|
||||||
|
uoLinkClient.saveWorld = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
data: { reason: 'this shard saves at most every 300 seconds, and the last save was 12 seconds ago' },
|
||||||
|
})
|
||||||
|
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
|
||||||
|
assert.equal(out.ok, false)
|
||||||
|
assert.notEqual(out.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a save reports only that it started', async () => {
|
||||||
|
// What actually happened rides `world.save.before`/`after` on the event stream.
|
||||||
|
// Asserting anything more here would be asserting something the reply does not
|
||||||
|
// know.
|
||||||
|
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
|
||||||
|
assert.deepEqual(out, { ok: true, detail: { started: true } })
|
||||||
|
assert.deepEqual(calls.save[0], { idempotencyKey: 'k' })
|
||||||
|
})
|
||||||
382
server/test/uoEventLeaseParticipation.test.js
Normal file
382
server/test/uoEventLeaseParticipation.test.js
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
// module-uo's half of protocol 6 part b (EVENTS_PLAN.md Phase 11b).
|
||||||
|
//
|
||||||
|
// One lease and two participation verbs. What is worth asserting here is not that
|
||||||
|
// the calls happen — a rig proves that better — but the handful of places where
|
||||||
|
// the obvious implementation is subtly the wrong one, and where nothing would fail
|
||||||
|
// if it were written the other way:
|
||||||
|
//
|
||||||
|
// • a lease's `restore()` must turn `lease.drifted` into `{ drifted: true }`
|
||||||
|
// rather than an error, because core records drift as a distinct SUCCESSFUL
|
||||||
|
// outcome and an error would put the row on the retry ladder instead
|
||||||
|
// • `inForce()` must not be a comparison against `read()` — a changed value is
|
||||||
|
// drift, which teardown reports, and orphaning the row first destroys it
|
||||||
|
// • `apply()` must send a DURATION, not the deadline, or a shard whose clock is
|
||||||
|
// fast restores the lease the instant it takes it
|
||||||
|
// • `uo.participation.open` must NOT reconcile by boot stamp, which every other
|
||||||
|
// resource in this module does — the ledger is persisted in the world save
|
||||||
|
// precisely so that it survives the restart the stamp would report it lost by
|
||||||
|
// • a `userId` is a foreign key and a character serial is not, so an unresolved
|
||||||
|
// one is undefined rather than coerced
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const uoLinkClient = require('../utils/uoLinkClient')
|
||||||
|
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||||
|
require('./_setup')
|
||||||
|
const actions = require('../config/uoEventActions')
|
||||||
|
|
||||||
|
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||||
|
const lease = () => actions.LEASES.find((l) => l.id === 'uo.playercaps.skillcap')
|
||||||
|
|
||||||
|
const STUBBED = [
|
||||||
|
'getLeases',
|
||||||
|
'applyLease',
|
||||||
|
'releaseLease',
|
||||||
|
'openParticipation',
|
||||||
|
'snapshotParticipation',
|
||||||
|
'closeParticipation',
|
||||||
|
]
|
||||||
|
|
||||||
|
let calls
|
||||||
|
const saved = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = { apply: [], release: [], open: [], snapshot: [], close: [] }
|
||||||
|
for (const name of STUBBED) saved[name] = uoLinkClient[name]
|
||||||
|
saved.listLandmarks = shardAtlas.listLandmarks
|
||||||
|
|
||||||
|
uoLinkClient.getLeases = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
|
||||||
|
})
|
||||||
|
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.openParticipation = async (b) => { calls.open.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.snapshotParticipation = async (b) => {
|
||||||
|
calls.snapshot.push(b)
|
||||||
|
return { ok: true, status: 200, data: { participants: [] } }
|
||||||
|
}
|
||||||
|
uoLinkClient.closeParticipation = async (b) => { calls.close.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
|
||||||
|
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const name of STUBBED) uoLinkClient[name] = saved[name]
|
||||||
|
shardAtlas.listLandmarks = saved.listLandmarks
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The lease ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the lease satisfies the shape core validates it with', () => {
|
||||||
|
const l = lease()
|
||||||
|
assert.ok(l.id.startsWith('uo.'), 'a lease is namespaced to its module')
|
||||||
|
assert.ok(l.label && l.description)
|
||||||
|
assert.equal(l.type, 'float')
|
||||||
|
// Required for the numeric types, and unlike a cap a bad lease value is in
|
||||||
|
// force the moment it is applied.
|
||||||
|
assert.ok(Number.isFinite(l.min) && Number.isFinite(l.max) && l.min < l.max)
|
||||||
|
assert.ok(Number.isInteger(l.maxDurationMs) && l.maxDurationMs > 0)
|
||||||
|
for (const fn of ['read', 'apply', 'restore', 'inForce']) {
|
||||||
|
assert.equal(typeof l[fn], 'function', `a lease needs ${fn}()`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply sends a DURATION, because a deadline is measured against two clocks', async () => {
|
||||||
|
const until = new Date(Date.now() + 90 * 60_000)
|
||||||
|
const answer = await lease().apply(1200, until)
|
||||||
|
|
||||||
|
assert.equal(answer.ok, true)
|
||||||
|
const sent = calls.apply[0]
|
||||||
|
// The number the shard arms its timer off. Computed here from the deadline, so
|
||||||
|
// a shard running ten minutes fast holds the lease for ninety minutes of its
|
||||||
|
// own time rather than restoring it the instant it takes it.
|
||||||
|
assert.ok(Math.abs(sent.holdMs - 90 * 60_000) < 2000, `holdMs was ${sent.holdMs}`)
|
||||||
|
// And the absolute time still rides along, for a console that wants to say when
|
||||||
|
// the hold ends in terms the operator's own clock agrees with.
|
||||||
|
assert.equal(sent.untilMs, until.getTime())
|
||||||
|
// The action hands the value on unchanged; `uoLinkClient.applyLease` is what
|
||||||
|
// renders it as TEXT, which is the wire's contract for every lease type: `1200`
|
||||||
|
// and `1200.0` are one number to a JSON parser and two different strings to a
|
||||||
|
// compare-and-set.
|
||||||
|
assert.equal(sent.value, 1200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a deadline that has already passed is refused rather than sent as a negative hold', async () => {
|
||||||
|
const answer = await lease().apply(1200, new Date(Date.now() - 60_000))
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.match(answer.error, /already passed/)
|
||||||
|
assert.equal(calls.apply.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drift comes back as drifted, not as an error', async () => {
|
||||||
|
// The distinction core acts on. `cleanup.js` records `drifted` as its own
|
||||||
|
// outcome — the module did exactly what it was asked and found somebody else's
|
||||||
|
// value in place — while an error would put the row on the retry ladder and
|
||||||
|
// eventually spend its attempts on a situation only a human can resolve.
|
||||||
|
uoLinkClient.releaseLease = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { kind: 'lease.drifted', key: 'PlayerCaps.SkillCap', current: '1300' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const answer = await lease().restore('1000', { expected: '1200' })
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.drifted, true)
|
||||||
|
assert.equal(answer.current, '1300')
|
||||||
|
assert.equal(answer.error, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restore sends both what it applied and what to put back', async () => {
|
||||||
|
await lease().restore('1000', { expected: '1200' })
|
||||||
|
// Core's `restore(baseline, { expected })` carries no key of its own -- teardown
|
||||||
|
// is core's own sweep rather than a step dispatch -- so neither does this.
|
||||||
|
assert.deepEqual(calls.release[0], {
|
||||||
|
key: 'PlayerCaps.SkillCap',
|
||||||
|
expected: '1200',
|
||||||
|
baseline: '1000',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inForce asks whether the shard still HOLDS it, not whether the value still matches', async () => {
|
||||||
|
// The reason this callable exists at all. A shard reporting a value that is not
|
||||||
|
// what the run applied is reporting DRIFT, which teardown delivers through
|
||||||
|
// `restore()` so the ledger row lands `drifted` with the current value beside
|
||||||
|
// it. Answering "not in force" here would orphan the row first and tell the
|
||||||
|
// operator the lease vanished rather than that somebody moved it.
|
||||||
|
uoLinkClient.getLeases = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1300', held: true }] },
|
||||||
|
})
|
||||||
|
assert.deepEqual(await lease().inForce(), { ok: true, held: true })
|
||||||
|
|
||||||
|
// And a shard that restarted: a config lease is memory-only there by design, so
|
||||||
|
// the value is back at baseline AND the record is gone. This is the case core
|
||||||
|
// could not see before this phase.
|
||||||
|
uoLinkClient.getLeases = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
|
||||||
|
})
|
||||||
|
assert.deepEqual(await lease().inForce(), { ok: true, held: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a shard that cannot answer leaves the ledger alone', async () => {
|
||||||
|
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, error: 'shard not connected' })
|
||||||
|
const answer = await lease().inForce()
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
// `ok: false` is what core reads as "I could not ask", and it keeps believing
|
||||||
|
// its own ledger. Never `held: false`, which would orphan a live lease the
|
||||||
|
// first time a sidecar was slow.
|
||||||
|
assert.equal(answer.held, undefined)
|
||||||
|
assert.equal((await lease().read()).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Participation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('open resolves a named place to the point the shard counts around', async () => {
|
||||||
|
const answer = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Britain', radius: 40, durationMinutes: 120 },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(answer.ok, true)
|
||||||
|
assert.deepEqual(calls.open[0], {
|
||||||
|
runId: 42,
|
||||||
|
map: 'Felucca',
|
||||||
|
x: 1496,
|
||||||
|
y: 1628,
|
||||||
|
radius: 40,
|
||||||
|
holdMs: 7_200_000,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
})
|
||||||
|
assert.deepEqual(answer.resources, [
|
||||||
|
{ kind: 'participation', ref: '42', payload: { runId: 42, place: 'Felucca/Britain', radius: 40 } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a place the atlas does not know is a refusal an author can read, not a retry', async () => {
|
||||||
|
const answer = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Atlantis', radius: 40 },
|
||||||
|
})
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.retry, false)
|
||||||
|
assert.match(answer.error, /no landmark called "Atlantis"/)
|
||||||
|
assert.equal(calls.open.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an area outside the bound is refused before anything is sent', async () => {
|
||||||
|
for (const radius of [0, -1, actions.MAX_AREA_RADIUS + 1, 1.5]) {
|
||||||
|
const answer = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Britain', radius },
|
||||||
|
})
|
||||||
|
assert.equal(answer.ok, false, String(radius))
|
||||||
|
assert.equal(answer.retry, false, String(radius))
|
||||||
|
}
|
||||||
|
assert.equal(calls.open.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dry run checks the place and the radius and opens nothing', async () => {
|
||||||
|
const good = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Britain', radius: 40 },
|
||||||
|
verify: true,
|
||||||
|
})
|
||||||
|
assert.deepEqual(good, { ok: true })
|
||||||
|
assert.equal(calls.open.length, 0)
|
||||||
|
|
||||||
|
// And it is a real check rather than an unconditional yes: the failure an
|
||||||
|
// author most wants caught before the night of the event is a place that is not
|
||||||
|
// on this shard's map.
|
||||||
|
const bad = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Atlantis', radius: 40 },
|
||||||
|
verify: true,
|
||||||
|
})
|
||||||
|
assert.equal(bad.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the ledger is NOT reconciled by boot stamp, unlike everything else here', async () => {
|
||||||
|
// The phase's one genuine divergence from wave 1. `reconcileByBootId` works
|
||||||
|
// because a crier line and a news article live in shard memory, so a changed
|
||||||
|
// `bootId` IS the proof they are gone. A participation ledger is written into
|
||||||
|
// the world save specifically so that it survives a restart — reporting it lost
|
||||||
|
// on a boot change would orphan the one resource the phase persisted.
|
||||||
|
const open = byId('uo.participation.open')
|
||||||
|
assert.notEqual(open.reconcile, actions.reconcileByBootId)
|
||||||
|
// No stamp on the resource either, so nothing downstream can be tempted to
|
||||||
|
// compare one.
|
||||||
|
const answer = await open.perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Britain', radius: 40 },
|
||||||
|
})
|
||||||
|
assert.equal(answer.resources[0].payload.bootId, undefined)
|
||||||
|
|
||||||
|
// It asks instead, and only an explicit 404 takes a row out.
|
||||||
|
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
|
||||||
|
|
||||||
|
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 404, data: {} })
|
||||||
|
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: [] })
|
||||||
|
|
||||||
|
// A shard that is down has not said the ledger is gone.
|
||||||
|
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 503, data: {} })
|
||||||
|
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a run the shard has already forgotten is a successful revert', async () => {
|
||||||
|
// §L: "gone, and that is fine". A shard that restarted past its grace window,
|
||||||
|
// or a second teardown attempt, must not leave a row failing forever.
|
||||||
|
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 404, data: {} })
|
||||||
|
assert.deepEqual(await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }), { ok: true })
|
||||||
|
|
||||||
|
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 503, data: {} })
|
||||||
|
assert.deepEqual(
|
||||||
|
await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }),
|
||||||
|
{ ok: true, failed: ['42'] },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collect files the tally as participants, keyed by character serial', async () => {
|
||||||
|
uoLinkClient.snapshotParticipation = async (b) => {
|
||||||
|
calls.snapshot.push(b)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
serial: '0x400150E8',
|
||||||
|
name: 'Darrow',
|
||||||
|
acct: 'seed_001',
|
||||||
|
webId: '17',
|
||||||
|
seconds: 3600,
|
||||||
|
minutes: '60.00',
|
||||||
|
kills: 3,
|
||||||
|
score: '75.0000',
|
||||||
|
firstMs: 1788550182074,
|
||||||
|
},
|
||||||
|
// No account link: the shard reports no webId, and there is nothing to
|
||||||
|
// resolve. Most characters are this one.
|
||||||
|
{
|
||||||
|
serial: '0x1',
|
||||||
|
name: 'Nobody',
|
||||||
|
seconds: 60,
|
||||||
|
minutes: '1.00',
|
||||||
|
kills: 0,
|
||||||
|
score: '1.0000',
|
||||||
|
firstMs: 1788550182074,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||||
|
|
||||||
|
assert.equal(answer.ok, true)
|
||||||
|
assert.equal(calls.snapshot[0].idempotencyKey, 'k-2')
|
||||||
|
assert.deepEqual(answer.participants.map((p) => p.memberKey), ['0x400150E8', '0x1'])
|
||||||
|
// The one field core will not take on trust: it is a foreign key into `users`,
|
||||||
|
// so a serial passed here would either fail the insert or attribute somebody's
|
||||||
|
// attendance to a stranger.
|
||||||
|
assert.equal(answer.participants[0].userId, 17)
|
||||||
|
assert.equal(answer.participants[1].userId, undefined)
|
||||||
|
// The score is opaque to core; the components are carried so a results table
|
||||||
|
// can say why somebody scored what they did.
|
||||||
|
assert.deepEqual(answer.participants[0].meta, {
|
||||||
|
name: 'Darrow', seconds: 3600, minutes: '60.00', kills: 3,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a webId that is not a positive integer resolves to nothing at all', () => {
|
||||||
|
for (const bad of [null, undefined, '', 'abc', '0', '-3', '1.5', {}]) {
|
||||||
|
assert.equal(actions.webUserId(bad), undefined, JSON.stringify(bad))
|
||||||
|
}
|
||||||
|
assert.equal(actions.webUserId('17'), 17)
|
||||||
|
assert.equal(actions.webUserId(17), 17)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a busy shard is retried, because the work is happening', async () => {
|
||||||
|
// 425 is `bridge.busy`: a snapshot of this run is already walking across Core
|
||||||
|
// ticks. Transient by construction, and deliberately not in PERMANENT_STATUSES.
|
||||||
|
uoLinkClient.snapshotParticipation = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 425,
|
||||||
|
data: { kind: 'bridge.busy', reason: 'a command under this key is in flight' },
|
||||||
|
})
|
||||||
|
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.retry, true)
|
||||||
|
|
||||||
|
// Where the event plane simply being switched off is not: 403 is an operator's
|
||||||
|
// deliberate refusal and will still be true in sixty seconds.
|
||||||
|
uoLinkClient.snapshotParticipation = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
data: { kind: 'participation.error', reason: 'the event plane is disabled on this shard' },
|
||||||
|
})
|
||||||
|
const off = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||||
|
assert.equal(off.retry, false)
|
||||||
|
// And the shard's own words reach the run log, because for an event that ran at
|
||||||
|
// four in the morning that log is the only place anyone will learn why.
|
||||||
|
assert.match(off.error, /event plane is disabled/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dry run of collect reads nothing', async () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2', verify: true }),
|
||||||
|
{ ok: true },
|
||||||
|
)
|
||||||
|
assert.equal(calls.snapshot.length, 0)
|
||||||
|
})
|
||||||
@@ -662,6 +662,53 @@ const MAPPERS = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Protocol 6. A boss defeat, which until now could only be GUESSED at from
|
||||||
|
// `champ.update` losing its `bossUp` — a signal that also fires when a spawn is
|
||||||
|
// reset by a GM, when a boss despawns, and when the sweep simply reconnects.
|
||||||
|
// This one fires on the death itself.
|
||||||
|
//
|
||||||
|
// **The subject is the SPAWN, so it matches `uo.champ.boss_up`'s.** A rule with
|
||||||
|
// a cooldown on one altar therefore counts a boss going up and that same boss
|
||||||
|
// coming down as the same subject, which is what an operator writing "not more
|
||||||
|
// than once an hour about Destard" means. A kill the shard could not attribute
|
||||||
|
// to an altar carries no spawn, so the boss's own serial stands in — it is a
|
||||||
|
// subject that exists exactly once, which is all a cooldown needs of it.
|
||||||
|
//
|
||||||
|
// **Damagers are not surfaced as variables.** The table is on the frame and it
|
||||||
|
// is `staff` in the visibility config; putting names into a trigger's data
|
||||||
|
// would route them into mail an operator can address to `subscribers`, which is
|
||||||
|
// the field rule undone one layer up. `damagerCount` is a number and says the
|
||||||
|
// thing worth saying: how many took part.
|
||||||
|
'champ.boss.killed': (ev, tracker, out) => {
|
||||||
|
const spawnSerial = ev.serial == null ? null : String(ev.serial)
|
||||||
|
const bossSerial = ev.bossSerial == null ? null : String(ev.bossSerial)
|
||||||
|
const subject = spawnSerial || bossSerial
|
||||||
|
if (!subject) return
|
||||||
|
|
||||||
|
// The board no longer has a boss on this altar. Kept in step with the sweep's
|
||||||
|
// own view so the next `champ.update` carrying `bossUp: true` is read as a
|
||||||
|
// transition rather than as more of the same.
|
||||||
|
if (spawnSerial) tracker.champBossUp.set(spawnSerial, false)
|
||||||
|
|
||||||
|
const damagers = Array.isArray(ev.damagers) ? ev.damagers : []
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
triggerId: 'uo.champ.boss_killed',
|
||||||
|
data: defined({
|
||||||
|
spawnSerial: subject,
|
||||||
|
champsUrl: PATHS.champs,
|
||||||
|
bossName: ev.boss || ev.bossType || 'the champion',
|
||||||
|
category: ev.category || undefined,
|
||||||
|
location: place(ev),
|
||||||
|
atPlace: trailing(place(ev), (p) => ` at ${p}`),
|
||||||
|
killerName: actorName(ev.killer),
|
||||||
|
damagerCount: damagers.length || undefined,
|
||||||
|
damagerNote: trailing(damagers.length || null, (n) =>
|
||||||
|
n === 1 ? ' One player fought it.' : ` ${n} players fought it.`),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
'champ.remove': (ev, tracker) => {
|
'champ.remove': (ev, tracker) => {
|
||||||
if (ev.serial == null) return
|
if (ev.serial == null) return
|
||||||
tracker.champActive.delete(String(ev.serial))
|
tracker.champActive.delete(String(ev.serial))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const shardStateModel = require('../model/shardState/shardState.model')
|
|||||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||||
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
|
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
|
||||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const { settings: settingsModel } = require('../core')
|
const { settings: settingsModel, events: coreEvents } = require('../core')
|
||||||
const broadcaster = require('./shardBroadcast')
|
const broadcaster = require('./shardBroadcast')
|
||||||
const shardPush = require('./shardPush')
|
const shardPush = require('./shardPush')
|
||||||
const shardEngagement = require('./shardEngagement')
|
const shardEngagement = require('./shardEngagement')
|
||||||
@@ -103,11 +103,12 @@ async function resolveShardName(shard, deps) {
|
|||||||
|
|
||||||
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
||||||
async function applyStateChange(event, deps) {
|
async function applyStateChange(event, deps) {
|
||||||
const { shardState, uoLinkConfig, log } = deps
|
const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case 'server.hello': {
|
case 'server.hello': {
|
||||||
const incoming = event.bootId || null
|
const incoming = event.bootId || null
|
||||||
if (incoming && state.bootId && incoming !== state.bootId) {
|
const restarted = Boolean(incoming && state.bootId && incoming !== state.bootId)
|
||||||
|
if (restarted) {
|
||||||
log.warn('shard restarted (bootId changed) — clearing online roster', {
|
log.warn('shard restarted (bootId changed) — clearing online roster', {
|
||||||
from: state.bootId,
|
from: state.bootId,
|
||||||
to: incoming,
|
to: incoming,
|
||||||
@@ -116,6 +117,31 @@ async function applyStateChange(event, deps) {
|
|||||||
}
|
}
|
||||||
if (incoming) state.bootId = incoming
|
if (incoming) state.bootId = incoming
|
||||||
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
|
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
|
||||||
|
if (restarted && !fromBackfill) {
|
||||||
|
// EVENTS.md F: core has no concept of the game being up, so the module
|
||||||
|
// says when a ledger of live shard resources has become a claim about a
|
||||||
|
// world that no longer exists. This is that moment, and a changed
|
||||||
|
// `bootId` is the only thing that distinguishes it from a sidecar
|
||||||
|
// reconnect — which changes nothing in the game and must not orphan a row.
|
||||||
|
//
|
||||||
|
// **After `recordStatus`, and that ordering is load-bearing.** Every
|
||||||
|
// action's `reconcile()` decides what is still in force by comparing its
|
||||||
|
// stamp against the CURRENT boot id, which it reads back out of this
|
||||||
|
// row. Asking first would have every resource compared against the boot
|
||||||
|
// that has just ended, and every one of them would look live.
|
||||||
|
//
|
||||||
|
// **And never on a backfill replay**, which is the same rule the
|
||||||
|
// engagement fan-out and the SSE broadcast state below and is far more
|
||||||
|
// expensive to break here. A reconnect replays the last several
|
||||||
|
// `server.hello` frames in order — this rig saw three, each with a
|
||||||
|
// different `bootId` — so every replayed frame looks like a restart, and
|
||||||
|
// the intermediate ones would compare a resource stamped with the CURRENT
|
||||||
|
// boot against a boot that ended hours ago. The row is then `orphaned`:
|
||||||
|
// a live crier line core will never take down again, lost to nothing
|
||||||
|
// worse than the website reconnecting. The website-was-down case is not
|
||||||
|
// missed by skipping these — core asks every module at its own boot.
|
||||||
|
eventsReconcile()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'server.shutdown':
|
case 'server.shutdown':
|
||||||
@@ -292,6 +318,15 @@ function resolveDeps(deps) {
|
|||||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||||
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
||||||
engagement: deps.engagement || shardEngagement.fromShardEvent,
|
engagement: deps.engagement || shardEngagement.fromShardEvent,
|
||||||
|
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). Injectable for the same reason
|
||||||
|
// every member above is: a test that asserted a shard restart triggers a
|
||||||
|
// reconcile must be able to see the call without a live event engine behind
|
||||||
|
// it.
|
||||||
|
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()),
|
||||||
|
// Not injectable — it is the caller's statement about this frame rather than
|
||||||
|
// a dependency. It reaches `applyStateChange` because the reconcile below is
|
||||||
|
// the one state change that must not act on a replay; see the note there.
|
||||||
|
fromBackfill: Boolean(deps.fromBackfill),
|
||||||
log: deps.log || defaultLog,
|
log: deps.log || defaultLog,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,27 @@ const FEATURES = {
|
|||||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||||
status: { audience: 'anonymous', fields: {} },
|
status: { audience: 'anonymous', fields: {} },
|
||||||
activity: { audience: 'anonymous', fields: {} },
|
activity: { audience: 'anonymous', fields: {} },
|
||||||
champs: { audience: 'anonymous', fields: {} },
|
// Protocol 6 adds `champ.boss.killed` to this feature, and with it the first
|
||||||
|
// field on a champs frame that is about PEOPLE rather than about an altar.
|
||||||
|
//
|
||||||
|
// `damagers` is the ranked table of who fought the boss and for how much. It is
|
||||||
|
// the honest basis for "who slew the champion" and it is also a performance
|
||||||
|
// record of named players that nobody consented to publish, which is precisely
|
||||||
|
// the tension the ladder exists to let a shard resolve for itself. It defaults
|
||||||
|
// to `staff`: the kill is public (a champion falling is announced in-world and
|
||||||
|
// is the content the board is for), the roll of who did the damage is not. A
|
||||||
|
// shard that wants a public board lowers one rule.
|
||||||
|
//
|
||||||
|
// Nested for the same reason `market.fees` and `houses.schedule` are: one rule
|
||||||
|
// covers the whole table rather than a rule per column, and the columns here
|
||||||
|
// are actor objects whose `acct`/`webId` remain admin-only by the locked-field
|
||||||
|
// rule regardless of what this is set to.
|
||||||
|
//
|
||||||
|
// `killer` is deliberately NOT listed. It is the single actor whose blow landed
|
||||||
|
// last, it is announced in-game to everyone present, and it is the same shape
|
||||||
|
// and the same disclosure `mob.killed` has published on the public activity
|
||||||
|
// feed since before this framework existed.
|
||||||
|
champs: { audience: 'anonymous', fields: { damagers: 'staff' } },
|
||||||
guilds: { audience: 'anonymous', fields: {} },
|
guilds: { audience: 'anonymous', fields: {} },
|
||||||
governors: { audience: 'anonymous', fields: {} },
|
governors: { audience: 'anonymous', fields: {} },
|
||||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||||
@@ -189,6 +209,10 @@ const KIND_FEATURE = new Map(
|
|||||||
// boards
|
// boards
|
||||||
'champ.update': 'champs',
|
'champ.update': 'champs',
|
||||||
'champ.remove': 'champs',
|
'champ.remove': 'champs',
|
||||||
|
// Protocol 6. Without this line rule 2 would fail the new kind closed to
|
||||||
|
// admin-only — correct as a default, and wrong as an outcome: a champion
|
||||||
|
// falling is exactly what the public board is for.
|
||||||
|
'champ.boss.killed': 'champs',
|
||||||
'guild.update': 'guilds',
|
'guild.update': 'guilds',
|
||||||
'guild.remove': 'guilds',
|
'guild.remove': 'guilds',
|
||||||
'guild.join': 'guilds',
|
'guild.join': 'guilds',
|
||||||
@@ -222,6 +246,14 @@ const KIND_FEATURE = new Map(
|
|||||||
// needs it live. An admin can turn it on.
|
// needs it live. An admin can turn it on.
|
||||||
'vendor.listing': 'market',
|
'vendor.listing': 'market',
|
||||||
'vendor.listing.remove': 'market',
|
'vendor.listing.remove': 'market',
|
||||||
|
// Protocol 6 part b's `lease.applied` and `lease.expired` are deliberately NOT
|
||||||
|
// here, on the same reasoning that keeps `account.login.result` off it. They are
|
||||||
|
// operational frames about the WEBSITE changing this shard's configuration --
|
||||||
|
// which key, from what to what, on whose run, and whether the shard's own
|
||||||
|
// deadline had to put it back because nobody asked. Rule 2 fails an unmapped
|
||||||
|
// kind closed to admin-only, which is where an audit trail of the site's writes
|
||||||
|
// belongs; mapping them would mean choosing a feature an operator could then
|
||||||
|
// widen, and there is no rung below admin these frames belong on.
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -351,10 +351,17 @@ function tagValue(block, name) {
|
|||||||
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
||||||
* flat, so a per-record regex sweep is both correct and cheap.
|
* flat, so a per-record regex sweep is both correct and cheap.
|
||||||
*
|
*
|
||||||
* Only the fields the site can actually show are kept. Everything to do with
|
* Only the fields the site can actually use are kept. Everything to do with
|
||||||
* triggering, refractory windows, proximity, sequential spawning, sounds and
|
* triggering, refractory windows, proximity, sequential spawning and sounds is
|
||||||
* `UniqueId` is dropped here rather than downstream — that is what holds the
|
* dropped here rather than downstream, which is what keeps the parsed atlas
|
||||||
* committed artifact under 1 MB.
|
* small.
|
||||||
|
*
|
||||||
|
* **`UniqueId` was on that list until Phase 12b and is now kept**, because a
|
||||||
|
* property lease has to name one particular spawner and this is the only name
|
||||||
|
* for one that exists off-shard. The line that justified dropping it cited a
|
||||||
|
* committed artifact; there is no committed artifact — `spawnAtlasSource.js`
|
||||||
|
* says so in its own header ("nothing is precomputed and committed") — so the
|
||||||
|
* only real cost was ~37 bytes a row in a table, and it bought a dropdown.
|
||||||
*
|
*
|
||||||
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
||||||
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||||
@@ -389,6 +396,14 @@ function parsePoints(source) {
|
|||||||
|
|
||||||
points.push({
|
points.push({
|
||||||
name: tagValue(block, 'Name'),
|
name: tagValue(block, 'Name'),
|
||||||
|
// **Kept from Phase 12b, having been discarded since the atlas shipped.**
|
||||||
|
// It is `XmlSpawner.UniqueId` — the shard writes it into the spawn files
|
||||||
|
// and carries it on the live spawner — so it is the ONE way an authoring
|
||||||
|
// form can name a particular spawner without the shard being up. A serial
|
||||||
|
// cannot do that job: serials are assigned when the world is built and
|
||||||
|
// nothing off-shard knows them, which is why a property lease that could
|
||||||
|
// only be addressed by serial could have no dropdown at all.
|
||||||
|
uniqueId: tagValue(block, 'UniqueId'),
|
||||||
facet,
|
facet,
|
||||||
x: toInt(tagValue(block, 'X')),
|
x: toInt(tagValue(block, 'X')),
|
||||||
y: toInt(tagValue(block, 'Y')),
|
y: toInt(tagValue(block, 'Y')),
|
||||||
@@ -548,6 +563,48 @@ function walkLocations(node, facet, path, out) {
|
|||||||
* A spawn with no `type` is randomised on every activation, which the site must
|
* A spawn with no `type` is randomised on every activation, which the site must
|
||||||
* render as "random" rather than as an empty type.
|
* render as "random" rather than as an empty type.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Item types a shard uses as decoration, from one `Data/Decoration/*.cfg`.
|
||||||
|
*
|
||||||
|
* The format is a header line naming a type and an item id, optionally followed
|
||||||
|
* by a parenthesised property list, and then one `x y z` line per placement:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* # switch
|
||||||
|
* Static 0x108F
|
||||||
|
* 5552 1864 11
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Only the header matters here. The properties are decoration-authoring details
|
||||||
|
* (`Hue=`, `Facing=`, `Name=`) and the coordinates are where the SHARD put its
|
||||||
|
* own scenery, neither of which an event author is choosing — they pick a type
|
||||||
|
* and a place of their own.
|
||||||
|
*
|
||||||
|
* Returns one entry per header line, not per distinct type: the same type
|
||||||
|
* appears under many item ids (a `BarredMetalDoor` for each facing), and how
|
||||||
|
* often a shard reaches for something is worth keeping. `spawnAtlasSource`
|
||||||
|
* aggregates.
|
||||||
|
*/
|
||||||
|
function parseDecoration(source) {
|
||||||
|
const out = []
|
||||||
|
if (!source) return out
|
||||||
|
|
||||||
|
for (const raw of String(source).split(/\r?\n/)) {
|
||||||
|
const line = raw.trim()
|
||||||
|
|
||||||
|
// A coordinate line starts with a digit or a minus (z is often negative),
|
||||||
|
// so the type test is not merely "not a comment".
|
||||||
|
if (line === '' || line.startsWith('#')) continue
|
||||||
|
|
||||||
|
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s+0x([0-9A-Fa-f]+)/.exec(line)
|
||||||
|
if (!match) continue
|
||||||
|
|
||||||
|
out.push({ type: match[1], itemId: parseInt(match[2], 16) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function parseChampions(source) {
|
function parseChampions(source) {
|
||||||
const root = parseXml(source)
|
const root = parseXml(source)
|
||||||
const champions = []
|
const champions = []
|
||||||
@@ -675,6 +732,7 @@ module.exports = {
|
|||||||
parseRegions,
|
parseRegions,
|
||||||
parseLocations,
|
parseLocations,
|
||||||
parseChampions,
|
parseChampions,
|
||||||
|
parseDecoration,
|
||||||
buildPlacementIndex,
|
buildPlacementIndex,
|
||||||
resolveRegion,
|
resolveRegion,
|
||||||
facetKey,
|
facetKey,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const {
|
|||||||
parseRegions,
|
parseRegions,
|
||||||
parseLocations,
|
parseLocations,
|
||||||
parseChampions,
|
parseChampions,
|
||||||
|
parseDecoration,
|
||||||
buildPlacementIndex,
|
buildPlacementIndex,
|
||||||
buildFacetIndex,
|
buildFacetIndex,
|
||||||
resolveFacetName,
|
resolveFacetName,
|
||||||
@@ -35,6 +36,7 @@ const REGIONS_FILE = path.join('Data', 'Regions.xml')
|
|||||||
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
||||||
const SPAWNS_DIR = 'Spawns'
|
const SPAWNS_DIR = 'Spawns'
|
||||||
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
||||||
|
const DECORATION_DIR = path.join('Data', 'Decoration')
|
||||||
|
|
||||||
class AtlasSourceError extends Error {
|
class AtlasSourceError extends Error {
|
||||||
constructor(message, code) {
|
constructor(message, code) {
|
||||||
@@ -62,6 +64,33 @@ function listXml(dir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `.cfg` under `dir`, recursively, tree-relative and forward-slashed.
|
||||||
|
*
|
||||||
|
* Recursive because `Data/Decoration` nests two deep in places
|
||||||
|
* (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`) and a flat read
|
||||||
|
* would silently index a third of what the shard actually has — the failure
|
||||||
|
* mode being a dropdown that is quietly missing whole expansions rather than an
|
||||||
|
* error anyone would notice.
|
||||||
|
*/
|
||||||
|
function listCfgTree(dir, prefix = '') {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = []
|
||||||
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||||
|
if (entry.isDirectory()) out.push(...listCfgTree(path.join(dir, entry.name), rel))
|
||||||
|
else if (entry.name.toLowerCase().endsWith('.cfg')) out.push(rel)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function readIfPresent(file) {
|
function readIfPresent(file) {
|
||||||
try {
|
try {
|
||||||
return fs.readFileSync(file, 'utf8')
|
return fs.readFileSync(file, 'utf8')
|
||||||
@@ -111,6 +140,12 @@ function readSources(root) {
|
|||||||
|
|
||||||
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
||||||
|
|
||||||
|
// Optional, like the champion file: a shard that has stripped its decoration still
|
||||||
|
// has a usable atlas, it just cannot offer the decoration verb anything to place.
|
||||||
|
for (const rel of listCfgTree(path.join(root, DECORATION_DIR))) {
|
||||||
|
push(`Data/Decoration/${rel}`, path.join(root, DECORATION_DIR, rel))
|
||||||
|
}
|
||||||
|
|
||||||
return { files }
|
return { files }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +175,13 @@ function hashSources(root) {
|
|||||||
*
|
*
|
||||||
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||||
* seconds in the source, decided by `DelayInSec`).
|
* seconds in the source, decided by `DelayInSec`).
|
||||||
|
* 3 — the decoration index, from `Data/Decoration/**\/*.cfg`.
|
||||||
|
* 4 — a spawn point keeps its `UniqueId`, which is what a property lease
|
||||||
|
* targets (Phase 12b). The bump is what re-reads a tree the boot path
|
||||||
|
* would otherwise skip on an unchanged hash — the source files have not
|
||||||
|
* changed, only what is kept from them.
|
||||||
*/
|
*/
|
||||||
const PARSER_VERSION = 2
|
const PARSER_VERSION = 4
|
||||||
|
|
||||||
/** True when two source fingerprints describe the same tree. */
|
/** True when two source fingerprints describe the same tree. */
|
||||||
function sameSources(a, b) {
|
function sameSources(a, b) {
|
||||||
@@ -294,6 +334,43 @@ function buildAtlas(root, options = {}) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Decoration: what this shard already calls scenery, which is what makes the
|
||||||
|
// authoring dropdown the operator's own vocabulary rather than our taste.
|
||||||
|
//
|
||||||
|
// **Keyed case-INSENSITIVELY, because the decoration files disagree with
|
||||||
|
// themselves about casing.** Stock 57.4 names four types under two spellings
|
||||||
|
// each — `CheckerBoard`/`Checkerboard`, `ChessBoard`/`Chessboard`,
|
||||||
|
// `MetalChest`/`Metalchest`, `SpinningWheelEastAddon`/`SpinningwheelEastAddon`
|
||||||
|
// — and in every pair exactly one is a real class, the other a mis-cased line
|
||||||
|
// the shard's own loader resolves anyway. A case-sensitive Map keeps both, and
|
||||||
|
// then `shard_decor_types.type` (a PRIMARY KEY under MariaDB's default
|
||||||
|
// `..._ai_ci` collation, which folds case) rejects the second row and takes the
|
||||||
|
// WHOLE import transaction down with it. That is not a decoration bug: with no
|
||||||
|
// atlas, every option source answers empty and no world verb can be authored at
|
||||||
|
// all. The shard end of this feature already knew — `BridgeWorld.cs` resolves a
|
||||||
|
// decor type with `FindTypeByName(name, ignoreCase: true)` and says why — so
|
||||||
|
// folding here is the two ends agreeing rather than a new rule.
|
||||||
|
//
|
||||||
|
// The first spelling seen wins, exactly as the first item id does. Either
|
||||||
|
// spelling resolves on the shard, so which one survives is cosmetic.
|
||||||
|
const decorUses = new Map()
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.label.startsWith('Data/Decoration/')) continue
|
||||||
|
for (const entry of parseDecoration(file.text)) {
|
||||||
|
const key = entry.type.toLowerCase()
|
||||||
|
const seen = decorUses.get(key)
|
||||||
|
if (seen) {
|
||||||
|
seen.uses += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The FIRST item id wins, and it is only a preview: a type appears under
|
||||||
|
// as many ids as it has facings or variants, and picking one arbitrarily
|
||||||
|
// is honest in a way that picking "the most used" would not be.
|
||||||
|
decorUses.set(key, { type: entry.type, itemId: entry.itemId, uses: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const decor = [...decorUses.values()].sort((a, b) => a.type.localeCompare(b.type))
|
||||||
|
|
||||||
const creatures = aggregateCreatures(points)
|
const creatures = aggregateCreatures(points)
|
||||||
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||||
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||||
@@ -311,6 +388,7 @@ function buildAtlas(root, options = {}) {
|
|||||||
regions: regions.length,
|
regions: regions.length,
|
||||||
landmarks: landmarks.length,
|
landmarks: landmarks.length,
|
||||||
champions: champions.length,
|
champions: champions.length,
|
||||||
|
decor: decor.length,
|
||||||
unresolvedPoints: unresolved,
|
unresolvedPoints: unresolved,
|
||||||
},
|
},
|
||||||
source,
|
source,
|
||||||
@@ -321,6 +399,7 @@ function buildAtlas(root, options = {}) {
|
|||||||
landmarks,
|
landmarks,
|
||||||
champions,
|
champions,
|
||||||
points,
|
points,
|
||||||
|
decor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,48 @@
|
|||||||
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
|
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
|
||||||
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
|
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
|
||||||
// token on every call.
|
// token on every call.
|
||||||
|
//
|
||||||
|
// ── Protocol 6: `idempotencyKey` on a write ────────────────────────────────
|
||||||
|
//
|
||||||
|
// The three write helpers the event engine drives take an optional
|
||||||
|
// `idempotencyKey`, which the sidecar passes to the shard verbatim. The shard
|
||||||
|
// executes a key at most once and answers a repeat with the ORIGINAL reply, which
|
||||||
|
// is what makes retrying a world write safe — before it, a lost acknowledgement
|
||||||
|
// and a command that never applied were the same event seen from here.
|
||||||
|
//
|
||||||
|
// **A key is a function of the caller's unit of work, never of the attempt.** The
|
||||||
|
// event runner derives it from `sha256(runId|stepId)`, so every retry of one step
|
||||||
|
// carries the same key and a different step never collides with it. Passing a
|
||||||
|
// fresh value per call would satisfy the type and defeat the entire mechanism.
|
||||||
|
//
|
||||||
|
// **The DELETEs deliberately take no key.** Their idempotency is inherent — the
|
||||||
|
// second removal of a town-crier entry or a news article is a no-op the shard is
|
||||||
|
// already happy to perform — and the sidecar builds those commands from the path
|
||||||
|
// rather than from a body, so carrying one would be a protocol change bought for
|
||||||
|
// a guarantee that already holds.
|
||||||
|
//
|
||||||
|
// A caller that sends no key gets exactly the pre-protocol-6 behaviour, which is
|
||||||
|
// what leaves the admin screens (which send none, being driven by a human who can
|
||||||
|
// see whether the thing happened) unchanged.
|
||||||
|
//
|
||||||
|
// One new status can now come back from a keyed write: **425**, the sidecar's
|
||||||
|
// mapping of `bridge.busy` — a command under this key is still in flight on the
|
||||||
|
// shard. It is transient and retryable, and `shardAnnounce.classify` already
|
||||||
|
// treats it so by falling through to its retry case.
|
||||||
|
|
||||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const log = require('../core').logger('uo-link-client')
|
const log = require('../core').logger('uo-link-client')
|
||||||
|
|
||||||
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
|
// The sidecar waits up to 10s on the shard before answering 504, so this sits
|
||||||
|
// just above it — every call answers rather than being abandoned mid-flight.
|
||||||
|
//
|
||||||
|
// **Exported because the event actions are declared against it** (EVENTS_PLAN.md
|
||||||
|
// Phase 9). An action's `budgetMs` must exceed this or core's dispatch deadline
|
||||||
|
// fires first and classifies the step `retry` without asking the module, which
|
||||||
|
// for a broadcast means announcing twice. `config/uoEventActions.js` states that
|
||||||
|
// relationship and its test asserts it, and both need the number to come from
|
||||||
|
// here rather than from a copy that can drift.
|
||||||
|
const TIMEOUT_MS = 12000
|
||||||
const CONFIG_TTL_MS = 5000
|
const CONFIG_TTL_MS = 5000
|
||||||
|
|
||||||
let cachedConfig = null
|
let cachedConfig = null
|
||||||
@@ -159,15 +196,18 @@ const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
|
|||||||
})
|
})
|
||||||
const unlinkAccount = ({ actor, account }) =>
|
const unlinkAccount = ({ actor, account }) =>
|
||||||
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
|
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
|
||||||
const postTownCrier = ({ id, lines, durationSec }) =>
|
const postTownCrier = ({ id, lines, durationSec, idempotencyKey }) =>
|
||||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
|
||||||
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
|
||||||
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
|
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
|
||||||
// in the in-game News window; re-posting the same id REPLACES it. `announce`
|
// in the in-game News window; re-posting the same id REPLACES it. `announce`
|
||||||
// (default true on the sidecar) controls whether the criers proclaim the title.
|
// (default true on the sidecar) controls whether the criers proclaim the title.
|
||||||
const postNews = ({ id, title, body, image, url, announce }) =>
|
const postNews = ({ id, title, body, image, url, announce, idempotencyKey }) =>
|
||||||
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
|
call('/news', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
|
||||||
|
})
|
||||||
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
|
||||||
// ── Staff write plane (§6) ─────────────────────────────────────────────────
|
// ── Staff write plane (§6) ─────────────────────────────────────────────────
|
||||||
@@ -180,15 +220,166 @@ const adminBan = ({ actor, account, serial, durationSec, reason }) =>
|
|||||||
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
|
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
|
||||||
const adminUnban = ({ actor, account }) =>
|
const adminUnban = ({ actor, account }) =>
|
||||||
call('/admin/unban', { method: 'POST', body: { actor, account } })
|
call('/admin/unban', { method: 'POST', body: { actor, account } })
|
||||||
const adminBroadcast = ({ actor, text, hue }) =>
|
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
|
||||||
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
|
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
|
||||||
|
|
||||||
|
// ── The event plane (protocol 6, EVENTS_PLAN.md Phase 11b) ─────────────────
|
||||||
|
//
|
||||||
|
// Leases and the run-scoped participation ledger. Both are gated on the shard by
|
||||||
|
// `Bridge.EventsEnabled`, which is deliberately NOT the admin plane's switch: an
|
||||||
|
// operator consenting to staff moderation from a screen has not thereby consented
|
||||||
|
// to the website changing their world on a schedule at four in the morning. A
|
||||||
|
// shard with the plane off answers 403, and the actions turn that into a refusal
|
||||||
|
// an author can read rather than a retry.
|
||||||
|
|
||||||
|
// Every lease this shard offers, with what each is worth right now and what is
|
||||||
|
// holding it. One read serves both questions core asks — `read()` wants the
|
||||||
|
// current value, `inForce()` wants to know whether the shard still has a record
|
||||||
|
// of the hold — so a lease costs one round trip, not two.
|
||||||
|
// **A targeted lease must name its target here** (protocol 7 part b). A key like
|
||||||
|
// `Spawner.MaxCount` is one capability over thousands of spawners, so it has no
|
||||||
|
// single `current` and the catalog walk cannot fill one in — while `read()` needs
|
||||||
|
// exactly one value for exactly one target before it applies anything. Naming both
|
||||||
|
// narrows the frame to that row and fills it.
|
||||||
|
//
|
||||||
|
// The frame also carries `holds`: every hold this shard has, whatever key or
|
||||||
|
// target. A catalog walk enumerates the KEYS but can never enumerate the holds on
|
||||||
|
// a targeted one — there is no list of spawners to walk — so `inForce()` reads
|
||||||
|
// that rather than the row's `held` flag.
|
||||||
|
const getLeases = ({ key, target } = {}) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (key) params.set('key', key)
|
||||||
|
if (target) params.set('target', target)
|
||||||
|
const query = params.toString()
|
||||||
|
return call(query ? `/lease?${query}` : '/lease')
|
||||||
|
}
|
||||||
|
|
||||||
|
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
|
||||||
|
// computed here and honoured there is a deadline measured against two clocks, and
|
||||||
|
// a shard running ten minutes fast would restore a ten-minute lease the moment it
|
||||||
|
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
|
||||||
|
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
|
||||||
|
const applyLease = ({ key, target, value, holdMs, untilMs, runId, idempotencyKey }) =>
|
||||||
|
call('/lease', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { key, target, value: String(value), holdMs, untilMs, runId, idempotencyKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
// `expected` is what this run applied and `baseline` is what to put back, both out
|
||||||
|
// of core's ledger rather than the shard's memory — so a release still works after
|
||||||
|
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
|
||||||
|
// reverts every config lease by design) answers honestly instead of refusing.
|
||||||
|
const releaseLease = ({ key, target, expected, baseline, idempotencyKey }) =>
|
||||||
|
call('/lease/release', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
key,
|
||||||
|
target,
|
||||||
|
expected: expected == null ? undefined : String(expected),
|
||||||
|
baseline: baseline == null ? undefined : String(baseline),
|
||||||
|
idempotencyKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The participation ledger. The area is a map, a point and a radius rather than a
|
||||||
|
// region name, because protocol 6's own walk established that the most specific
|
||||||
|
// region containing an event is routinely anonymous.
|
||||||
|
const openParticipation = ({ runId, map, x, y, radius, holdMs, idempotencyKey }) =>
|
||||||
|
call('/participation', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { runId: String(runId), map, x, y, radius, holdMs, idempotencyKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
// A POST for a read, and the reason is the phase's headline: on a well-attended
|
||||||
|
// run the shard walks its members across Core ticks rather than in one inbound
|
||||||
|
// call, so a repeat arriving mid-walk is answered `bridge.busy` (425). A read that
|
||||||
|
// can legitimately be refused as a repeat in flight is not a GET.
|
||||||
|
const snapshotParticipation = ({ runId, idempotencyKey }) =>
|
||||||
|
call(`/participation/${encodeURIComponent(runId)}/snapshot`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { idempotencyKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeParticipation = ({ runId, idempotencyKey }) =>
|
||||||
|
call(`/participation/${encodeURIComponent(runId)}/close`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { idempotencyKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The world verbs (protocol 7) ───────────────────────────────
|
||||||
|
//
|
||||||
|
// One endpoint for five author-facing verbs. `what` is the discriminator, and the
|
||||||
|
// per-verb fields ride alongside it: `type`/`name`/`hue`/`spread` for creatures and
|
||||||
|
// decoration, the three multipliers for a boss, `greeting`/`lines` for an oracle,
|
||||||
|
// `target`/`holdMs` for a gate.
|
||||||
|
//
|
||||||
|
// The shard registers every serial it places against the run and persists that
|
||||||
|
// registry, which is what makes `despawnWorld` below safe to point at a list of
|
||||||
|
// serials: it can only delete what the run actually owns.
|
||||||
|
const spawnWorld = (body) => call('/world', { method: 'POST', body })
|
||||||
|
|
||||||
|
// What the run still owns. A GET, unlike the participation snapshot: it carries no
|
||||||
|
// idempotency key and the shard answers it in one pass. An unknown run answers with an
|
||||||
|
// empty hand rather than a 404 — "owns nothing" and "never heard of it" are the same
|
||||||
|
// fact once the registry is the only record, and they stay the same fact across a
|
||||||
|
// restart, because the registry is written by the same world save as the objects it
|
||||||
|
// describes.
|
||||||
|
const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`)
|
||||||
|
|
||||||
|
// Give back what the run owns. No `serials` means everything, which is the call
|
||||||
|
// teardown makes. The reply splits three ways: `removed` was deleted, `gone` was
|
||||||
|
// already absent (a player killed it — an ordinary success), and `refused` was never
|
||||||
|
// this run's to delete.
|
||||||
|
//
|
||||||
|
// **It takes no idempotency key, and the parameter is gone rather than optional.**
|
||||||
|
// It used to accept one, and `revertOwned` passed the step's — the key the SPAWN
|
||||||
|
// went out under. The shard's at-most-once store is keyed on the key alone, so the
|
||||||
|
// despawn was answered with the spawn's stored reply and nothing was ever deleted.
|
||||||
|
// A repeat despawn needs no key: the second pass answers `gone`, which both ends
|
||||||
|
// already treat as a success. Removed from the signature so it cannot be handed
|
||||||
|
// one again by accident.
|
||||||
|
const despawnWorld = ({ runId, serials }) =>
|
||||||
|
call(`/world/${encodeURIComponent(runId)}/despawn`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { serials },
|
||||||
|
})
|
||||||
|
|
||||||
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
||||||
const respondPage = (pageId, { message, close }) =>
|
const respondPage = (pageId, { message, close }) =>
|
||||||
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
||||||
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
||||||
|
|
||||||
|
// ── The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b) ────────────
|
||||||
|
//
|
||||||
|
// Neither owned nor borrowed: done is done. Both are gated on the shard by the
|
||||||
|
// same `Bridge.EventsEnabled` as the rest of the plane.
|
||||||
|
|
||||||
|
// What this shard will actually build, with the bounds it will build within. The
|
||||||
|
// module holds the same allowlist for its dropdown, so the form still works with
|
||||||
|
// the shard down; this is what is true when that copy is wrong.
|
||||||
|
const getGrantCatalog = () => call('/items')
|
||||||
|
|
||||||
|
// **The recipients are not sent.** The shard has held this run's participation
|
||||||
|
// ledger since it opened, keyed by the same character serials core stores as
|
||||||
|
// `member_key`, so the grant names a run and the shard resolves who was there.
|
||||||
|
// Sending a list would put the same list on the wire twice with a window in which
|
||||||
|
// the two disagree — and would have needed a core surface handing a module core's
|
||||||
|
// own participants.
|
||||||
|
const grantItem = ({ runId, item, amount, hue, name, where, idempotencyKey }) =>
|
||||||
|
call('/items/grant', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { runId: String(runId), item, amount, hue, name, where, idempotencyKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Starts a save. What actually happened rides `world.save.before`/`after` on the
|
||||||
|
// event stream, which have been there since protocol 2 — so this asserts only that
|
||||||
|
// the save was started, and a caller that needs the completion watches the feed it
|
||||||
|
// is already connected to.
|
||||||
|
const saveWorld = ({ idempotencyKey } = {}) =>
|
||||||
|
call('/world/save', { method: 'POST', body: { idempotencyKey } })
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
TIMEOUT_MS,
|
||||||
invalidateConfig,
|
invalidateConfig,
|
||||||
health,
|
health,
|
||||||
getCharBySerial,
|
getCharBySerial,
|
||||||
@@ -215,6 +406,18 @@ module.exports = {
|
|||||||
deleteTownCrier,
|
deleteTownCrier,
|
||||||
postNews,
|
postNews,
|
||||||
deleteNews,
|
deleteNews,
|
||||||
|
getLeases,
|
||||||
|
applyLease,
|
||||||
|
releaseLease,
|
||||||
|
openParticipation,
|
||||||
|
snapshotParticipation,
|
||||||
|
closeParticipation,
|
||||||
|
spawnWorld,
|
||||||
|
ownedWorld,
|
||||||
|
despawnWorld,
|
||||||
|
getGrantCatalog,
|
||||||
|
grantItem,
|
||||||
|
saveWorld,
|
||||||
adminKick,
|
adminKick,
|
||||||
adminBan,
|
adminBan,
|
||||||
adminUnban,
|
adminUnban,
|
||||||
|
|||||||
Reference in New Issue
Block a user