Skip to content

Spell System

This document describes the lifecycle of a spell from definition to client animation.


Spell Data Model

SpellTemplate (persisted — Avalon.Domain.World)

Stored in the World database. Loaded at server startup into StaticData.SpellTemplates.

Field Type Description
Id SpellId Primary key
Name string Display name
CastTime uint Cast duration in milliseconds; 0 = instant
Cooldown uint Cooldown in milliseconds
Cost uint Power cost (mana / energy / fury depending on class)
SpellScript string Script class name used to resolve the SpellScript type
Range SpellRange Targeting range category
Effects SpellEffect Flags: Damage, Heal, AoE, DoT, HoT, etc.
EffectValue uint Base effect magnitude (damage dealt, healing done, etc.)
AllowedClasses List<CharacterClass> Which classes can learn this spell
AnimationId uint Client animation ID to play on cast (not yet propagated to packet)

SpellMetadata (runtime — Avalon.World.Public.Spells)

In-memory representation loaded from SpellTemplate. Immutable (uses init properties).

public class SpellMetadata
{
    public string Name { get; init; }
    public float CastTime { get; init; }  // seconds
    public float Cooldown { get; init; }  // seconds
    public uint Cost { get; init; }
    public string ScriptName { get; init; }
    public SpellRange Range { get; init; }
    public SpellEffect Effects { get; init; }
    public uint EffectValue { get; init; }
    public uint AnimationId { get; init; }
}

Spell Lifecycle

Player presses spell key
CCharacterAttackPacket / CCastSpellPacket
CharacterAttackHandler.Execute
  ├── AoE check: skip target if SpellEffect.AoE
  ├── Single-target: validate target exists and is in range
  └── context.QueueSpell(character, target, spell)
InstanceSpellSystem.QueueSpell
  ├── Power check: character.CurrentPower >= spell.Metadata.Cost?
  │   └── NO → return false (client notified out-of-power)
  ├── Deduct power: character.CurrentPower -= Cost
  └── Add SpellInstance to _spellQueue
InstanceSpellSystem.Update (per-tick)
  ├── Decrement CastTimeTimer
  ├── Check for cast interruption (movement)
  └── CastTimeTimer <= 0
        ├── Resolve SpellScript type via IScriptManager
        ├── Instantiate SpellScript via DI
        └── SpellScript.Prepare() → add to _activeSpells
        SpellScript.Update (per-tick)
          └── Script logic: hit detection, projectile movement, DoT ticks, etc.
        On impact / completion
          ├── Apply damage / heal to target
          ├── Broadcast SUnitDamagePacket or SHealPacket
          └── BroadcastFinishCastAnimation (uses AnimationId)

Power Cost Deduction

Power deduction happens at queue time (when the spell is accepted into the queue), not at cast completion. This prevents exploiting cast interruptions to "bank" the power refund.

Power Types

PowerType Class(es) Mechanic
Mana Wizard, Healer Depleted on cast; regenerates over time
Energy Hunter Depleted on cast; regenerates quickly
Fury Warrior Accumulation mechanic — do not deduct on cast; generated by melee and consumed differently
None No resource check

SpellScript.Clone()

SpellScript instances are cloned from a prototype when a spell is activated. The base class provides a virtual Clone() using MemberwiseClone, which handles all value-type fields. Subclasses override when they have additional mutable state.

Base Implementation

public virtual SpellScript Clone()
{
    // Shallow clone of all value-type fields
    var clone = (SpellScript)MemberwiseClone();

    // Deep-clone the chained scripts list
    var clonedChain = new List<SpellScript>(ChainedScripts.Count);
    foreach (var chained in ChainedScripts)
        clonedChain.Add(chained.Clone());

    clone._chainedScripts = clonedChain;

    return clone;
}

Concrete scripts with additional mutable state (e.g. an elapsed timer field) override and reset those fields:

public override SpellScript Clone()
{
    var clone = (FireballScript)base.Clone();
    clone._elapsedTime = 0f;
    return clone;
}

Animation ID

SpellTemplate and SpellMetadata carry an AnimationId field. The SUnitAttackAnimationPacket is currently broadcast with a hardcoded default animation; propagating spell.Metadata.AnimationId to the packet is a pending improvement. An EF migration adding animation_id to the spell_templates table is also needed.

Reserved Animation IDs

ID Meaning
0 No animation (silent cast)
1 Default melee swing
2+ Spell-specific (defined in client assets)

Creature Spell Support

Creatures currently always execute melee attacks. Support for caster creatures requires adding IReadOnlyList<SpellId> SpellIds to ICreatureMetadata and updating CreatureCombatScript.AttackTarget to select a spell when one is off cooldown and in range.


AoE Targeting

AoE spells have SpellEffect.AoE in their Effects flags. They do not require a single target in the attack packet. The CharacterAttackHandler routes AoE spells without a target directly to QueueSpell; the SpellScript is responsible for finding affected targets within its radius during Prepare() or Update().


Test Coverage

Scenario
Sufficient power → spell queued, power deducted
Insufficient power → QueueSpell returns false
Clone has independent chain list
Subclass clone resets mutable fields
SpellMetadata.AnimationId preserved through clone
Creature with spell → QueueSpell called
AoE spell, no target → queued
Non-AoE spell, no target → rejected