## 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`.
683 lines
23 KiB
Markdown
683 lines
23 KiB
Markdown
# ModernUO Content Creation Patterns
|
||
|
||
This document covers the patterns and templates for creating game content in ModernUO: items, creatures, spells, skills, loot, context menus, and file organization.
|
||
|
||
## Table of Contents
|
||
1. [New Item](#new-item)
|
||
2. [New Creature](#new-creature)
|
||
3. [New Spell](#new-spell)
|
||
4. [Skill Implementation](#skill-implementation)
|
||
5. [Loot System](#loot-system)
|
||
6. [Context Menus](#context-menus)
|
||
7. [Entity Lifecycle](#entity-lifecycle)
|
||
8. [File Organization](#file-organization)
|
||
|
||
---
|
||
|
||
## New Item
|
||
|
||
### Minimal Item
|
||
```csharp
|
||
using ModernUO.Serialization;
|
||
|
||
namespace Server.Items;
|
||
|
||
[SerializationGenerator(0)]
|
||
public partial class SimpleItem : Item
|
||
{
|
||
[Constructible]
|
||
public SimpleItem() : base(0x1234) // itemID from UO art
|
||
{
|
||
Weight = 1.0;
|
||
}
|
||
|
||
public override string DefaultName => "a simple item";
|
||
// OR: public override int LabelNumber => 1234567; // cliloc number
|
||
}
|
||
```
|
||
|
||
### Item with Properties and Behavior
|
||
```csharp
|
||
using ModernUO.Serialization;
|
||
|
||
namespace Server.Items;
|
||
|
||
[SerializationGenerator(0)]
|
||
public partial class MagicLantern : Item
|
||
{
|
||
[SerializableField(0)]
|
||
[InvalidateProperties]
|
||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||
private int _charges;
|
||
|
||
[SerializableField(1)]
|
||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||
private Mobile _owner;
|
||
|
||
private TimerExecutionToken _glowTimer;
|
||
|
||
[Constructible]
|
||
public MagicLantern() : base(0xA25)
|
||
{
|
||
_charges = Utility.RandomMinMax(5, 15);
|
||
Weight = 2.0;
|
||
Light = LightType.Circle300;
|
||
StartGlow();
|
||
}
|
||
|
||
public override string DefaultName => "a magic lantern";
|
||
|
||
private void StartGlow()
|
||
{
|
||
Timer.StartTimer(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3), Glow, out _glowTimer);
|
||
}
|
||
|
||
[AfterDeserialization]
|
||
private void AfterDeserialization() => StartGlow();
|
||
|
||
public override void OnAfterDelete()
|
||
{
|
||
_glowTimer.Cancel();
|
||
base.OnAfterDelete();
|
||
}
|
||
|
||
private void Glow()
|
||
{
|
||
if (_charges > 0)
|
||
Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
|
||
}
|
||
|
||
public override void OnDoubleClick(Mobile from)
|
||
{
|
||
if (!IsChildOf(from.Backpack))
|
||
{
|
||
from.SendLocalizedMessage(1042001); // Must be in your backpack
|
||
return;
|
||
}
|
||
|
||
if (_charges <= 0)
|
||
{
|
||
from.SendMessage("The lantern is depleted.");
|
||
return;
|
||
}
|
||
|
||
Charges--;
|
||
from.SendMessage("The lantern flares brightly!");
|
||
from.FixedParticles(0x376A, 9, 32, 5042, EffectLayer.Waist);
|
||
}
|
||
|
||
public override void GetProperties(IPropertyList list)
|
||
{
|
||
base.GetProperties(list);
|
||
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Common Item Base Classes
|
||
| Base Class | Use For |
|
||
|---|---|
|
||
| `Item` | Generic items |
|
||
| `BaseWeapon` | Melee weapons |
|
||
| `BaseRanged` | Ranged weapons (bows, crossbows) |
|
||
| `BaseArmor` | Armor pieces |
|
||
| `BaseShield` | Shields |
|
||
| `BaseClothing` | Wearable clothing |
|
||
| `BaseJewel` | Rings, bracelets, necklaces |
|
||
| `BaseContainer` | Containers (bags, boxes) |
|
||
| `BasePotion` | Potions |
|
||
| `BaseReagent` | Spell reagents |
|
||
| `Food` | Edible items |
|
||
| `SpellScroll` | Spell scrolls |
|
||
|
||
### Key Item Properties
|
||
```csharp
|
||
Weight = 1.0; // Item weight in stones
|
||
Stackable = true; // Can stack with same type
|
||
Amount = 1; // Stack amount
|
||
Movable = true; // Can be picked up
|
||
Visible = true; // Visible to players
|
||
Hue = 0; // Color hue (0 = default)
|
||
Light = LightType.Circle300; // Light emission
|
||
LootType = LootType.Regular; // Regular, Newbied, Blessed, Cursed
|
||
Layer = Layer.OneHanded; // Equipment layer
|
||
```
|
||
|
||
---
|
||
|
||
## New Creature
|
||
|
||
### Basic Creature
|
||
```csharp
|
||
using ModernUO.Serialization;
|
||
using Server.Items;
|
||
|
||
namespace Server.Mobiles;
|
||
|
||
[SerializationGenerator(0)]
|
||
public partial class ForestWolf : BaseCreature
|
||
{
|
||
[Constructible]
|
||
public ForestWolf() : base(AIType.AI_Melee, FightMode.Closest)
|
||
{
|
||
Body = 225; // Wolf body graphic
|
||
BaseSoundID = 0xE5; // Base sound ID
|
||
|
||
SetStr(80, 120);
|
||
SetDex(90, 110);
|
||
SetInt(20, 40);
|
||
|
||
SetHits(60, 80);
|
||
SetMana(0);
|
||
|
||
SetDamage(8, 14);
|
||
|
||
SetDamageType(ResistanceType.Physical, 100);
|
||
|
||
SetResistance(ResistanceType.Physical, 25, 35);
|
||
SetResistance(ResistanceType.Fire, 5, 10);
|
||
SetResistance(ResistanceType.Cold, 15, 25);
|
||
SetResistance(ResistanceType.Poison, 10, 15);
|
||
SetResistance(ResistanceType.Energy, 5, 10);
|
||
|
||
SetSkill(SkillName.MagicResist, 30.0, 50.0);
|
||
SetSkill(SkillName.Tactics, 50.0, 70.0);
|
||
SetSkill(SkillName.Wrestling, 50.0, 70.0);
|
||
|
||
Fame = 600;
|
||
Karma = 0;
|
||
|
||
VirtualArmor = 28;
|
||
|
||
Tamable = true;
|
||
ControlSlots = 1;
|
||
MinTameSkill = 50.1;
|
||
}
|
||
|
||
public override string CorpseName => "a wolf corpse";
|
||
public override string DefaultName => "a forest wolf";
|
||
public override int Meat => 1;
|
||
public override int Hides => 6;
|
||
public override HideType HideType => HideType.Regular;
|
||
public override FoodType FavoriteFood => FoodType.Meat;
|
||
public override PackInstinct PackInstinct => PackInstinct.Canine;
|
||
|
||
public override void GenerateLoot()
|
||
{
|
||
AddLoot(LootPack.Meager);
|
||
}
|
||
}
|
||
```
|
||
|
||
### AI Types
|
||
| AIType | Use For |
|
||
|---|---|
|
||
| `AI_Melee` | Warriors, melee fighters |
|
||
| `AI_Mage` | Spellcasters |
|
||
| `AI_Archer` | Ranged attackers |
|
||
| `AI_Animal` | Passive animals (flee/fight back) |
|
||
| `AI_Predator` | Hunting animals |
|
||
| `AI_Healer` | Healing NPCs |
|
||
| `AI_Vendor` | Shop NPCs |
|
||
| `AI_Berserk` | Mindless aggressors |
|
||
| `AI_Thief` | Pickpockets |
|
||
|
||
### Fight Modes
|
||
| FightMode | Behavior |
|
||
|---|---|
|
||
| `None` | Never attacks |
|
||
| `Aggressor` | Only retaliates |
|
||
| `Strongest` | Targets highest-stat enemy |
|
||
| `Weakest` | Targets lowest-stat enemy |
|
||
| `Closest` | Targets nearest enemy |
|
||
| `Evil` | Attacks aggressors or evil-karma targets |
|
||
|
||
### Creature Stats Guide
|
||
| Creature Level | Str | Dex | Int | Hits | Damage | Fame |
|
||
|---|---|---|---|---|---|---|
|
||
| Weak | 30-60 | 30-50 | 10-20 | 20-40 | 2-6 | 100-300 |
|
||
| Average | 80-120 | 60-90 | 20-40 | 60-100 | 6-14 | 500-1500 |
|
||
| Strong | 150-250 | 80-120 | 50-100 | 120-200 | 12-22 | 2000-5000 |
|
||
| Elite | 300-500 | 100-150 | 100-200 | 250-500 | 18-30 | 5000-15000 |
|
||
| Boss | 500-1000 | 150-250 | 200-400 | 500-2000 | 25-40 | 15000+ |
|
||
|
||
### Optional Creature Overrides
|
||
```csharp
|
||
public override Poison PoisonImmune => Poison.Regular; // Poison immunity
|
||
public override Poison HitPoison => Poison.Lesser; // Melee poison
|
||
public override double HitPoisonChance => 0.2; // 20% poison chance
|
||
public override bool CanRummageCorpses => true; // Loots corpses
|
||
public override bool BardImmune => true; // Cannot be provoked/peaced
|
||
public override bool Unprovokable => true; // Cannot be provoked
|
||
public override bool CanFly => true; // Can fly
|
||
public override int TreasureMapLevel => 3; // Drops treasure map
|
||
public override double WeaponAbilityChance => 0.4; // Weapon ability chance
|
||
```
|
||
|
||
### Creature Speeds (think vs move clocks)
|
||
|
||
All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks:
|
||
|
||
- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision
|
||
(combat decisions, target acquisition, spell timing).
|
||
- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per
|
||
step. Inherits the matching think value until overridden, so a creature configured with
|
||
only think speeds behaves as one clock. The properties read the raw override (`0` =
|
||
inheriting); `CurrentMoveSpeed` is the resolved pace. Any value is legal — steps are
|
||
scheduled independently of think ticks, so the two need not divide evenly.
|
||
|
||
Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type
|
||
lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code:
|
||
|
||
```csharp
|
||
public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json
|
||
```
|
||
|
||
Code-level overrides for special cases:
|
||
|
||
```csharp
|
||
SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics)
|
||
SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted
|
||
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).
|
||
|
||
Two conditions cap the resolved step pace without touching either clock, so nothing is
|
||
stored and nothing needs undoing when the condition ends:
|
||
|
||
- **Herding** — a creature with a `TargetLocation` is driven at a fixed `HerdingMoveSpeed`.
|
||
- **Pacing to the master** — a pet following its master, or guarding from outside guard
|
||
range, is capped at `FollowMoveSpeed` (AOS 0.1, earlier eras 0; RunUO's pet sprint). It is
|
||
a cap, not an override: a creature configured faster keeps its own pace, and its
|
||
`ActiveMoveSpeed`/`PassiveMoveSpeed` are left untouched. Override the virtual to change
|
||
the pace or to enable it in an era that has it off. Decisions are unaffected — a following
|
||
pet thinks on its active clock.
|
||
|
||
The client's `Running` bit is derived from the step pace, never passed by callers
|
||
(`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk
|
||
interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` /
|
||
`WalkMountDelay`) — is flagged as a run, or the client falls behind and snaps. An isolated
|
||
step (resuming after at least a walk interval standing) goes out as a walk regardless of
|
||
pace — the client renders each step alone, so a run-flagged single step darts — unless the
|
||
pace beats the run interpolation (a true sprinter), where a walk-rendered first step would
|
||
flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`,
|
||
`ApproachTarget`, `MoveToPoint`) take no run argument; to make a creature run, make it
|
||
fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just
|
||
taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace.
|
||
|
||
### Target Acquisition: the reaction-time gradient
|
||
|
||
Acquisition is event-driven, not polled. The periodic scan (`AcquireFocusMob`) is gated by
|
||
`ReacquireDelay` (10 s default) and every scan re-arms it in full, success or failure — it
|
||
is target stickiness plus the fallback for what movement cannot signal (reveals, doors,
|
||
summons). Reaction time comes from `BaseCreature.OnMovement`: an enemy moving inside
|
||
`AcquireOnApproachRange` (10 — on-screen; the periodic scan keeps the wider
|
||
`RangePerception`) clamps the next scan to
|
||
at most **`AcquireOnApproachDelay`** — the intelligence gradient. `TimeSpan.Zero`
|
||
(paragons) also prods the AI, so the ranked scan engages within a timer-wheel turn; the
|
||
2 s default reads as "took a beat to notice you"; larger is dumber; a creature that
|
||
overrides the delay above `ReacquireDelay` is effectively oblivious to approach. Repeated
|
||
steps cannot shorten the clamp, so an armed creature scans once per delay period, not once
|
||
per step or think. `ReacquireOnMovement` remains the broader hook (any mover, no enemy
|
||
check, scan next think). The gate self-heals: a deadline further out than `ReacquireDelay`
|
||
is illegal and reads as open, so no wedged or wrapped value can silence acquisition beyond
|
||
one delay period.
|
||
|
||
### 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
|
||
|
||
### Targeted Damage Spell (Magery)
|
||
```csharp
|
||
using System;
|
||
using Server.Targeting;
|
||
|
||
namespace Server.Spells.Third;
|
||
|
||
public class FireballSpellCustom : MagerySpell, ITargetingSpell<Mobile>
|
||
{
|
||
private static readonly SpellInfo _info = new(
|
||
"Fireball", // Name
|
||
"Vas Flam", // Mantra
|
||
212, // Cast animation
|
||
9041, // Cast sound
|
||
Reagent.BlackPearl // Reagents (comma-separated)
|
||
);
|
||
|
||
public FireballSpellCustom(Mobile caster, Item scroll = null) : base(caster, scroll, _info) { }
|
||
|
||
public override SpellCircle Circle => SpellCircle.Third;
|
||
public override bool DelayedDamage => true;
|
||
|
||
public void Target(Mobile m)
|
||
{
|
||
if (CheckHSequence(m)) // Harmful spell sequence check
|
||
{
|
||
var source = Caster;
|
||
SpellHelper.Turn(source, m);
|
||
SpellHelper.CheckReflect((int)Circle, ref source, ref m);
|
||
|
||
double damage;
|
||
if (Core.AOS)
|
||
{
|
||
damage = GetNewAosDamage(19, 1, 5, m);
|
||
}
|
||
else
|
||
{
|
||
damage = Utility.Random(10, 7);
|
||
if (CheckResisted(m))
|
||
{
|
||
damage *= 0.75;
|
||
m.SendLocalizedMessage(501783); // You resist
|
||
}
|
||
damage *= GetDamageScalar(m);
|
||
}
|
||
|
||
source.MovingParticles(m, 0x36D4, 7, 0, false, true, 9502, 4019, 0x160);
|
||
source.PlaySound(0x15E);
|
||
|
||
// Damage types must sum to 100
|
||
SpellHelper.Damage(this, m, damage, 0, 100, 0, 0, 0);
|
||
// phys fire cold pois energy
|
||
}
|
||
}
|
||
|
||
public override void OnCast()
|
||
{
|
||
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Harmful);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Spell Helper Methods
|
||
```csharp
|
||
SpellHelper.Turn(caster, target); // Face target
|
||
SpellHelper.CheckReflect(circle, ref source, ref target); // Magic reflect
|
||
SpellHelper.Damage(spell, target, damage, phys, fire, cold, poison, energy);
|
||
SpellHelper.AddStatCurse(caster, target, stat);
|
||
SpellHelper.AddStatBonus(caster, target, stat);
|
||
SpellHelper.CanRevealCaster(spell);
|
||
|
||
CheckHSequence(target); // Harmful spell checks (LOS, range, criminal)
|
||
CheckBSequence(target); // Beneficial spell checks
|
||
CheckResisted(target); // Resistance check
|
||
GetNewAosDamage(bonus, dice, sides, target); // AOS damage formula
|
||
GetDamageScalar(target); // Pre-AOS damage multiplier
|
||
```
|
||
|
||
### Spell Circles (Magery)
|
||
| Circle | Mana | Base Delay |
|
||
|---|---|---|
|
||
| First | 4 | 0.25s + circle |
|
||
| Second | 6 | 0.50s + circle |
|
||
| Third | 9 | 0.75s + circle |
|
||
| Fourth | 11 | 1.00s + circle |
|
||
| Fifth | 14 | 1.25s + circle |
|
||
| Sixth | 20 | 1.50s + circle |
|
||
| Seventh | 40 | 1.75s + circle |
|
||
| Eighth | 50 | 2.00s + circle |
|
||
|
||
---
|
||
|
||
## Skill Implementation
|
||
|
||
### Registering a Skill Handler
|
||
```csharp
|
||
namespace Server.SkillHandlers;
|
||
|
||
public static class MySkillHandler
|
||
{
|
||
public static void Initialize()
|
||
{
|
||
SkillInfo.Table[(int)SkillName.Tracking].Callback = OnUse;
|
||
}
|
||
|
||
public static TimeSpan OnUse(Mobile from)
|
||
{
|
||
from.SendMessage("You begin tracking...");
|
||
from.Target = new TrackingTarget();
|
||
return TimeSpan.FromSeconds(10.0); // Cooldown
|
||
}
|
||
}
|
||
```
|
||
|
||
### Skill Check
|
||
```csharp
|
||
// Difficulty-based check (with skill gain chance)
|
||
if (from.CheckSkill(SkillName.Mining, 0.0, 100.0))
|
||
{
|
||
// Success
|
||
}
|
||
|
||
// Direct chance check
|
||
if (from.CheckSkill(SkillName.Hiding, minSkill: 25.0, maxSkill: 75.0))
|
||
{
|
||
// Success
|
||
}
|
||
```
|
||
|
||
### SkillName Enum (58 skills)
|
||
Key skills: `Alchemy`, `Anatomy`, `AnimalLore`, `AnimalTaming`, `Archery`, `ArmsLore`, `Begging`, `Blacksmith`, `Bushido`, `Camping`, `Carpentry`, `Cartography`, `Chivalry`, `Cooking`, `DetectHidden`, `Discordance`, `EvalInt`, `Fencing`, `Fishing`, `Fletching`, `Focus`, `Forensics`, `Healing`, `Herding`, `Hiding`, `Inscribe`, `ItemID`, `Lockpicking`, `Lumberjacking`, `Macing`, `Magery`, `MagicResist`, `Meditation`, `Mining`, `Musicianship`, `Necromancy`, `Ninjitsu`, `Parry`, `Peacemaking`, `Poisoning`, `Provocation`, `RemoveTrap`, `Snooping`, `Spellweaving`, `SpiritSpeak`, `Stealing`, `Stealth`, `Swords`, `Tactics`, `Tailoring`, `TasteID`, `Tinkering`, `Tracking`, `Veterinary`, `Wrestling`
|
||
|
||
---
|
||
|
||
## Loot System
|
||
|
||
### Using Predefined Packs
|
||
```csharp
|
||
public override void GenerateLoot()
|
||
{
|
||
AddLoot(LootPack.Poor); // ~50g equivalent
|
||
AddLoot(LootPack.Meager); // ~100g equivalent
|
||
AddLoot(LootPack.Average); // ~250g equivalent
|
||
AddLoot(LootPack.Rich); // ~500g equivalent
|
||
AddLoot(LootPack.FilthyRich); // ~1000g equivalent
|
||
AddLoot(LootPack.UltraRich); // ~2000g equivalent
|
||
AddLoot(LootPack.SuperBoss); // Boss-level loot
|
||
|
||
// Auxiliary packs
|
||
AddLoot(LootPack.Gems, 2); // 2 random gems
|
||
AddLoot(LootPack.Potions); // Random potion
|
||
AddLoot(LootPack.LowScrolls); // Low circle scroll
|
||
AddLoot(LootPack.MedScrolls); // Med circle scroll
|
||
AddLoot(LootPack.HighScrolls); // High circle scroll
|
||
}
|
||
```
|
||
|
||
Packs auto-select era-appropriate loot (Pre-AOS, AOS, SE variants).
|
||
|
||
### Specific Items
|
||
```csharp
|
||
PackItem(new Arrow(Utility.RandomMinMax(20, 40)));
|
||
PackGold(100, 200);
|
||
PackItem(new Bandage(Utility.RandomMinMax(5, 10)));
|
||
```
|
||
|
||
---
|
||
|
||
## Context Menus
|
||
|
||
```csharp
|
||
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 RepairEntry(this));
|
||
}
|
||
}
|
||
|
||
private class RepairEntry : ContextMenuEntry
|
||
{
|
||
private readonly Item _item;
|
||
|
||
public RepairEntry(Item item) : base(6100) // Cliloc number
|
||
{
|
||
_item = item;
|
||
Enabled = item is { Deleted: false };
|
||
}
|
||
|
||
public override void OnClick(Mobile from, IEntity target)
|
||
{
|
||
if (_item.Deleted || !from.InRange(_item, 2))
|
||
return;
|
||
|
||
from.SendMessage("You repair the item.");
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Entity Lifecycle
|
||
|
||
### Two-Phase Deletion
|
||
```csharp
|
||
// Phase 1: Pre-removal cleanup
|
||
public override void OnDelete()
|
||
{
|
||
_timerToken.Cancel(); // Cancel managed timers
|
||
// Remove from tracking systems
|
||
base.OnDelete();
|
||
}
|
||
|
||
// Phase 2: Post-removal cleanup
|
||
public override void OnAfterDelete()
|
||
{
|
||
_timer?.Stop(); // Stop Timer references
|
||
_timer = null;
|
||
_owner = null; // Null Item/Mobile refs
|
||
base.OnAfterDelete();
|
||
}
|
||
```
|
||
|
||
### OnDoubleClick Validation
|
||
```csharp
|
||
public override void OnDoubleClick(Mobile from)
|
||
{
|
||
if (!IsChildOf(from.Backpack))
|
||
{
|
||
from.SendLocalizedMessage(1042001); // Must be in backpack
|
||
return;
|
||
}
|
||
|
||
if (!from.InRange(GetWorldLocation(), 2))
|
||
{
|
||
from.SendLocalizedMessage(500446); // Too far away
|
||
return;
|
||
}
|
||
|
||
// Item logic here
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## File Organization
|
||
|
||
```
|
||
Projects/UOContent/
|
||
├── Items/
|
||
│ ├── Weapons/Swords/ # Swords
|
||
│ ├── Weapons/Maces/ # Maces
|
||
│ ├── Weapons/Ranged/ # Bows, crossbows
|
||
│ ├── Armor/Plate/ # Plate armor
|
||
│ ├── Armor/Chain/ # Chain armor
|
||
│ ├── Armor/Leather/ # Leather armor
|
||
│ ├── Clothing/ # Wearable clothing
|
||
│ ├── Containers/ # Bags, boxes
|
||
│ ├── Misc/ # General items
|
||
│ ├── Special/ # Unique/quest items
|
||
│ └── Resources/ # Crafting materials
|
||
├── Mobiles/
|
||
│ ├── Animals/Bears/ # Bears (BlackBear, GrizzlyBear)
|
||
│ ├── Animals/Birds/ # Birds
|
||
│ ├── Monsters/AOS/ # AOS-era monsters
|
||
│ ├── Monsters/SE/ # SE-era monsters
|
||
│ ├── Monsters/ML/ # ML-era monsters
|
||
│ ├── Special/ # Champions, bosses
|
||
│ ├── Vendors/ # NPC vendors
|
||
│ └── Townfolk/ # NPCs
|
||
├── Spells/
|
||
│ ├── Base/ # Spell base classes
|
||
│ ├── First/ - Eighth/ # Magery circles
|
||
│ ├── Necromancy/ # Necromancer spells
|
||
│ ├── Chivalry/ # Paladin spells
|
||
│ ├── Bushido/ # Samurai abilities
|
||
│ ├── Ninjitsu/ # Ninja abilities
|
||
│ └── Spellweaving/ # Spellweaving
|
||
├── Skills/ # Skill handlers
|
||
├── Gumps/ # UI dialogs
|
||
│ └── Base/ # Gump base classes
|
||
├── Engines/ # Complex systems
|
||
│ ├── Craft/ # Crafting system
|
||
│ ├── CannedEvil/ # Champion spawns
|
||
│ ├── Factions/ # Faction system
|
||
│ └── Quests/ # Quest system
|
||
└── Misc/ # Miscellaneous
|
||
└── LootPack.cs # Loot tables
|
||
```
|
||
|
||
### Naming Rules
|
||
- File name = class name
|
||
- One primary class per file
|
||
- Group related items in subdirectories
|
||
- Era-specific content goes in era-named subdirectories
|