From c411a035e48f3b1ee28e4731f65b435db5dc25e3 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:31:34 -0700 Subject: [PATCH] docs: document the OnThink excess-call contract and MonsterAbility pacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnThink is a scheduler pass, not an action: the AI timer fires it at least at the think cadence but also on command prods and reschedules, and players macro commands deliberately. Overrides must gate consequential work on their own tick-count deadlines (or be idempotent); bare per-call random rolls are cosmetics-only. MonsterAbility lives under the same contract: the trigger cooldown is the rate limit, ChanceToTrigger is per-sample jitter, and a zero-cooldown Think/CombatAction ability triggers every sampled think. Order-breaking abilities must own their hold duration — think latency is not a hold. Co-Authored-By: Claude Fable 5 --- .../modernuo-content-patterns.md | 7 +++ dev-docs/content-patterns.md | 55 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index ce7f18acf..cdb3f9e49 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -29,6 +29,13 @@ description: > overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think AND clears move overrides, `SetMoveSpeed()` sets move only -- see `dev-docs/content-patterns.md` § Creature Speeds +8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the + think cadence (player commands prod it; speed-ups reschedule it). Gate consequential + work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call + random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the + trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a + zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see + `dev-docs/content-patterns.md` § OnThink: the excess-call contract ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index ec56e1bb3..80d925580 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -283,6 +283,61 @@ ClearMoveSpeed(); // back to inheriting the think clock All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). +### OnThink: the excess-call contract + +`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the +think cadence (`CurrentSpeed`), but it can and does fire more often: a player command +wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and +players run command macros that drive extra thinks deliberately (order spam is spam-safe +by design — reaction, never action). RunUO had the same property (its timer restarted +with a random delay on every speed change), so this has never been a fixed-rate callback. + +**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant +an extra action: + +- Gate consequential work on its own deadline field, compared in subtraction form + (`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent. +- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll — + its frequency then scales with think rate, which players can influence. Per-call rolls + are acceptable only for pure cosmetics (idle animations, flavor sounds). +- The engine already gates the expensive things: steps (the `NextMove` budget), weapon + swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal, + rummage, aura) all carry their own clocks. Follow that pattern. + +```csharp +private long _nextSpecial; + +public override void OnThink() +{ + base.OnThink(); + + if (Core.TickCount - _nextSpecial >= 0) + { + DoSpecial(); + _nextSpecial = Core.TickCount + 5000; // the real rate limit lives here + } +} +``` + +### MonsterAbility: same contract + +`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and +`CombatAction`-triggered abilities, so abilities live under the same rule: + +- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds + no matter how often thinks fire. Always give a triggered ability a real cooldown. +- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected + trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as + the rate limiter, and keep cooldowns long relative to the think interval so the jitter + stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between + natural and spammed think rates). +- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled + think that passes its chance — only ever correct for passive alteration hooks, never + for `Think`/`CombatAction` triggers. +- An ability that breaks pet orders (fear-style effects) must own its duration explicitly + (a hold state, or a "refuses orders until" deadline checked in the order handlers) — + pets react to re-issued commands immediately, so think latency is not a hold. + --- ## New Spell