7 Commits

Author SHA1 Message Date
61dc692088 chore(tools): delete the cliloc converter the Asset Bridge replaced (Phase 2)
All checks were successful
PR Checks / client-build (pull_request) Successful in 39s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 5m52s
`server/tools/cliloc-export/` existed for one reason: every modern UO client
ships its cliloc table in the Mythic container, and nothing in this stack could
read it — not the site, and not ServUO's own bundled `Ultima.StringList`. So an
operator installed UOFiddler, built this against its `Ultima.dll`, ran it over
their client and copied a 5 MB file to the web host, every time they patched.

Protocol 8 phase 2 put the decompressor in the shard plugin, where the client
files already are, and module-uo imports the table over the bridge. The tool has
nothing left to do. See docs/link/v8.md §9 and docs/website/CLILOCS.md.

Nothing in core referenced it — it was a standalone .NET console app under
`server/tools/`, and that directory is now empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 11:13:38 -05:00
baa4f7d5ba Merge pull request 'fix(events): midnight in an announcement is 12:00 am on the Node we ship' (#200) from fix/events-announce-midnight-hourcycle into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 13m32s
Reviewed-on: #200
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 00:27:58 +00:00
7d7840eb6b fix(events): midnight in an announcement is 12:00 am on the Node we ship
All checks were successful
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 5m56s
`startsAtLabel` asked for `hour12: true` on `en-GB`. That is not the same
request as a 12-hour clock, and it does not survive a Node upgrade: for a
locale whose default cycle is h23, Node 20 resolves `hour12: true` to h11,
whose hours run 0-11, so midnight renders "0:00 am". Node 22 and later
resolve it to h12 and it renders "12:00 am". Same ICU on both sides, so it
is V8's ECMA-402 behaviour rather than locale data.

The image ships node:20-alpine and CI runs Node 20, while a dev machine is
newer -- which is how this rendered correctly in front of everyone who wrote
it and wrongly for every real recipient. An event mail announcing a midnight
start said "0:00 am" while the schedule editor beside it said "12:00 AM":
one instant, two spellings, which is the exact contradiction the option was
added to prevent.

`hourCycle: 'h12'` is the request that means what was meant. `recurrence.js`
already states the mirror-image rule for `h23`, and every other formatter in
this repo and in module-uo uses `hourCycle`; there is no `hour12` left here.

This is the one test that has been red on every events PR since #192, and
the only one -- each of those runs reported `# fail 1`. Verified by running
the suite under node:20-alpine, where the test fails without this change and
2152 tests pass with it; on Node 22+ it passes either way, so the test's
comment now says that a green run on a dev machine is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 19:20:31 -05:00
b92b85c3a9 Merge pull request 'fix(events): the public calendar, a stranded revert, and three dropped facts (Phase 16a)' (#198) from fix/events-p16a-walk into edge
Some checks failed
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Failing after 8m56s
Reviewed-on: #198
2026-09-09 13:47:24 +00:00
6dd4e5e3eb fix(events): the public calendar, a stranded revert, and three dropped facts (Phase 16a)
Some checks failed
PR Checks / client-build (pull_request) Successful in 34s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Failing after 5m47s
Three defects the acceptance walk found in shipped code.

**The public calendar showed neither what is live nor what is recent.** §I says
`GET /public/events` is "the calendar: upcoming, **live** and **recent**". Built,
it was upcoming only: `listInWindow` filtered on `scheduled_for >= from` alone and
the shipped page asks for no window at all, so it took the default of now → +31d.
A run that began five minutes ago and has three hours to go was absent; so was one
that ended an hour ago. The site contradicted itself — `live: true` on
`/site/events/<slug>` while `/site/events` served `entries: []`.

A run is an interval, not an instant. `listInWindow` now matches a run whose
occupied interval OVERLAPS the window, which fixes the admin calendar's identical
hole (a run that started last Sunday and is still going was missing from "this
week"), and the public default reaches `DEFAULT_RECENT_DAYS` back so "recent" has
somewhere to live. Forecasts are still computed from `now`, never from the tail:
a projection into the past would advertise an occurrence that did not happen.

**A resource left `reverting` by a crash was never reclaimed.** `claimRevert`'s
comment said `reverting` is not claimable "exactly as a step with a live claim is"
— but a step's claim carries `claim_expires_at` and is reclaimed when the lease
lapses, and a resource in `reverting` had no expiry and nothing released it. A
process killed mid-teardown stranded the row for good: the sweep skipped it every
15s for ever, `cleanup_status` never left `pending`, and `POST …/cleanup` — the
recourse §I names — answered 200 and did nothing, because it claims through the
same function. On the rig it stranded a lease, which then BLOCKED the next run of
the same event from taking that value until the shard's own deadline lapsed.

The stale test is `updated_at`, which for a `reverting` row is exactly when the
claim was taken, so no column is added. `updated_at` is re-stamped explicitly and
that is load-bearing rather than tidy: this connector sends `CLIENT_FOUND_ROWS`,
so without the write a second claimer would still match the row. `revert_attempts`
is untouched — a stale claim is a process that died, not an attempt that failed.

**Three facts every event announcement computed and none could use.**
`announce.js` `baseFor()` puts `summary`, `seriesName` and `timezone` on all seven
`event.*` payloads, but four triggers declared none of them and a fifth declared
one, so `validatePayload` dropped them, they were absent from the variable list an
author picks from, and every emit logged `emit carried undeclared variables` at
DEBUG. They are now one shared `EVENT_AMBIENT` declaration spread into all seven,
with the per-trigger copies removed so the seven cannot drift.

Verified against a real ServUO + sidecar + website rig: the public page now shows
a live run as "Happening now" beside recent finished ones (it showed nothing at
all before), and a lease stranded by a real mid-teardown crash was reclaimed
within one sweep, taking `cleanup_status` from `pending` to `complete`.

The three `claimRevert` tests live in `eventRunnerSql.test.js` against a real
MariaDB, because every part of the answer is the server's — `NOW() - INTERVAL`,
`ON UPDATE`, and above all what `affectedRows` counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 08:28:17 -05:00
af9f4e191c Merge pull request 'fix(events): carry a module's own account of a successful step' (#197) from fix/events-module-detail into edge
Reviewed-on: #197
2026-09-09 00:22:36 +00:00
e46842a28c fix(events): carry a module's own account of a successful step
Some checks failed
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Failing after 8m57s
`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.

`module-uo` believed it, twice, since Phase 12b:

  * `uo.item.grant` answers `{ granted, missed, why }`
  * `uo.world.save` answers `{ started: true }`

The grant is the one that matters. A grant reaches the players a run's
participation ledger holds, and **which of them missed out is knowable only to
the module and reported nowhere else** — so an operator saw a step marked
`done` and never learned four of twelve got nothing.

Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.

## What this adds

`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.

**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.

  * objects only — the column is JSON and the console renders keys, so a bare
    string has nothing to render under, and core inventing a key would be core
    interpreting it after all;
  * 4KB of serialised JSON, dropped rather than truncated, because half a JSON
    object is not a JSON object;
  * unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
    runner would make the log INSERT throw, inside the one write documented
    never to;
  * re-parsed rather than passed through, so core holds no live reference into
    a module's object;
  * **anything wrong with it is dropped and logged, never a failure.** A step
    that did what it was asked must not be re-run because its module's
    commentary was malformed: that is a world write repeated for a log line.

The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.

## The renderer, which is half the fix

`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.

    uo.item.grant — granted: 8, missed: 4, why: bank full, offline
    uo.world.save — started: true

**`module-uo` needs no change**: the code it already shipped starts working.

MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:48:57 -05:00
18 changed files with 732 additions and 285 deletions

View File

@@ -692,9 +692,46 @@ const KIND_WORDS = {
'step.refused': 'Refused', 'step.refused': 'Refused',
'run.budget': 'Caps', 'run.budget': 'Caps',
'version.verified': 'Dry run passed', 'version.verified': 'Dry run passed',
// Phase 15. "Reported" rather than "Detail": the line is the module talking
// about its own verb, and every other word here names something core did.
'step.detail': 'Step reported',
note: 'Note', note: 'Note',
} }
// How deep and how long a module's own `detail` value is allowed to render.
// The dispatcher already caps the whole object at 4KB, so this is about a line
// staying a line — an operator scanning a run's log should not have one row
// wrap eight times because a module answered with an array of forty names.
const DETAIL_LIST_SHOWN = 5
const DETAIL_TEXT_MAX = 80
/**
* One value out of a module's `detail`, as text.
*
* **Core does not interpret these keys and neither does this.** A module wrote
* the object; the console shows it. That is the whole reason the renderer is
* generic rather than a switch — a switch would be core learning a module's
* vocabulary, which is the thing the module system exists to prevent.
*/
function detailValue(value) {
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) {
const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ')
return value.length > DETAIL_LIST_SHOWN
? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more`
: shown
}
if (typeof value === 'object') {
// A nested object is rendered by its keys rather than as JSON: an operator
// reading a log wants "granted: 8, missed: 4", not a brace.
return Object.entries(value)
.map(([k, v]) => `${k} ${detailValue(v)}`)
.join(', ')
}
const text = String(value)
return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}` : text
}
export const logKindWord = (kind) => KIND_WORDS[kind] || kind export const logKindWord = (kind) => KIND_WORDS[kind] || kind
/** /**
@@ -758,6 +795,22 @@ export function describeLogLine(line) {
.join(', ') || 'no caps apply to this run' .join(', ') || 'no caps apply to this run'
case 'version.verified': case 'version.verified':
return `Version ${d.version} passed its dry run — scheduled occurrences may start` return `Version ${d.version} passed its dry run — scheduled occurrences may start`
// Phase 15. The one line whose body core did not compose: a module may answer
// a successful step with a `detail` object, and this renders whatever keys it
// put there. `action` is core's own and is pulled out to lead the sentence;
// everything after it is the module's.
//
// **Without this case the row would render as the literal string
// "step.detail"**, because the default below is a kind word and not a
// sentence — which would be the reporting channel existing and showing
// nothing, the exact failure it was built to fix.
case 'step.detail': {
const { action, ...rest } = d
const body = Object.entries(rest)
.map(([key, value]) => `${key}: ${detailValue(value)}`)
.join(', ')
return body ? `${action || 'A step'}${body}` : `${action || 'A step'} reported nothing`
}
default: default:
return logKindWord(line?.kind) return logKindWord(line?.kind)
} }

View File

@@ -347,6 +347,54 @@ test('the log lines a run produces all render as something', () => {
} }
}) })
// ── The one log line core did not compose (Phase 15) ──────────────────────
test('a module detail line renders the module keys, not the kind id', () => {
// The failure this guards is subtle and total: `step.detail` falling to the
// default renders the literal string "step.detail", which is the reporting
// channel existing and showing nothing — exactly what it was built to fix.
const text = describeLogLine({
kind: 'step.detail',
detail: { action: 'uo.item.grant', granted: 8, missed: 4, why: ['bank full', 'offline'] },
})
assert.ok(!text.includes('step.detail'), `the kind id leaked into the sentence: ${text}`)
assert.match(text, /uo\.item\.grant/)
assert.match(text, /granted: 8/)
assert.match(text, /missed: 4/)
assert.match(text, /bank full/)
})
test('a module detail is rendered generically, whatever a module puts in it', () => {
// Core does not interpret these keys and neither does the renderer — a switch
// here would be the browser learning one module vocabulary, which is the thing
// the module system exists to prevent. So an unfamiliar shape still reads.
const text = describeLogLine({
kind: 'step.detail',
detail: { action: 'rust.wipe.announce', servers: { eu: 3, us: 1 }, dryRun: false, at: null },
})
assert.ok(!text.includes('undefined'), text)
assert.ok(!text.includes('[object Object]'), `a nested object rendered as a brace: ${text}`)
assert.match(text, /eu 3/)
assert.match(text, /dryRun: false/, 'false is a value, not an absence')
})
test('a long module detail stays one line', () => {
const many = Array.from({ length: 40 }, (_, i) => `player-${i}`)
const text = describeLogLine({
kind: 'step.detail',
detail: { action: 'uo.item.grant', missed: many, note: 'x'.repeat(500) },
})
assert.match(text, /and 35 more/)
assert.ok(text.length < 300, `one row should not wrap eight times: ${text.length} chars`)
})
test('a module detail with nothing in it still reads as a sentence', () => {
const text = describeLogLine({ kind: 'step.detail', detail: { action: 'uo.world.save' } })
assert.ok(text.length > 0)
assert.ok(!text.includes('undefined'), text)
})
test('every run status has a word, and an unknown one falls through rather than blanking', () => { test('every run status has a word, and an unknown one falls through rather than blanking', () => {
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) { for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
assert.ok(runStatusWord(s).length > 0) assert.ok(runStatusWord(s).length > 0)

View File

@@ -27,6 +27,27 @@
"example": "The Yew Invasion", "example": "The Yew Invasion",
"description": "The event title." "description": "The event title."
}, },
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The events one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{ {
"name": "phase", "name": "phase",
"type": "string", "type": "string",
@@ -89,6 +110,27 @@
"example": "The Yew Invasion", "example": "The Yew Invasion",
"description": "The event title." "description": "The event title."
}, },
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The events one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{ {
"name": "reason", "name": "reason",
"type": "string", "type": "string",
@@ -135,7 +177,21 @@
"type": "string", "type": "string",
"required": false, "required": false,
"example": "Orcish warbands are massing north of Yew.", "example": "Orcish warbands are massing north of Yew.",
"description": "The event summary, as authored." "description": "The events one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
}, },
{ {
"name": "participantCount", "name": "participantCount",
@@ -185,6 +241,27 @@
"example": "The Yew Invasion", "example": "The Yew Invasion",
"description": "The event title." "description": "The event title."
}, },
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The events one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{ {
"name": "eventUrl", "name": "eventUrl",
"type": "url", "type": "url",
@@ -219,6 +296,27 @@
"example": "The Yew Invasion", "example": "The Yew Invasion",
"description": "The event title." "description": "The event title."
}, },
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The events one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{ {
"name": "phase", "name": "phase",
"type": "string", "type": "string",
@@ -272,7 +370,7 @@
"type": "string", "type": "string",
"required": false, "required": false,
"example": "Orcish warbands are massing north of Yew.", "example": "Orcish warbands are massing north of Yew.",
"description": "The event summary, as authored." "description": "The events one-line summary, when it has one."
}, },
{ {
"name": "seriesName", "name": "seriesName",
@@ -281,6 +379,13 @@
"example": "The Yew Campaign", "example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one." "description": "The arc this event belongs to, when it belongs to one."
}, },
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{ {
"name": "startsAt", "name": "startsAt",
"type": "datetime", "type": "datetime",
@@ -288,13 +393,6 @@
"example": "2026-09-12T20:00:00.000Z", "example": "2026-09-12T20:00:00.000Z",
"description": "When the occurrence is due to start, UTC." "description": "When the occurrence is due to start, UTC."
}, },
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The shard-local zone the schedule was authored in."
},
{ {
"name": "startsAtLabel", "name": "startsAtLabel",
"type": "string", "type": "string",
@@ -341,7 +439,7 @@
"type": "string", "type": "string",
"required": false, "required": false,
"example": "Orcish warbands are massing north of Yew.", "example": "Orcish warbands are massing north of Yew.",
"description": "The event summary, as authored." "description": "The events one-line summary, when it has one."
}, },
{ {
"name": "seriesName", "name": "seriesName",
@@ -350,6 +448,13 @@
"example": "The Yew Campaign", "example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one." "description": "The arc this event belongs to, when it belongs to one."
}, },
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{ {
"name": "startsAt", "name": "startsAt",
"type": "datetime", "type": "datetime",
@@ -357,13 +462,6 @@
"example": "2026-09-12T20:00:00.000Z", "example": "2026-09-12T20:00:00.000Z",
"description": "When it actually started, UTC." "description": "When it actually started, UTC."
}, },
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The shard-local zone the schedule was authored in."
},
{ {
"name": "startsAtLabel", "name": "startsAtLabel",
"type": "string", "type": "string",

View File

@@ -29,6 +29,29 @@
// test-send without a live game event, which is the reason template systems go // test-send without a live game event, which is the reason template systems go
// untested. // untested.
/**
* The three facts `events/announce.js` puts on EVERY `event.*` payload, declared
* once because they are spread into all seven.
*
* `baseFor()` has always computed them and nothing declared them, so
* `engagementEmit.validatePayload` dropped all three before a template could see
* one — they were absent from the variable list an author picks from, and every
* single event emit logged `emit carried undeclared variables`. Found by the
* Phase 16 acceptance walk, in the DEBUG line it had been writing all along.
*
* All three are optional, and each for its own reason rather than by default: an
* event need not carry a summary, most events belong to no series, and a run
* whose definition has been deleted resolves no zone.
*/
const EVENT_AMBIENT = [
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
description: 'The events one-line summary, when it has one.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
description: 'The arc this event belongs to, when it belongs to one.' },
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
description: 'The zone the run was computed in — what a time in the body should be read as.' },
]
const TRIGGERS = [ const TRIGGERS = [
{ {
id: 'news.post', id: 'news.post',
@@ -215,14 +238,9 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' }, description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion', { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' }, description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', ...EVENT_AMBIENT,
description: 'The event summary, as authored.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
description: 'The arc this event belongs to, when it belongs to one.' },
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z', { name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
description: 'When the occurrence is due to start, UTC.' }, description: 'When the occurrence is due to start, UTC.' },
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
description: 'The shard-local zone the schedule was authored in.' },
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions // **A presentational fragment, and §4.6.1 convention 1 is what sanctions
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO // one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
// string — correct as data and unreadable in a mail, and a template has no // string — correct as data and unreadable in a mail, and a template has no
@@ -254,14 +272,9 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' }, description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion', { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' }, description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', ...EVENT_AMBIENT,
description: 'The event summary, as authored.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
description: 'The arc this event belongs to, when it belongs to one.' },
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z', { name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
description: 'When it actually started, UTC.' }, description: 'When it actually started, UTC.' },
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
description: 'The shard-local zone the schedule was authored in.' },
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions // **A presentational fragment, and §4.6.1 convention 1 is what sanctions
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO // one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
// string — correct as data and unreadable in a mail, and a template has no // string — correct as data and unreadable in a mail, and a template has no
@@ -293,6 +306,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' }, description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion', { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' }, description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: true, example: 'assault', { name: 'phase', type: 'string', required: true, example: 'assault',
description: 'The phase key just entered, as authored in the spec.' }, description: 'The phase key just entered, as authored in the spec.' },
{ name: 'phaseLabel', type: 'string', required: false, example: 'The assault', { name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
@@ -323,6 +337,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' }, description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion', { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' }, description: 'The event title.' },
...EVENT_AMBIENT,
// The public page for THIS occurrence (Phase 14a). Relative, like // The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own // `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every // base, and an absolute one baked in here would be wrong on every
@@ -345,8 +360,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' }, description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion', { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' }, description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.', ...EVENT_AMBIENT,
description: 'The event summary, as authored.' },
// Counted from `event_run_participants` at emit. Zero on a run whose // Counted from `event_run_participants` at emit. Zero on a run whose
// module reported nobody, which is every run until a module collects — // module reported nobody, which is every run until a module collects —
// a template that says "47 took part" needs a number that is never // a template that says "47 took part" needs a number that is never
@@ -377,6 +391,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' }, description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion', { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' }, description: 'The event title.' },
...EVENT_AMBIENT,
// **The operator's reason, and not the run's `last_error`.** `cancel` // **The operator's reason, and not the run's `last_error`.** `cancel`
// takes a `{ reason }` a human typed for other humans; a diagnostic // takes a `{ reason }` a human typed for other humans; a diagnostic
// string is for the run console and would read as gibberish in a mail. // string is for the run console and would read as gibberish in a mail.
@@ -408,6 +423,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' }, description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion', { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' }, description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: false, example: 'assault', { name: 'phase', type: 'string', required: false, example: 'assault',
description: 'The phase it failed in, when it had entered one.' }, description: 'The phase it failed in, when it had entered one.' },
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503', { name: 'error', type: 'string', required: false, example: 'sidecar responded 503',

View File

@@ -67,7 +67,18 @@ function startsAtLabel(at, zone) {
// Explicit rather than left to the locale, because `en-GB` would otherwise // Explicit rather than left to the locale, because `en-GB` would otherwise
// render midnight as "00:00" while the schedule editor beside it writes // render midnight as "00:00" while the schedule editor beside it writes
// "12:00 AM" — one event, two spellings of the same instant. // "12:00 AM" — one event, two spellings of the same instant.
hour12: true, //
// `hourCycle: 'h12'` and NOT `hour12: true`, which is not the same request
// and does not survive a Node upgrade. For a locale whose default cycle is
// h23 — `en-GB` is one — Node 20 resolves `hour12: true` to **h11**, whose
// hours run 011, so midnight comes out "0:00 am"; Node 22 and later
// resolve it to h12 and it comes out "12:00 am". Same ICU on both, so this
// is V8's ECMA-402 behaviour and not locale data, and the image ships
// node:20-alpine while a dev machine is newer — which is how this rendered
// correctly in front of everyone who wrote it and wrongly for every real
// recipient. `recurrence.js` states the mirror-image rule for `h23`; there
// is no `hour12` left in this repo and it should stay that way.
hourCycle: 'h12',
}).format(when) }).format(when)
return `${text} (${zone || 'UTC'})` return `${text} (${zone || 'UTC'})`
} catch { } catch {

View File

@@ -36,6 +36,71 @@ const OUTCOMES = ['done', 'parked', 'retry', 'terminal']
// worked. // worked.
const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60 const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
// The upper bound on a module's `detail`, in bytes of serialised JSON. It lands
// in `event_run_log.detail` and is read back by the run console, so it is a
// diagnostic line rather than a data channel — a module with more to say than
// this has a table of its own to say it in. Dropped rather than truncated when it
// is over: a truncated JSON object is not a JSON object, and a console that
// rendered half of one would be a second bug on top of the first.
const MAX_DETAIL_BYTES = 4096
/**
* A module's own account of what a successful step actually did.
*
* Optional, module-opaque, and **never interpreted by core** — it is carried to
* the run log and rendered, and nothing here or in the runner reads a key out of
* it. That is the whole contract: a module knows things about its own verb that
* core cannot compute and has no other way to say. `uo.item.grant` is the case
* that forced it — a grant reaches the players a run's ledger holds, and *which
* of them missed out* is knowable only to the module and reported nowhere else,
* so an operator saw a step marked `done` and never learned four of twelve got
* nothing.
*
* **Anything wrong with it is dropped and logged, never a failure.** A step that
* did what it was asked must not be re-run because its module's commentary was
* malformed — that would be a world write repeated for a log line. Same posture
* `participants` takes, and for the same reason.
*/
function safeDetail(detail, actionId) {
if (detail === undefined || detail === null) return null
// Objects only. The column is JSON and the console renders keys, so a bare
// string or a number has nothing to render under — and core inventing a key to
// put it beneath would be core interpreting it after all.
if (typeof detail !== 'object' || Array.isArray(detail)) {
log.warn('action detail is not an object', { action: actionId, type: typeof detail })
return null
}
let encoded
try {
encoded = JSON.stringify(detail)
} catch (err) {
// A circular reference, or a `toJSON` that throws. Reaching the runner would
// make the INSERT throw instead, inside the one write that is documented
// never to.
log.warn('action detail could not be serialised', { action: actionId, message: err.message })
return null
}
if (encoded === undefined) {
log.warn('action detail serialised to nothing', { action: actionId })
return null
}
if (Buffer.byteLength(encoded, 'utf8') > MAX_DETAIL_BYTES) {
log.warn('action detail is too large', {
action: actionId,
bytes: Buffer.byteLength(encoded, 'utf8'),
max: MAX_DETAIL_BYTES,
})
return null
}
// Re-parsed rather than passed through, so what the runner writes is a plain
// JSON value with no getters, no prototype and no live reference into whatever
// the module still holds.
return JSON.parse(encoded)
}
/** /**
* Run `fn()` under a deadline. * Run `fn()` under a deadline.
* *
@@ -103,6 +168,7 @@ function classify(result, actionId) {
error: null, error: null,
resources: result.resources || [], resources: result.resources || [],
participants: result.participants || [], participants: result.participants || [],
detail: safeDetail(result.detail, actionId),
} }
} }
@@ -125,6 +191,11 @@ function classify(result, actionId) {
holdSeconds, holdSeconds,
resources: result.resources || [], resources: result.resources || [],
participants: result.participants || [], participants: result.participants || [],
// On the same two success shapes as `resources` and `participants`, and for
// the same reason: `await: 'human'` is a success, and a cue's confirm
// finishes the step without a second dispatch, so this is the only moment
// its module could ever have said anything.
detail: safeDetail(result.detail, actionId),
} }
} }
@@ -179,4 +250,4 @@ async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
return classification return classification
} }
module.exports = { dispatchStep, classify, withDeadline, OUTCOMES, MAX_HOLD_SECONDS } module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }

View File

@@ -37,11 +37,19 @@ const participantsDb = require('./eventRunParticipants.db')
const calendarModel = require('./eventCalendar.model') const calendarModel = require('./eventCalendar.model')
const recurrence = require('../../events/recurrence') const recurrence = require('../../events/recurrence')
// The public calendar's window when a caller names neither end: now through a // The public calendar's window when a caller names neither end: a few days BACK
// month out. A visitor arriving at /site/events wants "what is on", and a client // through a month out. A visitor arriving at /site/events wants "what is on", and
// that had to compute a window before it could ask anything would make every // a client that had to compute a window before it could ask anything would make
// deep link carry two ISO instants. // every deep link carry two ISO instants.
//
// **The backward tail is not padding — it is the "recent" in §I's "upcoming, live
// and recent".** The default used to start at `now`, so an event that finished an
// hour ago was already gone and a visitor had nowhere to find the results of the
// thing they had just attended. The LIVE half is answered by `listInWindow`'s
// overlap test rather than by this number, so the tail only has to be long enough
// to be a "recently" a reader would recognise.
const DEFAULT_WINDOW_DAYS = 31 const DEFAULT_WINDOW_DAYS = 31
const DEFAULT_RECENT_DAYS = 7
// How many past occurrences an event page carries. It shows what is next and // How many past occurrences an event page carries. It shows what is next and
// what happened recently; the whole history of a three-year-old weekly event is // what happened recently; the whole history of a three-year-old weekly event is
@@ -146,8 +154,15 @@ const publicProjectedEntry = (definition, occurrence) => ({
* surface that has no login in front of it. * surface that has no login in front of it.
*/ */
async function calendar({ from, to, seriesId = null, now = new Date() } = {}) { async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
const start = from ? new Date(from) : new Date(now) // The default `to` is measured from NOW, not from `start` — otherwise the
const end = to ? new Date(to) : new Date(start.getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS) // backward tail would silently push the horizon a week further out and a caller
// naming only `from` would get a different span than one naming neither.
const start = from
? new Date(from)
: new Date(new Date(now).getTime() - DEFAULT_RECENT_DAYS * recurrence.DAY_MS)
const end = to
? new Date(to)
: new Date(new Date(now).getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { ok: false, status: 400, errors: ['from and to must be dates'] } return { ok: false, status: 400, errors: ['from and to must be dates'] }
@@ -183,7 +198,22 @@ async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
if (!schedule || schedule.kind === 'manual') continue if (!schedule || schedule.kind === 'manual') continue
let occurrences = [] let occurrences = []
try { try {
occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', start, end) // **Forecast from `now`, never from `start`.** The default window now
// reaches a week backwards so that "recent" has somewhere to live, and a
// projection into that tail would advertise an occurrence that did not
// happen — a run that WAS created is a real row and arrives above, and one
// that was not is a slot the runner has already passed. A forecast is about
// the future; the tail is about the past. Only the materialised half fills
// it.
const forecastFrom = start > now ? start : new Date(now)
if (forecastFrom < end) {
occurrences = recurrence.occurrencesBetween(
schedule,
definition.timezone || 'UTC',
forecastFrom,
end,
)
}
} catch { } catch {
// A version whose schedule the recurrence engine will not read is one the // A version whose schedule the recurrence engine will not read is one the
// runner will not expand either. The calendar then shows that definition's // runner will not expand either. The calendar then shows that definition's
@@ -405,5 +435,6 @@ module.exports = {
publicStatus, publicStatus,
phaseLabel, phaseLabel,
DEFAULT_WINDOW_DAYS, DEFAULT_WINDOW_DAYS,
DEFAULT_RECENT_DAYS,
PAST_RUNS, PAST_RUNS,
} }

View File

@@ -65,6 +65,12 @@ const KINDS = [
'results.published', // the results table was ranked and stamped 'results.published', // the results table was ranked and stamped
'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling 'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
'announcement.enqueued', // a post was linked to this run and queued on the legs 'announcement.enqueued', // a post was linked to this run and queued on the legs
// Phase 15's one, and it is the only kind whose payload core does not compose.
// A module may answer a success envelope with a `detail` object; it is bounded
// and sanitised at the dispatcher and written here verbatim beside the action
// id. Nothing reads a key out of it — it exists because a module knows things
// about its own verb that core cannot compute and had no other way to say.
'step.detail', // a module's own account of what a successful step did
] ]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) } const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }

View File

@@ -194,18 +194,54 @@ async function unresolvedCounts(runIds) {
} }
/** /**
* Claim one row for a revert: `pending | confirmed | orphaned | drifted → reverting`. * Claim one row for a revert: `pending | confirmed | orphaned | drifted → reverting`,
* and `reverting` again once the claim on it has gone stale.
* *
* The compare-and-set that keeps the cleanup leg and the manual cleanup route off * The compare-and-set that keeps the cleanup leg and the manual cleanup route off
* each other's rows. `reverting` is deliberately not claimable — a row another * each other's rows. A row another pass is mid-revert on is left alone, exactly as
* pass is mid-revert on is left alone, exactly as a step with a live claim is. * a step with a live claim is.
*
* **"Exactly as a step" has to include the expiry, and it did not until the Phase
* 16 acceptance walk.** A step's claim carries `claim_expires_at`, so a step whose
* process died is reclaimed once the lease lapses — that reclaim is the whole
* reason §E's CAS survives §N4's single instance. A `reverting` row had no such
* bound and nothing released it, so a process killed mid-teardown stranded the row
* for good: the sweep skipped it every 15s forever, `cleanup_status` never left
* `pending`, and `POST …/cleanup` — the recourse §I names — answered 200 and did
* nothing, because it claims through this same function. Observed with a lease,
* which then blocked the NEXT run of the same event from taking the value.
*
* The stale test is `updated_at`, not a new column: the row is stamped exactly
* when it enters `reverting` and is not written again until the revert resolves,
* so for a `reverting` row `updated_at` IS "when this claim was taken". The bound
* is the run lease's, for the run lease's reason — it has to outlast a whole
* tick's work on one run, and every revert in a sweep is bounded by its action's
* own `budgetMs` long before this.
*
* `revert_attempts` is deliberately NOT incremented by reclaiming. A stale claim
* is a process that died, not an attempt that failed, and counting it would burn
* the retry budget on crashes — Engagement Phase 14's rule, one table over.
*
* **`updated_at` is re-stamped explicitly, and that is what keeps this a CAS.**
* This connector sends `CLIENT_FOUND_ROWS`, so `affectedRows` counts rows MATCHED
* rather than changed. For the four fresh statuses that is harmless — the winner
* moves the row to `reverting` and the loser's `status IN (…)` no longer matches.
* A stale `reverting` row has no such natural change: without re-stamping, the
* row would still satisfy `status = 'reverting' AND updated_at < …` and a second
* claimer would match it too. Writing the column is what makes the second one
* miss.
*/ */
const REVERT_CLAIM_TTL_MS = Number(process.env.EVENT_REVERT_CLAIM_TTL_MS) || 15 * 60 * 1000
async function claimRevert(id) { async function claimRevert(id) {
const result = await query( const result = await query(
`UPDATE event_run_resources `UPDATE event_run_resources
SET status = 'reverting' SET status = 'reverting', updated_at = NOW()
WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned', 'drifted')`, WHERE id = ?
[id], AND (status IN ('pending', 'confirmed', 'orphaned', 'drifted')
OR (status = 'reverting'
AND updated_at < (NOW() - INTERVAL ? MICROSECOND)))`,
[id, REVERT_CLAIM_TTL_MS * 1000],
) )
return (result.affectedRows || 0) > 0 return (result.affectedRows || 0) > 0
} }

View File

@@ -108,14 +108,32 @@ const materialise = async (run) => {
} }
/** /**
* Every run whose instant falls inside a window — the calendar's real half. * Every run whose OCCUPIED INTERVAL overlaps a window — the calendar's real half.
* *
* Ascending, unlike the admin run list: a calendar is read forwards. The join * Ascending, unlike the admin run list: a calendar is read forwards. The join
* reaches the series so a month can be filtered to one arc without a second * reaches the series so a month can be filtered to one arc without a second
* round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because * round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
* a run records the zone it was COMPUTED in and a definition's zone can be * a run records the zone it was COMPUTED in and a definition's zone can be
* edited afterwards. * edited afterwards.
*
* **A run OVERLAPS the window; it does not merely START in it.** This asked
* `scheduled_for >= from` alone until the Phase 16 acceptance walk, and a run is
* not an instant — it is an interval, and a multi-phase event's whole point is
* that the interval is long. A run that began before `from` and has not ended is
* happening DURING the window and belongs in it. With the instant test, the
* public calendar answered `entries: []` while that same event's own page said
* `live: true`, so the site disagreed with itself about whether something was on
* — and `EVENTS.md` §I promises this route serves "upcoming, **live** and
* recent". The admin calendar had the same hole for the same reason: a run that
* started last Sunday and is still going was missing from "this week".
*
* A finished run needs no clause: it is `recent` only if its instant is in the
* window, which is what the window's own `from` decides (see
* `eventPublic.model.calendar`, which backdates its default `from` so that
* "recent" has somewhere to live).
*/ */
const LIVE_STATUSES = ['starting', 'running', 'paused', 'ending']
const listInWindow = async ({ const listInWindow = async ({
from, from,
to, to,
@@ -125,8 +143,11 @@ const listInWindow = async ({
limit = 500, limit = 500,
publicOnly = false, publicOnly = false,
} = {}) => { } = {}) => {
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?'] const where = [
const args = [from, to] `((r.scheduled_for >= ? AND r.scheduled_for < ?)
OR (r.scheduled_for < ? AND r.status IN (${LIVE_STATUSES.map(() => '?').join(',')})))`,
]
const args = [from, to, to, ...LIVE_STATUSES]
if (status) { if (status) {
where.push('r.status = ?') where.push('r.status = ?')
args.push(status) args.push(status)

View File

@@ -361,6 +361,27 @@ async function drainStep(run, step, now, carry = {}) {
}, },
}) })
} }
// **What the module has to say about it**, which is the third thing a
// success envelope can carry and the only one core does not interpret.
// `resources` is what to undo and `participants` is who took part; this is
// everything else the module knows and core cannot compute — how many of a
// run's players a grant actually reached, whether a save had already started.
// Bounded and sanitised in `dispatch.js`; by here it is a plain JSON object
// or null.
//
// Its own line rather than a field on one of the two above, because a step
// very often has this and neither of those — the grant that forced it
// ledgers nothing (`reversible: 'none'`) and reports no participants.
if (result.detail) {
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.detail',
phase: step.phase,
detail: { action: step.action_id, ...result.detail },
})
}
} }
if (result.outcome === 'parked') { if (result.outcome === 'parked') {

View File

@@ -260,9 +260,15 @@ test('the start time is written out in the SHARD\'s zone, not the server\'s', ()
}) })
test('midnight reads as 12:00 am and never as 00:00', () => { test('midnight reads as 12:00 am and never as 00:00', () => {
// `hour12` is set explicitly. Left to the en-GB locale this would render // The hour cycle is set explicitly. Left to the en-GB locale this would render
// "00:00" while the schedule editor beside it writes "12:00 AM" — one event, // "00:00" while the schedule editor beside it writes "12:00 AM" — one event,
// two spellings of the same instant. // two spellings of the same instant.
//
// This assertion only has teeth on the Node the image ships (20), where
// `hour12: true` resolves to h11 and midnight reads "0:00 am". On Node 22+ it
// passes either way — so a green run on a dev machine is not evidence, and CI
// is what actually holds this line. See the note beside `hourCycle` in
// events/announce.js.
assert.match(announce.startsAtLabel(new Date('2026-09-13T04:00:00Z'), 'America/New_York'), /12:00 am/) assert.match(announce.startsAtLabel(new Date('2026-09-13T04:00:00Z'), 'America/New_York'), /12:00 am/)
}) })

View File

@@ -115,10 +115,15 @@ function installStubs() {
.filter((d) => d.state === 'ready' && (!listedOnly || d.listed)) .filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
.map((d) => ({ ...d, version_spec: d.spec })) .map((d) => ({ ...d, version_spec: d.spec }))
// Mirrors the OVERLAP predicate the real statement uses: a run is in the window
// if its instant falls inside it, OR if it began before the window and is still
// live. A run is an interval, not an instant — see `eventRuns.db.listInWindow`.
runsDb.listInWindow = async ({ from, to, publicOnly = false }) => runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
store.runs.filter((r) => { store.runs.filter((r) => {
const at = new Date(r.scheduled_for) const at = new Date(r.scheduled_for)
if (at < from || at >= to) return false const startsInside = at >= from && at < to
const liveAcross = at < to && ['starting', 'running', 'paused', 'ending'].includes(r.status)
if (!startsInside && !liveAcross) return false
if (!publicOnly) return true if (!publicOnly) return true
const d = store.definitions.find((x) => x.id === r.definition_id) const d = store.definitions.find((x) => x.id === r.definition_id)
return !r.rehearsal && d && d.listed && d.state !== 'archived' return !r.rehearsal && d && d.listed && d.state !== 'archived'
@@ -163,12 +168,89 @@ test('a calendar entry carries no operational field at all', async () => {
]) ])
}) })
test('the calendar defaults to a month from now when no window is given', async () => { test('the default window reaches back as well as forward', async () => {
// §I: this route is "upcoming, live and recent". The default used to start at
// `now`, which left no room for the third word — an event that finished an hour
// ago was already gone, so a visitor had nowhere to find the results of the
// thing they had just attended (Phase 16 walk).
const result = await publicModel.calendar({ now: NOW }) const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true) assert.equal(result.ok, true)
assert.equal(new Date(result.window.from).getTime(), NOW.getTime()) const back = (NOW - new Date(result.window.from)) / 86_400_000
const days = (new Date(result.window.to) - new Date(result.window.from)) / 86_400_000 const forward = (new Date(result.window.to) - NOW) / 86_400_000
assert.equal(days, publicModel.DEFAULT_WINDOW_DAYS) assert.equal(back, publicModel.DEFAULT_RECENT_DAYS)
assert.equal(forward, publicModel.DEFAULT_WINDOW_DAYS)
})
test('a run happening RIGHT NOW is on the calendar, whenever it started', async () => {
// The defect this pair was written for: the site said `live: true` on the
// event's own page and served `entries: []` from the calendar, because the
// window test read the START instant and a live run had already started. A run
// is an interval; the calendar asks which intervals overlap it.
store.runs = [
{
...run({
status: 'running',
// Well before any default window would begin.
scheduled_for: new Date('2026-08-01T00:00:00Z'),
ended_at: null,
}),
definition_title: 'The Yew Invasion',
definition_slug: 'the-yew-invasion',
},
]
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
const entry = result.entries.find((e) => e.kind === 'run')
assert.ok(entry, 'a live run must appear however long ago it began')
assert.equal(entry.live, true)
assert.equal(entry.status, 'live')
})
test('a run that finished inside the recent tail is still on the calendar', async () => {
store.runs = [
{
...run({
status: 'completed',
scheduled_for: new Date(NOW.getTime() - 2 * 86_400_000),
ended_at: new Date(NOW.getTime() - 2 * 86_400_000 + 3_600_000),
}),
definition_title: 'The Yew Invasion',
definition_slug: 'the-yew-invasion',
},
]
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
assert.equal(result.entries.filter((e) => e.kind === 'run').length, 1)
})
test('nothing is FORECAST into the recent tail', async () => {
// The tail is for what happened, and only the materialised half fills it. A
// projection into the past would advertise an occurrence that did not happen:
// one that WAS created is a real row and arrives as a run, and one that was not
// is a slot the runner has already gone past.
store.definitions = [
definition({
spec: {
...SPEC,
schedule: {
kind: 'weekly',
days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
time: '20:00',
},
},
}),
]
store.runs = []
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
const projected = result.entries.filter((e) => e.kind !== 'run')
assert.ok(projected.length > 0, 'a daily schedule must still forecast forwards')
for (const entry of projected) {
assert.ok(
new Date(entry.scheduledFor) >= NOW,
`forecast ${entry.scheduledFor} is before now — the tail must hold no projections`,
)
}
}) })
test('a window wider than the cap is refused rather than served slowly', async () => { test('a window wider than the cap is refused rather than served slowly', async () => {

View File

@@ -1028,6 +1028,112 @@ test('classify: the two success shapes that are not "finished"', () => {
assert.ok(classify({ ok: true, holdFor: 1e12 }, 'a').holdSeconds <= 7 * 24 * 60 * 60, 'holdFor is bounded') assert.ok(classify({ ok: true, holdFor: 1e12 }, 'a').holdSeconds <= 7 * 24 * 60 * 60, 'holdFor is bounded')
}) })
// ── A module's own account of a successful step (Phase 15) ────────────────
//
// The third thing a success envelope may carry, and the only one core does not
// interpret. It exists because a module knows things about its own verb that core
// cannot compute and had no other channel for: `uo.item.grant` reaches the players
// a run's ledger holds, and WHICH OF THEM MISSED OUT is reported nowhere else —
// so before this, an operator saw a step marked `done` and never learned that four
// of twelve got nothing. `module-uo` had been answering `detail` since Phase 12b
// on the strength of one sentence in EVENTS.md §H, and core had never read it.
test('classify: a success may carry a module detail, and it is never interpreted', () => {
assert.equal(classify({ ok: true }, 'a').detail, null, 'absent is null, not undefined')
assert.deepEqual(
classify({ ok: true, detail: { granted: 8, missed: 4 } }, 'a').detail,
{ granted: 8, missed: 4 },
"the keys are the module own and core changes none of them",
)
// The other success shape. A cue's confirm finishes the step without a second
// dispatch, so this is the only moment its module could ever have said anything.
assert.deepEqual(
classify({ ok: true, await: 'human', detail: { cued: 'britain' } }, 'a').detail,
{ cued: 'britain' },
)
})
test('classify: a bad detail is dropped, and never fails the step', () => {
// A step that did what it was asked must not be re-run because its module's
// commentary was malformed — that would be a world write repeated for a log
// line. Every one of these is `done` with a null detail.
const dropped = [
{ ok: true, detail: 'a string' },
{ ok: true, detail: 42 },
{ ok: true, detail: ['an', 'array'] },
{ ok: true, detail: { big: 'x'.repeat(5000) } },
]
for (const envelope of dropped) {
const verdict = classify(envelope, 'a')
assert.equal(verdict.outcome, 'done', 'a bad detail must not change the outcome')
assert.equal(verdict.detail, null)
}
// A circular object throws inside JSON.stringify. Reaching the runner would
// make the log INSERT throw instead, inside the one write documented never to.
const circular = { ok: true, detail: {} }
circular.detail.self = circular.detail
assert.equal(classify(circular, 'a').outcome, 'done')
assert.equal(classify(circular, 'a').detail, null)
})
test("classify: the detail core carries is a copy, not the module object", () => {
const live = { granted: 8 }
const carried = classify({ ok: true, detail: live }, 'a').detail
live.granted = 999
assert.equal(carried.granted, 8, 'core must not hold a live reference into a module')
})
test('a module detail reaches the run log as its own line', async () => {
register([scriptedAction('test.grant')])
scripted['test.grant'] = {
calls: [],
answer: { ok: true, detail: { granted: 8, missed: 4, why: ['bank full'] } },
}
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.grant')] }])
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'done')
const line = store.log.find((l) => l.runId === id && l.kind === 'step.detail')
assert.ok(line, 'the module said something and the run has no record of it')
assert.equal(line.detail.action, 'test.grant', "core's own key leads the line")
assert.equal(line.detail.granted, 8)
assert.equal(line.detail.missed, 4)
assert.deepEqual(line.detail.why, ['bank full'])
assert.equal(line.stepId, stepsOf(id)[0].id)
})
test('a step that says nothing writes no detail line', async () => {
// Its own line rather than a field on `resource.recorded`, so a run whose steps
// are all quiet must not gain a row per step saying so.
register([scriptedAction('test.quiet')])
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.quiet')] }])
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'done')
assert.equal(kinds(id).filter((k) => k === 'step.detail').length, 0)
})
test('a failed step reports no detail, however much it says', async () => {
// `detail` rides the SUCCESS shapes only. A failure's channel is `error`, and
// an action that answered both would otherwise get two bites at the log for a
// step that did not happen.
register([scriptedAction('test.refuse')])
scripted['test.refuse'] = {
calls: [],
answer: { ok: false, retry: false, error: 'no', detail: { tried: 3 } },
}
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.refuse', {}, 'skip')] }])
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'failed')
assert.equal(kinds(id).filter((k) => k === 'step.detail').length, 0)
})
test('an action that throws is a transient failure, not a crashed tick', async () => { test('an action that throws is a transient failure, not a crashed tick', async () => {
register([ register([
scriptedAction('test.thrower', { scriptedAction('test.thrower', {

View File

@@ -1448,6 +1448,77 @@ test('many released rows on one target coexist, which is the whole encoding', as
assert.equal(await dup(() => insertResource(a.runId, { kind: 'override', ref: 'demo.rate' })), null) assert.equal(await dup(() => insertResource(a.runId, { kind: 'override', ref: 'demo.rate' })), null)
}) })
// ── claimRevert's stale-claim reclaim (Phase 16) ───────────────────────────
//
// The statement's own comment explains WHY a `reverting` row must be reclaimable;
// this proves it against a real server, because every part of the answer is the
// server's: `NOW() - INTERVAL … MICROSECOND`, whether `ON UPDATE` re-stamps, and
// above all what `affectedRows` counts. This connector sends `CLIENT_FOUND_ROWS`,
// so it counts rows MATCHED — a stub counting CHANGED rows would call the reclaim
// a failure, and one counting matched rows would miss that the second claimer
// needs the re-stamp in order to lose. Only MariaDB settles it.
const CLAIM_REVERT = `
UPDATE event_run_resources
SET status = 'reverting', updated_at = NOW()
WHERE id = ?
AND (status IN ('pending', 'confirmed', 'orphaned', 'drifted')
OR (status = 'reverting'
AND updated_at < (NOW() - INTERVAL ? MICROSECOND)))`
const claimRevert = async (id, ttlMs) =>
Number((await pool.query(CLAIM_REVERT, [id, ttlMs * 1000]))?.affectedRows || 0) > 0
const ageResource = (id, seconds) =>
pool.query('UPDATE event_run_resources SET updated_at = NOW() - INTERVAL ? SECOND WHERE id = ?', [
seconds,
id,
])
test('a reverting row whose claim has gone stale is claimable again', async (t) => {
if (needDb(t)) return
// The Phase 16 walk's finding: a process killed mid-teardown leaves the row in
// `reverting` and nothing releases it. The sweep ran every 15s for ever finding
// nothing it could claim, `cleanup_status` never left `pending`, and the manual
// retry answered 200 while doing nothing — it claims through this statement too.
const run = await seedRun()
const id = await insertResource(run.runId, { status: 'reverting' })
// Fresh: somebody else really is mid-revert on it. Left alone.
assert.equal(await claimRevert(id, 900_000), false)
// Stale: the holder is not coming back.
await ageResource(id, 1800)
assert.equal(await claimRevert(id, 900_000), true)
})
test('reclaiming re-stamps, so the second claimer of one stale row loses', async (t) => {
if (needDb(t)) return
// Under CLIENT_FOUND_ROWS the four fresh statuses need no re-stamp — the winner
// moves the row out of `status IN (…)` and the loser stops matching. A stale
// `reverting` row has no such natural change, so without writing `updated_at`
// BOTH claimers would match it and two passes would revert the same resource.
const run = await seedRun()
const id = await insertResource(run.runId, { status: 'reverting' })
await ageResource(id, 1800)
assert.equal(await claimRevert(id, 900_000), true)
assert.equal(await claimRevert(id, 900_000), false, 'the re-stamp must make the second miss')
})
test('reclaiming a stale revert does not spend a retry attempt', async (t) => {
if (needDb(t)) return
// A stale claim is a process that died, not an attempt that failed. Counting it
// would burn MAX_REVERT_ATTEMPTS on crashes — Engagement Phase 14's rule, one
// table over.
const run = await seedRun()
const id = await insertResource(run.runId, { status: 'reverting' })
await ageResource(id, 1800)
await claimRevert(id, 900_000)
const [row] = await pool.query('SELECT revert_attempts FROM event_run_resources WHERE id = ?', [id])
assert.equal(Number(row.revert_attempts), 0)
})
test('an UPDATE that releases a row frees the target at once', async (t) => { test('an UPDATE that releases a row frees the target at once', async (t) => {
if (needDb(t)) return if (needDb(t)) return
// The generated column is STORED, so this is really asking whether MariaDB // The generated column is STORED, so this is really asking whether MariaDB

View File

@@ -1,140 +0,0 @@
// Cliloc export — converts a modern client's COMPRESSED Cliloc.enu into the
// plain format the website can read (docs/website/CLILOCS.md).
//
// Why this exists at all: every current UO client ships its cliloc files in the
// compressed "Mythic" format — the first DWORD's high byte is 0x8E — and the
// plain layout the website parses is what those files looked like before that
// change. Decompressing is a bit-level inverse-BWT coder that the site has no
// business carrying at runtime, and ServUO's own bundled `Ultima.StringList`
// cannot read it either (which is why `VendorSearch.GetItemName` is already
// inert on such a shard, and why the shard cannot supply names instead).
//
// So the conversion happens ONCE, here, against a decompressor that already
// exists and is maintained: UOFiddler's `Ultima.dll`.
//
// ── Why reflection rather than a project reference ────────────────────────
//
// UOFiddler ships as net10.0. Referencing it from a project built by an older
// SDK fails at COMPILE time with CS1705 ("uses System.Runtime 10.0 which has a
// higher version than referenced assembly"). Loading it reflectively moves that
// question to run time, where `RollForward: LatestMajor` answers it — so this
// builds on whatever SDK an operator happens to have and runs on the newest
// runtime installed.
//
// ── Why not StringList.SaveStringList ────────────────────────────────────
//
// It looks like exactly the right method and it is not: it RE-COMPRESSES on
// save, because its purpose is round-tripping a file back into the client. The
// output is byte-identical to the compressed input. The plain records below are
// written by hand for that reason.
//
// Usage:
// dotnet run -- <Ultima.dll> <Cliloc.enu> <output> [--tsv]
//
// Nothing produced by this tool is committed. See docs/website/CLILOCS.md.
using System;
using System.Collections;
using System.IO;
using System.Reflection;
using System.Text;
internal static class Program
{
private static int Main(string[] args)
{
if (args.Length < 3)
{
Console.Error.WriteLine("usage: clilocexport <path-to-Ultima.dll> <cliloc-file> <output-file> [--tsv]");
Console.Error.WriteLine(" Ultima.dll ships with UOFiddler (https://github.com/polserver/UOFiddler).");
return 2;
}
var (ultimaDll, input, output) = (args[0], args[1], args[2]);
var asTsv = Array.IndexOf(args, "--tsv") >= 0;
// The language code only names the file when StringList resolves the path
// itself; here the path is explicit, so it is cosmetic.
var language = Path.GetExtension(input).TrimStart('.');
if (string.IsNullOrWhiteSpace(language)) language = "enu";
var assembly = Assembly.LoadFrom(Path.GetFullPath(ultimaDll));
var stringListType = assembly.GetType("Ultima.StringList")
?? throw new InvalidOperationException("Ultima.StringList not found — is that really UOFiddler's Ultima.dll?");
// (language, path, decompress). `decompress: true` is the whole point;
// the loader falls back to a plain read on its own if the file turns out
// not to be compressed, so an already-converted file passes through.
var ctor = stringListType.GetConstructor(new[] { typeof(string), typeof(string), typeof(bool) })
?? throw new InvalidOperationException("Unexpected Ultima.StringList API — this tool targets UOFiddler 4.21+.");
var stringList = ctor.Invoke(new object[] { language, Path.GetFullPath(input), true });
// A partial parse is reported rather than thrown. Surfacing it matters:
// the output would otherwise be a quietly short table, which is exactly
// the failure mode the website's parser refuses to import.
var warning = stringListType.GetProperty("LoadWarning")?.GetValue(stringList) as string;
if (!string.IsNullOrWhiteSpace(warning)) Console.Error.WriteLine("warning: " + warning);
var entries = (IEnumerable)stringListType.GetProperty("Entries")!.GetValue(stringList)!;
var entryType = assembly.GetType("Ultima.StringEntry")!;
var numberProp = entryType.GetProperty("Number")!;
var textProp = entryType.GetProperty("Text")!;
var flagProp = entryType.GetProperty("Flag")!;
int written = 0, skipped = 0, maxBytes = 0;
if (asTsv)
{
using var writer = new StreamWriter(output, false, new UTF8Encoding(false));
foreach (var entry in entries)
{
var number = (int)numberProp.GetValue(entry)!;
var text = (string?)textProp.GetValue(entry) ?? "";
maxBytes = Math.Max(maxBytes, Encoding.UTF8.GetByteCount(text));
// A tab or newline inside a cliloc string would break the row.
// Neither occurs in real tables, but silently emitting a broken
// file is worse than collapsing the whitespace.
writer.WriteLine($"{number}\t{text.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ')}");
written++;
}
}
else
{
using var stream = new FileStream(output, FileMode.Create, FileAccess.Write);
using var binary = new BinaryWriter(stream);
binary.Write(2); // int32 — the plain-format version marker
binary.Write((short)1); // int16 — language marker
foreach (var entry in entries)
{
var number = (int)numberProp.GetValue(entry)!;
var text = (string?)textProp.GetValue(entry) ?? "";
var flag = Convert.ToByte(Convert.ToInt32(flagProp.GetValue(entry)));
var utf8 = Encoding.UTF8.GetBytes(text);
maxBytes = Math.Max(maxBytes, utf8.Length);
// The length field is 16 bits. Real tables peak around 12 KB, so
// this has never fired — but writing a truncated length would
// corrupt every record after it, so an oversize entry is dropped
// and counted instead.
if (utf8.Length > ushort.MaxValue) { skipped++; continue; }
binary.Write(number);
binary.Write(flag);
binary.Write((ushort)utf8.Length);
binary.Write(utf8);
written++;
}
}
Console.WriteLine($"wrote {written} entries to {output} (maxTextBytes={maxBytes}, skippedOversize={skipped})");
if (written == 0)
{
Console.Error.WriteLine("no entries were written — is that a cliloc file?");
return 1;
}
return 0;
}
}

View File

@@ -1,64 +0,0 @@
# cliloc-export
Converts a UO client's **compressed** `Cliloc.enu` into the plain format the
website can read.
This is a one-off operator utility, not part of the website build. Nothing in the
Node application references it and CI never touches it. Full background —
including why the conversion is necessary at all — is in
[`docs/website/CLILOCS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/website/CLILOCS.md).
## The short version
Every current UO client ships its cliloc files in the compressed "Mythic"
container (the first DWORD's high byte is `0x8E`). The website parses the plain
layout those files used before that change. Decompressing is an inverse-BWT coder
that the site has no business carrying at runtime — and ServUO's own bundled
`Ultima.StringList` cannot read it either, so the shard cannot supply item names
on our behalf.
So: convert once, here, using a decompressor that already exists and is already
maintained — [UOFiddler](https://github.com/polserver/UOFiddler)'s `Ultima.dll`.
## Usage
```bash
dotnet build -c Release
# plain binary (recommended — exact)
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.plain
# tab-delimited text (convenient; does not preserve leading/trailing whitespace)
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
```
Then point the site at the output: **Admin → Shard → cliloc path**, or the
`UO_CLIENT_PATH` environment variable. The setting wins over the environment.
Expected output for a stock English client:
```
wrote 123490 entries to /srv/uo-data/clilocs.plain (maxTextBytes=12150, skippedOversize=0)
```
The site stores ~67,500 of those — roughly half a cliloc table is empty strings
for ids the client reserves and never uses.
## Two implementation notes worth keeping
**`Ultima.dll` is loaded reflectively, not referenced.** UOFiddler ships as
net10.0; a project reference from an older SDK fails at *compile* time with
CS1705. Reflection moves that to run time, where `RollForward: LatestMajor`
answers it — so this builds on whatever SDK you have and runs on the newest
runtime installed.
**`StringList.SaveStringList` is not the export path**, despite looking exactly
like it. It *re-compresses* on save, because its purpose is round-tripping a file
back into the client — its output is byte-identical to its input. The plain
records are written by hand for that reason.
## Output is never committed
UO's strings are EA's. `.gitignore` covers this project's build output and the
conventional in-repo output location, but the supported arrangement is a path
**outside** the repository entirely.

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
A one-off operator utility, not part of the website build. Nothing in the
Node application references it and CI never touches it; it exists so an
operator can convert their client's compressed cliloc file without clicking
through a GUI. See README.md and docs/website/CLILOCS.md.
TargetFramework is deliberately net8.0 — the OLDEST runtime this needs — so
it builds on whatever SDK an operator already has. UOFiddler's Ultima.dll is
net10.0 and is loaded reflectively at run time rather than referenced, which
is what keeps that version difference from being a compile error; the
RollForward below is what lets the resulting binary run on it.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>clilocexport</AssemblyName>
<RootNamespace>ClilocExport</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<RollForward>LatestMajor</RollForward>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>