From 8c30cbbdddb649facab7e8c464d314ddafb2a6e8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 15:18:53 -0500 Subject: [PATCH] feat(events): a dry run carries a module's advice (MODULE_API 1.16.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An action answering verify: true with { ok: true, detail: { warnings } } has each line shown as a warning finding (code `advice`) that never fails the dry run. Strings only, at most ten per step, each cut at 500 characters; anything else under warnings is ignored, and a refused step carries none. Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the world as it stands now (a player on it, a rock beside it), which may change before the run, so the editor shows it as advice. One optional reading of an existing member, so minor; Module-uo's ^1.10.0 still resolves and says no warnings. STEAM_SIGNIN.md's claim moves to 1.17.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/modules/version.js | 5 ++- server/engagement-triggers.json | 2 +- server/src/events/verify.js | 44 ++++++++++++++++++++----- server/src/modules/version.js | 11 ++++++- server/test/eventModuleContract.test.js | 2 +- server/test/eventVerify.test.js | 38 +++++++++++++++++++++ 6 files changed, 90 insertions(+), 12 deletions(-) diff --git a/client/src/modules/version.js b/client/src/modules/version.js index 46a7bed..aea1ec1 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,9 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a +// module's advice, shown by core's own event editor as warning findings. +// Server-side; this file bumps for the reason at the top. // 1.15.0 — a failed step's `retryAfterMs` (Rust PLAN_REDESIGNS §14.3.13): when // to try again. Server-side; the run console's retry line now says when. This // file bumps for the reason at the top. @@ -91,4 +94,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.15.0' +export const MODULE_API_VERSION = '1.16.0' diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index 6061a63..cecb171 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", - "moduleApiVersion": "1.15.0", + "moduleApiVersion": "1.16.0", "triggers": [ { "id": "event.phase.changed", diff --git a/server/src/events/verify.js b/server/src/events/verify.js index 9aca95e..cf2e8d8 100644 --- a/server/src/events/verify.js +++ b/server/src/events/verify.js @@ -36,6 +36,26 @@ const authorize = require('./authorize') const settingsDb = require('../model/events/eventActionSettings.db') const registries = require('../modules/registries') +/** The most advice lines one step's dry run carries, and the longest one. */ +const ADVICE_MAX = 10 +const ADVICE_CHARS = 500 + +/** + * A dry run's `detail.warnings`, as the lines a dry run shows: strings only, + * trimmed, the empty dropped, at most ten, each cut at 500 characters. Anything + * else a module put there is ignored rather than refused — advice that cannot be + * read is not a reason to fail a plan that would run. + */ +function adviceOf(detail) { + if (!detail || typeof detail !== 'object' || !Array.isArray(detail.warnings)) return [] + return detail.warnings + .filter((w) => typeof w === 'string') + .map((w) => w.trim()) + .filter(Boolean) + .slice(0, ADVICE_MAX) + .map((w) => (w.length > ADVICE_CHARS ? `${w.slice(0, ADVICE_CHARS - 1)}…` : w)) +} + /** * Dry-run a spec. * @@ -108,13 +128,21 @@ async function verifySpec(spec, { user = null, scope = '' } = {}) { ) if (result.outcome === 'retry' || result.outcome === 'terminal') { findings.push({ ...where, level: 'error', code: 'refused', message: result.error }) - } else if (result.actionVersionDrift) { - findings.push({ - ...where, - level: 'warning', - code: 'version-drift', - message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`, - }) + } else { + if (result.actionVersionDrift) { + findings.push({ + ...where, + level: 'warning', + code: 'version-drift', + message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`, + }) + } + // MODULE_API 1.16.0: a module's ADVICE on a step that would run — the + // world as it stands now, which may change before the run does (Rust + // PLAN_REDESIGNS §11.4, D391). A warning, so it never fails the dry run. + for (const message of adviceOf(result.detail)) { + findings.push({ ...where, level: 'warning', code: 'advice', message }) + } } } @@ -150,4 +178,4 @@ async function verifySpec(spec, { user = null, scope = '' } = {}) { } } -module.exports = { verifySpec } +module.exports = { verifySpec, adviceOf } diff --git a/server/src/modules/version.js b/server/src/modules/version.js index cc0e435..9943cb3 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,15 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.16.0 — a dry run carries a module's advice: an action that answers +// `verify: true` with `{ ok: true, detail: { warnings: [...] } }` has each line +// shown as a WARNING finding (code `advice`) that does not fail the dry run +// (Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the +// world as it stands now, which may change before the run). Strings only, at most +// ten, each cut at 500 characters. One optional reading of an existing member, so +// minor. Module-uo declares `^1.10.0` and says no warnings: its dry runs read +// exactly as they did. STEAM_SIGNIN.md's claim moves to the next minor again. +// // 1.15.0 — a failed step may say when to try again: `{ ok: false, retry: true, // retryAfterMs }` (Rust PLAN_REDESIGNS §14.3.13, D371; docs/website/EVENTS.md §F). // Clamped to one minute–thirty minutes, never refused; absent, the flat @@ -207,6 +216,6 @@ // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.15.0' +const MODULE_API_VERSION = '1.16.0' module.exports = { MODULE_API_VERSION } diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index aa7747a..c25dd7f 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => { // 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the // assertion that says it does. The number itself is pinned in version.js. assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION) - assert.equal(MODULE_API_VERSION, '1.15.0') + assert.equal(MODULE_API_VERSION, '1.16.0') }) test('a module registers actions, budgets, leases and option sources', () => { diff --git a/server/test/eventVerify.test.js b/server/test/eventVerify.test.js index 2117485..85d9a1d 100644 --- a/server/test/eventVerify.test.js +++ b/server/test/eventVerify.test.js @@ -240,6 +240,44 @@ test('a step authored against an older action version is a WARNING, not an error assert.match(report.findings[0].message, /authored against version 1/) }) +test('a module’s advice is a WARNING finding per line, and the dry run still passes (MODULE_API 1.16.0)', async () => { + // Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the + // world as it stands now. A player standing on it may walk off before the run, + // so the check is advice, never a reason the plan fails. + register([ + recorder('test.spawn', { + answer: { ok: true, detail: { warnings: ['the spot is refused now: a player is within 50 m', ' ', 'at the spot now: Rock is too large', 7] } }, + }), + ]) + const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN }) + assert.equal(report.ok, true, 'advice must not fail the dry run') + assert.deepEqual( + report.findings.map((f) => [f.level, f.code, f.message, f.actionId]), + [ + ['warning', 'advice', 'the spot is refused now: a player is within 50 m', 'test.spawn'], + ['warning', 'advice', 'at the spot now: Rock is too large', 'test.spawn'], + ], + ) +}) + +test('advice is bounded: at most ten lines, each cut at 500 characters, and anything but strings ignored', async () => { + const { adviceOf } = require('../src/events/verify') + assert.deepEqual(adviceOf(null), []) + assert.deepEqual(adviceOf({ warnings: 'one line' }), []) + assert.deepEqual(adviceOf({ note: 'no warnings here' }), []) + assert.equal(adviceOf({ warnings: Array.from({ length: 30 }, (_, i) => `line ${i}`) }).length, 10) + const long = adviceOf({ warnings: ['x'.repeat(900)] })[0] + assert.equal(long.length, 500) + assert.ok(long.endsWith('…')) +}) + +test('a refused step carries no advice: the refusal is the finding', async () => { + register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such base', detail: { warnings: ['ignored'] } } })]) + const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN }) + assert.equal(report.ok, false) + assert.deepEqual(report.findings.map((f) => f.code), ['refused']) +}) + test('a finding says which phase and which step it is about', async () => { // A twelve-step definition needs the finding anchored, or the author is left // reading the message and counting rows.