ModernUO/dev-docs/claude-skills/modernuo-content-patterns.md
Kamron Batman 4420872b22
fix: pet obedience pacing, stale AI wake rescheduling, and Guard order persistence through combat (#2594)
Closes #2593. Closes #2595.

Two related pet-AI fixes: the post-#2591 pacing/wake regression (#2593), and the guard order silently converting to Attack during combat (#2595). Root-cause analyses are in the issues.

## #2593 — pets follow slowly; stale AITimer wakes

**Why pets slowed:**
- The per-step budget grew from **half a think interval** (`CurrentSpeed * 500`) to the full RunUO-parity move table (`CurrentMoveSpeed * 1000`). Medium-bucket pets (Horse, Dog, most tamables): passiveMove **1.05s/step**.
- Pet order speed depended on stale `Warmode`: `HandleGuardOrder` set it once, but `OnCombatantChange` clears it whenever the combatant drops, so obedience ran active or passive **by combat history** — usually passive. Net: Guard/Come at ~1.05s/step (~2.1x slower than pre-#2591), vs a player running at 0.1–0.2s/step.
- The AITimer never rescheduled its pending wheel entry: the wheel reads `Interval` only after the next fire, so a speed-up or a fresh order (`Activate()` no-ops while running) waited out the stale wake — up to a full passive think, stacked on the residual move budget on Guard → Follow.

**What changed:**
- **Order handlers own obedience speed** (RunUO `OnCurrentOrderChanged`/`DoOrder*` parity, re-derived continuously): issuing a movement order (Come/Follow/Guard/Attack) sets the **active** think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write **RunUO's AOS `CurrentSpeed = 0.1` sprint** — RunUO's guard else-branch had the identical write as follow. The bespoke 0.1 fuses to both clocks through #2591's existing classification, so `CurrentMoveSpeed` stays **pure herding + classification** with no obedience special case, and `DoMoveImpl`'s per-step flip skips obeying pets (their handler owns the pace) and loses its old follow-only 0.1 write. Combat still re-derives organically via warmode/combatant.
- **`AITimer`**: tracks the pending wake and reschedules (`Stop`, `Delay` = remaining, `Start`) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through `ScheduleNext`. New `Prod()` wakes the AI immediately on player commands — including from a stopped timer, so stable claims no longer wait out the random construction stagger. Sector/spawn wakes keep the stagger. Spam-safe: a prodded think grants reaction, never action — steps/swings/casts/abilities are gated by their own budgets and timers.

The residual move budget is deliberately **not** cleared on order change — that would let order-spam macros grant free steps. Deadline changes reschedule the timer; rate changes take effect at the next deadline computation.

## #2595 — Guard order converts to Attack during combat

**Why:** `FindCombatant()` set `ControlOrder = OrderType.Attack` when engaging, so a guarding pet left the Guard order for the whole fight: OPL tags wiped (pet `1080078` + master `501129`), no retargeting (`DoOrderAttack` locks its target), `TeleportPets` left the pet behind on recall/gate, and every engage→kill→resume cycle replayed the guard flourish.

**What changed:**
- **`FindGuardTarget()`** (was `FindCombatant`): a pure selector — prefers the aggressor **closest to the master** (RunUO guard parity, dynamic retargeting to protect the owner), keeps the current combatant unless a strictly closer one exists, and never mutates order state. `DoOrderGuard` engages through it while **staying in Guard** the whole fight.
- **Persistent-order semantics** (the ModernUO improvement over RunUO): an explicit `all attack` completes → `ResumePersistentOrder()` returns to Guard → the guard scan engages remaining threats in-order. The Attack-chaining fallback (`FightMode.Closest/Aggressor`) now applies only to non-guard persistent orders. Resuming Guard no longer replays the sound/"is now guarding you" message.
- **Peaceful guard stands down deterministically** (`Warmode`/`Combatant`/`FocusMob` cleared) and returns to the master at the RunUO sprint (see above); at the master's side it stays organically active.
- **`WalkMobileRange` honors the caller's run flag** (the internal hardcoded `dist > 5` gate silently overrode it). Run is animation-only server-side; the only callers passing anything but `false` — follow, guard, clone — gate on their own thresholds.

## Resulting behavior (Medium-bucket pet)

| Scenario | Broken | This PR |
|---|---|---|
| Guard trailing master (AOS) | ~1.05s/step, think-grid quantized | 0.1s/step sprint (RunUO parity), smooth move wakes |
| Guard during combat | order flips to Attack; tags lost; no retarget; left behind on recall | stays Guard; retargets to master's closest aggressor; teleports with master |
| `all attack` while guarding | resume spams guard flourish per kill; chains into Attack | resumes Guard silently; guard scan takes over |
| Come / friend-follow | 1.05s/step | activeMove 0.45s/step (≈ pre-#2591 feel) |
| Guard → Follow reaction | up to ~1.5s dead time | think within one wheel turn |
| Follow master (AOS sprint) | 0.1s/step | 0.1s/step (unchanged) |
| Wild creature chase | RunUO-parity move table | unchanged |

Also documents two contracts this work leaned on: the `ControlOrder` setter deliberately fires on every assignment (a reissued order is a command — retarget/break-off/re-anchor), and `OnThink`/`MonsterAbility` must be excess-call tolerant (`dev-docs/content-patterns.md` § OnThink: the excess-call contract).

## Testing

- Full suite passes (1570: 837 Server + 733 UOContent).
- `PetPacingTests`: order-issue think-clock parity, follow-master sprint via Obey, guard organically active at the master's side, combat-chase and herding boundaries, plus two deterministic timer-wheel tests (8ms-lockstep slicing) proving a fresh order and a mid-wait speed-up wake the AI promptly.
- `GuardOrderTests`: engage keeps the Guard order; retargets to the aggressor closest to the master; explicit attack resumes Guard without chaining into Attack; peaceful guard stands down. Setup self-validates LOS/terrain.
- `GuardFollowTests`: guard-following registers a move intent, steps toward the master, sprints at 0.1 under AOS (per-step flip must not undo it), and runs active pre-AOS.
- All behavioral tests were written first and failed for the documented reasons.
2026-08-30 16:39:29 -07:00

11 KiB

name description
modernuo-content-patterns Trigger when creating new items, mobiles, creatures, spells, skills, loot tables, or any game content under Projects/UOContent/. This is the hub skill that connects to all other ModernUO skills.

ModernUO Content Patterns (Hub Skill)

When This Activates

  • Creating new items, weapons, armor, clothing, containers
  • Creating new creatures, NPCs, vendors
  • Creating new spells
  • Implementing skill handlers
  • Adding loot tables
  • Adding context menus
  • Any new game content under Projects/UOContent/

Key Rules

  1. Always ask target era if the user hasn't specified (see modernuo-era-expansion.md)
  2. All serializable classes must be partial with [SerializationGenerator]
  3. All Item/Mobile constructors need [Constructible]
  4. Clean up timers and references in OnDelete()/OnAfterDelete()
  5. No LINQ in game logic -- use loops and PooledRefList<T>
  6. File placement matters -- follow the directory conventions below
  7. Creature speeds are delays in seconds, on two clocks -- think (ActiveSpeed/PassiveSpeed, seconds per AI decision) and move (ActiveMoveSpeed/PassiveMoveSpeed, seconds per step; inherits think until 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

using ModernUO.Serialization;

namespace Server.Items;

[SerializationGenerator(0)]
public partial class MyItem : Item
{
    [Constructible]
    public MyItem() : base(0x1234)  // itemID from art
    {
        Weight = 1.0;
        // Stackable = true;  // if stackable
        // Amount = 1;         // if stackable
    }

    public override string DefaultName => "a my item";
    // OR: public override int LabelNumber => 1234567;  // cliloc number

    public override void OnDoubleClick(Mobile from)
    {
        if (!IsChildOf(from.Backpack))
        {
            from.SendLocalizedMessage(1042001); // Must be in backpack
            return;
        }

        // Item use logic
    }

    public override void GetProperties(IPropertyList list)
    {
        base.GetProperties(list);
        // list.Add(1060741, $"{_charges}");  // charges: ~1_val~
    }
}

New Creature Template

using ModernUO.Serialization;
using Server.Items;

namespace Server.Mobiles;

[SerializationGenerator(0)]
public partial class MyCreature : BaseCreature
{
    [Constructible]
    public MyCreature() : base(AIType.AI_Melee, FightMode.Closest)
    {
        Body = 0;           // Body graphic ID
        BaseSoundID = 0;    // Base sound ID

        SetStr(100, 150);   // Strength min/max
        SetDex(80, 100);    // Dexterity min/max
        SetInt(30, 50);     // Intelligence min/max

        SetHits(80, 120);
        SetMana(0);

        SetDamage(8, 14);

        SetDamageType(ResistanceType.Physical, 100);

        SetResistance(ResistanceType.Physical, 30, 40);
        SetResistance(ResistanceType.Fire, 10, 20);
        SetResistance(ResistanceType.Cold, 10, 20);
        SetResistance(ResistanceType.Poison, 15, 25);
        SetResistance(ResistanceType.Energy, 10, 20);

        SetSkill(SkillName.MagicResist, 30.0, 50.0);
        SetSkill(SkillName.Tactics, 50.0, 70.0);
        SetSkill(SkillName.Wrestling, 50.0, 70.0);

        Fame = 1000;
        Karma = -1000;  // Negative = evil, positive = good, 0 = neutral

        VirtualArmor = 30;
    }

    public override string CorpseName => "a creature corpse";
    public override string DefaultName => "a creature";

    // Optional overrides:
    // public override int Meat => 1;
    // public override int Hides => 8;
    // public override HideType HideType => HideType.Regular;
    // public override FoodType FavoriteFood => FoodType.Meat;
    // public override PackInstinct PackInstinct => PackInstinct.Canine;
    // public override bool CanRummageCorpses => true;
    // public override Poison PoisonImmune => Poison.Lesser;
    // public override Poison HitPoison => Poison.Regular;

    public override void GenerateLoot()
    {
        AddLoot(LootPack.Average);
        AddLoot(LootPack.Gems, 1);
        // PackItem(new SpecificItem());
        // PackGold(50, 100);
    }
}

Tameable Creature Additions

// In constructor:
Tamable = true;
ControlSlots = 1;      // 1-5, how many pet slots it uses
MinTameSkill = 35.1;   // Required Animal Taming skill

AI Types

AIType Behavior
AI_Melee Charges into melee combat
AI_Mage Casts spells, keeps distance
AI_Archer Uses ranged attacks
AI_Animal Passive, flees or fights back
AI_Predator Hunts other creatures
AI_Healer Heals allies
AI_Vendor NPC vendor behavior
AI_Berserk Aggressive, attacks everything
AI_Thief Steals from players

Fight Modes

FightMode Target Selection
None Never attacks
Aggressor Only attacks those who attack first
Strongest Targets highest stats
Weakest Targets lowest stats
Closest Targets nearest entity
Evil Attacks aggressors or negative-karma entities

New Spell Template

using System;
using Server.Targeting;

namespace Server.Spells.First;

public class MySpell : MagerySpell, ITargetingSpell<Mobile>
{
    private static readonly SpellInfo _info = new(
        "Spell Name",      // Display name
        "In Vas Ort",       // Power words (mantra)
        212,                // Cast animation action
        9041,               // Cast sound
        Reagent.Bloodmoss,  // Required reagents
        Reagent.MandrakeRoot
    );

    public MySpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info)
    {
    }

    public override SpellCircle Circle => SpellCircle.First;

    public void Target(Mobile m)
    {
        if (CheckHSequence(m))  // Harmful spell check
        {
            SpellHelper.Turn(Caster, m);

            double damage = GetNewAosDamage(10, 1, 4, m);

            SpellHelper.Damage(this, m, damage, 0, 100, 0, 0, 0);
            // Damage types: phys, fire, cold, poison, energy (must sum to 100)
        }
    }

    public override void OnCast()
    {
        Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Harmful);
    }
}

Spell Circles (Magery)

Circle Mana Cost Min Skill
First 4 -50.0 (Pre-ML) / -46.0 (ML+)
Second 6 -30.0 / -32.0
Third 9 0.0 / -18.0
Fourth 11 10.0 / -4.0
Fifth 14 20.0 / 10.0
Sixth 20 30.0 / 24.0
Seventh 40 40.0 / 38.0
Eighth 50 50.0 / 52.0

Skill Implementation

using Server.Targeting;

namespace Server.Skills;

public static class MySkillHandler
{
    public static void Initialize()
    {
        // Register handler delegate
        SkillInfo.Table[(int)SkillName.Alchemy].Callback = OnUse;
    }

    public static TimeSpan OnUse(Mobile from)
    {
        from.SendMessage("You begin working...");
        from.Target = new InternalTarget();
        return TimeSpan.FromSeconds(1.0);  // Delay before next use
    }

    private class InternalTarget : Target
    {
        public InternalTarget() : base(2, false, TargetFlags.None)
        {
        }

        protected override void OnTarget(Mobile from, object targeted)
        {
            if (targeted is Item item)
            {
                // from.CheckSkill(SkillName.Alchemy, minSkill, maxSkill)
                if (from.CheckSkill(SkillName.Alchemy, 0.0, 100.0))
                {
                    from.SendMessage("Success!");
                }
                else
                {
                    from.SendMessage("You fail.");
                }
            }
        }
    }
}

Loot Packs

Use predefined packs -- they auto-select era-appropriate loot:

public override void GenerateLoot()
{
    AddLoot(LootPack.Poor);       // ~50 gold equivalent
    AddLoot(LootPack.Meager);     // ~100 gold equivalent
    AddLoot(LootPack.Average);    // ~250 gold equivalent
    AddLoot(LootPack.Rich);       // ~500 gold equivalent
    AddLoot(LootPack.FilthyRich); // ~1000 gold equivalent
    AddLoot(LootPack.UltraRich);  // ~2000 gold equivalent
    AddLoot(LootPack.SuperBoss);  // Boss-level loot

    AddLoot(LootPack.Gems, 2);    // 2 random gems
    AddLoot(LootPack.Potions);    // Random potion

    // Specific items
    PackItem(new Arrow(Utility.RandomMinMax(20, 40)));
    PackGold(100, 200);
}

Context Menus

public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
    base.GetContextMenuEntries(from, ref list);

    if (from.Alive && from.InRange(this, 2))
    {
        list.Add(new MyContextMenuEntry(this));
    }
}

private class MyContextMenuEntry : ContextMenuEntry
{
    private readonly Item _item;

    public MyContextMenuEntry(Item item) : base(6100)  // Cliloc number
    {
        _item = item;
    }

    public override void OnClick(Mobile from, IEntity target)
    {
        // Handle click
    }
}

Two-Phase Deletion

public override void OnDelete()
{
    _timerToken.Cancel();   // Cancel timers FIRST
    base.OnDelete();
}

public override void OnAfterDelete()
{
    _timer?.Stop();         // Stop Timer references
    _timer = null;
    _owner = null;          // Clear Mobile/Item references
    base.OnAfterDelete();
}

File Placement

Content Type Directory
Items Projects/UOContent/Items/{Category}/
Weapons Projects/UOContent/Items/Weapons/{Type}/
Armor Projects/UOContent/Items/Armor/{Type}/
Creatures Projects/UOContent/Mobiles/{Type}/
Animals Projects/UOContent/Mobiles/Animals/{Species}/
Monsters Projects/UOContent/Mobiles/Monsters/{Era}/
Spells Projects/UOContent/Spells/{School}/
Skills Projects/UOContent/Skills/
Engines Projects/UOContent/Engines/{SystemName}/
Gumps Projects/UOContent/Gumps/

Real Examples

  • Simple creature: Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs
  • Boss creature: Projects/UOContent/Mobiles/Special/Barracoon.cs
  • SE creature: Projects/UOContent/Mobiles/Monsters/SE/RaiJu.cs
  • Spell: Projects/UOContent/Spells/First/MagicArrow.cs
  • Skill check: Projects/UOContent/Skills/SkillCheck.cs
  • Loot packs: Projects/UOContent/Misc/LootPack.cs

See Also

  • dev-docs/claude-skills/modernuo-serialization.md - Serialization details
  • dev-docs/claude-skills/modernuo-era-expansion.md - Era-conditional code
  • dev-docs/claude-skills/modernuo-timers.md - Timer patterns
  • dev-docs/claude-skills/modernuo-property-lists.md - Item tooltips
  • dev-docs/claude-skills/modernuo-gump-system.md - UI dialogs
  • dev-docs/claude-skills/modernuo-commands-targeting.md - Commands and targeting
  • dev-docs/claude-skills/modernuo-events.md - Event system
  • dev-docs/content-patterns.md - Full content documentation