feat(events): an expired ledger status and ctx.events.expired (MODULE_API 1.11.0)
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:
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user