feat(events): the Event System — core (Phase 16b cutover, 2 of 6) #199
Reference in New Issue
Block a user
No description provided.
Delete Branch "edge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The Event System cutover, step 2 of 6 (
EVENTS_PLAN.mdPhase 16b).edge→main, a clean fast-forward —mainis 0 ahead. 17 commits, 109 files.Merge after
link#40+servuo-plugins#26, and beforeModule-uo#…. The order matters in one direction only: the module'sci/core-ref.jsonhas to name amainsha of this repo carryingMODULE_API_VERSION1.10.0, and that sha does not exist until this merges.What this is
A game-agnostic engine for scheduled, bounded, audited changes to a live game world. Core owns the schedule, the run, the permission gate, the ledger and the audit trail; it knows nothing about Ultima Online. Everything UO-specific arrives through the module contract, which is the point of the whole shape.
mayInvoke.MODULE_API_VERSION1.10.0.event.*triggers, the participation ledger,core.results.publish,core.announce.post.eventscapability string;eventUrlon the six public triggers.Nine new routes (
/admin/events/*,GET /player/events/history, three/public/events), all annotated and the OpenAPI spec regenerated. No new environment variables — every operator-facing knob is admin-managed, per this repo's own convention.What an operator sees on upgrade
Nothing to do. The schema is ensured on boot as always. The one migration worth naming lives in
module-uo's fragment rather than here — the storeduo_link_config.protocolrow is carried 5 → 7 one-shot against its own marker, so an existing deployment does not come up 409-ing against a protocol-7 sidecar, and an operator who has deliberately pinned an older sidecar stays pinned.Events are off until someone enables one. There is no seeded event.
Verification
edgehas hadpr-checkson every PR into it, and Phase 16a walked the whole system on a real rig — ServUO 57.4 →cargo --releasesidecar on protocol 7 → this core withmodule-uoinstalled from a release-shaped bundle → the Android app on an emulator.All three of Phase 16's deliberate failures pass: a mid-teardown process kill resumed with every step still at
attempts = 1; a sidecar killed mid-phase left the rundegradedwith its world writes parked; a cap breach answeredrefusedwithcode: "cap"and the dry run had already refused it in the author's own words.16b re-verifies this from the real released artefacts — installer → composed bundle → fresh ServUO → the module installed through this core's own https module installer → boot → one event with a real teardown.
🤖 Generated with Claude Code
https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not depend on the module contract — plus definitions CRUD, publish, archive, and the action registry with core as its first registrant. **Nothing dispatches.** There is no runner until Phase 2, so a run row is created and stays `scheduled`. That is this phase's correct answer and the surface renders it verbatim rather than hiding it. Schema (`db/schema.sql`, append-only): event_series, event_definitions, event_versions, event_runs, event_run_steps, event_run_log. The four that need a writer — event_action_settings, event_run_budget, event_run_resources, event_run_participants — arrive with the phases that give them one. Registry (`modules/registries.js` + `config/coreEventActions.js`): registerEventActions staging and commit, with its own id namespace, the closed risk and reversibility sets, revert() required iff and only iff reversible: 'ledger', a bounded budgetMs and a param shape whose every entry needs a type and an example. perform/revert/cost are stripped from everything the catalog serves. Core declares core.announce, core.wait and core.cue through the same staging area a module will use. It is reachable ONLY by registerCore(): loader.js builds its own api facade and has no method that delegates here, so no module can call it and MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump. Surface (13 routes under /api/v1/admin/events): Reads staff-wide; publish, archive and run creation admin-only from this phase per EVENTS.md §N2, even though the switchboard they will consult does not exist yet — a button that is admin-only later and open now is a gate nobody notices was missing. The live run controls and `verify` are absent rather than stubbed, because nothing is in flight yet. Four things the build settled, all recorded in docs: - event_definitions gained a `spec` column. A draft's working copy cannot be an event_versions row: that table is immutable and a run pins one. - The spec validator must accept its own output. It added `actionVersion` and `dormant` and then refused them as unknown keys, which would have made the second save of any definition — and publish's re-validation — impossible. A test caught it; both are now accepted and recomputed. - A param's `example` is required, optional params included, matching registerEventTriggers. It is the authoring form's placeholder. - Two routes the §API-surface table did not name: GET /admin/events/:id and GET /admin/events/series. Core's three perform() bodies answer { ok: false, retry: false } rather than { ok: true }: `ok: true` on an action that did nothing is a recorded world change that did not occur, which is the exact mistake §F's failure default exists to prevent. `conditions.checkLiteral` is exported and reused for step-param type checking — one switch over the six types, so "is this a datetime" has one answer. Verified: 44 new tests, whole server suite, `npm run check:modules`, routes manifest and swagger regenerated (the manifest diff is +13 routes, zero moved). Docs: RunicGateway/docs#209 Co-Authored-By: Claude <noreply@anthropic.com>Three screens, an Events nav group and the six live run controls Phase 1 left absent on purpose because nothing was in flight. An admin can now author, publish, start and watch an event that announces things and cues a human; a moderator can stop one that is going wrong. Six controls, not eight. `advance` is absent because a phase today advances when its steps go terminal — the per-step skip already does that — and Phase 5 is what gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`, until Phase 8's ledger exists. Every control is a compare-and-set on the status it may act from, so a console rendered thirty seconds ago cannot act on a run that has moved. Fixes a defect in the Phase 2 runner: `advanceRun` drained up to EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the tick, so a pause pressed mid-batch did nothing for up to 24 more steps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtLA phase used to advance on one fact - every step terminal. It can now also carry an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where: <conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?" answered in the condition builder's own words, with the tally, the elapsed time and the last related firing whether or not it counted. `POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that will never spawn, and that is the one state "force it anyway" names. One new table, `event_run_phase_gates`. The emit path writes the tally at the moment a firing happens - a gate waiting on three spawns counts things that occur between two ticks, and a tally held in a process's memory is one a restart silently zeroes - and the runner's tick reads it. A gate that never opens is HELD, with no automatic advance and no authored timeout (org lead, 2026-09-02). What the engine owes instead is visibility: `EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is now escalation-only so a later retry cannot demote it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtLA lease row had no reconcile path at all, and nothing failed to say so. `cleanup.js` resolves a resource to the action of the step that made it, and for a lease that action is `core.lease` -- a CORE action, on a path a module cannot register anything on. So every `override` row came back `unanswered` for the life of the run, and a lease the shard had quietly dropped (a config lease is memory-only there, so a restart reverts it by design) stayed in the ledger as live until teardown went hunting a baseline nobody was holding. `core.lease` gains a `reconcile()`, and `registerEventLeases` gains an optional `inForce()`: "does the game side still have any record of this hold?" Deliberately not `read()` plus a comparison. A value that differs from what the run applied is DRIFT, which teardown must deliver through `restore()` so the row lands `drifted` with the current value beside it; a reconcile that inferred absence from a changed value would orphan the row first and tell the operator the lease vanished rather than that somebody moved it. Only an explicit `{ ok: true, held: false }` takes a row out -- a throw, a timeout, an unrecognised shape and a lease with no `inForce()` all leave the ledger alone. MODULE_API_VERSION stays 1.10.0, amended in place. Co-Authored-By: Claude <noreply@anthropic.com>The core half of Phase 12b, and the half Phase 12a did not need. A targeted lease is a shape `core.lease` did not have. Every lease before this named a SINGLE value, so the lease id WAS the target and none of the four callables took one. `Spawner.MaxCount` is not that shape: it is one capability over thousands of spawners, and a reservation on the id alone would let one run turning up one spawner refuse every other run every other spawner. So a lease may declare a `target`, the callables are handed it, and the ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target refusal at the granularity the world actually has while leaving it coming from the same unique index it always did. Extending core rather than giving the module a lease verb of its own is what §F decided in Phase 8 ("the verb is core's"): a lease verb per module would re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and wrong in the first one that forgot. Half that objection no longer holds -- the target check comes free from the index whichever verb reserves the row -- and the other half still does. Three readers of a lease ref, not one. `cleanup.restoreLease` and `ledger.normalise` both looked a lease up by the whole `row.ref`, and both were correct for exactly as long as a ref was a bare id. Left alone, a targeted row would have missed in both -- cleanup reporting "no module registers the lease" and refusing to restore a world that really was changed, which is the worst failure this table has. All three now go through `eventLeaseForRef`. `values` closes a `string` lease's set. `min`/`max` bound the numeric types and nothing bounded `string`, so the only check on a string lease's value was the game side's -- a refusal arriving unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside `min`/`max` would be a second bound with no rule about which wins. Option sources become searchable, and the first one that needed it forced this phase's shape. `resolveOptionSource(id)` took no argument and every source answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn points, so a flat list would have dropped two thirds of the world and said nothing about which two thirds -- the failure 12a named for decoration, arriving for real. `resolve({ q })` is additive: every source is passed a term, none is required to read one, and a `searchable` flag says which do, because inferring it from a truncated answer reads correctly right up until a small deployment's list happens to fit. `MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the shape every phase since P10 has used while this workstream sits on `edge`. The swagger regeneration carries one incidental change: the committed spec said the session cookie is `rg_rig`, which is neither the documented default nor what this repo's own `server/.env` sets. It was generated somewhere with that env var set. The regeneration corrects it to `rg_token`. 2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite requires. Six new tests cover the targeted-lease shape, both refusal directions, the value set, and the search term. Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a step's params are a form rendered from the action's own declaration, and a phase's advance condition is the engagement condition builder. Adds the live cap meter, the searchable option source's first consumer, and a start dialog carrying the three fields the route has taken since Phase 10. One route: POST /admin/events/price, admin+editor. A module's cost() runs on the server and only there, so a meter has nothing to add up until something asks -- and the dry run is the wrong thing to ask on a debounce twice over: it dispatches every step through the module and a pass against a version is RECORDED, which is the stamp K's unattended-start gate reads. This dispatches nothing and records nothing, and takes the spec in the body because the plan being priced is unsaved between keystrokes. A form gives way to JSON on the condition builder's own rule: a value the editor cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the action does not declare and flattening `A and (B or C)` are the same mistake. Two defects fixed in already-merged code: * Creating an event has been impossible since Phase 6. `events/new` was added beside `events/:id` and binds no param, and React Router ranks a static segment above a dynamic one whatever the order -- so the editor was handed no id and fetched /admin/events/undefined. Worse, the failure was invisible: `!form` is true for every failed load, so the error state sat behind a spinner that never stopped. * 12b's searchable sources had no consumer. The server half shipped and the only UI that reads a source never sent a term, so the 6,707-entry spawner list was picked from a 2,000-entry truncation with nothing saying so. Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail. routes:manifest and swagger regenerated -- one route added, none moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4`EVENTS.md` §H told a module the revert contract accepts a `detail` on its envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`, `resources` and `participants` — and has never read a `detail`. So a module that answered one was writing into nothing. `module-uo` believed it, twice, since Phase 12b: * `uo.item.grant` answers `{ granted, missed, why }` * `uo.world.save` answers `{ started: true }` The grant is the one that matters. A grant reaches the players a run's participation 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. Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose template made the same mistake on §H's authority. ## What this adds `detail` becomes a real, optional member of the two SUCCESS envelopes, beside `resources` and `participants` — on both, because `await: 'human'` is a success and a cue's confirm finishes the step without a second dispatch, so that is the only moment its module could ever have said anything. **Core never interprets it.** `safeDetail()` bounds it and nothing else reads a key out of it, here or in the runner or in the browser. That is the point: a module knows things about its own verb core cannot compute, and it had no other way to say them. * objects only — the column is JSON and the console renders keys, so a bare string has nothing to render under, and core inventing a key would be core interpreting it after all; * 4KB of serialised JSON, dropped rather than truncated, because half a JSON object is not a JSON object; * unserialisable (circular, a throwing `toJSON`) is dropped — reaching the runner would make the log INSERT throw, inside the one write documented never to; * re-parsed rather than passed through, so core holds no live reference into a module's object; * **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 is a world write repeated for a log line. The runner writes it as a `step.detail` run-log row, its own kind rather than a field on `resource.recorded` — the grant that forced this ledgers nothing (`reversible: 'none'`) and reports no participants, so it would have had nowhere to ride. ## The renderer, which is half the fix `describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling through would have rendered as the literal string "step.detail" — the channel existing and showing nothing, exactly the failure being fixed. It gets a case that renders whatever keys the module put there, generically: a switch on known keys would be the browser learning one module's vocabulary. uo.item.grant — granted: 8, missed: 4, why: bank full, offline uo.world.save — started: true **`module-uo` needs no change**: the code it already shipped starts working. MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line route manifest diff; no route added. 2057 server tests, 400 client tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4