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:
parent
e77a566f32
commit
1391c563fe
34 changed files with 8549 additions and 0 deletions
151
dev-docs/claude-skills/modernuo-code-audit.md
Normal file
151
dev-docs/claude-skills/modernuo-code-audit.md
Normal 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
|
||||
223
dev-docs/claude-skills/modernuo-commands-targeting.md
Normal file
223
dev-docs/claude-skills/modernuo-commands-targeting.md
Normal 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
|
||||
177
dev-docs/claude-skills/modernuo-configuration.md
Normal file
177
dev-docs/claude-skills/modernuo-configuration.md
Normal 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
|
||||
370
dev-docs/claude-skills/modernuo-content-patterns.md
Normal file
370
dev-docs/claude-skills/modernuo-content-patterns.md
Normal 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
|
||||
134
dev-docs/claude-skills/modernuo-era-expansion.md
Normal file
134
dev-docs/claude-skills/modernuo-era-expansion.md
Normal 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
|
||||
150
dev-docs/claude-skills/modernuo-event-scheduler.md
Normal file
150
dev-docs/claude-skills/modernuo-event-scheduler.md
Normal 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
|
||||
184
dev-docs/claude-skills/modernuo-events.md
Normal file
184
dev-docs/claude-skills/modernuo-events.md
Normal 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
|
||||
350
dev-docs/claude-skills/modernuo-gump-system.md
Normal file
350
dev-docs/claude-skills/modernuo-gump-system.md
Normal 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
|
||||
259
dev-docs/claude-skills/modernuo-networking.md
Normal file
259
dev-docs/claude-skills/modernuo-networking.md
Normal 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
|
||||
205
dev-docs/claude-skills/modernuo-property-lists.md
Normal file
205
dev-docs/claude-skills/modernuo-property-lists.md
Normal 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
|
||||
235
dev-docs/claude-skills/modernuo-regions.md
Normal file
235
dev-docs/claude-skills/modernuo-regions.md
Normal 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
|
||||
253
dev-docs/claude-skills/modernuo-serialization.md
Normal file
253
dev-docs/claude-skills/modernuo-serialization.md
Normal 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
|
||||
162
dev-docs/claude-skills/modernuo-threading.md
Normal file
162
dev-docs/claude-skills/modernuo-threading.md
Normal 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
|
||||
219
dev-docs/claude-skills/modernuo-timers.md
Normal file
219
dev-docs/claude-skills/modernuo-timers.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue