feat(events): an expired ledger status and ctx.events.expired (MODULE_API 1.11.0)
All checks were successful
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 5m48s
PR Checks / bot-tests (pull_request) Successful in 7m51s

A game that ends a resource at its own deadline — a Rust zone erased when its
time is up — could reach core only through ctx.events.reconcile(), which files
it `orphaned`: amber, "it vanished", and still claimable for a revert. The new
call files it as the plan working (Rust PLAN_FIXES F14, D170, D183):

- event_run_resources.status gains `expired`, terminal like `reverted`: the
  sweep never takes it back, live_marker releases the target, and it joins
  neither HELD nor UNRESOLVED. The ENUM ALTER re-runs as a no-op on every boot
  (checked on MariaDB 11.8 with the stored generated column depending on it).
- ctx.events.expired({ kind, ref }) marks the calling module's own pending,
  confirmed or orphaned rows for that target expired and logs
  `resource.expired`; a revert in flight is left to finish. The owner is bound
  by the loader, like reconcile. A finished run whose last unresolved row this
  was goes to cleanup `complete`, even from `incomplete`.
- The run console shows it green, "ended by the game on time".

Additions only, so minor. Module-uo (coreApi ^1.10.0) calls none of it and
reads no ledger status; the contract test now asserts the range still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 21:13:07 -05:00
parent 1a76b98d76
commit cc1f49af29
14 changed files with 309 additions and 10 deletions

View File

@@ -427,6 +427,64 @@ async function reconcileAll() {
return out
}
// The bounds on what a module may name. They are the columns' own: a longer
// value cannot be a row, so it is refused rather than truncated into a match
// against somebody else's.
const MAX_KIND = 64
const MAX_REF = 190
/**
* The game ended one of this module's resources by itself, as it was told to —
* `ctx.events.expired({ kind, ref })`, MODULE_API 1.11.0 (Rust PLAN_FIXES D183).
*
* **Expired is not orphaned, and the difference is the reason this exists.** A
* Rust zone is created with a deadline and the game erases it when the deadline
* passes, without being asked again — the fail-safe that makes an unattended
* world change defensible. Until a module could say so, core learned of it only
* through reconcile, which files it as `orphaned`: a thing that vanished while
* nobody was looking, amber on the console and still claimable for a revert.
* An expiry is the plan working, so it is terminal and green like `reverted`.
*
* `owner` is bound by the loader, never taken from the module's arguments, for
* `reconcile`'s reason: without the binding a module could close another
* module's rows. Answers `{ expired }`; nothing matching is `{ expired: 0 }`,
* because a module hears expiries of things no run ledgered too.
*/
async function expireResource(owner, { kind, ref } = {}) {
if (typeof kind !== 'string' || !kind || kind.length > MAX_KIND) return { expired: 0 }
if (typeof ref !== 'string' || !ref || ref.length > MAX_REF) return { expired: 0 }
const rows = await resourcesDb.expirableByTarget(owner, kind, ref)
const runs = new Set()
let expired = 0
for (const row of rows) {
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) continue
expired += 1
runs.add(row.run_id)
await logDb.write({
runId: row.run_id,
kind: 'resource.expired',
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
})
}
// A finished run whose last unresolved row this was has nothing left to clean
// up. The sweep would settle a `pending` one by itself, but not an
// `incomplete` one — that status takes a run out of the sweep's scan, and it
// would go on saying "not finished" about a ledger that is. A run still in
// flight is left alone: its own terminal path computes the status.
for (const runId of runs) {
const run = await runsDb.getById(runId)
if (!run || !runsDb.TERMINAL.includes(run.status)) continue
if ((await resourcesDb.unresolvedCount(runId)) === 0) {
await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete'])
}
}
return { expired }
}
module.exports = {
MAX_REVERT_ATTEMPTS,
classifyRevert,
@@ -434,4 +492,5 @@ module.exports = {
sweep,
reconcileModule,
reconcileAll,
expireResource,
}