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

@@ -618,3 +618,113 @@ test('reconcileAll asks every module that owns a live row', async () => {
// orphaned by a module that is not there to be asked.
assert.equal(out.other.unanswered, 1)
})
// ── Expiry (MODULE_API 1.11.0, Rust PLAN_FIXES D183) ─────────────────────────
//
// A game that ends something at its own deadline — a Rust zone erased when its
// time is up — says so through `ctx.events.expired`. The properties:
//
// • **expired is terminal**: the sweep never tries to give it back, and it is
// not one of the statuses that makes a run's cleanup unfinished
// • **it is not orphaned**, and it wins over orphaned: a reconcile that ran
// before the module heard the expiry filed the row as vanished
// • **a module closes only its own rows**, and only the target it named
// • **a revert already in flight is left to finish** — it finds nothing, which
// §L calls a success, and settles as `reverted` by itself
// • **a finished run whose last row this was is complete**, even one the sweep
// had given up on as `incomplete`
function stubExpiry() {
resourcesDb.expirableByTarget = async (owner, kind, ref) =>
[...store.rows.values()]
.filter((r) => r.owner_module === owner && r.kind === kind && r.ref === ref)
.filter((r) => ['pending', 'confirmed', 'orphaned'].includes(r.status))
.map((r) => ({ ...r }))
resourcesDb.markExpired = async (id, detail = null) => {
const r = store.rows.get(id)
if (!r || !['pending', 'confirmed', 'orphaned'].includes(r.status)) return false
Object.assign(r, { status: 'expired', last_error: detail })
return true
}
store.run = { ...RUN }
runsDb.getById = async (id) => (id === RUN.id ? store.run : null)
}
test('an expired resource is terminal: the sweep never tries to give it back', async () => {
stubExpiry()
let reverts = 0
registerAction({ revert: async () => { reverts += 1; return { ok: true } } })
addStep(1, 'demo.spawn')
addResource({ kind: 'zone', ref: '3:rg-7-1' })
assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: '3:rg-7-1' }), { expired: 1 })
const row = [...store.rows.values()][0]
assert.equal(row.status, 'expired')
assert.ok(store.log.some((e) => e.kind === 'resource.expired' && e.detail.resource === 'zone:3:rg-7-1'))
const summary = await cleanup.cleanupRun(RUN)
assert.equal(summary.attempted, 0)
assert.equal(reverts, 0)
assert.equal(row.status, 'expired')
assert.equal(store.cleanupStatus, 'complete')
})
test('an expiry wins over orphaned, and leaves a revert in flight alone', async () => {
stubExpiry()
addResource({ kind: 'zone', ref: 'a', status: 'orphaned' })
addResource({ kind: 'zone', ref: 'b', status: 'reverting' })
addResource({ kind: 'zone', ref: 'c', status: 'reverted' })
for (const ref of ['a', 'b', 'c']) await cleanup.expireResource('demo', { kind: 'zone', ref })
const status = (ref) => [...store.rows.values()].find((r) => r.ref === ref).status
assert.equal(status('a'), 'expired')
assert.equal(status('b'), 'reverting')
assert.equal(status('c'), 'reverted')
})
test('a module closes only its own rows, and only the target it named', async () => {
stubExpiry()
addResource({ owner_module: 'demo', kind: 'zone', ref: 'x' })
addResource({ owner_module: 'other', kind: 'zone', ref: 'x' })
addResource({ owner_module: 'demo', kind: 'npc', ref: 'x' })
assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' }), { expired: 1 })
const rows = [...store.rows.values()]
assert.deepEqual(rows.map((r) => r.status), ['expired', 'confirmed', 'confirmed'])
})
test('an expiry nobody ledgered, or a malformed one, is not an error', async () => {
stubExpiry()
addResource({ kind: 'zone', ref: 'x' })
for (const bad of [undefined, {}, { kind: 'zone' }, { ref: 'x' }, { kind: 1, ref: 'x' }, { kind: 'zone', ref: 'y' },
{ kind: 'zone', ref: 'r'.repeat(191) }]) {
assert.deepEqual(await cleanup.expireResource('demo', bad), { expired: 0 }, JSON.stringify(bad))
}
assert.equal([...store.rows.values()][0].status, 'confirmed')
assert.equal(store.log.length, 0)
})
test('the last row of a finished run expiring completes an incomplete cleanup', async () => {
stubExpiry()
addResource({ kind: 'zone', ref: 'x' })
store.cleanupStatus = 'incomplete'
await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' })
assert.equal(store.cleanupStatus, 'complete')
})
test('a run still in flight keeps its cleanup status, and so does one with rows left', async () => {
stubExpiry()
addResource({ kind: 'zone', ref: 'x' })
store.run = { ...RUN, status: 'running' }
store.cleanupStatus = 'pending'
await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' })
assert.equal(store.cleanupStatus, 'pending')
store.rows.clear()
addResource({ kind: 'zone', ref: 'y' })
addResource({ kind: 'zone', ref: 'z' })
store.run = { ...RUN }
store.cleanupStatus = 'incomplete'
await cleanup.expireResource('demo', { kind: 'zone', ref: 'y' })
assert.equal(store.cleanupStatus, 'incomplete')
})