docs(events): Phase 5 as built — conditions, phase advancement and the panel #213
@@ -381,6 +381,12 @@ A phase advances on `{ after: '30m' }` or `{ on: '<triggerId>', where: <conditio
|
|||||||
trigger catalog a module already ships *is* the catalog of things that can advance a phase, so a
|
trigger catalog a module already ships *is* the catalog of things that can advance a phase, so a
|
||||||
module gains phase conditions by adding one entry to an array it already declares.
|
module gains phase conditions by adding one entry to an array it already declares.
|
||||||
|
|
||||||
|
> **Built in Phase 5, and `engagement/conditions.js` is reused unchanged** — its grammar, its type
|
||||||
|
> checking against the declaration, its depth and list bounds, and its operator labels. What Phase 5
|
||||||
|
> added is where the words are *read*: the same labels that fill the rule editor's dropdowns render
|
||||||
|
> the diagnosis panel, so `gte` says *"is at least"* in both places because there is only one place
|
||||||
|
> it is written down.
|
||||||
|
|
||||||
**A cap is checked before dispatch, and a breach is a refusal, not a failure.**
|
**A cap is checked before dispatch, and a breach is a refusal, not a failure.**
|
||||||
A step that would exceed a cap does not run and does not retry. It is recorded `refused` with the
|
A step that would exceed a cap does not run and does not retry. It is recorded `refused` with the
|
||||||
dimension and the numbers, and shown to the author — because "you asked for 40 monsters and this
|
dimension and the numbers, and shown to the author — because "you asked for 40 monsters and this
|
||||||
@@ -395,8 +401,9 @@ completion, cancellation and abort alike.
|
|||||||
|
|
||||||
## D — Data model
|
## D — Data model
|
||||||
|
|
||||||
Nine core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core
|
Eleven core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core
|
||||||
tables carry no module prefix.
|
tables carry no module prefix. (The count said "nine" over a list of ten from the revision that added
|
||||||
|
`event_series` until Phase 5 counted them.)
|
||||||
|
|
||||||
| Table | Holds | Why a table |
|
| Table | Holds | Why a table |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -409,6 +416,7 @@ tables carry no module prefix.
|
|||||||
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap` — so the cap holds under concurrent steps without a transaction. |
|
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap` — so the cap holds under concurrent steps without a transaction. |
|
||||||
| `event_run_resources` | `run_id`, `step_id`, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among non-reverted rows | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. |
|
| `event_run_resources` | `run_id`, `step_id`, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among non-reverted rows | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. |
|
||||||
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. |
|
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. |
|
||||||
|
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
|
||||||
| `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. |
|
| `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. |
|
||||||
|
|
||||||
> **`spec` on `event_definitions` is Phase 1's one addition to this table's column list**, and it
|
> **`spec` on `event_definitions` is Phase 1's one addition to this table's column list**, and it
|
||||||
@@ -462,6 +470,12 @@ resources.
|
|||||||
**As built, the tick has four legs**, ordered: **reclaim** (release leases whose holder died),
|
**As built, the tick has four legs**, ordered: **reclaim** (release leases whose holder died),
|
||||||
**materialise**, **advance**, **drain**, then a **prune** on its own six-hourly clock.
|
**materialise**, **advance**, **drain**, then a **prune** on its own six-hourly clock.
|
||||||
|
|
||||||
|
**A phase advances on every one of its steps going terminal — and, if it authored one, on its GATE
|
||||||
|
being open as well** (Phase 5). The gate is an *additional* condition and never a replacement: a
|
||||||
|
phase whose steps are still running is not advanced by a boss that spawned early, and the force
|
||||||
|
control refuses a phase held by a step for the same reason. A phase with no gate behaves exactly as
|
||||||
|
every phase did before Phase 5, and carries no `advance` key at all.
|
||||||
|
|
||||||
**Materialise is two halves, and Phase 4 completed it.** The first EXPANDS: every `ready` definition's
|
**Materialise is two halves, and Phase 4 completed it.** The first EXPANDS: every `ready` definition's
|
||||||
recurrence is computed in its own IANA zone, and every occurrence inside a **fourteen-day horizon**
|
recurrence is computed in its own IANA zone, and every occurrence inside a **fourteen-day horizon**
|
||||||
(`EVENT_MATERIALISE_AHEAD_DAYS`) becomes a real `scheduled` row via `INSERT IGNORE` against the
|
(`EVENT_MATERIALISE_AHEAD_DAYS`) becomes a real `scheduled` row via `INSERT IGNORE` against the
|
||||||
@@ -552,6 +566,56 @@ is worse than the problem: a cancelled row still holds its slot in `uq_evrun_occ
|
|||||||
occurrence would not come back on the new version, it would vanish. A run that **has** started keeps
|
occurrence would not come back on the new version, it would vanish. A run that **has** started keeps
|
||||||
its pin for ever.
|
its pin for ever.
|
||||||
|
|
||||||
|
### Advance conditions, and the two writers a gate has
|
||||||
|
|
||||||
|
A phase's `advance` is one of exactly two shapes, both validated at save:
|
||||||
|
|
||||||
|
| Shape | Means | Closed by |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `{ after: '30m' }` | thirty minutes from the moment the phase was **entered**, whatever its steps took | the runner's tick, `satisfied_by: 'elapsed'` |
|
||||||
|
| `{ on: '<triggerId>', where: <conditions>, count: n }` | `n` firings of that trigger that satisfy the predicate, counted from phase entry | the **emit path**, `satisfied_by: 'condition'` |
|
||||||
|
|
||||||
|
`after` is one integer and one unit from `s m h d`, normalised to its largest whole unit — `'120m'`
|
||||||
|
is stored as `'2h'` — for the reason `days` is normalised into week order: the spec is diffed between
|
||||||
|
versions, and two spellings of one delay differing as JSON is a version history reporting edits
|
||||||
|
nobody made. `count` defaults to 1; `where` is optional and `null` means any firing.
|
||||||
|
|
||||||
|
**The two shapes are closed by two different writers, and that division is the design.** A gate
|
||||||
|
waiting on three boss spawns is counting things that happen *between* two ticks; fifteen seconds
|
||||||
|
after the third spawn there is nothing left for a poller to observe, and a tally held in a process's
|
||||||
|
memory is one a restart silently returns to zero — with the phase then waiting for three more of
|
||||||
|
something that already happened. So `ctx.events.emit` writes the count where it happens, beside
|
||||||
|
`engine.dispatch` and with the same posture (never awaited, never allowed to reject), and the tick
|
||||||
|
reads the row. The increment is **one statement with the threshold inside it**, the argument
|
||||||
|
`event_run_budget`'s conditional increment makes one phase early: two firings arriving together each
|
||||||
|
add one and exactly one of them crosses `needed`.
|
||||||
|
|
||||||
|
**The clock and the tally both start at phase ENTRY**, not when the steps finish. `after: '30m'` is
|
||||||
|
thirty minutes after the phase began however long its dispatches took, and a trigger that fires while
|
||||||
|
the announce step is still in flight counts. Both are what makes a gate predictable from the authored
|
||||||
|
spec alone.
|
||||||
|
|
||||||
|
**A gate that never opens is held, and made loud** (org lead, 2026-09-02). There is deliberately no
|
||||||
|
authored timeout and no disposition to choose: nothing advances a phase but its condition or a human.
|
||||||
|
What the engine does instead is *say so* — an `on` gate that has waited past `EVENT_PHASE_STALL_MS`
|
||||||
|
(one hour) takes the run's `health` to **`stalled`**, §E's third value and the first thing in this
|
||||||
|
system ever to write it, logged once. It has to be loud, because a held run also holds its
|
||||||
|
concurrency key, so every later occurrence of the same definition goes `missed` behind it. An
|
||||||
|
`after` gate is never stalled: a phase waiting out six hours it was authored to wait is working, and
|
||||||
|
health that said otherwise would train an operator to ignore it.
|
||||||
|
|
||||||
|
**Health is a high-water mark, and `setHealth` now enforces it.** Nothing has ever cleared
|
||||||
|
`degraded` — a run whose announcement landed on the second attempt did have trouble, and that stays
|
||||||
|
true for the rest of its life — so the write is escalation-only. Without that guard, a step retrying
|
||||||
|
after a stall would demote `stalled` back to `degraded`, and a run that waited ninety minutes on a
|
||||||
|
boss that never came would end its life claiming it merely wobbled.
|
||||||
|
|
||||||
|
**A gate on a trigger nothing registers is DORMANT**, exactly as a step naming an unregistered action
|
||||||
|
is: it saves, so uninstalling a module is not destructive to an author's work, and it refuses to
|
||||||
|
publish, because a version runs are pinned to must not wait on a trigger that can never fire. Its
|
||||||
|
predicate is carried through unvalidated rather than dropped — there is no declaration to check it
|
||||||
|
against, and deleting it would silently discard what the author wrote.
|
||||||
|
|
||||||
### Concurrency
|
### Concurrency
|
||||||
|
|
||||||
| Contention | Protection | Not an in-process mutex, because |
|
| Contention | Protection | Not an in-process mutex, because |
|
||||||
@@ -945,14 +1009,24 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie
|
|||||||
cancel-with-cleanup, cancel-without-cleanup — each logged with the actor. *Editing* a running event
|
cancel-with-cleanup, cancel-without-cleanup — each logged with the actor. *Editing* a running event
|
||||||
is not one of them (see [Versioning](#versioning-and-editing-a-live-event)).
|
is not one of them (see [Versioning](#versioning-and-editing-a-live-event)).
|
||||||
|
|
||||||
> **Six of those exist as of Phase 3, and two do not — for reasons, not for scope.** Pause, resume,
|
> **Seven of those exist as of Phase 5, and one does not — for a reason, not for scope.** Pause,
|
||||||
> cancel, and a step's confirm, skip and retry are built and gated to `admin` + `moderator`.
|
> resume, cancel, and a step's confirm, skip and retry arrived in Phase 3, gated to `admin` +
|
||||||
> **`advance` — force a phase forward — is not**, because it has no honest meaning yet: a phase today
|
> `moderator`. **`advance` joined them in Phase 5**, which is when it started meaning something: a
|
||||||
> advances when its steps go terminal, and the per-step skip already does that one step at a time.
|
> phase used to advance when its steps went terminal and on nothing else, so "force it anyway" named
|
||||||
> Phase 5 gives a phase an advance *condition*, and that is the first moment "force it anyway" names
|
> no state an operator could be in, and the per-step skip already did that one step at a time. A
|
||||||
> something an operator could predict. **Cancel takes `{ reason }` and not `{ cleanup }`**, because
|
> phase with a gate can wait on a boss that will never spawn, and then it names exactly one.
|
||||||
> the resource ledger a cleanup would work over arrives in Phase 8; a flag that changes nothing is
|
>
|
||||||
> the "control that answers 200 and does nothing" this plan has refused twice already.
|
> **Its three refusals are the design.** A run that is not `running` is waiting on nothing. A phase
|
||||||
|
> with no advance condition already advances on its steps, so forcing it would duplicate the runner
|
||||||
|
> rather than override it. And a phase whose steps have not all gone terminal is held by a *step*,
|
||||||
|
> not by its gate — the step-level skip is the honest control for that, one step at a time, and a
|
||||||
|
> force that swept past pending steps would be a cancel of half a phase under a button labelled
|
||||||
|
> advance. It satisfies the gate and stops; the next tick performs the phase boundary, exactly as it
|
||||||
|
> does after `resume`, so there is one implementation of what a phase boundary is rather than two.
|
||||||
|
>
|
||||||
|
> **Cancel takes `{ reason }` and not `{ cleanup }`**, because the resource ledger a cleanup would
|
||||||
|
> work over arrives in Phase 8; a flag that changes nothing is the "control that answers 200 and does
|
||||||
|
> nothing" this plan has refused twice already.
|
||||||
|
|
||||||
> **Retry is one control, not two.** A step may be retried only while its run is `paused`, and a
|
> **Retry is one control, not two.** A step may be retried only while its run is `paused`, and a
|
||||||
> paused run is paused *at* that step — so re-queueing without resuming would leave the run in
|
> paused run is paused *at* that step — so re-queueing without resuming would leave the run in
|
||||||
@@ -973,6 +1047,21 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie
|
|||||||
> the row immortal. `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does **unattended**, and a named
|
> the row immortal. `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does **unattended**, and a named
|
||||||
> person deciding once is the thing it is unattended from.
|
> person deciding once is the thing it is unattended from.
|
||||||
|
|
||||||
|
**The diagnosis panel** (Phase 5) sits above the step list for the same reason the parked cue does:
|
||||||
|
a phase waiting on a condition is `running` and looks completely healthy, and the one screen an
|
||||||
|
operator opens to find out why nothing is happening must say so before they have to read a log. It
|
||||||
|
carries the clause in the condition builder's own words, the tally, how long the phase has waited,
|
||||||
|
and the **last related firing whether or not it counted** — because *"the boss did spawn, in
|
||||||
|
Britain"* and *"no boss has spawned"* are different answers that look identical without it.
|
||||||
|
|
||||||
|
**The sentence is rendered on the server**, and this is the one place this feature does not hand the
|
||||||
|
client a vocabulary to render. `gates[].where` arrives as text. The labels are defined in
|
||||||
|
`engagement/conditions.js`; a renderer in the browser would be a second implementation of a grammar
|
||||||
|
the server owns, and the first clause the two spelled differently would meet its operator at two in
|
||||||
|
the morning. Only the variables the condition **names** are recorded on the gate row and shown — not
|
||||||
|
the payload — because that row is read back onto an admin screen and a copy of a whole game event's
|
||||||
|
data would be a second copy of exactly the content `engagement_sends` is careful not to keep.
|
||||||
|
|
||||||
**Public surface.** An upcoming-events calendar with series and arcs, a live-status page, and
|
**Public surface.** An upcoming-events calendar with series and arcs, a live-status page, and
|
||||||
published results. It must tolerate a run with `health: 'degraded'` without saying so — "the shard is
|
published results. It must tolerate a run with `health: 'degraded'` without saying so — "the shard is
|
||||||
having trouble" is operator information, and the existing shard pages already model exactly this by
|
having trouble" is operator information, and the existing shard pages already model exactly this by
|
||||||
@@ -1081,6 +1170,7 @@ controller stamps it from the session.
|
|||||||
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe, and it does not exist today.* |
|
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe, and it does not exist today.* |
|
||||||
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
|
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
|
||||||
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
|
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
|
||||||
|
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
|
||||||
| **A step would exceed its cap** | `refused`, with the dimension and the numbers, surfaced to the author. Not a retry and not a failure — it is an authoring error. |
|
| **A step would exceed its cap** | `refused`, with the dimension and the numbers, surfaced to the author. Not a retry and not a failure — it is an authoring error. |
|
||||||
| **An action fails** | Per-step `on_failure`, defaulted from the risk class: `retry(n) → skip` for `notify`, `retry(n) → pause` for `change`, `retry(n) → abort_run` for `irreversible`. `pause` stops the run advancing and waits for a human — the right default when the world is half-changed. `n` is `EVENT_STEP_MAX_ATTEMPTS`, 3 by default. **All three dispositions write the STEP `failed`**: `on_failure` says what happens to the run, and a step attempted three times that never worked is `failed` under every one of them. `skipped` is reserved for a step a human skipped from the run console — a status meaning both "nobody ran this" and "this failed and we moved on" would make the console's summary line unreadable. |
|
| **An action fails** | Per-step `on_failure`, defaulted from the risk class: `retry(n) → skip` for `notify`, `retry(n) → pause` for `change`, `retry(n) → abort_run` for `irreversible`. `pause` stops the run advancing and waits for a human — the right default when the world is half-changed. `n` is `EVENT_STEP_MAX_ATTEMPTS`, 3 by default. **All three dispositions write the STEP `failed`**: `on_failure` says what happens to the run, and a step attempted three times that never worked is `failed` under every one of them. `skipped` is reserved for a step a human skipped from the run console — a status meaning both "nobody ran this" and "this failed and we moved on" would make the console's summary line unreadable. |
|
||||||
| **A run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); cleanup steps are generated from the ledger and run. Cancelling *without* cleanup is a separate, logged, admin-only action. **A PARKED step is cancelled with the pending ones** (Phase 3): a cue is not a command already sent, it is an instruction nobody is holding, and leaving it `running` would have the console claim a cancelled event is still waiting for someone. The live lease is what tells the two apart, and it is in the `WHERE` clause. |
|
| **A run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); cleanup steps are generated from the ledger and run. Cancelling *without* cleanup is a separate, logged, admin-only action. **A PARKED step is cancelled with the pending ones** (Phase 3): a cue is not a command already sent, it is an instruction nobody is holding, and leaving it `running` would have the console claim a cancelled event is still waiting for someone. The live lease is what tells the two apart, and it is in the `WHERE` clause. |
|
||||||
@@ -1140,10 +1230,10 @@ no URL moved.
|
|||||||
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup |
|
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup |
|
||||||
| `GET /admin/events/runs/:runId/log` | staff | the diagnostic log |
|
| `GET /admin/events/runs/:runId/log` | staff | the diagnostic log |
|
||||||
| `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }`, and gains `cleanup` with the ledger in Phase 8 |
|
| `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }`, and gains `cleanup` with the ledger in Phase 8 |
|
||||||
| `POST /admin/events/runs/:runId/advance` | admin, moderator | force a phase forward — **not built**; it has no honest meaning until Phase 5 gives a phase an advance condition |
|
| `POST /admin/events/runs/:runId/advance` | admin, moderator | force the current phase past its advance condition (Phase 5). Legal only while the phase is genuinely waiting on its gate; `409` naming what it is waiting on otherwise. Satisfies the gate and stops — the next tick performs the boundary |
|
||||||
| `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | Phase 3. `confirm` resolves a GM cue step; `retry` re-queues the step a paused run is stopped at and resumes it |
|
| `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | Phase 3. `confirm` resolves a GM cue step; `retry` re-queues the step a paused run is stopped at and resumes it |
|
||||||
| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources |
|
| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources |
|
||||||
| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions |
|
| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions — and, since Phase 5, the **trigger** catalog and condition operators the advance form renders. Served here rather than borrowed from `/admin/engagement/triggers` because that route is `adminOnly` while a definition is authored by `admin` **and** `editor` |
|
||||||
| `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param |
|
| `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param |
|
||||||
| `GET /admin/events/series` | staff | the arcs a definition may belong to |
|
| `GET /admin/events/series` | staff | the arcs a definition may belong to |
|
||||||
| `POST /admin/events/series` | admin, editor | create an arc (Phase 4) |
|
| `POST /admin/events/series` | admin, editor | create an arc (Phase 4) |
|
||||||
@@ -1191,9 +1281,14 @@ runner ticks every fifteen seconds, so a console rendered thirty seconds ago des
|
|||||||
since moved, and a control that checked in JavaScript and then wrote would race the tick it exists to
|
since moved, and a control that checked in JavaScript and then wrote would race the tick it exists to
|
||||||
interrupt. A refusal is a `409` naming the status the run is actually in.
|
interrupt. A refusal is a `409` naming the status the run is actually in.
|
||||||
|
|
||||||
Still absent, and still for reasons rather than for scope: `advance` (no advance condition until Phase
|
Still absent, and still for reasons rather than for scope: `cleanup` and cancel's `cleanup` flag (no
|
||||||
5), `cleanup` and cancel's `cleanup` flag (no resource ledger until Phase 8), `verify` and `GET/PUT
|
resource ledger until Phase 8), `verify` and `GET/PUT /admin/events/actions` (no caps to price
|
||||||
/admin/events/actions` (no caps to price against and no switchboard to serve until Phase 6).
|
against and no switchboard to serve until Phase 6). `advance` left this list in Phase 5.
|
||||||
|
|
||||||
|
A third derived field joined `waitingSteps` and `parked` there: a run's detail carries **`gates`**,
|
||||||
|
one entry per phase that authored an advance condition, already rendered. Every gate the run has
|
||||||
|
opened is returned rather than only the current phase's — *"how long did phase 2 wait, and what
|
||||||
|
released it"* is the same question as the live one, asked the morning after.
|
||||||
|
|
||||||
Two response fields arrived with the console and are worth naming because both are **derived, not
|
Two response fields arrived with the console and are worth naming because both are **derived, not
|
||||||
columns**. A run carries `waitingSteps`, the number of its steps parked on a human, so the run LIST
|
columns**. A run carries `waitingSteps`, the number of its steps parked on a human, so the run LIST
|
||||||
@@ -1213,7 +1308,8 @@ which is what keeps the browser from being able to name a transport.
|
|||||||
An administrator must be able to answer *"why didn't phase 3 start?"* without reading server logs —
|
An administrator must be able to answer *"why didn't phase 3 start?"* without reading server logs —
|
||||||
and there is a piece of reuse here better than it first looks. The condition grammar already carries
|
and there is a piece of reuse here better than it first looks. The condition grammar already carries
|
||||||
**human labels**, written for the rule editor's dropdowns: `eq` is *"is"*, `gte` is *"is at least"*,
|
**human labels**, written for the rule editor's dropdowns: `eq` is *"is"*, `gte` is *"is at least"*,
|
||||||
`present` is *"is present"*. The same labels render the diagnosis.
|
`present` is *"is present"*. The same labels render the diagnosis. **Built in Phase 5**, on the
|
||||||
|
server, from those labels — the caps line arrives with the budgets in Phase 6.
|
||||||
|
|
||||||
```
|
```
|
||||||
Phase 3 — "The Boss" has not started.
|
Phase 3 — "The Boss" has not started.
|
||||||
@@ -1230,6 +1326,8 @@ Phase 3 — "The Boss" has not started.
|
|||||||
| Every step: action, params, attempt, duration, outcome, module answer | `event_run_steps` + `event_run_log` |
|
| Every step: action, params, attempt, duration, outcome, module answer | `event_run_steps` + `event_run_log` |
|
||||||
| Cap draws and refusals, per dimension | `event_run_budget` + log |
|
| Cap draws and refusals, per dimension | `event_run_budget` + log |
|
||||||
| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
|
| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
|
||||||
|
| What a phase is waiting for, its tally and its last related firing | `event_run_phase_gates`, served already-rendered as a run's `gates` |
|
||||||
|
| A phase opening a gate, and a gate opening — on a firing, a deadline or a human | `event_run_log`, kinds `phase.gate` and `phase.advanced` |
|
||||||
| Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` |
|
| Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` |
|
||||||
| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources` |
|
| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources` |
|
||||||
|
|
||||||
@@ -1260,7 +1358,7 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
|
|||||||
| **P2** ✓ | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` |
|
| **P2** ✓ | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` |
|
||||||
| **P3** ✓ | The minimal admin surface — **first demo** | `website` |
|
| **P3** ✓ | The minimal admin surface — **first demo** | `website` |
|
||||||
| **P4** ✓ | Schedule, recurrence, timezones, series and the calendar | `website` `docs` |
|
| **P4** ✓ | Schedule, recurrence, timezones, series and the calendar | `website` `docs` |
|
||||||
| **P5** | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
|
| **P5** ✓ | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
|
||||||
| **P6** | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
|
| **P6** | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
|
||||||
| **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |
|
| **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |
|
||||||
| **P8** | The resource ledger, leases and generated cleanup | `website` `docs` |
|
| **P8** | The resource ledger, leases and generated cleanup | `website` `docs` |
|
||||||
|
|||||||
@@ -453,16 +453,120 @@ vendor.*
|
|||||||
|
|
||||||
### Phase 5 — Conditions and phase advancement (`website` + `docs`)
|
### Phase 5 — Conditions and phase advancement (`website` + `docs`)
|
||||||
|
|
||||||
Phase advance on `{ after: '30m' }` and on `{ on: '<triggerId>', where: <conditions>, count: n }`,
|
> **Complete.** `edge` in `website` and `docs`. Phase advance on `{ after: '30m' }` and on
|
||||||
reusing `engagement/conditions.js` **unchanged** — its grammar, its type checking against the
|
> `{ on: '<triggerId>', where: <conditions>, count: n }`, reusing `engagement/conditions.js`
|
||||||
declaration, its depth and list bounds, and its operator labels.
|
> **unchanged** — its grammar, its type checking against the declaration, its depth and list bounds,
|
||||||
|
> and its operator labels. `event_run_log` gained `phase.gate`, `condition.evaluated` (written for
|
||||||
The runner subscribes to the trigger stream it already has; `event_run_log` gains
|
> **both** outcomes) and `phase.advanced`. The diagnosis panel is the phase's real deliverable, and
|
||||||
`condition.evaluated` rows for both outcomes.
|
> `POST /admin/events/runs/:runId/advance` — absent since Phase 3 for want of a meaning — arrived
|
||||||
|
> beside it. One new table, `event_run_phase_gates`.
|
||||||
**The diagnosis panel lands here**, and it is the phase's real deliverable: the operator question
|
>
|
||||||
*"why didn't phase 3 start?"* answered in the condition builder's own words — `gte` renders as *"is at
|
> **The decisions the org lead settled (2026-09-02), all as recommended:**
|
||||||
least"*, `present` as *"is present"* — with the tally, the elapsed time and the last related event.
|
>
|
||||||
|
> - **A new table, not a query over the log.** The tally, the entry time and the last related firing
|
||||||
|
> are a row with an atomic conditional increment, the protection §E gives caps. Deriving them from
|
||||||
|
> `event_run_log` would have been a JSON predicate no index supports, and it would have made the
|
||||||
|
> retention sweep load-bearing for whether a phase advances.
|
||||||
|
> - **A gate that never opens is HELD, and the run goes `stalled`.** No automatic advance, ever, and
|
||||||
|
> no authored timeout: "what should happen when the world did not cooperate" is a decision an
|
||||||
|
> operator makes live, not one an author guesses at months earlier. What the engine owes is
|
||||||
|
> visibility — `EVENT_PHASE_STALL_MS` (1h) takes `health` to §E's third value, the first thing in
|
||||||
|
> this system ever to write it, logged once. It has to be loud: **a held run keeps its concurrency
|
||||||
|
> key**, so every later occurrence of that definition goes `missed` behind it.
|
||||||
|
> - **Force-advance ships here, not in Phase 6.** A gate without an override is a panel that explains
|
||||||
|
> a problem nobody can act on, and §K already had the gate written (`admin` + `moderator`).
|
||||||
|
> - **The clock and the tally start at phase ENTRY**, not when the steps finish. `after: '30m'` means
|
||||||
|
> thirty minutes from the moment the phase began whatever its dispatches took, and a firing during
|
||||||
|
> the announce counts. Both make a gate predictable from the authored spec alone.
|
||||||
|
>
|
||||||
|
> **Three things the build settled:**
|
||||||
|
>
|
||||||
|
> - **A gate is an ADDITIONAL condition, never a replacement.** A phase whose steps are still running
|
||||||
|
> is not advanced by a boss that spawned early, and force-advance refuses a phase held by a step —
|
||||||
|
> that phase is held by the *step*, and skip is its control, one step at a time. A force that swept
|
||||||
|
> past pending steps would be a cancel of half a phase under a button labelled advance.
|
||||||
|
> - **The emit path writes, the tick reads.** A gate waiting on three spawns counts things that
|
||||||
|
> happen *between* two ticks; fifteen seconds later there is nothing left for a poller to see, and
|
||||||
|
> a tally in a process's memory is one a restart silently zeroes. So `observe()` sits beside
|
||||||
|
> `engine.dispatch` in `ctx.events.emit` — a second subscriber rather than a leg of dispatch,
|
||||||
|
> because a rules lookup that throws must not lose the count and a gate write that throws must not
|
||||||
|
> lose the mail.
|
||||||
|
> - **The panel's sentence is rendered on the SERVER.** Everywhere else this feature serves the
|
||||||
|
> client a vocabulary; here it serves text. The labels live in `engagement/conditions.js`, and a
|
||||||
|
> renderer in the browser would be a second implementation of a grammar the server owns. Only the
|
||||||
|
> variables the condition **names** are stored on the gate row — the row is read onto an admin
|
||||||
|
> screen, and a copy of a whole game event's payload would be a second copy of what
|
||||||
|
> `engagement_sends` is careful not to keep.
|
||||||
|
>
|
||||||
|
> **The defect only a real database found, and it was the phase's own statement.** The conditional
|
||||||
|
> increment was written `SET tally = tally + 1, … satisfied_at = CASE WHEN tally + 1 >= needed …`,
|
||||||
|
> which is wrong on MariaDB: **an UPDATE's SET assignments are evaluated left to right, each seeing
|
||||||
|
> the values already assigned**, so the CASE read the incremented tally and a gate needing two
|
||||||
|
> firings closed on the first. Every stub agreed with the intent rather than with the server, exactly
|
||||||
|
> as engagement's cooldown claim did over `foundRows: true`. The increment now comes **last** and the
|
||||||
|
> order of that SET list is load-bearing; `eventRunnerSql.test.js` is what catches a reorder.
|
||||||
|
>
|
||||||
|
> **Two things that had to change underneath.** `setHealth` is now **escalation-only** — health has
|
||||||
|
> always been a high-water mark here, and without a rank a retry after a stall would demote
|
||||||
|
> `stalled` back to `degraded`. And the **catalog route serves triggers**: `/admin/engagement/triggers`
|
||||||
|
> is `adminOnly` while a definition is authored by `admin` *and* `editor`, so pointing the editor at
|
||||||
|
> it would have left an editor typing a trigger id from memory into a field the save path refuses.
|
||||||
|
>
|
||||||
|
> **A leg a stubbing file did not know about, for the third time.** `runs.detail()` gained the gate
|
||||||
|
> read, and `eventsAdmin.test.js` does not stub `eventPhaseGates.db` — so the run-console test hung
|
||||||
|
> ten seconds against the dead-port pool and failed with `ECONNREFUSED`, saying nothing whatever
|
||||||
|
> about the route it was testing. Phase 4's expansion leg did the same to `eventRunner.test.js`,
|
||||||
|
> where it only made the file slow. **When the runner or a model gains a leg, every file that stubs
|
||||||
|
> the layer under it needs the stub** — and the symptom is a ten-second test, whether it then fails
|
||||||
|
> or merely passes.
|
||||||
|
>
|
||||||
|
> **Verified:** `npm test` — **1810 tests, 1746 pass, 63 skipped, 1 fail**, that one still the
|
||||||
|
> pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch's changes
|
||||||
|
> and watching it fail unchanged; `edge` before: 1768/1711/56/1). **+42 is exactly the tests added**,
|
||||||
|
> and the +7 skipped are the new SQL cases skipping without a database. `eventGates.test.js` (14) covers the
|
||||||
|
> renderer against the grammar's own labels and the observer's near-miss branch; 10 in
|
||||||
|
> `eventRunner.test.js`, 5 in `eventRunControls.test.js`, 6 in `eventSpec.test.js`, and **7 in
|
||||||
|
> `eventRunnerSql.test.js` against a real MariaDB** (39/39 with a database, skipped without) — one of
|
||||||
|
> which is the left-to-right defect above. Client: **361 pass**, 7 new. One route added, none moved;
|
||||||
|
> the client builds.
|
||||||
|
>
|
||||||
|
> **The live walk, on the local review stack.** A three-phase `Yew Champion Muster` — an `on` gate
|
||||||
|
> needing two `uo.champ.boss_up` firings `where location contains "Yew"`, then an `after: '10m'`
|
||||||
|
> gate, then an ungated wind-down — authored, published and started as `navadmin`, with firings sent
|
||||||
|
> through the real `ctx.events.emit` seam:
|
||||||
|
>
|
||||||
|
> - **The save-time refusals named the variable**, live: `"regoin" is not a variable of
|
||||||
|
> "uo.champ.boss_up"`, `"gt" cannot be applied to a string`, and `1h30m` refused with the grammar
|
||||||
|
> spelled out. That is the phase's Trap, held on a running server.
|
||||||
|
> - **A near miss was recorded and did not count.** A boss up in *Britain* left the tally at 0 of 2
|
||||||
|
> and put `did not count (location: "Britain (10, 20, 0)")` on the panel — and **only `location`**,
|
||||||
|
> the one variable the condition names. `spawnName`, `bossName` and `spawnSerial` were in the
|
||||||
|
> payload and never touched the row.
|
||||||
|
> - **Two matching firings advanced the phase**, and the next phase opened its own `after` gate with
|
||||||
|
> a `dueAt` ten minutes out. The log reads: three `condition.evaluated` lines (0 of 2, 1 of 2, 2 of
|
||||||
|
> 2), `phase.advanced`, `phase.completed`, `phase.gate`, `phase.entered`.
|
||||||
|
> - **A second run went `stalled`** with `EVENT_PHASE_STALL_MS=45000` — `degraded` first from a
|
||||||
|
> failing announce, then escalated to `stalled` and logged **once**, which is the escalation-only
|
||||||
|
> guard working live.
|
||||||
|
> - **The panel said exactly what §Observability asked for**: *"Phase muster has not started —
|
||||||
|
> STALLED / waiting on `uo.champ.boss_up` where location contains "Yew" / seen so far 0 of 2 /
|
||||||
|
> since 9:56:58 PM (6 min) / last related event …"*.
|
||||||
|
> - **§N2's split held:** `navmod` pressed *Advance phase* and got 200; the refusals answered 409
|
||||||
|
> naming what was actually happening — *waiting on step 0 (core.announce), not on its advance
|
||||||
|
> condition* — and a second force said *already past its advance condition*.
|
||||||
|
>
|
||||||
|
> **The walk found three defects, all fixed here:**
|
||||||
|
>
|
||||||
|
> 1. **`validate` refused its own output.** The normalised gate carries `dormant`, and the input
|
||||||
|
> check did not allow it — so a gated definition *saved* and then *failed to publish* over a field
|
||||||
|
> the validator itself wrote. The rule was already on the page for a step's `actionVersion` and
|
||||||
|
> `dormant`; the gate just had to follow it. `validate(validate(x)) === validate(x)` is now a test.
|
||||||
|
> 2. **A forced advance was logged twice** — once by the control with the actor and the reason, then
|
||||||
|
> again by the tick that acted on the satisfied gate, the less informative one last. `phase.advanced`
|
||||||
|
> is now written by whoever made the decision, and the tick skips `forced`.
|
||||||
|
> 3. **A satisfied gate's clock kept running.** `elapsedSeconds` measured to read time, so the panel
|
||||||
|
> said 139s beside a logged `waitedSeconds` of 121. It now stops at `satisfied_at`: live it answers
|
||||||
|
> "how long has this been waiting", afterwards "how long did it wait".
|
||||||
|
|
||||||
**Ships:** multi-phase events that advance on what happens in the game rather than only on a clock.
|
**Ships:** multi-phase events that advance on what happens in the game rather than only on a clock.
|
||||||
**Verify:** `npm test`; a rig run where a phase legitimately does not advance, confirming the panel
|
**Verify:** `npm test`; a rig run where a phase legitimately does not advance, confirming the panel
|
||||||
@@ -470,7 +574,11 @@ explains why without a server log.
|
|||||||
|
|
||||||
**Trap:** a condition is validated at **save** against the trigger's declaration, with the offending
|
**Trap:** a condition is validated at **save** against the trigger's declaration, with the offending
|
||||||
variable named — not at evaluation. A predicate that silently reads `undefined` is a phase that
|
variable named — not at evaluation. A predicate that silently reads `undefined` is a phase that
|
||||||
silently never advances, and the day you find out is the night of the event.
|
silently never advances, and the day you find out is the night of the event. *Held: `spec.js` calls
|
||||||
|
`conditions.validate(declaration, where)` at save and re-roots the grammar's own errors at the phase,
|
||||||
|
so an author fixing five clauses at once can tell which phase each belongs to. A gate naming a
|
||||||
|
trigger nothing registers is **dormant** on the rule a step's unregistered action already follows —
|
||||||
|
it saves, and it will not publish.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -495,7 +603,8 @@ delegation model a later option rather than a redesign.
|
|||||||
The role split from `EVENTS.md` §K is applied to the routes here — completing what P3 started, and
|
The role split from `EVENTS.md` §K is applied to the routes here — completing what P3 started, and
|
||||||
including §N2's departure from the module-uo shape: **publishing and starting are `admin` only,
|
including §N2's departure from the module-uo shape: **publishing and starting are `admin` only,
|
||||||
while cancelling and aborting a run in flight are `admin` + `moderator`**. Start and stop are
|
while cancelling and aborting a run in flight are `admin` + `moderator`**. Start and stop are
|
||||||
deliberately not the same gate.
|
deliberately not the same gate. (`advance` took that same gate in Phase 5, which is when it first
|
||||||
|
named a state an operator could be in.)
|
||||||
|
|
||||||
**Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of
|
**Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of
|
||||||
anything.
|
anything.
|
||||||
|
|||||||
Reference in New Issue
Block a user