chore: Adds AI instructions and SKILLs for ModernUO codebase (#2347)

Summary

  - Adds CLAUDE.md at repo root with 14 terse code audit rules (always loaded, low token cost)
  - Adds pointer files for other AI tools: AGENTS.md (Codex), GEMINI.md, .github/COPILOT-INSTRUCTIONS.md (Copilot), .cursorrules (Cursor) — all redirect to CLAUDE.md as single source of truth
  - Gitignores /.claude so personal AI config isn't distributed
  - Moves Claude skills to dev-docs/claude-skills/ (opt-in, not auto-loaded)
  - Adds 14 dev-docs covering codebase conventions

  Code Audit Rules (in CLAUDE.md)

  1. LINQ tiered rules (Tier 1 free, Tier 2 warm, Tier 3 forbidden)
  2. No Console.WriteLine — use LogFactory.GetLogger()
  3. No concurrency primitives in game code
  4. No World.Mobiles/World.Items iteration
  5. Clean up refs in OnDelete()/OnAfterDelete()
  6. Cancel timers in OnDelete()/OnAfterDelete()
  7. STArrayPool<T>.Shared not ArrayPool<T>.Shared
  8. PooledRefList<T> not new List<T>() on hot paths
  9. Serialization: partial class, [Constructible], no serialized TimerExecutionToken
  10. No Task.Run/new Thread() in game code
  11. Never assume era — ask which expansion
  12. _camelCase fields, PascalCase properties/methods
  13. No empty gumps — use DisplayTo() pattern
  14. PropertyList string literals must be {} holes, cliloc-as-argument uses :#
This commit is contained in:
Kamron Batman 2026-03-01 11:42:19 -08:00 • committed by GitHub
parent e77a566f32
commit 1391c563fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 8549 additions and 0 deletions

View file

@ -0,0 +1,151 @@
---
name: modernuo-code-audit
description: >
Auto-trigger whenever writing or modifying .cs files under Projects/. Audits code for ModernUO convention violations. Warnings only - flag issues and ask before fixing.
---
# ModernUO Code Audit
## When This Activates
- Any time you write, edit, or modify a `.cs` file under `Projects/`
- After generating code snippets for the user
- During code review
## Audit Rules (Warnings Only)
Flag these issues but do NOT auto-fix. Ask the user before making changes.
### 1. LINQ: Know What's Optimized (.NET 10)
Not all LINQ is banned. .NET 10 JIT/PGO eliminates overhead for specific patterns. Anything not listed below is still forbidden on hot paths.
**Tier 1 — Zero-cost (use freely on hot paths):**
- `foreach` over `IEnumerable<T>` backed by `T[]`, `List<T>`, `Stack<T>`, `Queue<T>` — PGO devirtualizes the enumerator, zero heap allocation
- `.Contains()` after a preceding LINQ operator (`.Distinct()`, `.OrderBy()`, `.Reverse()`, `.Union()`, `.Intersect()`, `.Except()`, `.Concat()`, `.SelectMany()`, `.Where().Select()`, `.Skip()`, `.Take()`, `.OfType()`, `.Cast()`, `.Shuffle()`) — LINQ has ~30 specialized overrides that skip the intermediate work (no sort, no HashSet, no buffering)
- `.Count()` on sized collections (`ICollection<T>`, or after `Range`/`Repeat`/`Skip`/`Take`/`Append`) — O(1) property access, no enumeration
- `.OrderBy().First()` / `.OrderByDescending().First()` / `.OrderBy().Last()` — O(N) min/max scan, no sort performed
- `.Shuffle().Take(n)` — reservoir sampling, single pass, O(n) memory
- `Enumerable.Range()` / `Enumerable.Sequence()` followed by `.Count()`, `.Contains()`, `.ToArray()`, `.ToList()`, `.ElementAt()`, `.Last()` — arithmetic, not enumeration
**Tier 2 — Low overhead (acceptable on warm paths, benchmark if critical):**
- `.Skip(n).Take(m).ToArray()` on `T[]`/`List<T>` — vectorized `Span<T>.CopyTo` (still allocates output)
- `.LeftJoin()` / `.RightJoin()` — ~2x faster than manual `GroupJoin`+`SelectMany`+`DefaultIfEmpty`
- `.Where(predicate)` on `T[]`/`List<T>` — `WhereIterator` still heap-allocates, but enumeration is PGO-optimized. Manual `foreach`+`if` is still faster for true hot paths.
**Tier 3 — Still forbidden on hot paths (write manual code):**
- `.Select(f).Where(p)` (this order — each intermediate iterator allocates)
- `.GroupBy()`, `.ToDictionary()`, `.ToHashSet()`, `.ToLookup()` (always allocate internal structures)
- `.Aggregate()` (delegate overhead per element)
- `.Sum()` / `.Min()` / `.Max()` on `float`/`double` (no SIMD in LINQ on ARM)
- `.SelectMany()` when iterating results (not `.Contains()`) — multiple enumerator allocations
- `.Zip()` when iterating — enumerator allocations
- Any LINQ over `IAsyncEnumerable<T>` — no PGO/escape analysis
- Long chains like `.Where().Select().OrderBy().Take()` — each step allocates an iterator
**Prerequisites**: .NET 10, tiered compilation + Dynamic PGO enabled (default). Tier 1 optimizations require ~30+ calls for JIT warmup.
**Quick decision**: If the exact pattern is in Tier 1 → use it. If it's in Tier 2 → acceptable unless profiling shows it's a bottleneck. If it's anything else → manual `for`/`foreach` + `PooledRefList<T>`.
### 2. No Console.WriteLine
**Bad**: `Console.WriteLine(...)`, `Console.Write(...)`
**Good**: `private static readonly ILogger logger = LogFactory.GetLogger(typeof(MyClass));` then `logger.Information(...)`, `logger.Warning(...)`, `logger.Error(...)`
**Requires**: `using Server.Logging;`
### 3. No Concurrency Primitives in Game Code
**Bad**: `ConcurrentDictionary`, `ConcurrentQueue`, `ConcurrentBag`, `volatile`, `lock(...)`, `Mutex`, `Semaphore`, `Monitor`, `Interlocked`, `ReaderWriterLock`
**Why**: Server is single-threaded. These add overhead for no benefit.
**Instead**: Use regular `Dictionary<K,V>`, `List<T>`, plain fields.
### 4. Never Iterate World.Mobiles or World.Items Directly
**Bad**: `foreach (var m in World.Mobiles.Values)`, `World.Items.Values.Where(...)`
**Good**: `map.GetMobilesInBounds<T>(bounds)`, `map.GetMobilesInRange<T>(point, range)`, `map.GetItemsInRange<T>(point, range)`
**Why**: Full world iteration is O(n) over all entities. Spatial queries use sector indexing.
### 5. Clean Up References in OnDelete/OnAfterDelete
**Check**: Classes with `Item` or `Mobile` references should clean them in `OnDelete()` or `OnAfterDelete()`.
**Pattern**:
```csharp
public override void OnAfterDelete()
{
_someReference = null;
base.OnAfterDelete();
}
```
### 6. Cancel Timers in OnDelete/OnAfterDelete
**Check**: Any class with `TimerExecutionToken` or `Timer` fields must cancel them on deletion.
**Pattern**:
```csharp
public override void OnAfterDelete()
{
_timerToken.Cancel(); // For TimerExecutionToken
_timer?.Stop(); // For Timer references
_timer = null;
base.OnAfterDelete();
}
```
### 7. Use STArrayPool, Not ArrayPool
**Bad**: `ArrayPool<T>.Shared.Rent(...)` in game logic
**Good**: `STArrayPool<T>.Shared.Rent(...)` in game logic
**Why**: STArrayPool is single-threaded optimized (no locks). Use ArrayPool only in explicitly multi-threaded code.
**Also**: Always return rented arrays in a `finally` block.
### 8. No new List in Hot Paths
**Bad**: `var list = new List<Mobile>();` in frequently-called methods
**Good**: `using var list = PooledRefList<Mobile>.Create();`
**Why**: PooledRefList uses pooled arrays, zero GC pressure. It's a ref struct (stack-allocated).
### 9. Serialization Class Requirements
**Check**: Classes with `[SerializationGenerator]` MUST be `partial`.
**Check**: `[Constructible]` on parameterless constructors for items/mobiles.
**Check**: `TimerExecutionToken` fields must NOT have `[SerializableField]`.
**Check**: Use `using ModernUO.Serialization;` when using serialization attributes.
### 10. No Task.Run or new Thread
**Bad**: `Task.Run(...)`, `new Thread(...)`, `ThreadPool.QueueUserWorkItem(...)` in game code
**Why**: Game logic runs on the single-threaded event loop. Background threads cause race conditions.
**Exception**: Server infrastructure code (Projects/Server/Main.cs, World saves) may use threading.
### 11. Never Assume Era
**Check**: If code uses era-conditional logic (`Core.AOS`, `Core.SE`, etc.) and the user hasn't specified a target era, ASK which expansion to target.
**Why**: Different eras have dramatically different mechanics.
### 12. Naming Conventions
**Check**: `_camelCase` for private fields, `PascalCase` for properties/methods/classes.
**Note**: Legacy code may use `m_` prefix -- don't flag existing `m_` fields but use `_` for new code.
### 13. No Empty Gumps
**Check**: Any gump (legacy `Gump` constructor, or `BuildLayout`) must not have a code path that produces zero visual elements (no `AddBackground`, no `AddPage` with content, etc.).
**Why**: The client has no way to close an empty gump — no close button, no right-click dismiss. This leaks a gump slot on both client and server until relog.
**Common cause**: Early `return` in a constructor or `BuildLayout` when prerequisites aren't met.
**Fix**: Use a static `DisplayTo(Mobile from)` method that validates prerequisites **before** constructing the gump. Make the constructor `private`. See `Projects/UOContent/Gumps/Go/GoGump.cs` for the canonical pattern.
### 14. PropertyList String Literals Must Be Holes
**Check**: In any `IPropertyList.Add()` interpolated string, string constants must be wrapped as holes `{"text"}`, not bare literals.
**Bad**: `list.Add(1060658, $"Chances\t{_charges}");` — "Chances" becomes a delimiter, not an argument.
**Good**: `list.Add(1060658, $"{"Chances"}\t{_charges}");` — "Chances" is an argument.
**Why**: The handler treats bare text as delimiters and `{}` contents as arguments. The property list system is used beyond the game client (e.g., web rendering) which must distinguish arguments from delimiters. Only `\t` should be a bare literal.
**Also**: If you don't know the text for a cliloc number, see `Projects/Server/Localization/Localization.cs` `LoadClilocs()` to learn the binary format, and ask the user where their `cliloc.enu` file is.
## Severity Levels
- **ERROR**: Rules 3, 9, 10, 13 (will cause bugs, build failures, or client-side leaks)
- **WARNING**: Rules 1 (Tier 3 LINQ), 2, 4, 5, 6, 7, 8, 12, 14 (performance/convention issues)
- **INFO**: Rule 1 (Tier 2 LINQ on warm paths — note it but don't flag as violation)
- **ASK**: Rule 11 (need user input)
## How to Report
When you find violations, report them as:
```
[AUDIT] {SEVERITY}: {Description}
File: {path}:{line}
Suggestion: {fix}
```
Do NOT silently fix issues. Always flag and ask.
## See Also
- `dev-docs/code-standards.md` - Full coding standards documentation
- `dev-docs/claude-skills/modernuo-serialization.md` - Serialization rules
- `dev-docs/claude-skills/modernuo-timers.md` - Timer cleanup rules
- `dev-docs/claude-skills/modernuo-threading.md` - Threading model details
- `dev-docs/claude-skills/modernuo-property-lists.md` - PropertyList interpolation rules

View file

@ -0,0 +1,223 @@
---
name: modernuo-commands-targeting
description: >
Trigger when creating in-game commands, targeting mechanics, or working with CommandSystem/Target. When implementing [commands or player interactions.
---
# ModernUO Commands & Targeting
## When This Activates
- Creating new in-game `[` commands
- Implementing targeting mechanics
- Working with `CommandSystem.Register()` or `Target` class
- Adding GM/admin tools
## Key Rules
1. **Register commands in `Configure()`** static method (called at startup)
2. **Use `[Usage]` and `[Description]`** attributes on handler methods
3. **Prefix is `[`** by default (e.g., `[mycommand`)
4. **Target inherits from `Target`** class, override `OnTarget()`
5. **Always validate inputs** in command handlers
## Command Registration
```csharp
using Server.Commands;
namespace Server.Custom;
public static class MyCommands
{
public static void Configure()
{
CommandSystem.Register("MyCommand", AccessLevel.GameMaster, MyCommand_OnCommand);
CommandSystem.Register("MyOtherCmd", AccessLevel.Player, MyOtherCmd_OnCommand);
}
[Usage("MyCommand <name> [count]")]
[Description("Does something with a name and optional count")]
public static void MyCommand_OnCommand(CommandEventArgs e)
{
var from = e.Mobile;
if (e.Length < 1)
{
from.SendMessage("Usage: [MyCommand <name> [count]");
return;
}
var name = e.GetString(0);
var count = e.Length > 1 ? e.GetInt32(1) : 1;
from.SendMessage($"Processing {name} x{count}");
}
}
```
## CommandEventArgs
```csharp
e.Mobile // Mobile who issued the command
e.Command // Command name string
e.ArgString // Raw argument string
e.Arguments // string[] split arguments
e.Length // Number of arguments
// Typed argument accessors:
e.GetString(0) // Get string at index (empty string if missing)
e.GetInt32(1) // Get int at index (0 if missing/invalid)
e.GetUInt32(0) // Get uint at index
e.GetBoolean(0) // Get bool at index
e.GetDouble(0) // Get double at index
e.GetTimeSpan(0) // Get TimeSpan at index
```
## Access Levels
```csharp
AccessLevel.Player // Regular players
AccessLevel.Counselor // Support staff
AccessLevel.GameMaster // GMs
AccessLevel.Seer // Event coordinators
AccessLevel.Administrator // Server admins
AccessLevel.Developer // Developers
AccessLevel.Owner // Server owner
```
## Target System
### Basic Target
```csharp
using Server.Targeting;
private class MyTarget : Target
{
public MyTarget() : base(
12, // Range (-1 for unlimited)
false, // AllowGround
TargetFlags.None // None, Harmful, or Beneficial
)
{
// Optional settings:
// CheckLOS = false; // Skip line-of-sight check
// DisallowMultis = true; // Don't allow targeting multi objects
}
protected override void OnTarget(Mobile from, object targeted)
{
if (targeted is Mobile m)
{
from.SendMessage($"You targeted {m.Name}");
}
else if (targeted is Item item)
{
from.SendMessage($"You targeted item {item.Name}");
}
else if (targeted is LandTarget land)
{
from.SendMessage($"You targeted land at {land.Location}");
}
else if (targeted is StaticTarget st)
{
from.SendMessage($"You targeted static {st.ItemID}");
}
}
protected override void OnTargetCancel(Mobile from, TargetCancelType cancelType)
{
from.SendMessage("Targeting cancelled.");
}
protected override void OnTargetFinish(Mobile from)
{
// Always called after targeting completes (success or cancel)
}
}
// Usage:
from.Target = new MyTarget();
```
### TargetFlags
```csharp
TargetFlags.None // Neutral targeting
TargetFlags.Harmful // Criminal check, combat targeting
TargetFlags.Beneficial // Healing, buffing
```
### Target Object Types
- `Mobile` -- a player or creature
- `Item` -- an item in the world or container
- `LandTarget` -- ground tile (`Location`, `TileID`, `Name`)
- `StaticTarget` -- static map object (`Location`, `ItemID`, `Name`, `Hue`)
### Command + Target Pattern
```csharp
public static void Configure()
{
CommandSystem.Register("Tame", AccessLevel.GameMaster, Tame_OnCommand);
}
[Usage("Tame")]
[Description("Force-tames a creature")]
public static void Tame_OnCommand(CommandEventArgs e)
{
e.Mobile.SendMessage("Select a creature to tame.");
e.Mobile.Target = new TameTarget();
}
private class TameTarget : Target
{
public TameTarget() : base(-1, false, TargetFlags.None) { }
protected override void OnTarget(Mobile from, object targeted)
{
if (targeted is BaseCreature { Tamable: true } creature)
{
creature.SetControlMaster(from);
from.SendMessage($"You have tamed {creature.Name}.");
}
else
{
from.SendMessage("That cannot be tamed.");
}
}
}
```
### Target Validation Overrides
```csharp
// Override these for custom validation:
protected override bool CanTarget(Mobile from, Mobile mobile, ref Point3D loc, ref Map map)
protected override bool CanTarget(Mobile from, Item item, ref Point3D loc, ref Map map)
protected override bool CanTarget(Mobile from, LandTarget land, ref Point3D loc, ref Map map)
protected override bool CanTarget(Mobile from, StaticTarget st, ref Point3D loc, ref Map map)
// Error handlers:
protected override void OnTargetOutOfRange(Mobile from, object targeted)
protected override void OnTargetOutOfLOS(Mobile from, object targeted)
protected override void OnTargetNotAccessible(Mobile from, object targeted)
protected override void OnTargetDeleted(Mobile from, object targeted)
protected override void OnTargetUntargetable(Mobile from, object targeted)
```
## Anti-Patterns
- **Registering commands outside `Configure()`**: Won't be called during startup
- **Missing access level validation**: Always set appropriate `AccessLevel`
- **Not checking `e.Length`**: Accessing missing arguments returns defaults silently
- **Hardcoded range in targeting**: Use -1 for unlimited, appropriate range for game mechanics
## Real Examples
- Command registration: `Projects/UOContent/Commands/StaffAccess.cs`
- Target from item: `Projects/UOContent/Items/Misc/InteriorDecorator.cs`
- Spell targeting: `Projects/UOContent/Spells/First/MagicArrow.cs`
- Command system: `Projects/Server/Commands.cs`
- Target base: `Projects/Server/Targeting/Target.cs`
- Attributes: `Projects/Server/Attributes.cs` (Usage, Description, Aliases)
## See Also
- `dev-docs/commands-targeting.md` - Complete documentation
- `dev-docs/claude-skills/modernuo-gump-system.md` - Commands that open gumps
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Content creation

View file

@ -0,0 +1,177 @@
---
name: modernuo-configuration
description: >
Trigger when adding server settings, reading config values, or working with modernuo.json or JsonConfig.
---
# ModernUO Configuration System
## When This Activates
- Adding new server settings
- Reading config values with `ServerConfiguration`
- Using `JsonConfig.Serialize/Deserialize<T>`
- Working with `modernuo.json` or custom config files
## Key Rules
1. **Read settings in `Configure()`** static method
2. **Use `GetOrUpdateSetting()`** when you want the setting created with a default if missing
3. **Use `GetSetting()`** for read-only access (won't write default to file)
4. **Custom config files use `JsonConfig.Serialize/Deserialize<T>()`**
5. **Config path base**: `Distribution/Configuration/`
## ServerConfiguration
### GetOrUpdateSetting (Creates Default If Missing)
```csharp
// In Configure() method:
var maxAccounts = ServerConfiguration.GetOrUpdateSetting("accountHandler.maxAccountsPerIP", 1);
var saveDelay = ServerConfiguration.GetOrUpdateSetting("autosave.saveDelay", TimeSpan.FromMinutes(5));
var enabled = ServerConfiguration.GetOrUpdateSetting("mySystem.enabled", true);
```
If the key doesn't exist in `modernuo.json`, it writes the default value and returns it.
### GetSetting (Read-Only)
```csharp
var statMax = ServerConfiguration.GetSetting("stats.statMax", 100);
var usePub45 = ServerConfiguration.GetSetting("stats.usePub45StatGain", false);
```
Returns the default if key is missing but does NOT write to the config file.
### Supported Types
```csharp
ServerConfiguration.GetSetting(string key, int defaultValue)
ServerConfiguration.GetSetting(string key, bool defaultValue)
ServerConfiguration.GetSetting(string key, double defaultValue)
ServerConfiguration.GetSetting(string key, TimeSpan defaultValue)
ServerConfiguration.GetSetting<T>(string key, T defaultValue) where T : struct, Enum
```
### SetSetting
```csharp
ServerConfiguration.SetSetting("mySystem.customValue", "42");
// Immediately persisted to modernuo.json
```
## Configuration Pattern
```csharp
namespace Server.Custom;
public static class MySystem
{
private static bool _enabled;
private static int _maxItems;
private static TimeSpan _cooldown;
public static void Configure()
{
_enabled = ServerConfiguration.GetOrUpdateSetting("mySystem.enabled", true);
_maxItems = ServerConfiguration.GetOrUpdateSetting("mySystem.maxItems", 100);
_cooldown = ServerConfiguration.GetOrUpdateSetting("mySystem.cooldown", TimeSpan.FromMinutes(5));
}
// System logic uses _enabled, _maxItems, _cooldown...
}
```
## Custom Config Files with JsonConfig
For complex configuration that doesn't fit in `modernuo.json`:
```csharp
using Server.Json;
public static class MyComplexSystem
{
private static MyConfig _config;
private static readonly string ConfigPath =
Path.Combine(Core.BaseDirectory, "Configuration/MySystem/config.json");
public static void Configure()
{
_config = JsonConfig.Deserialize<MyConfig>(ConfigPath)
?? new MyConfig();
}
public static void SaveConfig()
{
JsonConfig.Serialize(ConfigPath, _config);
}
}
public class MyConfig
{
public bool Enabled { get; set; } = true;
public int MaxItems { get; set; } = 100;
public List<string> BlockedNames { get; set; } = new();
public Dictionary<string, int> Scores { get; set; } = new();
}
```
### JsonConfig Options
- Pretty-printed (WriteIndented = true)
- Comments allowed (ReadCommentHandling = Skip)
- Trailing commas allowed
- Null values omitted (WhenWritingNull)
- Enums serialized as strings (JsonStringEnumConverter)
- Built-in converters for: `ClientVersion`, `Guid`, `Map`, `Point3D`, `Rectangle3D`, `TimeSpan`, `IPEndPoint`, `Type`, `WorldLocation`, `TextDefinition`
## modernuo.json Structure
Location: `Distribution/Configuration/modernuo.json`
```json
{
"assemblyDirectories": ["./Assemblies"],
"dataDirectories": ["C:\\Ultima Online Classic"],
"listeners": ["0.0.0.0:2593"],
"settings": {
"accountHandler.enableAutoAccountCreation": "True",
"accountHandler.maxAccountsPerIP": "1",
"autosave.enabled": "True",
"autosave.saveDelay": "00:05:00",
"world.savePath": "Saves",
"stats.statMax": "100",
"timer.initialPoolCapacity": "1024",
"mySystem.enabled": "True"
}
}
```
All settings are stored as strings in the `settings` dictionary.
## Naming Convention for Keys
Use dot-separated hierarchical keys:
```
systemName.settingName
systemName.subSystem.settingName
```
Examples:
- `accountHandler.maxAccountsPerIP`
- `stats.statMax`
- `movement.delay.walkFoot`
- `autosave.saveDelay`
## Anti-Patterns
- **Reading config in constructors**: Use `Configure()` static method instead
- **Hardcoding values**: Use `ServerConfiguration.GetOrUpdateSetting()` for tunable values
- **Complex objects in modernuo.json**: Use `JsonConfig` with separate files instead
- **Not providing defaults**: Always pass a sensible default value
## Real Examples
- ServerConfiguration: `Projects/Server/Configuration/ServerConfiguration.cs`
- JsonConfig: `Projects/Server/Json/JsonConfig.cs`
- Config usage: `Projects/UOContent/Skills/SkillCheck.cs` (Configure method)
- Timer pool config: `Projects/Server/Timer/Timer.Pool.cs`
- Main config: `Distribution/Configuration/modernuo.json`
## See Also
- `dev-docs/configuration.md` - Complete configuration documentation
- `dev-docs/claude-skills/modernuo-era-expansion.md` - Expansion configuration
- `dev-docs/claude-skills/modernuo-events.md` - Configure() pattern

View file

@ -0,0 +1,370 @@
---
name: modernuo-content-patterns
description: >
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
## New Item Template
```csharp
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0, false)]
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
```csharp
using ModernUO.Serialization;
using Server.Items;
namespace Server.Mobiles;
[SerializationGenerator(0, false)]
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
```csharp
// 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
```csharp
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
```csharp
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:
```csharp
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
```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 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
```csharp
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

View file

@ -0,0 +1,134 @@
---
name: modernuo-era-expansion
description: >
Trigger when writing era-conditional code, using Core.AOS/SE/ML etc., or when user hasn't specified target era. Always ask which expansion to target if not specified.
---
# ModernUO Era & Expansion Support
## When This Activates
- Writing code that depends on game era (damage formulas, skill caps, mechanics)
- Using `Core.AOS`, `Core.SE`, `Core.ML`, etc.
- User asks for a feature without specifying era
- Implementing mechanics that changed across expansions
## CRITICAL RULE
**Never assume era.** If the user hasn't specified which expansion to target, ASK before writing era-dependent code.
## Expansion Enum
```csharp
public enum Expansion
{
None, // 0 - Pre-T2A
T2A, // 1 - The Second Age
UOR, // 2 - Renaissance
UOTD, // 3 - Third Dawn
LBR, // 4 - Blackthorn's Revenge
AOS, // 5 - Age of Shadows (major overhaul)
SE, // 6 - Samurai Empire
ML, // 7 - Mondain's Legacy
SA, // 8 - Stygian Abyss
HS, // 9 - High Seas
TOL, // 10 - Time of Legends
EJ // 11 - Endless Journey
}
```
## Era Check Properties
Each returns `true` when `Core.Expansion >= that era`:
```csharp
Core.T2A // >= The Second Age
Core.UOR // >= Renaissance
Core.UOTD // >= Third Dawn
Core.LBR // >= Blackthorn's Revenge
Core.AOS // >= Age of Shadows
Core.SE // >= Samurai Empire
Core.ML // >= Mondain's Legacy
Core.SA // >= Stygian Abyss
Core.HS // >= High Seas
Core.TOL // >= Time of Legends
Core.EJ // >= Endless Journey
```
For exact expansion: `Core.Expansion == Expansion.AOS`
## Key Era Boundaries
### AOS (Age of Shadows) -- Most Significant Change
- Complete combat overhaul: resistance-based damage system
- Property-based item system (magic properties)
- New damage formula: `GetNewAosDamage()` vs flat `Utility.Random()`
- Luck system for loot
- Insurance system
### SE (Samurai Empire)
- Bushido / Ninjitsu skills
- Samurai/Ninja classes
- Loot pack adjustments
### ML (Mondain's Legacy)
- Spellweaving
- Adjusted skill gain chances
- Container weight display changes
## Pattern: Era-Conditional Code
```csharp
// Ternary for simple values
var delay = Core.SE ? 250 : Core.AOS ? 500 : 1000;
// If/else for logic branches
if (Core.AOS)
{
damage = GetNewAosDamage(10, 1, 4, target);
}
else
{
damage = Utility.Random(4, 4);
if (CheckResisted(target))
damage *= 0.75;
}
// Property display varies by era
if (Core.ML)
{
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");
}
else
{
list.Add(1050044, $"{TotalItems}\t{TotalWeight}");
}
```
## Pattern: Era-Dependent Loot
`LootPack` properties auto-select based on expansion:
```csharp
// These automatically pick the right era variant
LootPack.Poor // OldPoor / AosPoor / SePoor
LootPack.Average // OldAverage / AosAverage / SeAverage
LootPack.Rich // OldRich / AosRich / SeRich
LootPack.FilthyRich // OldFilthyRich / AosFilthyRich / SeFilthyRich
LootPack.UltraRich // OldUltraRich / AosUltraRich / SeUltraRich
```
## Anti-Patterns
- **Hardcoding mechanics for one era** without conditional checks
- **Assuming AOS** when the user might want pre-AOS
- **Using era-specific APIs** without checking (e.g., `GetNewAosDamage()` pre-AOS)
## Real Examples
- Era-conditional damage: `Projects/UOContent/Spells/First/MagicArrow.cs`
- Era-conditional properties: `Projects/Server/Items/Container.cs` (`GetProperties`)
- Era-conditional values: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs`
- Expansion enum: `Projects/Server/ExpansionInfo.cs`
- Stat config by era: `Projects/UOContent/Skills/SkillCheck.cs`
## See Also
- `dev-docs/era-expansion.md` - Complete expansion documentation
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Content templates
- `dev-docs/claude-skills/modernuo-configuration.md` - Configuration system

View file

@ -0,0 +1,150 @@
---
name: modernuo-event-scheduler
description: >
Trigger when creating holiday events, seasonal content, scheduled maintenance, daily/weekly resets, or any wall-clock/calendar-based scheduling. When using EventScheduler, ScheduledEvent, YearlyScheduledEvent, or IRecurrencePattern.
---
# ModernUO EventScheduler (Wall-Clock / Calendar Scheduling)
## When This Activates
- Creating holiday or seasonal events (Halloween, Christmas, etc.)
- Scheduling daily/weekly/monthly resets or activities
- Any event that must fire at a real-world time or date
- Working with `EventScheduler`, `ScheduledEvent`, `YearlyScheduledEvent`, `CallbackScheduledEvent`
- Working with `IRecurrencePattern`, `AllowedDays`, `AllowedMonths`, `MonthDay`
## Key Rules
1. **EventScheduler for wall-clock/calendar, Timer for game-tick delays** — if the event is "at 9 AM every Monday," use EventScheduler; if it's "5 seconds from now," use Timer
2. **Always specify timezone** for local-time events — omitting defaults to UTC
3. **Prefer `CallbackScheduledEvent` (via static methods)** for simple recurring actions
4. **Use `YearlyScheduledEvent`** for seasonal windows (e.g., Oct 15 - Nov 1 each year)
5. **Cancel events on cleanup** — call `Cancel()` when disabling or shutting down
6. **1-second granularity** — EventScheduler ticks every second, not suitable for sub-second precision
## Timer vs EventScheduler Decision
| Need | Use |
|---|---|
| "Every 5 seconds" | `Timer.StartTimer` |
| "At 6:00 AM daily" | `EventScheduler.DailyAt` |
| "Delete after 10 seconds" | `Timer.StartTimer` |
| "Every Monday at noon" | `EventScheduler.WeeklyAt` |
| "Combat tick every 250ms" | `Timer.StartTimer` |
| "Oct 15 - Nov 1 each year" | `YearlyScheduledEvent` |
| "First Tuesday of each month" | `MonthlyOrdinalRecurrencePattern` |
## Quick Patterns
### Daily Event at a Specific Time
```csharp
var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
var startOn = new DateTime(2025, 1, 1, 6, 0, 0); // 6:00 AM
EventScheduler.DailyAt(startOn, ResetDailyQuests, eastern);
```
### Weekly Event
```csharp
var startOn = new DateTime(2025, 1, 6, 18, 0, 0); // Monday 6:00 PM
EventScheduler.WeeklyAt(startOn, StartWeeklyTournament, eastern);
```
### Monthly Event on a Specific Day
```csharp
var startOn = new DateTime(2025, 1, 15, 12, 0, 0); // 15th at noon
EventScheduler.MonthlyAt(startOn, MonthlyRewards, eastern);
```
### Yearly Seasonal Event (with Window)
```csharp
var halloween = new YearlyCallbackScheduledEvent(
new TimeOnly(0, 0),
new MonthDay(2025, 10, 15), // Start: Oct 15
new MonthDay(2025, 11, 1), // End: Nov 1
SpawnHalloweenContent,
EventScheduler.Daily
);
halloween.Schedule(DateTime.UtcNow, eastern);
```
### Filtered Weekly (Specific Days/Months)
```csharp
var pattern = new WeeklyRecurrencePattern(
intervalWeeks: 1,
allowedMonths: AllowedMonths.June | AllowedMonths.July | AllowedMonths.August,
allowedDays: AllowedDays.Friday | AllowedDays.Saturday
);
var evt = new CallbackScheduledEvent(new TimeOnly(20, 0), SummerWeekendEvent, pattern);
evt.Schedule(DateTime.UtcNow, eastern);
```
### Ordinal Monthly (e.g., "Second Tuesday")
```csharp
var pattern = new MonthlyOrdinalRecurrencePattern(
OrdinalDayOccurrence.Second,
DayOfWeek.Tuesday
);
var evt = new CallbackScheduledEvent(new TimeOnly(12, 0), MonthlyMeeting, pattern);
evt.Schedule(DateTime.UtcNow, eastern);
```
## Custom Event Class Template
```csharp
using System;
using Server.Engines.Events;
public class MyScheduledEvent : ScheduledEvent
{
public MyScheduledEvent(TimeOnly time, IRecurrencePattern recurrence)
: base(time, recurrence)
{
}
public override void OnEvent()
{
// Your event logic here
}
}
// Schedule it:
var evt = new MyScheduledEvent(new TimeOnly(9, 0), EventScheduler.Daily);
evt.Schedule(DateTime.UtcNow, timeZone);
// Cancel it:
evt.Cancel();
```
### Custom Yearly Seasonal Event Template
```csharp
public class MySeasonalEvent : YearlyScheduledEvent
{
protected MySeasonalEvent(
TimeOnly time,
MonthDay yearlyStart,
MonthDay yearlyEnd,
IRecurrencePattern recurrence
) : base(time, yearlyStart, yearlyEnd, recurrence)
{
}
public override void OnEvent()
{
// Only fires when date is within [yearlyStart, yearlyEnd]
}
}
```
## Anti-Patterns
- **Using `Timer.StartTimer` for calendar events**: Timers drift across restarts and have no timezone support — use EventScheduler
- **Forgetting to specify timezone**: Event fires at UTC instead of expected local time — always pass `TimeZoneInfo`
- **Not cancelling events on cleanup**: Scheduled events keep firing after the system is disabled — call `Cancel()`
- **Using EventScheduler for sub-second timing**: 1-second granularity is too coarse — use `Timer.StartTimer`
- **Constructing `MonthDay` with invalid day**: Throws `ArgumentOutOfRangeException` — validate against `DateTime.DaysInMonth`
## See Also
- `dev-docs/event-scheduler.md` — Complete EventScheduler documentation
- `dev-docs/timers.md` — Game-tick timer system (Timer.StartTimer, TimerExecutionToken)
- `dev-docs/claude-skills/modernuo-timers.md` — Timer skill for game-tick delays

View file

@ -0,0 +1,184 @@
---
name: modernuo-events
description: >
Trigger when subscribing to or creating game events, working with EventSink or generated events. When hooking into player login, death, speech, or other game events.
---
# ModernUO Events System
## When This Activates
- Subscribing to game events (login, logout, death, speech)
- Creating new events
- Working with `EventSink` static events
- Using `[GeneratedEvent]` / `[OnEvent]` attributes
## Key Rules
1. **Subscribe to events in `Configure()`** static method
2. **EventSink events are `static event Action<T>`** -- subscribe with `+=`
3. **Event handlers must match the delegate signature**
4. **Unsubscribe if your system can be disabled** (prevent leaks)
## EventSink Events
Subscribe in `Configure()`:
```csharp
public static void Configure()
{
EventSink.Connected += OnPlayerConnected;
EventSink.Disconnected += OnPlayerDisconnected;
EventSink.Logout += OnLogout;
EventSink.ServerStarted += OnServerStarted;
}
```
### Available Events
#### Core Lifecycle
```csharp
EventSink.ServerStarted // Action -- Server fully started
EventSink.Shutdown // Action -- Server shutting down
EventSink.WorldLoad // Action -- World loaded from saves
EventSink.WorldSave // Action -- World save triggered
EventSink.WorldSavePostSnapshot // Action<WorldSavePostSnapshotEventArgs>
```
#### Player Connection
```csharp
EventSink.Connected // Action<Mobile> -- Player connected
EventSink.BeforeDisconnected // Action<Mobile> -- About to disconnect
EventSink.Disconnected // Action<Mobile> -- Player disconnected
EventSink.Logout // Action<Mobile> -- Player logged out
```
#### Account
```csharp
EventSink.AccountLogin // Action<AccountLoginEventArgs>
// AccountLoginEventArgs: .State (NetState), .Username, .Password, .Accepted (set), .RejectReason (set)
```
#### Communication
```csharp
EventSink.Speech // Action<SpeechEventArgs>
// SpeechEventArgs: .Mobile, .Speech, .Type, .Hue, .Keywords, .Handled (set), .Blocked (set)
```
#### Combat
```csharp
EventSink.AggressiveAction // Action<AggressiveActionEventArgs>
// AggressiveActionEventArgs: .Aggressed, .Aggressor, .Criminal
```
#### Movement
```csharp
EventSink.Movement // Action<MovementEventArgs>
// MovementEventArgs: .Mobile, .Direction, .Blocked (set)
```
#### Network
```csharp
EventSink.SocketConnect // Action<SocketConnectEventArgs>
// SocketConnectEventArgs: .Address, .AllowConnection (set)
EventSink.ServerCrashed // Action<ServerCrashedEventArgs>
// ServerCrashedEventArgs: .Exception, .Close (set)
```
#### UI
```csharp
EventSink.PaperdollRequest // Action<Mobile, Mobile> -- (beholder, beheld)
```
## Event Handler Pattern
```csharp
public static class MyEventSystem
{
public static void Configure()
{
EventSink.Connected += OnConnected;
EventSink.Speech += OnSpeech;
}
private static void OnConnected(Mobile m)
{
if (m is PlayerMobile pm)
{
pm.SendMessage("Welcome back!");
}
}
private static void OnSpeech(SpeechEventArgs e)
{
if (e.Speech.InsensitiveContains("help"))
{
e.Mobile.SendMessage("How can I help you?");
e.Handled = true; // Prevent further processing
}
}
}
```
## Generated Events (Code-Generated)
For custom events on game entities, use the CodeGeneratedEvents package:
### Defining Events
```csharp
// On the class that fires the event:
[GeneratedEvent(nameof(PlayerLoginEvent))]
public static partial void PlayerLoginEvent(PlayerMobile player);
```
### Subscribing to Events
```csharp
// On the handler class:
[OnEvent(nameof(PlayerMobile.PlayerLoginEvent))]
public static void HandleLogin(PlayerMobile player)
{
// Handle the event
}
```
### Known Generated Events
- `PlayerMobile.PlayerLoginEvent`
- `PlayerMobile.PlayerDeathEvent`
- `BaseCreature.CreatureDeathEvent`
External reference: https://github.com/modernuo/CodeGeneratedEvents
## EventArgs Pool Pattern
Some EventArgs use object pooling to avoid allocation:
```csharp
// Movement uses pooling:
var args = MovementEventArgs.Create(mobile, dir);
EventSink.InvokeMovement(args);
args.Free(); // Return to pool
// AggressiveAction uses pooling:
var args = AggressiveActionEventArgs.Create(aggressed, aggressor, criminal);
EventSink.InvokeAggressiveAction(args);
args.Free();
```
## Anti-Patterns
- **Subscribing outside `Configure()`**: Won't be called during startup
- **Not checking player type**: `EventSink.Connected` fires for all mobiles, cast to `PlayerMobile` if needed
- **Blocking in event handlers**: Event handlers run on the game loop -- keep them fast
- **Not unsubscribing**: If system can be disabled, unsubscribe to prevent leaks
## Real Examples
- EventSink core: `Projects/Server/Events/EventSink.cs`
- Speech events: `Projects/Server/Events/SpeechEvent.cs`
- Movement events: `Projects/Server/Events/MovementEvent.cs`
- Account events: `Projects/Server/Events/AccountLoginEvent.cs`
- World events: `Projects/Server/Events/EventSink.cs` (WorldLoad, WorldSave, ServerStarted, Shutdown)
- Connection events: `Projects/Server/Events/SocketConnectionEvent.cs`
- GumpSystem subscription: `Projects/UOContent/Gumps/Base/GumpSystem.cs`
## See Also
- `dev-docs/events.md` - Complete events documentation
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Content hooks
- `dev-docs/claude-skills/modernuo-configuration.md` - Configure() pattern

View file

@ -0,0 +1,350 @@
---
name: modernuo-gump-system
description: >
Trigger when creating or modifying gumps (UI dialogs). When working with BaseGump, StaticGump, DynamicGump, or GumpSystem.
---
# ModernUO Gump System
## When This Activates
- Creating new gumps (UI dialogs/windows)
- Modifying existing gump layouts
- Handling gump button responses
- Using `mobile.SendGump()`, `HasGump<T>()`, `CloseGump<T>()`
## Key Rules
1. **Use `StaticGump<TSelf>`** when layout is fixed (cached, better performance)
2. **Use `DynamicGump`** when layout depends on instance data (rebuilt each time)
3. **Use `mobile.SendGump(gump)`** to send -- requires `using Server.Gumps;`
4. **Override `Singleton => true`** if only one instance should be open per player
5. **Never forget `using Server.Gumps;`** -- extension methods won't resolve without it
## Hierarchy
```
BaseGump (abstract)
├── StaticGump<TSelf> -- Cached layout, use for menus/dialogs
└── DynamicGump -- Rebuilt layout, use for dynamic content
```
## StaticGump Pattern (Preferred for Fixed Layouts)
```csharp
using Server.Gumps;
namespace Server.Gumps;
public class MyGump : StaticGump<MyGump>
{
private readonly Mobile _player;
private readonly string _message;
public override bool Singleton => true; // One per player
public MyGump(Mobile player, string message) : base(50, 50)
{
_player = player;
_message = message;
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
// Static text (cached)
builder.AddHtmlLocalized(10, 10, 380, 20, 1060635, 0x7800); // "Warning"
// Dynamic text placeholder (filled per-instance)
builder.AddHtmlPlaceholder(10, 40, 380, 220, "content", false, true);
// Buttons
builder.AddButton(150, 265, 4005, 4007, 1); // OK button (buttonID=1)
builder.AddHtmlLocalized(185, 267, 100, 20, 1011036); // "OK"
builder.AddButton(250, 265, 4017, 4019, 0); // Cancel (buttonID=0 = close)
builder.AddHtmlLocalized(285, 267, 100, 20, 1011012); // "Cancel"
}
protected override void BuildStrings(ref GumpStringsBuilder builder)
{
// Fill dynamic placeholders
builder.SetHtmlText("content", _message, "#FFC000");
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID == 1)
{
_player.SendMessage("You clicked OK!");
}
}
}
```
## DynamicGump Pattern (For Variable Layouts)
```csharp
using Server.Gumps;
namespace Server.Gumps;
public class InventoryGump : DynamicGump
{
private readonly Mobile _player;
private readonly List<Item> _items;
public override bool Singleton => true;
public InventoryGump(Mobile player, List<Item> items) : base(50, 50)
{
_player = player;
_items = items;
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 40 + _items.Count * 30, 5054);
builder.AddHtml(10, 10, 380, 20, "Inventory");
for (var i = 0; i < _items.Count; i++)
{
var y = 40 + i * 30;
builder.AddLabel(10, y, 0, _items[i].Name ?? "Unknown");
builder.AddButton(350, y, 4005, 4007, i + 1); // Button per item
}
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID > 0 && info.ButtonID <= _items.Count)
{
var item = _items[info.ButtonID - 1];
_player.SendMessage($"Selected: {item.Name}");
}
}
}
```
## Sending and Managing Gumps
```csharp
using Server.Gumps;
// Send gump
mobile.SendGump(new MyGump(mobile, "Hello!"));
// Check if gump is open
if (mobile.HasGump<MyGump>())
{
// Already open
}
// Find existing gump
var gump = mobile.FindGump<MyGump>();
// Close gump
mobile.CloseGump<MyGump>();
```
## Builder Methods Reference
### Layout Elements
```csharp
builder.AddPage(int page = 0);
builder.AddBackground(int x, int y, int width, int height, int gumpID);
builder.AddAlphaRegion(int x, int y, int width, int height);
builder.AddImageTiled(int x, int y, int width, int height, int gumpID);
builder.AddImage(int x, int y, int gumpID, int hue = 0);
builder.AddItem(int x, int y, int itemID, int hue = 0);
```
### Text
```csharp
builder.AddLabel(int x, int y, int hue, ReadOnlySpan<char> text);
builder.AddHtml(int x, int y, int w, int h, ReadOnlySpan<char> text, ...);
builder.AddHtmlLocalized(int x, int y, int w, int h, int number, ...);
builder.AddLabelPlaceholder(int x, int y, int hue, ReadOnlySpan<char> slotKey);
builder.AddHtmlPlaceholder(int x, int y, int w, int h, ReadOnlySpan<char> slotKey, ...);
```
### Interactive
```csharp
builder.AddButton(int x, int y, int normalID, int pressedID, int buttonID, ...);
builder.AddCheckbox(int x, int y, int inactiveID, int activeID, bool selected, int switchID);
builder.AddRadio(int x, int y, int inactiveID, int activeID, bool selected, int switchID);
builder.AddTextEntry(int x, int y, int w, int h, int hue, int entryID, ...);
```
### Modifiers
```csharp
builder.SetNoClose(); // Cannot close with right-click
builder.SetNoMove(); // Cannot move the gump
builder.SetNoResize(); // Cannot resize
builder.SetNoDispose(); // Cannot dispose
builder.AddTooltip(int number); // Hover tooltip
```
## Response Handling
```csharp
public override void OnResponse(NetState sender, in RelayInfo info)
{
var buttonID = info.ButtonID; // 0 = close, 1+ = button clicks
var isChecked = info.IsSwitched(0); // Checkbox/radio state by switchID
var text = info.GetTextEntry(0); // Text entry value by entryID
}
```
## Static vs Dynamic: When to Use Which
| Use StaticGump | Use DynamicGump |
|---|---|
| Confirmation dialogs | Lists of variable length |
| Static menus | Player-specific content |
| Warning prompts | Crafting interfaces |
| Settings panels | Search results |
| Help pages | Dynamic data display |
## Important Properties
### Singleton
```csharp
public override bool Singleton => true;
```
Automatically closes any existing instance of this gump type for the player before sending a new one. **Always set this for gumps that shouldn't stack.** Without it, repeated sends create duplicates the player must close individually.
### Cached (StaticGump only)
```csharp
protected virtual bool Cached => true; // default
```
Controls whether `StaticGump<T>` caches its compiled layout bytes. Override to `false` **during development only** to force layout rebuild each send — useful for iterating on layout without restarting the server:
```csharp
protected override bool Cached => false; // TEMPORARY — remove before commit
```
## Empty Gump Rule (CRITICAL AUDIT RULE)
**NEVER send a gump with no visual components.** An empty gump cannot be closed by the client — no close button, no right-click dismiss. This causes a **gump leak** on both client and server.
Empty gumps happen when short-circuiting inside a constructor or `BuildLayout`:
```csharp
// BAD — early return in constructor leaves gump empty but it still gets sent
public MyGump(Mobile from) : base(50, 50)
{
if (!from.Alive) return; // Empty gump — LEAK!
// ...layout...
}
```
### Fix: Static DisplayTo Pattern
Validate prerequisites **before** constructing the gump. Make the constructor `private`:
```csharp
public class MyGump : DynamicGump
{
public override bool Singleton => true;
private MyGump(Mobile from, SomeData data) : base(50, 50) { /* store fields */ }
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
// Always has visual content — DisplayTo guarantees valid state
}
public static void DisplayTo(Mobile from)
{
if (!from.Alive || from.NetState == null) return; // No gump created
var data = GetData(from);
if (data == null) return; // No gump created
from.SendGump(new MyGump(from, data));
}
}
```
Reference: `Projects/UOContent/Gumps/Go/GoGump.cs`
## Converting Legacy Gump to DynamicGump / StaticGump
### Choose target type
- Layout is fixed structure → `StaticGump<T>` (cached, best performance)
- Layout varies per instance (loops, conditionals) → `DynamicGump`
- When in doubt → `DynamicGump` (simpler, still much better than legacy)
### Conversion checklist
1. Change base class: `Gump` → `DynamicGump` or `StaticGump<MyGump>`
2. Move layout code from constructor into `BuildLayout(ref DynamicGumpBuilder builder)` or `BuildLayout(ref StaticGumpBuilder builder)`
3. Store any state the constructor used as fields (constructor now just stores state, doesn't build layout)
4. Replace property flags with builder methods:
- `Closable = false` → `builder.SetNoClose()`
- `Draggable = false` → `builder.SetNoMove()`
- `Resizable = false` → `builder.SetNoResize()`
- `Disposable = false` → `builder.SetNoDispose()`
5. `AddPage(0)` → `builder.AddPage()` (0 is default)
6. For `StaticGump<T>`: extract dynamic text into placeholders (`AddLabelPlaceholder` / `AddHtmlPlaceholder`) and fill in `BuildStrings(ref GumpStringsBuilder builder)`
7. Update `OnResponse` signature: `RelayInfo info` → `in RelayInfo info`
8. Add `public override bool Singleton => true;` if appropriate
9. Make constructor `private`, add `public static void DisplayTo(Mobile from)` method
10. Move all validation/short-circuit logic into `DisplayTo` (never leave `BuildLayout` empty)
### Example: legacy → modern
```csharp
// BEFORE (legacy Gump)
public class OldGump : Gump
{
public OldGump(Mobile from) : base(50, 50)
{
Closable = false;
AddPage(0);
AddBackground(0, 0, 400, 300, 5054);
AddLabel(20, 20, 0x480, from.Name);
AddButton(20, 260, 4005, 4007, 1);
}
public override void OnResponse(NetState sender, RelayInfo info) { }
}
// AFTER (DynamicGump)
public class NewGump : DynamicGump
{
private readonly Mobile _from;
public override bool Singleton => true;
private NewGump(Mobile from) : base(50, 50) => _from = from;
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.SetNoClose();
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
builder.AddLabel(20, 20, 0x480, _from.Name);
builder.AddButton(20, 260, 4005, 4007, 1);
}
public override void OnResponse(NetState sender, in RelayInfo info) { }
public static void DisplayTo(Mobile from) => from.SendGump(new NewGump(from));
}
```
## Anti-Patterns
- **Missing `using Server.Gumps;`**: `SendGump()`, `HasGump<T>()` won't resolve
- **Creating DynamicGump for static layouts**: Wastes CPU rebuilding every time
- **Not setting `Singleton`**: Multiple copies of same gump stack up
- **ButtonID 0 for actions**: 0 means "close" -- use 1+ for action buttons
- **Empty gumps**: Short-circuiting in constructor/BuildLayout causes gump leaks — use `DisplayTo` pattern
- **Leaving `Cached => false` in production**: Defeats the purpose of StaticGump — only disable during development
## Real Examples
- StaticGump warning: `Projects/UOContent/Gumps/StaticWarningGump.cs`
- DynamicGump craft: `Projects/UOContent/Engines/Craft/Core/CraftGump.cs`
- GumpSystem extensions: `Projects/UOContent/Gumps/Base/GumpSystem.cs`
- StaticGumpBuilder: `Projects/UOContent/Gumps/Base/StaticGumpBuilder.cs`
- DynamicGumpBuilder: `Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs`
- BaseGump: `Projects/UOContent/Gumps/Base/BaseGump.cs`
## See Also
- `dev-docs/gump-system.md` - Complete gump documentation
- `dev-docs/claude-skills/modernuo-commands-targeting.md` - Commands that open gumps

View file

@ -0,0 +1,259 @@
---
name: modernuo-networking
description: >
Trigger when creating or modifying packets, working with NetState, SpanWriter, SpanReader, or implementing network protocol handlers.
---
# ModernUO Networking & Packets
## When This Activates
- Creating new packets (outgoing or incoming)
- Modifying existing packet handlers
- Working with `NetState`, `SpanWriter`, `SpanReader`
- Implementing network protocol features
## Key Rules
1. **Outgoing packets**: Static `Create*` method fills `Span<byte>`, extension `Send*` method on `NetState`
2. **Incoming packets**: Register with function pointers in `Configure()`
3. **Use `stackalloc`** for small fixed-size outgoing packets
4. **Always check `ns.CannotSendPackets()`** before sending
5. **Big-endian by default** -- use `WriteLE()` for little-endian
## Outgoing Packet Pattern
### Step 1: Create Extension Method on NetState
```csharp
public static class OutgoingMyPackets
{
public const int MyPacketLength = 12;
public static void SendMyPacket(this NetState ns, Serial target, int value)
{
if (ns.CannotSendPackets())
return;
var buffer = stackalloc byte[MyPacketLength].InitializePacket();
CreateMyPacket(buffer, target, value);
ns.Send(buffer);
}
public static void CreateMyPacket(Span<byte> buffer, Serial target, int value)
{
if (buffer[0] != 0) // Already initialized check
return;
var writer = new SpanWriter(buffer);
writer.Write((byte)0xBF); // Packet ID
writer.Write((ushort)12); // Length
writer.Write((ushort)0x99); // Sub-command
writer.Write(target); // Serial (4 bytes)
writer.Write((short)value); // Value (2 bytes)
}
}
```
### Step 2: Call from Game Code
```csharp
mobile.NetState.SendMyPacket(target.Serial, 42);
// Or for all nearby players:
foreach (var ns in mobile.GetClientsInRange(18))
{
ns.SendMyPacket(target.Serial, 42);
}
```
### Variable-Length Outgoing Packets
```csharp
public static void SendMyDynamicPacket(this NetState ns, string name)
{
if (ns.CannotSendPackets())
return;
var writer = new SpanWriter(stackalloc byte[64]); // Or use pooled buffer for large packets
writer.Write((byte)0x99); // Packet ID
writer.Write((ushort)0); // Placeholder for length
writer.WriteBigUniNull(name); // Unicode string with null terminator
writer.WritePacketLength(); // Fill in actual length
ns.Send(writer.Span);
}
```
## Incoming Packet Pattern
### Step 1: Register Handler in Configure()
```csharp
public static class IncomingMyPackets
{
public static unsafe void Configure()
{
IncomingPackets.Register(0x99, 12, true, &MyPacketHandler);
// ^ID ^len ^inGameOnly ^handler
// len=0 for variable-length packets
}
public static void MyPacketHandler(NetState state, SpanReader reader)
{
var from = state.Mobile;
if (from == null)
return;
var targetSerial = (Serial)reader.ReadUInt32();
var value = reader.ReadInt16();
var target = World.FindMobile(targetSerial);
if (target != null)
{
// Process packet
}
}
}
```
### Encoded Packet Registration
```csharp
public static unsafe void Configure()
{
IncomingPackets.RegisterEncoded(0x28, true, &GuildGumpRequest);
// ^subID ^inGame ^handler
}
public static void GuildGumpRequest(NetState state, IEntity target, EncodedReader reader)
{
// Handle encoded packet
}
```
## SpanWriter Reference
```csharp
// Constructors
var writer = new SpanWriter(Span<byte> buffer);
var writer = new SpanWriter(stackalloc byte[64]);
var writer = new SpanWriter(int capacity, bool resize = false);
// Integer writes (big-endian by default)
writer.Write(bool value);
writer.Write(byte value);
writer.Write(sbyte value);
writer.Write(short value); // Big-endian
writer.Write(ushort value); // Big-endian
writer.Write(int value); // Big-endian
writer.Write(uint value); // Big-endian
writer.Write(long value);
writer.Write(Serial serial); // 4 bytes, big-endian
// Little-endian variants
writer.WriteLE(short value);
writer.WriteLE(ushort value);
writer.WriteLE(int value);
writer.WriteLE(uint value);
// String writes
writer.WriteAscii(string value);
writer.WriteAsciiNull(string value); // Null-terminated
writer.WriteAscii(string value, int fixedLength);
writer.WriteLatin1(string value);
writer.WriteLatin1Null(string value);
writer.WriteBigUni(string value); // UTF-16 big-endian
writer.WriteBigUniNull(string value);
writer.WriteLittleUni(string value); // UTF-16 little-endian
writer.WriteLittleUniNull(string value);
writer.WriteUTF8(string value);
writer.WriteUTF8Null(string value);
// Utilities
writer.Write(ReadOnlySpan<byte> data);
writer.Clear(int count); // Write zeros
writer.Seek(int offset, SeekOrigin origin);
writer.WritePacketLength(); // Fill in length at position 1-2
writer.EnsureCapacity(int capacity);
writer.Dispose(); // Return pooled buffer if any
// Properties
writer.Position; // Current write position
writer.Capacity; // Buffer size
writer.Span; // ReadOnlySpan<byte> of written data
```
## SpanReader Reference
```csharp
// Constructor
var reader = new SpanReader(ReadOnlySpan<byte> data);
// Integer reads (big-endian by default)
reader.ReadByte();
reader.ReadBoolean(); // byte > 0
reader.ReadSByte();
reader.ReadInt16(); // Big-endian
reader.ReadUInt16(); // Big-endian
reader.ReadInt32(); // Big-endian
reader.ReadUInt32(); // Big-endian
reader.ReadInt64();
reader.ReadUInt64();
// Little-endian variants
reader.ReadInt16LE();
reader.ReadUInt16LE();
reader.ReadUInt32LE();
// String reads
reader.ReadAscii(int fixedLength = -1);
reader.ReadAsciiSafe(int fixedLength = -1); // Filters control chars
reader.ReadLatin1(int fixedLength = -1);
reader.ReadLatin1Safe(int fixedLength = -1);
reader.ReadBigUni(int fixedLength = -1);
reader.ReadBigUniSafe(int fixedLength = -1);
reader.ReadLittleUni(int fixedLength = -1);
reader.ReadUTF8(int fixedLength = -1);
// Utilities
reader.Seek(int offset, SeekOrigin origin);
reader.Read(Span<byte> destination);
// Properties
reader.Position; // Current read position
reader.Length; // Total data length
reader.Remaining; // Bytes remaining
```
## Common Packet Patterns
### Sound Effect
```csharp
ns.SendSoundEffect(0x1E5, target);
```
### Mobile Animation
```csharp
ns.SendMobileAnimation(mobile.Serial, action, frameCount, repeatCount, forward, repeat, delay);
```
### Damage
```csharp
ns.SendDamage(target.Serial, amount);
```
## Anti-Patterns
- **Not checking `CannotSendPackets()`**: Player may have disconnected
- **Using `new byte[]` for packets**: Use `stackalloc` for fixed-size, `SpanWriter` for variable
- **Wrong endianness**: UO protocol is big-endian; only use `WriteLE` when spec requires it
- **Not calling `InitializePacket()`**: Required for `Create*` pattern with static buffer reuse
## Real Examples
- Mobile packets: `Projects/Server/Network/Packets/OutgoingMobilePackets.cs`
- Item packets: `Projects/Server/Network/Packets/OutgoingItemPackets.cs`
- Damage packets: `Projects/Server/Network/Packets/OutgoingDamagePackets.cs`
- Effect packets: `Projects/Server/Network/Packets/OutgoingEffectPackets.cs`
- Incoming registration: `Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs`
- Movement handler: `Projects/UOContent/Network/Packets/IncomingMovementPackets.cs`
- SpanWriter: `Projects/Server/Buffers/SpanWriter.cs`
- SpanReader: `Projects/Server/Buffers/SpanReader.cs`
## See Also
- `dev-docs/networking-packets.md` - Complete networking documentation
- `dev-docs/claude-skills/modernuo-threading.md` - Network I/O threading context

View file

@ -0,0 +1,205 @@
---
name: modernuo-property-lists
description: >
Trigger when implementing GetProperties(), working with IPropertyList/ObjectPropertyList, or customizing item tooltips.
---
# ModernUO Property Lists (Tooltips)
## When This Activates
- Implementing `GetProperties()` override
- Working with `IPropertyList` or `ObjectPropertyList`
- Customizing item or mobile tooltips
- Using `[InvalidateProperties]` attribute
- Adding cliloc-based text to items
## Key Rules
1. **Always call `base.GetProperties(list)` first** in overrides
2. **Use cliloc numbers** when possible (int IDs that map to localized strings)
3. **String interpolation** works with `IPropertyList` -- use `$"..."` syntax
4. **`[InvalidateProperties]`** on `[SerializableField]` auto-refreshes tooltip on change
5. **Call `InvalidateProperties()`** manually when non-serialized state changes tooltip
## IPropertyList Interface
```csharp
public interface IPropertyList
{
void Add(int number); // Cliloc number only
void Add(int number, string argument); // Cliloc with ~1_val~ arg
void Add(string text); // Raw string (uses internal cliloc)
void Add(int number, int value); // Cliloc with int arg
void AddLocalized(int value); // Cliloc number as value
void AddLocalized(int number, int value); // Cliloc wrapper for cliloc
// String interpolation overloads
void Add(ref InterpolatedStringHandler handler);
void Add(int number, ref InterpolatedStringHandler handler);
}
```
## Patterns
### Basic GetProperties Override
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list); // ALWAYS call base first
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
list.Add($"{"Quality: "}{_quality}"); // Raw string
list.Add(1060637, $"{_uses}\t{_maxUses}"); // "~1_val~ / ~2_val~"
}
```
### Cliloc Arguments Format
Cliloc strings use `~1_val~`, `~2_val~`, etc. as placeholders. Arguments are tab-separated:
```csharp
// Cliloc 1060637 = "~1_val~ / ~2_val~"
list.Add(1060637, $"{current}\t{max}");
// Cliloc 1072241 = "Contents: ~1_ITEMS~/~2_MAXITEMS~ items, ~3_WEIGHT~/~4_MAXWEIGHT~ stones"
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");
// Cliloc 1042971 = "~1_val~" (generic single argument)
list.Add(1042971, $"{"Custom text here"}");
```
### String Literals Must Be Holes (CRITICAL)
The interpolated string handler distinguishes **literals** (bare text between `{}` holes) from **holes** (values inside `{}`). Literals are delimiters. Holes are arguments. This matters because the property list system is also used for web rendering, which must tell arguments apart from delimiters.
**String constants must always be wrapped as holes: `{"..."}`**
```csharp
// BAD — "Chances" becomes a literal/delimiter, not an argument
list.Add(1060658, $"Chances\t{_charges}");
// GOOD — "Chances" is a hole → argument ~1_val~
list.Add(1060658, $"{"Chances"}\t{_charges}");
```
Real examples (`Teleporter.cs`):
```csharp
list.Add(1060658, $"{"Map"}\t{_mapDest}"); // "~1_val~: ~2_val~"
list.Add(1060659, $"{"Coords"}\t{_pointDest}");
list.Add(1060661, $"{"Range"}\t{_range}");
```
**Rule**: Only `\t` (argument separator) should be bare literal text. Everything else — including string constants — must be inside `{}` holes.
### Cliloc as Argument (Use `:#` Format Specifier)
When an argument is itself a cliloc number, use the `:#` format specifier — **not** a `"#number"` string:
```csharp
// BAD — "#1060000" is a string, web renderers will display it literally
list.Add(1050039, $"{m_Amount}\t{"#1060000"}");
// GOOD — :# tells the handler this is a cliloc number to resolve
list.Add(1050039, $"{m_Amount}\t{1060000:#}");
```
The `:#` format lets the handler (and other consumers like web renderers) know the value is a cliloc reference to resolve, not a raw number. Also available via `list.AddLocalized(number, clilocValue)`.
### Looking Up Cliloc Text
If you don't know what arguments a cliloc number expects, you can read the `cliloc.enu` binary file. Loading logic is in `Projects/Server/Localization/Localization.cs` → `LoadClilocs(string lang, string file)`. Ask the user where their `cliloc.enu` file is (typically in the UO client data directory).
### Auto-Refresh with [InvalidateProperties]
```csharp
[SerializableField(0)]
[InvalidateProperties] // Auto-calls InvalidateProperties() when Charges changes
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
```
### Manual Refresh
```csharp
public void UseCharge()
{
_charges--;
InvalidateProperties(); // Manually trigger tooltip refresh
this.MarkDirty();
}
```
### Conditional Properties
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
if (_charges > 0)
list.Add(1060741, $"{_charges}");
if (_owner != null)
list.Add($"{"Owned by: "}{_owner.Name}");
if (Core.AOS) // Era-conditional properties
list.Add(1061170, $"{_imbueLevel}"); // "animal " ~1_val~
}
```
### Mobile Properties
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
if (Core.AOS && Faction != null)
{
list.Add(1060776, $"{Rank.Title}\t{Faction.Definition.PropName}");
}
if (DisplayChampionTitle)
{
var titleLabel = ChampionTitleSystem.GetChampionTitleLabel(this);
if (titleLabel > 0)
list.Add(titleLabel);
}
}
```
## Common Cliloc Numbers
| Number | Text | Usage |
|---|---|---|
| 1042971 | `~1_val~` | Generic single argument |
| 1060741 | `charges: ~1_val~` | Charge count |
| 1060637 | `~1_val~ / ~2_val~` | Current/max values |
| 1060658 | `~1_val~: ~2_val~` | Key: value pair |
| 1050044 | `~1_ITEMS~ items, ~2_WEIGHT~ stones` | Container contents |
| 1072241 | `Contents: ~1~/~2~ items, ~3~/~4~ stones` | ML container |
| 1060776 | `~1_val~, ~2_val~` | Two comma-separated values |
| 1061170 | `animal lore ~1_val~` | Taming info |
| 1053099 | `damage ~1_val~ - ~2_val~` | Damage range |
## ObjectPropertyList Internals
- Packet ID: 0xD6
- Hash-based change detection -- only sends if content actually changed
- `InvalidateProperties()` rebuilds the list and compares hash
- Uses `STArrayPool<char>` for string building (zero GC)
- Global toggle: `ObjectPropertyList.Enabled`
## Anti-Patterns
- **Forgetting `base.GetProperties(list)`**: Loses default name/weight display
- **Not using cliloc**: Raw strings don't get localized
- **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops
- **Assuming tooltip support**: Check `ObjectPropertyList.Enabled` if needed
## Real Examples
- Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties)
- Mobile properties: `Projects/UOContent/Mobiles/PlayerMobile.cs` (GetProperties)
- Container properties: `Projects/Server/Items/Container.cs` (era-conditional display)
- Interface: `Projects/Server/PropertyList/IPropertyList.cs`
- Implementation: `Projects/Server/PropertyList/ObjectPropertyList.cs`
## See Also
- `dev-docs/property-lists.md` - Complete property list documentation
- `dev-docs/claude-skills/modernuo-serialization.md` - [InvalidateProperties] on fields
- `dev-docs/claude-skills/modernuo-era-expansion.md` - Era-conditional properties

View file

@ -0,0 +1,235 @@
---
name: modernuo-regions
description: >
Trigger when creating custom regions, dynamic item-controlled areas, dungeon sub-zones, travel restrictions, housing blocks, spawn control, or any spatial gameplay rule. When working with Region, BaseRegion, GuardedRegion, DungeonRegion, HouseRegion, or CheckTravel.
---
# ModernUO Regions
## When This Activates
- Creating a custom region (dungeon zone, boss arena, restricted area)
- Creating a dynamic region tied to an item (chest effect zone, spawn area)
- Adding travel spell restrictions, housing blocks, or spawn control
- Working with `Region`, `BaseRegion`, `GuardedRegion`, `DungeonRegion`, `HouseRegion`
- Overriding `CheckTravel`, `AllowHousing`, `OnEnter`, `OnBeginSpellCast`, etc.
- Modifying region JSON or `RegionJsonRegistration`
## Key Rules
1. **Inherit from the right base** — `BaseRegion` for general, `DungeonRegion` for dungeons, `GuardedRegion` for towns, `NoTravelSpellsAllowedRegion` for no-travel dungeons
2. **Dynamic regions must Register/Unregister** — call `Register()` after creation, `Unregister()` before recreation or deletion
3. **Use `Region.Find(location, map)` as parent** for dynamic child regions — this inherits the existing region's behavior
4. **Defer registration in `[AfterDeserialization]`** — use `Timer.StartTimer(TimeSpan.Zero, UpdateRegion)` since map/parents may not be loaded yet
5. **Always override `AllowHousing` returning false** if your region should block housing
6. **Staff bypass** — travel/spell checks should allow `AccessLevel > Player`
7. **Virtual hooks delegate to Parent** — you only need to override what you change
## Choosing a Base Class
| You Need | Inherit From |
|---|---|
| Custom overworld area | `BaseRegion` |
| Dungeon area (dark, no housing) | `DungeonRegion` |
| Dungeon + no travel spells | `NoTravelSpellsAllowedRegion` |
| Town with guards | `GuardedRegion` or `TownRegion` |
| No housing only | `NoHousingRegion` |
| Item-controlled dynamic area | `BaseRegion` (parent = `Region.Find(...)`) |
## Quick Patterns
### Static Region (JSON-Defined)
Add to `Distribution/Data/regions.json`:
```json
{
"$type": "NoTravelSpellsAllowedRegion",
"Name": "My Dungeon",
"Map": "Felucca",
"Parent": { "Name": "Felucca", "Map": "Felucca" },
"Area": [{ "x1": 100, "y1": 200, "x2": 300, "y2": 400 }],
"GoLocation": { "x": 150, "y": 250, "z": 0 },
"Music": "Dungeon9"
}
```
Register the type (if new) in `RegionJsonRegistration.Configure()`:
```csharp
RegionJsonSerializer.Register<MyCustomRegion>();
```
### Custom Region Class
```csharp
public class MyCustomRegion : BaseRegion
{
public MyCustomRegion(string name, Map map, Region parent, params Rectangle3D[] area)
: base(name, map, parent, area)
{
}
public override bool AllowHousing(Mobile from, Point3D p) => false;
public override bool CheckTravel(
Mobile m, Point3D newLocation, TravelCheckType travelType, out TextDefinition message)
{
message = null;
return m.AccessLevel > AccessLevel.Player;
}
}
```
### Dynamic Item-Tracked Region
Full pattern for an item that creates a region around itself:
```csharp
public partial class MySpecialItem : Item
{
private MyItemRegion _region;
public void UpdateRegion()
{
_region?.Unregister();
if (!Deleted && Map != Map.Internal)
{
_region = new MyItemRegion(this);
_region.Register();
}
}
public override void OnLocationChange(Point3D oldLoc)
{
base.OnLocationChange(oldLoc);
UpdateRegion();
}
public override void OnMapChange()
{
base.OnMapChange();
UpdateRegion();
}
public override void OnAfterDelete()
{
base.OnAfterDelete();
UpdateRegion(); // Unregisters because Deleted == true
}
[AfterDeserialization(false)]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.Zero, UpdateRegion); // Defer to next tick
}
}
public class MyItemRegion : BaseRegion
{
public MySpecialItem Item { get; }
public MyItemRegion(MySpecialItem item)
: base(null, item.Map,
Region.Find(item.Location, item.Map), // Parent = existing region
new Rectangle2D(item.X - 5, item.Y - 5, 11, 11))
{
Item = item;
}
public override void OnEnter(Mobile m)
{
base.OnEnter(m);
// Custom enter logic
}
}
```
### Child Region Inside a Dungeon
When you want effects that don't break dungeon rules (inherits lighting, no housing, etc.):
```csharp
public class BossArenaRegion : BaseRegion
{
public BossArenaRegion(Item source)
: base(null, source.Map,
Region.Find(source.Location, source.Map), // Parent = dungeon
new Rectangle2D(source.X - 10, source.Y - 10, 21, 21))
{
}
// Inherits DungeonRegion behavior from parent automatically
// Only add what's different:
public override bool CheckTravel(
Mobile m, Point3D newLocation, TravelCheckType travelType, out TextDefinition message)
{
message = null;
return m.AccessLevel > AccessLevel.Player; // No escape during boss fight
}
public override void SpellDamageScalar(Mobile caster, Mobile target, ref double damage)
{
base.SpellDamageScalar(caster, target, ref damage);
damage *= 1.25; // Bonus damage in arena
}
}
```
### Blocking Specific Spells
```csharp
public override bool OnBeginSpellCast(Mobile m, ISpell s)
{
if (m.AccessLevel == AccessLevel.Player && s is MarkSpell)
{
m.SendLocalizedMessage(501802); // Thy spell doth not appear to work...
return false;
}
return base.OnBeginSpellCast(m, s);
}
```
## Region Hierarchy & IsPartOf
Virtual hooks delegate to `Parent` — child regions inherit all parent behavior:
```csharp
// A child of DungeonRegion automatically has:
// - Dungeon lighting
// - No housing
// - YoungProtected = false
// Check hierarchy:
region.IsPartOf<DungeonRegion>() // true if this or any ancestor is DungeonRegion
region.GetRegion<GuardedRegion>() // returns first GuardedRegion in ancestor chain
```
## Existing Region Types Reference
| Type | Key Behavior |
|---|---|
| `BaseRegion` | CheckTravel, spawn weights, RuneName |
| `GuardedRegion` | NPC guards, no housing, town spell restrictions |
| `TownRegion` | Guard behavior + Entrance property |
| `DungeonRegion` | Dungeon lighting, no housing, young not protected |
| `NoTravelSpellsAllowedRegion` | Blocks all travel spells (extends DungeonRegion) |
| `NoHousingRegion` | Blocks housing placement |
| `NoHousingGuardedRegion` | Guards + housing block |
| `GreenAcresRegion` | No housing, no travel, no Mark |
| `JailRegion` | Full lockdown: no skills, spells, combat, travel |
| `HouseRegion` | Dynamic per-house; access, bans, lockdowns |
| `MondainRegion` | Mondain's Legacy dungeon (no travel) |
## Anti-Patterns
- **Registering regions in deserialization constructors**: Map/parents may not be loaded — use `[AfterDeserialization]` with `Timer.StartTimer(TimeSpan.Zero, ...)`
- **Forgetting `Unregister()` on item delete**: Ghost region stays on map — always unregister in `OnAfterDelete()`
- **Not using parent for dynamic regions**: Loses inherited behavior (lighting, guards, etc.) — pass `Region.Find(location, map)` as parent
- **Using `Region.Find(string, Map)` in hot paths**: Linear O(n) scan — use `Region.Find(Point3D, Map)` (sector-indexed)
- **Overriding a hook without calling `base`**: Breaks parent delegation chain — always call `base.Method()` unless intentionally blocking
## See Also
- `dev-docs/regions.md` — Complete region system documentation
- `dev-docs/timers.md` — Timer system (for deferred registration)
- `dev-docs/claude-skills/modernuo-content-patterns.md` — Item/Mobile lifecycle (OnAfterDelete, AfterDeserialization)
- `dev-docs/claude-skills/modernuo-serialization.md` — Serialization and AfterDeserialization hooks

View file

@ -0,0 +1,253 @@
---
name: modernuo-serialization
description: >
Trigger when creating or modifying classes inheriting Item, Mobile, BaseCreature, or any type with [SerializationGenerator]. When adding serialized fields. When discussing migration or version bumps.
---
# ModernUO Serialization System
## When This Activates
- Creating/modifying classes that inherit `Item`, `Mobile`, `BaseCreature`, or any serializable type
- Adding `[SerializableField]` or `[SerializableProperty]` attributes
- Bumping serialization versions
- Working with migration schemas
- Discussing save/load behavior
## Key Rules
1. **Always use `partial` class** when applying `[SerializationGenerator]`
2. **Always add `[Constructible]`** on parameterless constructors for Items/Mobiles
3. **Never serialize `TimerExecutionToken`** -- restore timers in `[AfterDeserialization]`
4. **Call `this.MarkDirty()`** in custom property setters that modify serialized state
5. **Use `using ModernUO.Serialization;`** for serialization attributes
6. **Field order matters** -- `[SerializableField(N)]` index determines serialization order
7. **Increment version** when adding, removing, or reordering fields
## Core Attributes
### [SerializationGenerator(version, encodedVersion)]
Applied to class. Generates Serialize/Deserialize methods.
- `version`: Current serialization version (0+)
- `encodedVersion`: Use `false` for Items/Mobiles (default `true` for other types)
```csharp
[SerializationGenerator(0, false)]
public partial class MyItem : Item { }
```
### [SerializableField(index, setter, saveIf)]
Applied to `_camelCase` private fields. Generates `PascalCase` property.
- `index`: Serialization order (0+)
- `setter`: Access level -- `"private"`, `"internal"`, or omit for public
- `saveIf`: Condition method name for conditional serialization
```csharp
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
// Generates: public int Charges { get; set; }
```
### [SerializableProperty(index, useField)]
Applied to properties with custom get/set logic.
- `index`: Serialization order
- `useField`: Backing field name if auto-detection fails
```csharp
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
get => _maxItems == -1 ? DefaultMaxItems : _maxItems;
set
{
_maxItems = value;
InvalidateProperties();
this.MarkDirty();
}
}
```
### [InvalidateProperties]
On serialized fields -- auto-calls `InvalidateProperties()` when field changes (refreshes client tooltip).
```csharp
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _balanced;
```
### [SerializedCommandProperty(accessLevel)]
Exposes field to `[Props` gump for in-game editing.
### [EncodedInt]
Variable-length int encoding (saves space for small values).
### [DeltaDateTime]
Stores DateTime as offset from current time (handles server restarts).
### [InternString]
Interns strings to reduce memory for repeated values.
### [Tidy]
Auto-removes null/deleted entries from collections after deserialization.
### [CanBeNull]
Marks field as nullable during deserialization.
### [AfterDeserialization]
Method called after all fields are deserialized. Use for initialization, timer restoration, relationship setup.
```csharp
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}
```
### [DeserializeTimerField(fieldIndex)]
Custom timer deserialization. Timer is saved as remaining TimeSpan.
```csharp
[SerializableField(0, setter: "private")]
private Timer _evaluateTimer;
[DeserializeTimerField(0)]
private void DeserializeEvaluateTimer(TimeSpan delay)
{
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
}
```
### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)]
Conditional serialization -- skip fields with default values.
```csharp
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeMaxItems() => _maxItems != -1;
[SerializableFieldDefault(0)]
private int MaxItemsDefaultValue() => -1;
```
### [TypeAlias(aliases)]
Maps old type names for backward-compatible deserialization.
```csharp
[TypeAlias("Server.Mobiles.Bear")]
[SerializationGenerator(0, false)]
public partial class BlackBear : BaseCreature { }
```
## Patterns
### Minimal Item (Version 0, No Custom Fields)
```csharp
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0, false)]
public partial class MyItem : Item
{
[Constructible]
public MyItem() : base(0x1234)
{
Weight = 1.0;
}
public override string DefaultName => "a my item";
}
```
### Item with Fields
```csharp
[SerializationGenerator(0, false)]
public partial class ChargedItem : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Mobile _owner;
private TimerExecutionToken _timerToken; // NOT serialized
[Constructible]
public ChargedItem() : base(0x1234) => _charges = 10;
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromSeconds(5), CheckExpiry, out _timerToken);
}
public override void OnAfterDelete()
{
_timerToken.Cancel();
base.OnAfterDelete();
}
}
```
### Item with Custom Properties
```csharp
[SerializationGenerator(2, false)]
public partial class BagOfSending : Item
{
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public BagOfSendingHue BagOfSendingHue
{
get => _bagOfSendingHue;
set
{
_bagOfSendingHue = value;
Hue = value switch
{
BagOfSendingHue.Yellow => 0x8A5,
BagOfSendingHue.Blue => 0x8AD,
BagOfSendingHue.Red => 0x89B,
_ => Hue
};
this.MarkDirty();
}
}
}
```
## Anti-Patterns
- **Missing `partial`**: `[SerializationGenerator]` requires `partial class`
- **Serializing timers**: `TimerExecutionToken` cannot be serialized
- **Missing `MarkDirty()`**: Custom property setters must call `this.MarkDirty()`
- **Wrong field prefix**: Use `_camelCase`, not `m_camelCase` for new fields
- **Forgetting `[Constructible]`**: Items/Mobiles need this for `[add` command
## Real Examples
- Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs`
- Serialized fields + timer: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs`
- Custom properties: `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs`
- Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs`
- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs`
- Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs`
- Conditional serialization: `Projects/Server/Items/Container.cs`
## Version Migration
Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`:
- Format: `TypeName.vN.json`
- Generated automatically by the serialization generator
- Used for reading old save formats
External reference: https://github.com/modernuo/SerializationGenerator
## See Also
- `dev-docs/serialization.md` - Complete serialization documentation
- `dev-docs/claude-skills/modernuo-timers.md` - Timer token patterns
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Item/Mobile templates
- `dev-docs/claude-skills/modernuo-property-lists.md` - [InvalidateProperties] usage

View file

@ -0,0 +1,162 @@
---
name: modernuo-threading
description: >
Trigger when discussing async patterns, world saves, game loop, or reviewing code for threading issues. When using await, Task, or any concurrency-related code in game logic.
---
# ModernUO Threading & Event Loop
## When This Activates
- Reviewing code for threading issues
- Discussing async/await patterns
- Working with world saves
- Any mention of `Task.Run`, `Thread`, `lock`, `ConcurrentDictionary`
- Understanding the game loop
## CRITICAL RULE: Single-Threaded Game Logic
ModernUO uses a **single-threaded game loop**. All game logic runs on one thread. There are NO exceptions for game code.
## Forbidden in Game Code
```csharp
// ALL of these are WRONG in Projects/UOContent/ code:
Task.Run(() => ProcessItems()); // Background thread
new Thread(BackgroundWork).Start(); // Manual thread
ThreadPool.QueueUserWorkItem(Work); // Thread pool
lock (_syncObj) { ... } // Locking
Monitor.Enter(obj); // Monitor
volatile int _counter; // Volatile
ConcurrentDictionary<int, Item> _items; // Concurrent collections
ConcurrentQueue<T> _queue; // Concurrent collections
Interlocked.Increment(ref _count); // Atomics
Mutex mutex; // OS mutex
Semaphore sem; // Semaphore
ReaderWriterLockSlim rwl; // RW lock
```
**Why**: The game loop is single-threaded. Concurrency primitives add overhead for no benefit, and background threads would cause data races with game state.
## Why await Is Safe
`EventLoopContext` implements `SynchronizationContext` and routes all `await` continuations back to the main thread:
```csharp
// This is SAFE in game code:
await Timer.Pause(TimeSpan.FromMilliseconds(100));
// Continuation runs on the game thread, not a thread pool thread
```
The flow:
1. `await` captures `EventLoopContext` as the synchronization context
2. When the awaited task completes, the continuation is posted to `EventLoopContext._queue`
3. `LoopContext.ExecuteTasks()` runs those continuations on the main thread during the next game loop tick
## Game Loop Structure
```csharp
// Simplified from Projects/Server/Main.cs
while (!Closing)
{
_tickCount = GetTimestamp();
_now = DateTime.UtcNow;
Mobile.ProcessDeltaQueue(); // Send mobile state changes to clients
Item.ProcessDeltaQueue(); // Send item state changes to clients
Timer.Slice(_tickCount); // Execute due timers
NetState.Slice(); // Process network I/O
LoopContext.ExecuteTasks(); // Run async continuations (Timer.Pause, etc.)
Timer.CheckTimerPool(); // Refill timer pool if needed
}
```
## EventLoopContext Details
```csharp
public sealed class EventLoopContext : SynchronizationContext
{
private readonly ConcurrentQueue<Action> _queue; // Normal tasks
private readonly ConcurrentQueue<Action> _priorityQueue; // High priority
private readonly int _maxPerFrame; // Default: 128
// Posts run on next ExecuteTasks() call
public void Post(Action d, Priority priority = Priority.Normal);
// Send blocks if called from another thread, immediate if on game thread
public override void Send(SendOrPostCallback d, object state);
// Called once per game loop tick
public void ExecuteTasks();
}
```
## Memory: STArrayPool vs ArrayPool
In game code, use `STArrayPool<T>.Shared` (single-threaded, no locks):
```csharp
// GOOD - no locking overhead
var buffer = STArrayPool<byte>.Shared.Rent(1024);
try { /* use buffer */ }
finally { STArrayPool<byte>.Shared.Return(buffer); }
```
`ArrayPool<T>.Shared` uses locks for thread safety -- unnecessary overhead in single-threaded context.
## Memory: PooledRefList
```csharp
// Stack-allocated list using pooled arrays
using var list = PooledRefList<Mobile>.Create();
list.Add(mobile);
// Automatically returns array to pool on Dispose
// For multi-threaded contexts (rare):
using var list = PooledRefList<Mobile>.CreateMT();
```
## World Saves
World saves DO involve background work, but this is handled by server infrastructure:
1. **Serialization** happens on the main thread (safe access to game state)
2. **Disk I/O** may happen on background threads (no game state access)
3. Main thread blocks briefly during serialization snapshot
```csharp
// From World.cs -- save flow:
World.Save();
→ Preserialize() on thread pool (allocate heaps)
→ Snapshot() on main thread (serialize game state)
→ WriteFiles() on thread pool (disk I/O only)
```
## Exceptions: Server Infrastructure
These files MAY use threading (they're server infrastructure, not game logic):
- `Projects/Server/Main.cs` - Event loop, thread setup
- `Projects/Server/World/World.cs` - World save I/O
- `Projects/Server/Network/` - Network I/O
- `Projects/Server/Timer/Timer.Pool.cs` - Pool refill
## Anti-Patterns
| Pattern | Problem | Solution |
|---|---|---|
| `Task.Run(...)` | Runs on thread pool, races with game state | Use `Timer.StartTimer()` |
| `new Thread(...)` | Same as above | Use `Timer.StartTimer()` |
| `lock(obj)` | Unnecessary overhead, no contention exists | Remove lock, use plain code |
| `ConcurrentDictionary` | Lock-free but still overhead | Use `Dictionary<K,V>` |
| `volatile` | Memory barriers not needed on single thread | Use plain field |
| `Thread.Sleep()` | Blocks entire game loop | Use `await Timer.Pause()` |
| `ArrayPool<T>.Shared` | Uses locks | Use `STArrayPool<T>.Shared` |
## Real Examples
- Game loop: `Projects/Server/Main.cs` (RunEventLoop)
- EventLoopContext: `Projects/Server/EventLoopTasks.cs`
- STArrayPool: `Projects/Server/Buffers/STArrayPool.cs`
- PooledRefList: `Projects/Server/Collections/PooledRefList.cs`
- World save: `Projects/Server/World/World.cs`
## See Also
- `dev-docs/threading-model.md` - Complete threading documentation
- `dev-docs/claude-skills/modernuo-code-audit.md` - Threading audit rules
- `dev-docs/claude-skills/modernuo-timers.md` - Timer-based scheduling

View file

@ -0,0 +1,219 @@
---
name: modernuo-timers
description: >
Trigger when creating delayed actions, recurring timers, or any time-based behavior. When using Timer.StartTimer, Timer.DelayCall, or TimerExecutionToken.
---
# ModernUO Timers & Scheduling
## When This Activates
- Creating delayed actions or recurring timers
- Working with `Timer`, `TimerExecutionToken`, `DelayCallTimer`
- Implementing decay, expiration, or periodic behavior
- Restoring timers after deserialization
## Key Rules
1. **Prefer `Timer.StartTimer` with token** for cancellable timers
2. **Prefer `Timer.StartTimer` without token** for fire-and-forget
3. **Never serialize `TimerExecutionToken`** -- restore in `[AfterDeserialization]`
4. **Always cancel timers in `OnDelete()`/`OnAfterDelete()`**
5. **8ms minimum precision** -- timer wheel uses 8ms tick rate
## Preferred APIs (In Order)
### 1. Timer.StartTimer with Token (Cancellable Fire-and-Forget)
```csharp
private TimerExecutionToken _timerToken;
// One-shot
Timer.StartTimer(TimeSpan.FromSeconds(5), DoSomething, out _timerToken);
// Repeating
Timer.StartTimer(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), CheckExpiry, out _timerToken);
// Repeating with count limit
Timer.StartTimer(TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(1), 10, Tick, out _timerToken);
// Cancel
_timerToken.Cancel(); // Safe to call multiple times
// Check state
if (_timerToken.Running) { }
```
### 2. Timer.StartTimer without Token (Fire-and-Forget, No Cancel)
```csharp
Timer.StartTimer(Delete); // Immediate
Timer.StartTimer(TimeSpan.FromSeconds(5), Delete); // Delayed
Timer.StartTimer(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), Tick); // Repeating
```
### 3. Timer.DelayCall (Returns Timer Object)
```csharp
var timer = Timer.DelayCall(TimeSpan.FromSeconds(5), DoSomething);
timer.Stop(); // Cancel later if needed
```
### 4. Timer.DelayCall with State (Avoids Closures)
```csharp
// Passes state without lambda allocation
Timer.DelayCall(TimeSpan.FromSeconds(2), ProcessTarget, from, target);
// Calls: ProcessTarget(Mobile from, Mobile target) after 2s
// Up to 5 parameters supported
Timer.DelayCall(TimeSpan.FromSeconds(1), DoWork, arg1, arg2, arg3);
```
### 5. Timer.Pause (Awaitable)
```csharp
await Timer.Pause(TimeSpan.FromMilliseconds(100));
await Timer.Pause(500); // 500ms overload
```
## TimerExecutionToken Properties
```csharp
_timerToken.Running // bool: is timer still active?
_timerToken.Index // int: how many times OnTick has fired
_timerToken.RemainingCount // int: ticks remaining (int.MaxValue if infinite)
_timerToken.Next // DateTime: when next tick fires
_timerToken.Cancel() // Stop and return to pool (safe to call multiple times)
```
## Timer Wheel Architecture
3-layer hierarchical wheel with 4096 slots per layer:
- Layer 0: 8ms resolution, ~33 second range
- Layer 1: ~33s resolution, ~22 minute range
- Layer 2: ~22m resolution, ~16 day range
All delays rounded up to nearest 8ms boundary.
## Patterns
### Cleanup in Deletion
```csharp
public override void OnDelete()
{
_timerToken.Cancel(); // TimerExecutionToken
base.OnDelete();
}
public override void OnAfterDelete()
{
_timer?.Stop(); // Timer reference
_timer = null;
base.OnAfterDelete();
}
```
### Timer Restoration After Deserialization
```csharp
[SerializationGenerator(0, false)]
public partial class DecayingItem : Item
{
private TimerExecutionToken _decayTimer; // NOT serialized
[SerializableField(0)]
[DeltaDateTime]
private DateTime _expireTime;
[Constructible]
public DecayingItem() : base(0x1234)
{
_expireTime = Core.Now + TimeSpan.FromHours(1);
Timer.StartTimer(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), CheckDecay, out _decayTimer);
}
[AfterDeserialization]
private void AfterDeserialization()
{
Timer.StartTimer(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), CheckDecay, out _decayTimer);
}
public override void OnAfterDelete()
{
_decayTimer.Cancel();
base.OnAfterDelete();
}
private void CheckDecay()
{
if (Core.Now >= _expireTime)
Delete();
}
}
```
### [DeserializeTimerField] Pattern (for Timer fields)
```csharp
[SerializableField(0, setter: "private")]
private Timer _evaluateTimer;
[DeserializeTimerField(0)]
private void DeserializeEvaluateTimer(TimeSpan delay)
{
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);
}
```
### Custom Timer Class (When You Need Complex Logic)
```csharp
private class DecayTimer : Timer
{
private readonly Corpse _corpse;
public DecayTimer(Corpse c, TimeSpan delay) : base(delay)
{
_corpse = c;
}
protected override void OnTick()
{
if (!_corpse.GetFlag(CorpseFlag.NoBones))
_corpse.TurnToBones();
else
_corpse.Delete();
}
}
// Usage:
_decayTimer = new DecayTimer(this, delay);
_decayTimer.Start();
// Cleanup:
_decayTimer?.Stop();
_decayTimer = null;
```
## Anti-Patterns
- **Serializing `TimerExecutionToken`**: It's a struct with internal Timer reference -- not serializable
- **Forgetting cleanup**: Timers keep running if not cancelled on deletion
- **Using `Thread.Sleep`**: Blocks the game loop. Use `Timer.StartTimer` or `await Timer.Pause` instead
- **Creating timers in constructors called during deserialization**: Use `[AfterDeserialization]` instead
## Real Examples
- Token cleanup: `Projects/UOContent/Spells/Third/WallOfStone.cs`
- Repeating timer: `Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs`
- Custom timer class: `Projects/UOContent/Items/Misc/Corpses/Corpse.cs`
- State-carrying delay: Various files using `Timer.DelayCall<T1,T2>(delay, callback, arg1, arg2)`
- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs`
- Timer pool config: `Projects/Server/Timer/Timer.Pool.cs`
## Timer Files
- `Projects/Server/Timer/Timer.cs` - Base class
- `Projects/Server/Timer/Timer.DelayCall.cs` - DelayCall + StartTimer
- `Projects/Server/Timer/Timer.TimerWheel.cs` - Scheduler
- `Projects/Server/Timer/Timer.Pool.cs` - Pool management
- `Projects/Server/Timer/Timer.DelayStateCall.cs` - Generic state timers
- `Projects/Server/Timer/TimerExecutionToken.cs` - Token struct
## See Also
- `dev-docs/timers.md` - Complete timer documentation
- `dev-docs/event-scheduler.md` - Wall-clock/calendar scheduling (EventScheduler) — use for daily resets, weekly events, holiday seasons instead of Timer
- `dev-docs/claude-skills/modernuo-event-scheduler.md` - EventScheduler skill for calendar-based events
- `dev-docs/claude-skills/modernuo-serialization.md` - Timer fields not serialized
- `dev-docs/claude-skills/modernuo-content-patterns.md` - Deletion patterns
- `dev-docs/claude-skills/modernuo-threading.md` - Single-threaded model