Closes #2593. Closes #2595. Two related pet-AI fixes: the post-#2591 pacing/wake regression (#2593), and the guard order silently converting to Attack during combat (#2595). Root-cause analyses are in the issues. ## #2593 — pets follow slowly; stale AITimer wakes **Why pets slowed:** - The per-step budget grew from **half a think interval** (`CurrentSpeed * 500`) to the full RunUO-parity move table (`CurrentMoveSpeed * 1000`). Medium-bucket pets (Horse, Dog, most tamables): passiveMove **1.05s/step**. - Pet order speed depended on stale `Warmode`: `HandleGuardOrder` set it once, but `OnCombatantChange` clears it whenever the combatant drops, so obedience ran active or passive **by combat history** — usually passive. Net: Guard/Come at ~1.05s/step (~2.1x slower than pre-#2591), vs a player running at 0.1–0.2s/step. - The AITimer never rescheduled its pending wheel entry: the wheel reads `Interval` only after the next fire, so a speed-up or a fresh order (`Activate()` no-ops while running) waited out the stale wake — up to a full passive think, stacked on the residual move budget on Guard → Follow. **What changed:** - **Order handlers own obedience speed** (RunUO `OnCurrentOrderChanged`/`DoOrder*` parity, re-derived continuously): issuing a movement order (Come/Follow/Guard/Attack) sets the **active** think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write **RunUO's AOS `CurrentSpeed = 0.1` sprint** — RunUO's guard else-branch had the identical write as follow. The bespoke 0.1 fuses to both clocks through #2591's existing classification, so `CurrentMoveSpeed` stays **pure herding + classification** with no obedience special case, and `DoMoveImpl`'s per-step flip skips obeying pets (their handler owns the pace) and loses its old follow-only 0.1 write. Combat still re-derives organically via warmode/combatant. - **`AITimer`**: tracks the pending wake and reschedules (`Stop`, `Delay` = remaining, `Start`) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through `ScheduleNext`. New `Prod()` wakes the AI immediately on player commands — including from a stopped timer, so stable claims no longer wait out the random construction stagger. Sector/spawn wakes keep the stagger. Spam-safe: a prodded think grants reaction, never action — steps/swings/casts/abilities are gated by their own budgets and timers. The residual move budget is deliberately **not** cleared on order change — that would let order-spam macros grant free steps. Deadline changes reschedule the timer; rate changes take effect at the next deadline computation. ## #2595 — Guard order converts to Attack during combat **Why:** `FindCombatant()` set `ControlOrder = OrderType.Attack` when engaging, so a guarding pet left the Guard order for the whole fight: OPL tags wiped (pet `1080078` + master `501129`), no retargeting (`DoOrderAttack` locks its target), `TeleportPets` left the pet behind on recall/gate, and every engage→kill→resume cycle replayed the guard flourish. **What changed:** - **`FindGuardTarget()`** (was `FindCombatant`): a pure selector — prefers the aggressor **closest to the master** (RunUO guard parity, dynamic retargeting to protect the owner), keeps the current combatant unless a strictly closer one exists, and never mutates order state. `DoOrderGuard` engages through it while **staying in Guard** the whole fight. - **Persistent-order semantics** (the ModernUO improvement over RunUO): an explicit `all attack` completes → `ResumePersistentOrder()` returns to Guard → the guard scan engages remaining threats in-order. The Attack-chaining fallback (`FightMode.Closest/Aggressor`) now applies only to non-guard persistent orders. Resuming Guard no longer replays the sound/"is now guarding you" message. - **Peaceful guard stands down deterministically** (`Warmode`/`Combatant`/`FocusMob` cleared) and returns to the master at the RunUO sprint (see above); at the master's side it stays organically active. - **`WalkMobileRange` honors the caller's run flag** (the internal hardcoded `dist > 5` gate silently overrode it). Run is animation-only server-side; the only callers passing anything but `false` — follow, guard, clone — gate on their own thresholds. ## Resulting behavior (Medium-bucket pet) | Scenario | Broken | This PR | |---|---|---| | Guard trailing master (AOS) | ~1.05s/step, think-grid quantized | 0.1s/step sprint (RunUO parity), smooth move wakes | | Guard during combat | order flips to Attack; tags lost; no retarget; left behind on recall | stays Guard; retargets to master's closest aggressor; teleports with master | | `all attack` while guarding | resume spams guard flourish per kill; chains into Attack | resumes Guard silently; guard scan takes over | | Come / friend-follow | 1.05s/step | activeMove 0.45s/step (≈ pre-#2591 feel) | | Guard → Follow reaction | up to ~1.5s dead time | think within one wheel turn | | Follow master (AOS sprint) | 0.1s/step | 0.1s/step (unchanged) | | Wild creature chase | RunUO-parity move table | unchanged | Also documents two contracts this work leaned on: the `ControlOrder` setter deliberately fires on every assignment (a reissued order is a command — retarget/break-off/re-anchor), and `OnThink`/`MonsterAbility` must be excess-call tolerant (`dev-docs/content-patterns.md` § OnThink: the excess-call contract). ## Testing - Full suite passes (1570: 837 Server + 733 UOContent). - `PetPacingTests`: order-issue think-clock parity, follow-master sprint via Obey, guard organically active at the master's side, combat-chase and herding boundaries, plus two deterministic timer-wheel tests (8ms-lockstep slicing) proving a fresh order and a mid-wait speed-up wake the AI promptly. - `GuardOrderTests`: engage keeps the Guard order; retargets to the aggressor closest to the master; explicit attack resumes Guard without chaining into Attack; peaceful guard stands down. Setup self-validates LOS/terrain. - `GuardFollowTests`: guard-following registers a move intent, steps toward the master, sprints at 0.1 under AOS (per-step flip must not undo it), and runs active pre-AOS. - All behavioral tests were written first and failed for the documented reasons.
20 KiB
ModernUO Content Creation Patterns
This document covers the patterns and templates for creating game content in ModernUO: items, creatures, spells, skills, loot, context menus, and file organization.
Table of Contents
- New Item
- New Creature
- New Spell
- Skill Implementation
- Loot System
- Context Menus
- Entity Lifecycle
- File Organization
New Item
Minimal Item
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class SimpleItem : Item
{
[Constructible]
public SimpleItem() : base(0x1234) // itemID from UO art
{
Weight = 1.0;
}
public override string DefaultName => "a simple item";
// OR: public override int LabelNumber => 1234567; // cliloc number
}
Item with Properties and Behavior
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class MagicLantern : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Mobile _owner;
private TimerExecutionToken _glowTimer;
[Constructible]
public MagicLantern() : base(0xA25)
{
_charges = Utility.RandomMinMax(5, 15);
Weight = 2.0;
Light = LightType.Circle300;
StartGlow();
}
public override string DefaultName => "a magic lantern";
private void StartGlow()
{
Timer.StartTimer(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3), Glow, out _glowTimer);
}
[AfterDeserialization]
private void AfterDeserialization() => StartGlow();
public override void OnAfterDelete()
{
_glowTimer.Cancel();
base.OnAfterDelete();
}
private void Glow()
{
if (_charges > 0)
Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
}
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001); // Must be in your backpack
return;
}
if (_charges <= 0)
{
from.SendMessage("The lantern is depleted.");
return;
}
Charges--;
from.SendMessage("The lantern flares brightly!");
from.FixedParticles(0x376A, 9, 32, 5042, EffectLayer.Waist);
}
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
}
}
Common Item Base Classes
| Base Class | Use For |
|---|---|
Item |
Generic items |
BaseWeapon |
Melee weapons |
BaseRanged |
Ranged weapons (bows, crossbows) |
BaseArmor |
Armor pieces |
BaseShield |
Shields |
BaseClothing |
Wearable clothing |
BaseJewel |
Rings, bracelets, necklaces |
BaseContainer |
Containers (bags, boxes) |
BasePotion |
Potions |
BaseReagent |
Spell reagents |
Food |
Edible items |
SpellScroll |
Spell scrolls |
Key Item Properties
Weight = 1.0; // Item weight in stones
Stackable = true; // Can stack with same type
Amount = 1; // Stack amount
Movable = true; // Can be picked up
Visible = true; // Visible to players
Hue = 0; // Color hue (0 = default)
Light = LightType.Circle300; // Light emission
LootType = LootType.Regular; // Regular, Newbied, Blessed, Cursed
Layer = Layer.OneHanded; // Equipment layer
New Creature
Basic Creature
using ModernUO.Serialization;
using Server.Items;
namespace Server.Mobiles;
[SerializationGenerator(0)]
public partial class ForestWolf : BaseCreature
{
[Constructible]
public ForestWolf() : base(AIType.AI_Melee, FightMode.Closest)
{
Body = 225; // Wolf body graphic
BaseSoundID = 0xE5; // Base sound ID
SetStr(80, 120);
SetDex(90, 110);
SetInt(20, 40);
SetHits(60, 80);
SetMana(0);
SetDamage(8, 14);
SetDamageType(ResistanceType.Physical, 100);
SetResistance(ResistanceType.Physical, 25, 35);
SetResistance(ResistanceType.Fire, 5, 10);
SetResistance(ResistanceType.Cold, 15, 25);
SetResistance(ResistanceType.Poison, 10, 15);
SetResistance(ResistanceType.Energy, 5, 10);
SetSkill(SkillName.MagicResist, 30.0, 50.0);
SetSkill(SkillName.Tactics, 50.0, 70.0);
SetSkill(SkillName.Wrestling, 50.0, 70.0);
Fame = 600;
Karma = 0;
VirtualArmor = 28;
Tamable = true;
ControlSlots = 1;
MinTameSkill = 50.1;
}
public override string CorpseName => "a wolf corpse";
public override string DefaultName => "a forest wolf";
public override int Meat => 1;
public override int Hides => 6;
public override HideType HideType => HideType.Regular;
public override FoodType FavoriteFood => FoodType.Meat;
public override PackInstinct PackInstinct => PackInstinct.Canine;
public override void GenerateLoot()
{
AddLoot(LootPack.Meager);
}
}
AI Types
| AIType | Use For |
|---|---|
AI_Melee |
Warriors, melee fighters |
AI_Mage |
Spellcasters |
AI_Archer |
Ranged attackers |
AI_Animal |
Passive animals (flee/fight back) |
AI_Predator |
Hunting animals |
AI_Healer |
Healing NPCs |
AI_Vendor |
Shop NPCs |
AI_Berserk |
Mindless aggressors |
AI_Thief |
Pickpockets |
Fight Modes
| FightMode | Behavior |
|---|---|
None |
Never attacks |
Aggressor |
Only retaliates |
Strongest |
Targets highest-stat enemy |
Weakest |
Targets lowest-stat enemy |
Closest |
Targets nearest enemy |
Evil |
Attacks aggressors or evil-karma targets |
Creature Stats Guide
| Creature Level | Str | Dex | Int | Hits | Damage | Fame |
|---|---|---|---|---|---|---|
| Weak | 30-60 | 30-50 | 10-20 | 20-40 | 2-6 | 100-300 |
| Average | 80-120 | 60-90 | 20-40 | 60-100 | 6-14 | 500-1500 |
| Strong | 150-250 | 80-120 | 50-100 | 120-200 | 12-22 | 2000-5000 |
| Elite | 300-500 | 100-150 | 100-200 | 250-500 | 18-30 | 5000-15000 |
| Boss | 500-1000 | 150-250 | 200-400 | 500-2000 | 25-40 | 15000+ |
Optional Creature Overrides
public override Poison PoisonImmune => Poison.Regular; // Poison immunity
public override Poison HitPoison => Poison.Lesser; // Melee poison
public override double HitPoisonChance => 0.2; // 20% poison chance
public override bool CanRummageCorpses => true; // Loots corpses
public override bool BardImmune => true; // Cannot be provoked/peaced
public override bool Unprovokable => true; // Cannot be provoked
public override bool CanFly => true; // Can fly
public override int TreasureMapLevel => 3; // Drops treasure map
public override double WeaponAbilityChance => 0.4; // Weapon ability chance
Creature Speeds (think vs move clocks)
All "speed" values are delays in seconds (smaller = faster). A creature runs two clocks:
- Think clock —
ActiveSpeed/PassiveSpeed/CurrentSpeed: seconds per AI decision (combat decisions, target acquisition, spell timing). - Move clock —
ActiveMoveSpeed/PassiveMoveSpeed/CurrentMoveSpeed: seconds per step. Inherits the matching think value until overridden, so a creature configured with only think speeds behaves as one clock. Any value is legal — steps are scheduled independently of think ticks, so the two need not divide evenly.
Speeds normally come from Distribution/Data/npc-speeds.json (via SpeedClass or type
lists); activeMove/passiveMove are optional per bucket. Prefer data over code:
public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json
Code-level overrides for special cases:
SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics)
SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted
ClearMoveSpeed(); // back to inheriting the think clock
All four are [props-tunable per instance (move values: set 0 to re-inherit); per-instance
move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity).
OnThink: the excess-call contract
OnThink() is a scheduler pass, not an action. The AI timer calls it at least at the
think cadence (CurrentSpeed), but it can and does fire more often: a player command
wakes the AI immediately (AITimer.Prod()), a speed-up reschedules the pending wake, and
players run command macros that drive extra thinks deliberately (order spam is spam-safe
by design — reaction, never action). RunUO had the same property (its timer restarted
with a random delay on every speed change), so this has never been a fixed-rate callback.
Every OnThink override must be excess-call tolerant. An extra call must never grant
an extra action:
- Gate consequential work on its own deadline field, compared in subtraction form
(
Core.TickCount - _nextX >= 0— seetick-counts.md), or make it idempotent. - Never pace a consequential action with a bare per-call
Utility.RandomDouble()roll — its frequency then scales with think rate, which players can influence. Per-call rolls are acceptable only for pure cosmetics (idle animations, flavor sounds). - The engine already gates the expensive things: steps (the
NextMovebudget), weapon swings, spell casts, detect-hidden, and the baseBaseCreature.OnThinkactions (heal, rummage, aura) all carry their own clocks. Follow that pattern.
private long _nextSpecial;
public override void OnThink()
{
base.OnThink();
if (Core.TickCount - _nextSpecial >= 0)
{
DoSpecial();
_nextSpecial = Core.TickCount + 5000; // the real rate limit lives here
}
}
MonsterAbility: same contract
MonsterAbility.CanTrigger is sampled once per think for Think- and
CombatAction-triggered abilities, so abilities live under the same rule:
MinTriggerCooldown/MaxTriggerCooldownis the real rate limit — the floor holds no matter how often thinks fire. Always give a triggered ability a real cooldown.ChanceToTriggeris a per-sample roll: above the cooldown floor, the expected trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as the rate limiter, and keep cooldowns long relative to the think interval so the jitter stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between natural and spammed think rates).- A zero-cooldown ability records no cooldown at all and triggers on every sampled
think that passes its chance — only ever correct for passive alteration hooks, never
for
Think/CombatActiontriggers. - An ability that breaks pet orders (fear-style effects) must own its duration explicitly (a hold state, or a "refuses orders until" deadline checked in the order handlers) — pets react to re-issued commands immediately, so think latency is not a hold.
New Spell
Targeted Damage Spell (Magery)
using System;
using Server.Targeting;
namespace Server.Spells.Third;
public class FireballSpellCustom : MagerySpell, ITargetingSpell<Mobile>
{
private static readonly SpellInfo _info = new(
"Fireball", // Name
"Vas Flam", // Mantra
212, // Cast animation
9041, // Cast sound
Reagent.BlackPearl // Reagents (comma-separated)
);
public FireballSpellCustom(Mobile caster, Item scroll = null) : base(caster, scroll, _info) { }
public override SpellCircle Circle => SpellCircle.Third;
public override bool DelayedDamage => true;
public void Target(Mobile m)
{
if (CheckHSequence(m)) // Harmful spell sequence check
{
var source = Caster;
SpellHelper.Turn(source, m);
SpellHelper.CheckReflect((int)Circle, ref source, ref m);
double damage;
if (Core.AOS)
{
damage = GetNewAosDamage(19, 1, 5, m);
}
else
{
damage = Utility.Random(10, 7);
if (CheckResisted(m))
{
damage *= 0.75;
m.SendLocalizedMessage(501783); // You resist
}
damage *= GetDamageScalar(m);
}
source.MovingParticles(m, 0x36D4, 7, 0, false, true, 9502, 4019, 0x160);
source.PlaySound(0x15E);
// Damage types must sum to 100
SpellHelper.Damage(this, m, damage, 0, 100, 0, 0, 0);
// phys fire cold pois energy
}
}
public override void OnCast()
{
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Harmful);
}
}
Spell Helper Methods
SpellHelper.Turn(caster, target); // Face target
SpellHelper.CheckReflect(circle, ref source, ref target); // Magic reflect
SpellHelper.Damage(spell, target, damage, phys, fire, cold, poison, energy);
SpellHelper.AddStatCurse(caster, target, stat);
SpellHelper.AddStatBonus(caster, target, stat);
SpellHelper.CanRevealCaster(spell);
CheckHSequence(target); // Harmful spell checks (LOS, range, criminal)
CheckBSequence(target); // Beneficial spell checks
CheckResisted(target); // Resistance check
GetNewAosDamage(bonus, dice, sides, target); // AOS damage formula
GetDamageScalar(target); // Pre-AOS damage multiplier
Spell Circles (Magery)
| Circle | Mana | Base Delay |
|---|---|---|
| First | 4 | 0.25s + circle |
| Second | 6 | 0.50s + circle |
| Third | 9 | 0.75s + circle |
| Fourth | 11 | 1.00s + circle |
| Fifth | 14 | 1.25s + circle |
| Sixth | 20 | 1.50s + circle |
| Seventh | 40 | 1.75s + circle |
| Eighth | 50 | 2.00s + circle |
Skill Implementation
Registering a Skill Handler
namespace Server.SkillHandlers;
public static class MySkillHandler
{
public static void Initialize()
{
SkillInfo.Table[(int)SkillName.Tracking].Callback = OnUse;
}
public static TimeSpan OnUse(Mobile from)
{
from.SendMessage("You begin tracking...");
from.Target = new TrackingTarget();
return TimeSpan.FromSeconds(10.0); // Cooldown
}
}
Skill Check
// Difficulty-based check (with skill gain chance)
if (from.CheckSkill(SkillName.Mining, 0.0, 100.0))
{
// Success
}
// Direct chance check
if (from.CheckSkill(SkillName.Hiding, minSkill: 25.0, maxSkill: 75.0))
{
// Success
}
SkillName Enum (58 skills)
Key skills: Alchemy, Anatomy, AnimalLore, AnimalTaming, Archery, ArmsLore, Begging, Blacksmith, Bushido, Camping, Carpentry, Cartography, Chivalry, Cooking, DetectHidden, Discordance, EvalInt, Fencing, Fishing, Fletching, Focus, Forensics, Healing, Herding, Hiding, Inscribe, ItemID, Lockpicking, Lumberjacking, Macing, Magery, MagicResist, Meditation, Mining, Musicianship, Necromancy, Ninjitsu, Parry, Peacemaking, Poisoning, Provocation, RemoveTrap, Snooping, Spellweaving, SpiritSpeak, Stealing, Stealth, Swords, Tactics, Tailoring, TasteID, Tinkering, Tracking, Veterinary, Wrestling
Loot System
Using Predefined Packs
public override void GenerateLoot()
{
AddLoot(LootPack.Poor); // ~50g equivalent
AddLoot(LootPack.Meager); // ~100g equivalent
AddLoot(LootPack.Average); // ~250g equivalent
AddLoot(LootPack.Rich); // ~500g equivalent
AddLoot(LootPack.FilthyRich); // ~1000g equivalent
AddLoot(LootPack.UltraRich); // ~2000g equivalent
AddLoot(LootPack.SuperBoss); // Boss-level loot
// Auxiliary packs
AddLoot(LootPack.Gems, 2); // 2 random gems
AddLoot(LootPack.Potions); // Random potion
AddLoot(LootPack.LowScrolls); // Low circle scroll
AddLoot(LootPack.MedScrolls); // Med circle scroll
AddLoot(LootPack.HighScrolls); // High circle scroll
}
Packs auto-select era-appropriate loot (Pre-AOS, AOS, SE variants).
Specific Items
PackItem(new Arrow(Utility.RandomMinMax(20, 40)));
PackGold(100, 200);
PackItem(new Bandage(Utility.RandomMinMax(5, 10)));
Context Menus
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
base.GetContextMenuEntries(from, ref list);
if (from.Alive && from.InRange(this, 2))
{
list.Add(new RepairEntry(this));
}
}
private class RepairEntry : ContextMenuEntry
{
private readonly Item _item;
public RepairEntry(Item item) : base(6100) // Cliloc number
{
_item = item;
Enabled = item is { Deleted: false };
}
public override void OnClick(Mobile from, IEntity target)
{
if (_item.Deleted || !from.InRange(_item, 2))
return;
from.SendMessage("You repair the item.");
}
}
Entity Lifecycle
Two-Phase Deletion
// Phase 1: Pre-removal cleanup
public override void OnDelete()
{
_timerToken.Cancel(); // Cancel managed timers
// Remove from tracking systems
base.OnDelete();
}
// Phase 2: Post-removal cleanup
public override void OnAfterDelete()
{
_timer?.Stop(); // Stop Timer references
_timer = null;
_owner = null; // Null Item/Mobile refs
base.OnAfterDelete();
}
OnDoubleClick Validation
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001); // Must be in backpack
return;
}
if (!from.InRange(GetWorldLocation(), 2))
{
from.SendLocalizedMessage(500446); // Too far away
return;
}
// Item logic here
}
File Organization
Projects/UOContent/
├── Items/
│ ├── Weapons/Swords/ # Swords
│ ├── Weapons/Maces/ # Maces
│ ├── Weapons/Ranged/ # Bows, crossbows
│ ├── Armor/Plate/ # Plate armor
│ ├── Armor/Chain/ # Chain armor
│ ├── Armor/Leather/ # Leather armor
│ ├── Clothing/ # Wearable clothing
│ ├── Containers/ # Bags, boxes
│ ├── Misc/ # General items
│ ├── Special/ # Unique/quest items
│ └── Resources/ # Crafting materials
├── Mobiles/
│ ├── Animals/Bears/ # Bears (BlackBear, GrizzlyBear)
│ ├── Animals/Birds/ # Birds
│ ├── Monsters/AOS/ # AOS-era monsters
│ ├── Monsters/SE/ # SE-era monsters
│ ├── Monsters/ML/ # ML-era monsters
│ ├── Special/ # Champions, bosses
│ ├── Vendors/ # NPC vendors
│ └── Townfolk/ # NPCs
├── Spells/
│ ├── Base/ # Spell base classes
│ ├── First/ - Eighth/ # Magery circles
│ ├── Necromancy/ # Necromancer spells
│ ├── Chivalry/ # Paladin spells
│ ├── Bushido/ # Samurai abilities
│ ├── Ninjitsu/ # Ninja abilities
│ └── Spellweaving/ # Spellweaving
├── Skills/ # Skill handlers
├── Gumps/ # UI dialogs
│ └── Base/ # Gump base classes
├── Engines/ # Complex systems
│ ├── Craft/ # Crafting system
│ ├── CannedEvil/ # Champion spawns
│ ├── Factions/ # Faction system
│ └── Quests/ # Quest system
└── Misc/ # Miscellaneous
└── LootPack.cs # Loot tables
Naming Rules
- File name = class name
- One primary class per file
- Group related items in subdirectories
- Era-specific content goes in era-named subdirectories