ModernUO/dev-docs/claude-skills/modernuo-content-patterns.md
Kamron Batman f606225f47
fix: pets freeze, forget their orders, obey the wrong players, and fight when told not to (#2614)
## Summary

Pet orders lived in two files with nothing enforcing which phase owned what: `PetOrderHandlers.cs` ran one-shot handlers inside the `ControlOrder` setter and `PetOrders.cs` ran `DoOrderXxx` every AI tick from `Obey`. Friend/Unfriend refusals repeated their message every tick, Rename froze the pet, Drop on a dead pet never ended, the loyalty drain bypassed the release handler, and the command issuer leaked through a public field that only some handlers cleared (#2613 fixed the Release casualty of that split; this finishes the job).

Every order now lives in one place, `PetOrders.cs`, with two named phases:

- **Issue** — `BaseAI.IssueOrder(order, previous, issuer, resuming, interruptedTarget)` runs once, synchronously, from the new `BaseCreature.SetControlOrder` funnel. It may only set state and emit (message, sound, reveal) and returns the order to rest in. The funnel loops to a fixed point, so transient orders (Drop, Friend, Unfriend, Transfer, Release, Rename, Stop, Patrol) resolve before the setter returns and can never rest.
- **Tick** — `DoOrderXxx` runs from `Obey` for the six restable orders only (None, Come, Guard, Attack, Stay, Follow). Anything else that arrives there came from an old save and falls back to the standing order.

The issuer is a parameter: `BaseCreature.IssueOrder(order, issuer, target)` is the entry for player commands (speech, context menu, targeting), a raw `ControlOrder = x` assignment is a system-issued order, and nothing has to remember to clear anything. A resumed Follow restores the mobile the standing Follow was following, never a transient's target.

Because the funnel is synchronous it also carries the order being interrupted, so an administrative command can hand control back to what the pet was doing without storing anything per creature.

## Era behaviour, with sources

Two publishes govern most of the questions here, and the inherited code matched neither exactly.

[**Publish 16**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-4-23rd-july/) (23 July 2002) — *"The 'stop' command will stop a pet from guarding, following, and attacking."* Stop cancels the current attack and leaves the pet idle but still reactive. That is what this branch does whenever the stand-down policy below is off, in every era. (The same publish's *"Friends will only be able to issue movement commands to pets"* is the rule already enforced by `IsFriendOrder`.)

[**Publish 51**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2008-2/publish-51-26th-march/) (26 March 2008) lists it per command:

> Follow: The pet should follow. It will not attack anything, even if it is attacked.
>
> Come: The pet should come. It will not attack anything, even if it is attacked.
>
> Stay: The pet will stay where it is currently, and will not attack anything, even if it is attacked.
>
> Stop: The pet will stop attacking. It will not attack anything, even if it is attacked, and may wander.
>
> Guard: The pet should guard as it does currently.
>
> Kill/Attack: The pet will attack its target as it does currently.

The inherited rule covered Follow and Stay only, so a pet told to come fought back, and it could not cover Stop at all: Stop resolves to None rather than resting, and None is exactly the state the publish describes. `BaseAI.IsStandDownOrder` now names the set — Follow, Come, Stay, None — and both halves of `AggressiveAction` read it.

## Configuration

`taming.petsStandDownOnCommand` (default `Core.ML`) controls the Publish 51 behaviour. The publish has no step of its own on the expansion ladder — it lands between ML and SA, and Kingdom Reborn was a client rather than an expansion — so it keeps riding ML as before, and the setting carries the rest: the behaviour is popular well outside its era, so a shard on AOS that wants it sets the key, and one that does not clears it. `BaseCreature.StandsDownOnCommand` is virtual for a creature that should differ.

## Bugs fixed along the way

- Friend/Unfriend refusal spam (every tick until the next command); Rename freezing the pet; Drop on a dead or non-`CanDrop` pet freezing the pet.
- Loyalty-zero release skipping the name clear and the summoned kill; a released pet keeping its `Friends` list and its previous owner's standing order.
- A transferred pet still answering to the previous owner's friends; transfer playing the idle sound twice.
- `BaseTalisman` summons issued `Friend` with no target (*looks confused* forever, or young-player spam); they follow their owner.
- Speech: single-pet commands lost their name gate after #2232 (a bare "come" moved every pet in range; "all stay" issued Stay twice); the speech cases passed a hardcoded `isOwner: true`, so a pet friend could say "`<name>` drop" and dump the pack, or issue Come/Guard.
- GM "`<name>` obey" was unreachable for a controlled pet; context-menu Release and speech Release disagreed about the control roll (resolved by removing it from both, below).
- Login derived the standing order from proximity even for a pet saved on Stay (zeroing its post), and only recorded the derived order without issuing it, so a pet saved mid-transient idled after a restart.
- `Friends` mutations never marked the creature dirty for delta saves.
- A resumed standing Follow was left without a target and cancelled itself to idle on the next think — the same defect as #2616, fixed here by `IssueFollow(resuming)` restoring the remembered target.

## Behaviour changes a shard maintainer will notice

- Every player command reveals its issuer, including context-menu commands from a hidden owner. This restores the blanket reveal (RunUO reveals in every order arm) minus its bug: it revealed the **control master**, so a friend's command popped the owner wherever they stood. Speech already reveals through `Mobile.OnSaid`, so the practical change is the context menu, target picks and the release gump.
- Resumed/chained orders are silent (no idle sound when falling back after Drop, Stop, a refused Friend, etc.).
- **Administrative commands no longer call the pet off.** Drop, Friend, Unfriend and Rename keep the pet's combat posture and hand control back to the order they interrupted, target and all; the standing order is the fallback only when the interrupted order cannot resume (a transient, or an attack whose target died, left or hid). Resuming an attack does not repeat its aggression or replay its bark.
- **Friend and Unfriend no longer rewrite the standing order.** Previously a success pointed the pet at the new friend and made Follow its standing order, so a pet left on Stay silently became a Follow with its anchor cleared. Friending grants a permission and nothing else; the friend has movement commands and can ask the pet to follow.
- **Releasing a pet no longer rolls the control chance**, on either path. A refused roll cost 3 loyalty, and loyalty reaching zero releases the pet anyway, so refusing only converted a deliberate release into an involuntary one minutes later. Both paths gate on `CanBeControlledBy` instead: if you can command it, you can dismiss it.
- **The pet distraction roll is gone.** A pet on Follow had a 10% chance per damage callback of dropping the order and attacking whoever hit it, issued without consulting anything, so it overrode the stand-down policy a few hits after the aggression path had correctly ignored it. Its era gate was guesswork by its own comment's admission and no publish describes it; `CanBeDistracted`, `CheckDistracted`, both call sites and the `Golem` override are deleted. Pre-ML shards lose the mechanic.
- **A pet's follow pace moved off the think clock.** The AOS sprint wrote a bespoke `CurrentSpeed = 0.1`, which fused both clocks — discarding any configured `ActiveMoveSpeed`/`PassiveMoveSpeed` — and pinned a following pet's AI at 10 Hz even while standing still. `BaseCreature.FollowMoveSpeed` (virtual, AOS 0.1) now caps the resolved step delay while the pet is closing on its master, the same way herding does: nothing stored, and a creature configured faster keeps its own pace.
- Transfer with an invalid target is a refusal (resumes) instead of forcing Stay; the transfer combat gate rests on the aggressor lists and `NextCombatTime`.
- Stop with no standing order anchors the idle where the pet stands (a vendor-bought pet no longer wanders off unbounded).
- The old "master must be alive" bails in the handlers are gone; stand-down and sounds run for orphaned pets too. RunUO gates only on the master being null or deleted, and a living friend commanding a dead owner's pet could not previously call it out of a fight.
- Login: a pet at None near its master is issued Follow silently; a saved Stay/Follow/Guard is adopted as is.
- GM "all obey" only reaches wild creatures; a controlled pet must be named.
- Death still issues Follow with the idle sound, as RunUO did.

## Tests

`PetOrderTests` grew from 16 to 63, plus 13 in a new `PetRetaliationTests` for the Publish 51 matrix and 13 in `PetPacingTests` for the clocks. Together they cover order resolution, reveal on every entry path, release parity (player vs drain, summoned), transfer/friend refusals and successes, stand-down and war-mode invariants, the interrupted-order resume, speech gating and permissions, GM obey, login derivation, the load probe, and the retaliation matrix across eras and both damage callbacks. Whole project green: 845 `UOContent.Tests`, 869 `Server.Tests`.
2026-09-10 19:28:13 -07:00

12 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. Herding and pacing to a master (FollowMoveSpeed) cap the resolved pace without writing either clock. The client Running bit is derived from the step pace (BaseAI.ShouldRun); movement APIs take no run argument -- see dev-docs/content-patterns.md § Creature Speeds. Reaction time to approaching enemies is AcquireOnApproachDelay (TimeSpan gradient; Zero = paragon snap, 2s default, ReacquireDelay-only = oblivious) -- see § Target Acquisition
  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