8 Commits

Author SHA1 Message Date
720103e3d4 Merge branch 'main' into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 13m24s
2026-09-15 10:04:54 +00:00
f373f2e897 Merge pull request 'chore(tools): delete the cliloc converter the Asset Bridge replaced (Phase 2)' (#201) from feat/asset-bridge-p2 into edge
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 13m28s
Reviewed-on: #201
2026-09-10 16:20:24 +00:00
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
655fbf3f69 Merge pull request 'feat(events): the Event System — core (Phase 16b cutover, 2 of 6)' (#199) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 1m32s
Build container images / build (push) Successful in 2m12s
Build container images / deploy (push) Successful in 49s
SonarQube / analysis (push) Failing after 33m0s
Reviewed-on: #199
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 00:43:29 +00: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
12 changed files with 426 additions and 284 deletions

View File

@@ -27,6 +27,27 @@
"example": "The Yew Invasion",
"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",
"type": "string",
@@ -89,6 +110,27 @@
"example": "The Yew Invasion",
"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",
"type": "string",
@@ -135,7 +177,21 @@
"type": "string",
"required": false,
"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",
@@ -185,6 +241,27 @@
"example": "The Yew Invasion",
"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",
"type": "url",
@@ -219,6 +296,27 @@
"example": "The Yew Invasion",
"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",
"type": "string",
@@ -272,7 +370,7 @@
"type": "string",
"required": false,
"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",
@@ -281,6 +379,13 @@
"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": "startsAt",
"type": "datetime",
@@ -288,13 +393,6 @@
"example": "2026-09-12T20:00:00.000Z",
"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",
"type": "string",
@@ -341,7 +439,7 @@
"type": "string",
"required": false,
"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",
@@ -350,6 +448,13 @@
"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": "startsAt",
"type": "datetime",
@@ -357,13 +462,6 @@
"example": "2026-09-12T20:00:00.000Z",
"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",
"type": "string",

View File

@@ -29,6 +29,29 @@
// test-send without a live game event, which is the reason template systems go
// 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 = [
{
id: 'news.post',
@@ -215,14 +238,9 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
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.' },
...EVENT_AMBIENT,
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
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
// 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
@@ -254,14 +272,9 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
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.' },
...EVENT_AMBIENT,
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
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
// 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
@@ -293,6 +306,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: true, example: 'assault',
description: 'The phase key just entered, as authored in the spec.' },
{ 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.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
// The public page for THIS occurrence (Phase 14a). Relative, like
// `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
@@ -345,8 +360,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
description: 'The event summary, as authored.' },
...EVENT_AMBIENT,
// Counted from `event_run_participants` at emit. Zero on a run whose
// module reported nobody, which is every run until a module collects —
// 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.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
// **The operator's reason, and not the run's `last_error`.** `cancel`
// 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.
@@ -408,6 +423,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: false, example: 'assault',
description: 'The phase it failed in, when it had entered one.' },
{ 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
// render midnight as "00:00" while the schedule editor beside it writes
// "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)
return `${text} (${zone || 'UTC'})`
} catch {

View File

@@ -37,11 +37,19 @@ const participantsDb = require('./eventRunParticipants.db')
const calendarModel = require('./eventCalendar.model')
const recurrence = require('../../events/recurrence')
// The public calendar's window when a caller names neither end: now through a
// month out. A visitor arriving at /site/events wants "what is on", and a client
// that had to compute a window before it could ask anything would make every
// deep link carry two ISO instants.
// The public calendar's window when a caller names neither end: a few days BACK
// through a month out. A visitor arriving at /site/events wants "what is on", and
// a client that had to compute a window before it could ask anything would make
// 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_RECENT_DAYS = 7
// 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
@@ -146,8 +154,15 @@ const publicProjectedEntry = (definition, occurrence) => ({
* surface that has no login in front of it.
*/
async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
const start = from ? new Date(from) : new Date(now)
const end = to ? new Date(to) : new Date(start.getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
// The default `to` is measured from NOW, not from `start` — otherwise the
// 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())) {
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
let occurrences = []
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 {
// 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
@@ -405,5 +435,6 @@ module.exports = {
publicStatus,
phaseLabel,
DEFAULT_WINDOW_DAYS,
DEFAULT_RECENT_DAYS,
PAST_RUNS,
}

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
* each other's rows. `reverting` is deliberately not claimable — a row another
* pass is mid-revert on is left alone, exactly as a step with a live claim is.
* each other's rows. A row another pass is mid-revert on is left alone, exactly as
* 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) {
const result = await query(
`UPDATE event_run_resources
SET status = 'reverting'
WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned', 'drifted')`,
[id],
SET status = 'reverting', updated_at = NOW()
WHERE 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
}

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
* 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
* a run records the zone it was COMPUTED in and a definition's zone can be
* 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 ({
from,
to,
@@ -125,8 +143,11 @@ const listInWindow = async ({
limit = 500,
publicOnly = false,
} = {}) => {
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
const args = [from, to]
const where = [
`((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) {
where.push('r.status = ?')
args.push(status)

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', () => {
// `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,
// 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/)
})

View File

@@ -115,10 +115,15 @@ function installStubs() {
.filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
.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 }) =>
store.runs.filter((r) => {
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
const d = store.definitions.find((x) => x.id === r.definition_id)
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 })
assert.equal(result.ok, true)
assert.equal(new Date(result.window.from).getTime(), NOW.getTime())
const days = (new Date(result.window.to) - new Date(result.window.from)) / 86_400_000
assert.equal(days, publicModel.DEFAULT_WINDOW_DAYS)
const back = (NOW - new Date(result.window.from)) / 86_400_000
const forward = (new Date(result.window.to) - NOW) / 86_400_000
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 () => {

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)
})
// ── 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) => {
if (needDb(t)) return
// 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>