Edict Rules

Preamble

As some of you know, for a long time I have been working on Asteroidians, a project with the goal to recreate Worms: Armageddon faithfully, including with customization options. Today I’d like to discuss the technical aspects of one of the backbone structural systems that will allow for user customization in my game, what I call the Edict Rule System.

A “rule” is a single value which can be adjusted by the user before a game starts, and after this, becomes unchangeable. In this way, an Edict defines all adjustable rules for the game, including how weapons behave, turn timers, all kinds of things. Those familiar with W:A might be thinking, ‘Isn’t that just a scheme?‘ to which I’d answer, yes, almost. Unlike W:A schemes, an Edict is more encompassing. It would have scheme-rule data, team information, character information, level generator settings, and more. Aside from a series of inputs, the Edict would be all you need for a game Replay.

From the user’s perspective, this sounds great. From the developer’s perspective, this raises many problems, most notably, how do you keep track of code that accesses this system for maintenance purposes? How do you keep track of the type of data stored to each rule? The magic of C# generics!


System Design Goals

  1. Have a collection of generic-typed structs where the data type indexed per slot is compile time constant, so that front-end code can reference it in a decentralized manner.
  2. Rules need to be uniquely indexed, but also need to be adjustable from a development perspective, so that finding code that accesses them (for refactors) is not a huge pain to manage.
  3. Rules need to be named for front end UI display.
  4. Allow for byte serialization of the entire collection, and of each supported data type. (File saving/loading, and Network IO)
  5. Trigger an event when rule value(s) are changed. This will let us hook game systems together.
  6. Allow for “grouping” of rule data together. Needed for some cases where many rules represent a single complex object, and any change in one rule’s value means an entire refresh of the group is needed. (For instance, level generator values)

The Results

Spoilers, but I’ve already written this system, of course! It has the dependencies of DarkRiftNetworking (serialization only) and Enums.NET. Below is a raw data view of my Edict Rule system in Asteroidians’ console.


Class Diagram

The following is a diagram generated by JetBrains Rider’s diagram tool, with some tweaks and image editing.

That might be confusing, but the main types to focus on are Edict, RuleCollection, and Rule types. Everything else is implementation, or supporting types.


Core System Code

Code: Rules

Diving into the code, let’s start with non-generic Rule, at the top left of the diagram. Rule is the base-most class for all rule types, and instances of it serve as a container for some unknown type of data.

Rule Base Class
/// <summary>
/// Base class for all <see cref="Rule"/> types held within an <see cref="Edict"/>.
/// </summary>
public abstract class Rule : IEquatable<Rule>, IDarkRiftSerializable
{
    protected Rule(Edict edict, RuleConstruct args, ushort collectionID, ushort dataLength)
    {
        this.Owner = edict;
        this._constructor = args;
        this.DataByteCount = dataLength;
        this.Index = new RuleIndex(collectionID, _constructor.ruleID);
    }
    protected readonly Edict Owner;

    /// <summary>
    /// Represents the index to access this <see cref="Rule"/> instance in the owning <see cref="Edict"/>.
    /// </summary>
    public readonly RuleIndex Index;
    
    private readonly RuleConstruct _constructor;
    /// <summary>
    /// Defines if this rule should be visible to the user.
    /// </summary>
    public bool IsUserVisible => _constructor.isUserVisible;
    /// <summary>
    /// Friendly display name.
    /// </summary>
    public string Name => _constructor.name;
    public Type DataType => _constructor.dataType;

    public abstract void Reset(object? caller = null);
    public abstract void ResetNoNotify();

    /// <summary>
    /// Length of data in bytes
    /// </summary>
    internal ushort DataByteCount { get; }

    public abstract bool Equals(Rule? other);
    public abstract override string ToString();
    
    // Also contains methods for serialization through DarkRift:
    public void Serialize(SerializeEvent e) => WriteData(e.Writer);
    public void Deserialize(DeserializeEvent e) => ReadData(e.Reader);
    protected abstract void WriteData(DarkRiftWriter writer);
    protected abstract void ReadData(DarkRiftReader reader);
}
C#
Expand

And then moving one lower to generic Rule‘s code, we have the following. It stores an unknown generic type TData as ‘Data’ and ‘defaultData‘. Take note of the ConstructSelf method.

Generic Rule Base Class
/// <summary>
/// Secondary base class for all <see cref="Rule"/> types held within an <see cref="Edict"/>. Defines an arbitrary type of <typeparamref name="TData"/> that the <see cref="Rule"/> holds.
/// </summary>
public abstract class Rule<TData> : Rule where TData : struct, IEquatable<TData>
{
    protected Rule(Edict edict, RuleConstruct args, ushort collectionID, ushort dataLength) 
        : base(edict, ref args, collectionID, dataLength){ }
        
    // helper method, invoked by inheritors
    protected void ConstructSelf<TArgData>(RuleConstruct args, Func<TArgData, TData>? dataConverter = null)
    {
        if (args.defaultData is TData tData)
            this.defaultData = tData;
        else if(args.defaultData is TArgData argData && dataConverter != null)
            this.defaultData = dataConverter.Invoke(argData);
        else
        {
            if(args.defaultData != null)
                Edict.ErrorLogger?.Invoke($"Mismatching type for default data at {args.ruleID}! Got [{args.defaultData.GetType()}], Expected [{typeof(TArgData)} => {typeof(TData)}], and no matching dataConversion func was provided. Using default");
            this.defaultData = default;
        }
        this.Data = this.defaultData;
    }
    
    protected TData defaultData;
    /// <summary>
    /// Returns this rule's current value.
    /// </summary>
    public TData Data { get; protected set; }
    
    /// <summary>
    /// Sets the value and invokes edict changed event.
    /// </summary>
    public virtual void Set(TData value, object? caller = null)
    {
        if (Data.Equals(value))
            return;
        Data = value;
        Owner.NotifyChanged(this, caller);
    }
    /// <summary>
    /// Sets the value. Does not invoke edict changed event.
    /// </summary>
    public void SetWithNoNotify(TData value) 
        => Data = value;
    
    /// <summary>
    /// Resets the value to default. Invokes edict changed event.
    /// </summary>
    public override void Reset(object? caller = null) 
        => Set(defaultData, caller);
    /// <summary>
    /// Resets the value to default. Does not invoke edict changed event.
    /// </summary>
    public override void ResetNoNotify() => Data = defaultData;
    
    public bool Equals(TData other) => (Data as IEquatable<TData>).Equals(other);
    public sealed override bool Equals(Rule? other)
    {
        if (other != null && other is Rule<TData> o)
            return o.Equals(Data) && o.Index == this.Index;
        return false;
    }
}
C#
Expand

Finally, we get to a concrete Rule implementation, ByteRule. This is actually all that’s needed.

A Concrete Rule
/// <summary>
/// A <see cref="Rule"/> that holds a <see cref="byte"/>
/// </summary>
public sealed class ByteRule : Rule<byte>
{
    internal ByteRule(Edict edict, RuleConstruct args, ushort collectionID) 
        : base(edict, ref args, collectionID, sizeof(byte))
    {
        ConstructSelf<byte>(args);
    }
    
    protected override void ReadData(DarkRiftReader reader) 
        => Set(reader.ReadByte());
    protected override void WriteData(DarkRiftWriter writer) 
        => writer.Write(Data);
}
C#

You might ask, ‘Why all this duplicate boilerplate code?‘ It is not for no reason. Multiple abstractions of our rule classes allow our system to treat different implementations of rule data as if they were the same type. For example, ByteRule and IntRule are both still Rule types. This allows our system to use common functionality between differing types, such as serialization, by taking advantage of polymorphism. This will become more obvious as we move toward our main Edict class. 


Code: Rule Collection

RuleCollection is our first collection class. It implements a Dictionary to allow unordered addressing. It also is responsible for creating concrete Rule types like ByteRule above, which is what the private static RuleFactory does. (Note that the RuleFactory has to have an entry for each supported type the entire system can handle.)

RuleCollection Class
/// <summary>
/// Keyed collection of <see cref="Rule"/> in an <see cref="Edict"/>. Can hold up to 256 <see cref="Rule"/>s.
/// </summary>
public class RuleCollection : IReadOnlyDictionary<byte, Rule>, IDarkRiftSerializable, IGuid
{
    private static readonly ReadOnlyDictionary<Type, Func<Edict, RuleConstruct, ushort, Rule>> RuleFactory
        = new Dictionary<Type, Func<Edict, RuleConstruct, ushort, Rule>>()
    {
        {typeof(bool),   (Edict e, RuleConstruct c, ushort i) => new BooleanRule(e, c, i)},
        {typeof(byte),   (Edict e, RuleConstruct c, ushort i) => new ByteRule   (e, c, i)},
        {typeof(ushort), (Edict e, RuleConstruct c, ushort i) => new UShortRule (e, c, i)},
        {typeof(int),    (Edict e, RuleConstruct c, ushort i) => new IntRule    (e, c, i)},
        {typeof(long),   (Edict e, RuleConstruct c, ushort i) => new LongRule   (e, c, i)},
        {typeof(string), (Edict e, RuleConstruct c, ushort i) => new StringRule (e, c, i)},
        {typeof(float),  (Edict e, RuleConstruct c, ushort i) => new FloatRule  (e, c, i)},
        {typeof(fint),   (Edict e, RuleConstruct c, ushort i) => new FintRule   (e, c, i)},
        {typeof(Guid),   (Edict e, RuleConstruct c, ushort i) => new GuidRule   (e, c, i)},
    }.AsReadOnly();
    
    protected readonly Edict Owner;
    /// <summary>
    /// Defines if this rule collection should be visible to the user.
    /// </summary>
    public readonly bool IsUserVisible;
    /// <summary>
    /// Friendly display name.
    /// </summary>
    public readonly string Name;
    /// <summary>
    /// Index of this collection in our parent edict.
    /// </summary>
    public readonly ushort ID;
    /// <summary>
    /// If true, all rules in this collection will be marked as having been changed when a single rule changes.
    /// </summary>
    internal readonly bool dirtyEntireGroupOnSingleModify;
    
    protected readonly Dictionary<byte, Rule> _rules;
    
    public Guid Guid { get; protected set; }
    
    internal RuleCollection(Edict owner, ushort id, string name, IRuleCollectionFormatProvider setups, bool isUserVisible = true, bool dirtyEntireGroupOnSingleRuleModify = false)
    {
        this.Owner = owner;
        this.ID = id;
        this.Name = name;
        this.IsUserVisible = isUserVisible;
        this.dirtyEntireGroupOnSingleModify = dirtyEntireGroupOnSingleRuleModify;
        
        if (setups == null || setups.Count == 0)
        {
            _rules = new Dictionary<byte, Rule>(0);
            return;
        }
        
        _rules = new Dictionary<byte, Rule>( Math.Min(setups.Count, 255) );
        foreach (RuleConstruct item in setups)
        {
            if (_rules.TryGetValue(item.ruleID, out Rule? overwrite))
            {
                Edict.ErrorLogger?.Invoke($"Tried to insert a duplicate rule index at {item.ruleID}! The following is already there: {overwrite}");
                continue;
            }
            if (RuleFactory.TryGetValue(item.dataType, out var ruleConstructor))
            {
                _rules.Add(item.ruleID, ruleConstructor.Invoke( Owner, item, id ));
            }
        }
    }
    
    public Rule? this[byte r]
    {
        get
        {
            if (_rules.TryGetValue(r, out Rule? rule))
                return rule;
            else
                return null;
        }
    }

    /// <summary>
    /// Returns the value at the specified index, or returns <paramref name="failReturnDefault"/> on lookup failure.
    /// </summary>
    /// <typeparam name="TData">The data type to be returned</typeparam>
    public TData Get<TData>(byte ruleID, TData failReturnDefault = default) where TData : struct, IEquatable<TData>
    {
        if (_rules.TryGetValue(ruleID, out Rule rule) && rule is Rule<TData> rd)
            failReturnDefault = rd.Data;
        else
            Edict.ErrorLogger?.Invoke($"Error getting rule index {ruleID}");
        return failReturnDefault;
    }
    /// <summary>
    /// Sets the value at the specified index. Invokes edict changed event.
    /// </summary>
    /// <typeparam name="TData">The data type to be set at the index</typeparam>
    public void Set<TData>(byte ruleID, TData value, object? caller = null) where TData : struct, IEquatable<TData>
    {
        if (_rules[ruleID] is Rule<TData> rd)
            rd.Set(value, caller);
        else
            Edict.ErrorLogger?.Invoke($"Error setting rule at [{this.ID}, {ruleID}] of type {typeof(TData)}\nof value {value}");
    }
    /// <summary>
    /// Sets the value at the specified index. Does not trigger data changed events.
    /// </summary>
    /// <typeparam name="TData">The data type to be set at the index</typeparam>
    public void SetWithNoNotify<TData>(byte ruleID, TData value) where TData : struct, IEquatable<TData>
    {
        if (_rules[ruleID] is Rule<TData> rd)
            rd.SetWithNoNotify(value);
        else
            Edict.ErrorLogger?.Invoke($"Error setting rule at [{this.ID}, {ruleID}] of type {typeof(TData)}\nof value {value}");
    }
    
    /// <summary>
    /// Resets all values to default. Invokes edict modified event.
    /// </summary>
    public virtual void Reset(object? caller = null)
    {
        bool handleModifications = !Owner.IsMakingModifications; // check if edict is in modification state here, enable it if not
        if (handleModifications)
            Owner.BeginModify(caller);
        
        foreach (KeyValuePair<byte, Rule> pair in _rules)
            pair.Value.Reset(caller);
        
        if (handleModifications)
            Owner.EndModify();
    } 
    /// <summary>
    /// Resets all values to default. Does not invoke edict modified event.
    /// </summary>
    public virtual void ResetNoNotify()
    {
        foreach (KeyValuePair<byte, Rule> pair in _rules)
            pair.Value.ResetNoNotify();
    }
    

    internal virtual ushort DataLength { get { return (ushort)(RulesByteCount + HeaderByteCount); } }
    /// <summary>
    /// Length of header in bytes
    /// </summary>
    protected ushort HeaderByteCount { get { return sizeof(byte) + sizeof(ushort); } }
    /// <summary>
    /// Length of rules in bytes
    /// </summary>
    protected virtual ushort RulesByteCount
    {
        get
        {
            ushort result = 0;
            foreach (KeyValuePair<byte, Rule> pair in _rules)
                result += pair.Value.DataLength;
            return result;
        }
    }
    
    public virtual void Serialize(SerializeEvent e) //Darkrift serialize event
    {
        e.Writer.Write((byte)_rules.Count);
        foreach (KeyValuePair<byte, Rule> pair in _rules)
        {
            e.Writer.Write(pair.Key);
            e.Writer.Write(pair.Value);
        }
    }
    
    public virtual void Deserialize(DeserializeEvent e) //Darkrift serialize event
    {
        byte ruleCount = e.Reader.ReadByte();
        for (int i = 0; i < ruleCount; i++)
        {
            byte id = e.Reader.ReadByte();
            if (_rules.ContainsKey(id) == false)
                throw new KeyNotFoundException($"{ID}:{id}");
            
            Rule rule = _rules[id];
            e.Reader.ReadSerializableInto(ref rule);
        }
    }
    
    // IReadOnlyDictionary boilerplate implementation too
}
C#
Expand

That’s quite a bit, but we’re starting to see something that could resemble a file type, and that’s not on accident. In my game, some things like a player’s “Team” is represented entirely in one RuleCollection slot within the Edict. There are cases where I may need to send one RuleCollection across the network; it would not make sense to send less. The ‘dirtyEntireGroupOnSingleModify‘ boolean value is what controls this.


Code: Edict

To make more sense of this event system behavior, we lastly need to look at the Edict class:

Edict Class
/// <summary>
/// <see cref="Edict"/> represents the entirety of all game rules defined for a match.
/// </summary>
public sealed class Edict : IReadOnlyDictionary<ushort, RuleCollection>, IDarkRiftSerializable
{
    public static Action<string> ErrorLogger;
    
    public string Name;
    private bool _initialized;
    private readonly Dictionary<ushort, RuleCollection> _groups;
    private readonly Dictionary<Guid, ushort> _contentIDLookup;
#region Events
    /// <summary>
    /// The single event called when one or more rules get modified. 
    /// EventArgs contain HashSet of indexes with updated values.
    /// </summary>
    public event EventHandler<EdictModifiedEventArgs> OnEdictModified;

    /// <summary>
    /// Indexes of rules that have been modified since BeginModify() was called.
    /// </summary>
    private HashSet<RuleIndex> _pendingNotifications;
    /// <summary>
    /// Indexes of groups of rules that dirty the entire group when one rule changes.
    /// </summary>
    private readonly Dictionary<ushort, ReadOnlyHashSet<RuleIndex>> _dirtyGroupLookups;
    
    private object? _modificationSource;
    /// <summary>
    /// if true, invocations to OnEdictModified is halted until EndModify() is called.
    /// </summary>
    private bool _makingMassModifications;
    /// <summary>
    /// If true, BeginModify() has been called and invocations to OnEdictModified event are halted until EndModify() is called.
    /// </summary>
    public bool IsMakingModifications => _makingMassModifications;

    /// <summary>
    /// Halts invocations to OnEdictModified event until EndModify() is called.
    /// Changes made to edict during this time are instead batched and sent all at once.
    /// </summary>
    public void BeginModify(object? source)
    {
        _makingMassModifications = true;
        _modificationSource = source;
    }
    internal void NotifyChanged(Rule r, object? caller = null)
    {
        if (_pendingNotifications.Contains(r.Index)) 
            return; //Still waiting for this notification to be sent; ignore

        if (_dirtyGroupLookups.TryGetValue(r.Index.collectionID, out ReadOnlyHashSet<RuleIndex>? indices)) //Should dirty entire group?
        {
            bool startedModify = false;
            if (!_makingMassModifications)
            {
                BeginModify(caller);
                startedModify = true;
            }

            foreach (RuleIndex i in indices)
                _pendingNotifications.Add(i);

            if (startedModify)
                EndModify();
        }
        else
        {
            if (_makingMassModifications)
            {
                _pendingNotifications.Add(r.Index);
            }
            else
            {
                //invoke OnEdictModified immediately
                OnEdictModified?.Invoke(caller, new EdictModifiedEventArgs(this, r.Index));
            }
        }
    }
    /// <summary>
    /// Resumes invocations to OnEdictModified. If there were changes made, OnEdictModified is invoked immediately.
    /// </summary>
    public void EndModify()
    {
        if (!_makingMassModifications)
            return;

        if (_pendingNotifications.Count != 0)
        {
            OnEdictModified?.Invoke(_modificationSource, new EdictModifiedEventArgs(this, _pendingNotifications));
            _pendingNotifications.Clear();
        }
        _makingMassModifications = false;
        _modificationSource = null;
    }
#endregion Events

#region Init
    public Edict()
    {
        _groups = new Dictionary<ushort, RuleCollection>(64);
        _contentIDLookup = new Dictionary<Guid, ushort>(32);
        _dirtyGroupLookups = new Dictionary<ushort, ReadOnlyHashSet<RuleIndex>>(16);
        _pendingNotifications = new HashSet<RuleIndex>();
    }

    public void Initialize(IEdictFormatProvider formatProvider)
    {
        if(_initialized)
            return;

        void AddCollectionToSelf(RuleCollection rules)
        {
            _groups.Add(rules.ID, rules);
            if (rules.dirtyEntireGroupOnSingleModify)
            {
                _dirtyGroupLookups.Add(rules.ID, 
                    new HashSet<RuleIndex>( from r in rules.Values select r.Index ).AsReadOnly() );
                // Regular hashset could be stored here instead.
            }
            if (rules.Guid != Guid.Empty)
                _contentIDLookup.Add(rules.Guid, rules.ID);
        }
        
        IReadOnlyDictionary<ushort, IRuleCollectionFormatProvider> format = formatProvider.GetFormat();
        foreach (KeyValuePair<ushort, IRuleCollectionFormatProvider> pair in format)
        {
            RuleCollection rc = new RuleCollection(this, pair.Key, pair.Value.Name, pair.Value, pair.Value.IsUserVisible);
            AddCollectionToSelf(rc);
        }
        
        _initialized = true;
        ResetNoNotify();
    }
#endregion Init

#region Data Access
    /// <summary>
    /// Sets the game setting at the specified index. Invokes edict changed event. Does nothing on lookup failure.
    /// </summary>
    /// <typeparam name="TData">The data type to set</typeparam>
    /// <param name="index">First and second indexer</param>
    /// <param name="data">The data to be assigned to the rule</param>
    /// <param name="caller">Optional object to be passed to the event</param>
    public void Set<TData>(RuleIndex index, TData data, object? caller = null) where TData : struct, IEquatable<TData>
        => (this[index] as Rule<TData>)?.Set(data, caller);
    /// <summary>
    /// Sets the game setting at the specified index. Does nothing on lookup failure.
    /// </summary>
    /// <typeparam name="TData">The data type to set</typeparam>
    /// <param name="index">First and second indexer</param>
    /// <param name="data">The data to be assigned to the rule</param>
    public void SetWithNoNotify<TData>(RuleIndex index, TData data) where TData : struct, IEquatable<TData>
        => (this[index] as Rule<TData>)?.SetWithNoNotify(data);
    
    /// <summary>
    /// Returns the value at the specified indexes, or returns <paramref name="failReturnDefault"/> on lookup failure.
    /// </summary>
    /// <param name="collectionID">First indexer</param>
    /// <param name="ruleID">Second indexer</param>
    /// <param name="failReturnDefault">Return value on lookup failure.</param>
    /// <typeparam name="TData">The data type to be returned</typeparam>
    public TData Get<TData>(ushort collectionID, byte ruleID, TData failReturnDefault = default) where TData : struct, IEquatable<TData>
    {
        if (_groups.TryGetValue(collectionID, out RuleCollection? collection) && collection.TryGetRuleAs<TData>(ruleID, out Rule<TData> rule))
            failReturnDefault = rule.Data;
        return failReturnDefault;
    }
    /// <summary>
    /// Returns the value at the specified index, or returns <paramref name="failReturnDefault"/> on lookup failure.
    /// </summary>
    /// <param name="index">First and second indexer</param>
    /// <param name="failReturnDefault">Return value on lookup failure.</param>
    /// <typeparam name="TData">The data type to be returned</typeparam>
    public TData Get<TData>(RuleIndex index, TData failReturnDefault = default) where TData : struct, IEquatable<TData>
    {
        if (_groups.TryGetValue(index.collectionID, out RuleCollection? collection) && collection.TryGetRuleAs<TData>(index.ruleID, out Rule<TData>? rule))
            failReturnDefault = rule.Data;
        return failReturnDefault;
    }
    /// <summary>
    /// Returns the game setting at the specified indexes, using enums as the lookup
    /// </summary>
    /// <typeparam name="TData">The data type to be returned</typeparam>
    /// <typeparam name="TEnum">The rule enum type to use as an indexer. Must be underlying type of byte!</typeparam>
    /// <param name="collectionID">First indexer enum</param>
    /// <param name="ruleID">Second indexer enum</param>
    public TData Get<TData, TEnum>(EdictMap collectionID, TEnum ruleID) where TData : struct, IEquatable<TData> where TEnum : struct, Enum
    {
        if (Enums.GetUnderlyingType<TEnum>() != typeof(byte))
        {
            ErrorLogger?.Invoke($"Edict GetRule<TData, TEnum> error: TEnum [{typeof(TEnum)}] must be type of byte!");
            return default(TData);
        }
        return Get<TData>(Enums.ToUInt16(collectionID), Enums.ToByte(ruleID), default);
    }

    public RuleCollection? this[ushort r]
    {
        get
        {
            if (_groups.TryGetValue(r, out RuleCollection? col))
                return col;
            return null;
        }
    }
    public RuleCollection? this[EdictMap r] => this[Enums.ToUInt16(r)];

    public Rule? this[RuleIndex i]
    {
        get
        {
            if (_groups.TryGetValue(i.collectionID, out RuleCollection? col) && col.TryGetValue(i.ruleID, out Rule? rule))
                return rule;
            return null;
        }
    }
#endregion Data Access

#region Serialize
    public int DataLength { get { return RulesByteCount + HeaderByteCount; } }
    /// <summary>
    /// Length of header in bytes
    /// </summary>
    private ushort HeaderByteCount { get { return sizeof(ushort) + sizeof(ushort); } } //ruleCount, expectedDataLength
    /// <summary>
    /// Length of rules in bytes
    /// </summary>
    private int RulesByteCount
    {
        get
        {
            int result = 0;
            foreach (KeyValuePair<ushort, RuleCollection> pair in _groups)
                result += pair.Value.DataLength;
            return result;
        }
    }

#region IDarkRiftSerializable
    public void Serialize(SerializeEvent e)
    {
        e.Writer.Write((ushort)_groups.Count);
        foreach (KeyValuePair<ushort, RuleCollection> pair in _groups)
        {
            e.Writer.Write(pair.Key);
            e.Writer.Write(pair.Value);
        }
    }

    public void Deserialize(DeserializeEvent e)
    {
        RuleCollection rule;
        ushort ruleCount = e.Reader.ReadUInt16();
        for (int i = 0; i < ruleCount; i++)
        {
            ushort id = e.Reader.ReadUInt16();
            if (_groups.ContainsKey(id) == false)
                throw new KeyNotFoundException(id.ToString());

            rule = _groups[id];
            e.Reader.ReadSerializableInto(ref rule);
        }
    }
#endregion IDarkRiftSerializable
#endregion Serialize

    /// <summary>
    /// Resets all values to default and invokes edict changed event.
    /// </summary>
    public void Reset(object? caller = null)
    {
        BeginModify(caller);
        foreach (KeyValuePair<ushort, RuleCollection> pair in this)
            pair.Value.Reset();
        EndModify();
    }
    /// <summary>
    /// Resets all values to default without invoking edict changed event.
    /// </summary>
    public void ResetNoNotify()
    {
        foreach (KeyValuePair<ushort, RuleCollection> pair in this)
            pair.Value.ResetNoNotify();
    }

    // IReadOnlyDictionary boilerplate here
}
C#
Expand

Edict, being the main manager class, has a lot of collections in it that are used to look up various properties about the data it holds. There is also event system batching; BeginModify() will stop and cache all rule changes until EndModify() is called, at which point all changes will be sent as a single event invocation – a very handy optimization. Serializing the Edict will serialize all data it holds too. This marks off goals 4, 5, and 6.


Code: Initialization

Because Enums are just glorified named integers that are compile-time constant, they provide a very ideal use case for my system, especially because the IDE will track code-references to them. I don’t care about what the index value of the enum is, and if I do it’s probably in relationship to other index values. The fact that they’re named makes them easier to remember and use, without sacrificing speed like you would have by using strings. My system can index any rule using an ushort and a byte value.

The meta-system I came up with to support building Edict‘s classes based upon enums… is complicated. It uses custom attributes and reflection code. At the end of the day, they boil down to IEdictFormatProvider and IRuleCollectionFormatProvider. The latter interface provides a list of RuleConstruct:

RuleConstruct
/// <summary>  
/// Rule key  
/// </summary>  
public readonly byte ruleID;  
/// <summary>  
/// Rule display name  
/// </summary>  
public readonly string name;  
/// <summary>  
/// Rule struct type  
/// </summary>  
public readonly Type dataType;  
/// <summary>  
/// Rule default value  
/// </summary>  
public readonly object? defaultData;  
/// <summary>  
/// If true, the rule gets exposed to the UI  
/// </summary>  
public readonly bool isUserVisible;
C#

Here’s what that looks like for a front-end inheritor of RuleCollection:

RuleCollection Inheritor + Enum
public enum GeneralRule : byte  
{  
    // strings are used to provide data type, second arg is DefaultValue
    [RuleData("ushort", (ushort)100)] Health = 3,  
    [RuleData("byte", (byte)4)] CharactersPerTeam = 4,  
  
    [RuleData("ushort", (ushort)5)] PreTurnTime = 7,  
    [RuleData("ushort", (ushort)45)] TurnTime = 8,  
    [RuleData("ushort", (ushort)5)] RetreatTime = 9,  
  
    [RuleData("bool", true)] FallDamageEnabled = 10,  
    [RuleData("bool", true)] TerrainDamageEnabled = 11,  
    [RuleData("ushort", (ushort)50)] PickupHealthValue = 20,  
    [RuleData("byte", (byte)128)] PickupHealthChance = 21,  
  
    [RuleData("long", (long)0)] Seed_Placement = 43,  
    [RuleData("long", (long)0)] Seed_General = 44,  
}

public sealed class GeneralRules : RuleCollection, IDarkRiftSerializable  
{  
    public GeneralRules(Edict owner) : base(owner, 
            (ushort)EdictMap.General, 
            "GeneralRules", 
            BuildFromEnumAttributes.ToRulesFormat<GeneralRule>(), 
            true)  
    { }
    // etc..
}
C#

As you can see, GeneralRules inherits RuleCollection, then to its base, passes what collection slot it belongs to (EdictMap.General on line 23), and also passes a IRuleCollectionFormatProvider, which is constructed reflectively from the GeneralRule enum (BuildFromEnumAttributes.ToRulesFormat<GeneralRule>() on line 25 – How this reflection method works is beyond the scope of this blog post). The end result is that in byte slot 3, we will have a ushort of value 100 and the name “Health” (line 4), slot 10 has value “true” with name “FallDamageEnabled” (line 11), and so on. These values can be changed as desired at runtime; the values provided in the RuleData attribute only serve as starting defaults. The type of value is hardcoded!

EdictMap is an ushort enum which represents valid RuleCollection slots. From there, implementers of RuleCollection are expected to provide their own byte enum for indexing, like above with GeneralRules, though technically is not required. Here’s what EdictMap looks like:

EdictMap Enum
public enum EdictMap : ushort  
{  
    // strings are used to look up concrete RuleCollection types
    [RuleCollectionType("General")]  
    General = 1,  
    [RuleCollectionType("Level")]  
    Level = 2,  
    
    // (Lots of weapon rules here!)
  
    [RuleCollectionType("Team")] Team1 = 60000,  
    [RuleCollectionType("Team")] Team2,  
    [RuleCollectionType("Team")] Team3,  
    [RuleCollectionType("Team")] Team4,  
    [RuleCollectionType("Team")] Team5,  
    [RuleCollectionType("Team")] Team6,  
  
    [RuleCollectionType("Machine")] Machine1 = 60010,  
    [RuleCollectionType("Machine")] Machine2,  
    [RuleCollectionType("Machine")] Machine3,  
    [RuleCollectionType("Machine")] Machine4,  
    [RuleCollectionType("Machine")] Machine5,  
    [RuleCollectionType("Machine")] Machine6,
}
C#

I have lots of repeating Team and Machine rule collections because I embed that data into multiple slots, as needed.

Front-End Read Value

Finally, we can look at some front-end usage code!

Front End Example
bool doFallDmg = edict.Get<bool>((ushort)EdictMap.General, (byte)GeneralRule.FallDamageEnabled);
C#

Walking through what this does:

  1. Get EdictMap.General enum value, convert it to ushort (= 1)
  2. Get GeneralRule.FallDamageEnabled enum value, convert it to byte (= 10)
  3. Inside edict.Get<bool>(1, 10):
    1. Get the RuleCollection with ushort id 1, and from that, get the Rule with byte id 10 (else return default)
    2. Cast the Rule to Rule<bool> (else return default)
    3. Return Rule<bool>.Data value.
  4. Profit!

This means that all the busywork of managing indexes is kept centralized on the enums themselves. The front end code doesn’t and shouldn’t care, as long as it can access the value it needs. If we repurpose the enum member name for UI display, this satisfies goals 1, 2, and 3!

Scroll to Top