Compare commits
10 Commits
2e9ed50e21
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| 720103e3d4 | |||
| f373f2e897 | |||
| 61dc692088 | |||
| 655fbf3f69 | |||
| baa4f7d5ba | |||
| 7d7840eb6b | |||
| b92b85c3a9 | |||
| 6dd4e5e3eb | |||
| af9f4e191c | |||
| e46842a28c |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 event’s 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 event’s 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 event’s 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 event’s 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 event’s 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 event’s 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 event’s 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",
|
||||||
|
|||||||
@@ -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 event’s 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',
|
||||||
|
|||||||
@@ -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 0–11, 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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user