feat(syncvar): Add SyncDictionary (#602)

* Added basic SyncDictionary support, no support for structs yet

* Fixed TryGetValue usage

* Removed extraneous hardcoded SyncDictionary type

* Added a couple basic tests, more coming

* Added 4 more tests

* Added two tests and SyncDictionary now bubbles item to Callback on Remove (both Remove cases)

* Added the remainder of tests

* Added basic documentation about SyncDictionaries on StateSync.md page

* Simplify test syntax

Co-Authored-By: Katori <znorth@gmail.com>

* Simplify test syntax

Co-Authored-By: Katori <znorth@gmail.com>

* Simplify test syntax

Co-Authored-By: Katori <znorth@gmail.com>

* Simplify test syntax

Co-Authored-By: Katori <znorth@gmail.com>

* Remove null-check when setting value directly (and updated expected test behaviour)

* fix: Provide default implementation for SyncDictionary serializers

* feat: Add Weaver support for syncdictionary

* Fix minor issue with Set code and made test use Weaved serialization instead of manual

* Added a new test for bare set (non-overwrite)

* Added another test for BareSetNull and cleaned up some tests

* Updated SyncDictionary documentation on StateSync.md

* Update docs with SyncDictionary info

* Update SyncDictionary docs wording

* docs: document the types and better example

* Add two SyncDictionary constructors

* Removed unnecessary initialization

* Style fixes

* - Merged many operation cases
- Fixed Contains method
- Added new test to test contains (and flag its earlier improper usage)
- Use PackedUInt32 instead of int for Changes and Counts

* - Simplify "default" syntax
- Use Rodol's remove method (faster)
- Don't use var

* Removed unnecessary newline, renamed <B, T> to <K, V> per vis2k, corrected wording of InvalidOperationException on ReadOnly AddOp

* Code simplification, style fixes, docs example style fixes, newly improved implementation for CopyTo that fails gracefully
This commit is contained in:
Zac North 2019-03-24 05:18:31 -04:00 committed by vis2k
parent fea46b801d
commit 7d21bded9a
9 changed files with 705 additions and 1 deletions

View File

@ -0,0 +1,19 @@
// this class generates OnSerialize/OnDeserialize for SyncLists
using Mono.Cecil;
using Mono.Cecil.Cil;
namespace Mirror.Weaver
{
static class SyncDictionaryProcessor
{
/// <summary>
/// Generates serialization methods for synclists
/// </summary>
/// <param name="td">The synclist class</param>
public static void Process(TypeDefinition td)
{
SyncObjectProcessor.GenerateSerialization(td, 0, "SerializeKey", "DeserializeKey");
SyncObjectProcessor.GenerateSerialization(td, 1, "SerializeItem", "DeserializeItem");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 29e4a45f69822462ab0b15adda962a29
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -64,6 +64,7 @@ class Weaver
public static TypeReference MessageBaseType;
public static TypeReference SyncListType;
public static TypeReference SyncDictionaryType;
public static MethodReference NetworkBehaviourDirtyBitsReference;
public static TypeReference NetworkClientType;
@ -1125,6 +1126,7 @@ static void SetupTargetTypes()
MessageBaseType = NetAssembly.MainModule.GetType("Mirror.MessageBase");
SyncListType = NetAssembly.MainModule.GetType("Mirror.SyncList`1");
SyncDictionaryType = NetAssembly.MainModule.GetType("Mirror.SyncDictionary`2");
NetworkBehaviourDirtyBitsReference = Resolvers.ResolveProperty(NetworkBehaviourType, CurrentAssembly, "syncVarDirtyBits");
@ -1364,6 +1366,12 @@ static bool CheckSyncList(TypeDefinition td)
didWork = true;
break;
}
else if (parent.FullName.StartsWith(SyncDictionaryType.FullName))
{
SyncDictionaryProcessor.Process(td);
didWork = true;
break;
}
try
{
parent = parent.Resolve().BaseType;

View File

@ -0,0 +1,303 @@
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
namespace Mirror
{
[EditorBrowsable(EditorBrowsableState.Never)]
public abstract class SyncDictionary<K, V> : IDictionary<K, V>, SyncObject
{
public delegate void SyncDictionaryChanged(Operation op, K key, V item);
readonly Dictionary<K, V> m_Objects;
public int Count => m_Objects.Count;
public bool IsReadOnly { get; private set; }
public event SyncDictionaryChanged Callback;
public enum Operation : byte
{
OP_ADD,
OP_CLEAR,
OP_REMOVE,
OP_SET,
OP_DIRTY
}
struct Change
{
internal Operation operation;
internal K key;
internal V item;
}
readonly List<Change> Changes = new List<Change>();
// how many changes we need to ignore
// this is needed because when we initialize the list,
// we might later receive changes that have already been applied
// so we need to skip them
int changesAhead = 0;
protected virtual void SerializeKey(NetworkWriter writer, K item) {}
protected virtual void SerializeItem(NetworkWriter writer, V item) {}
protected virtual K DeserializeKey(NetworkReader reader) => default;
protected virtual V DeserializeItem(NetworkReader reader) => default;
public bool IsDirty => Changes.Count > 0;
public ICollection<K> Keys => m_Objects.Keys;
public ICollection<V> Values => m_Objects.Values;
// throw away all the changes
// this should be called after a successfull sync
public void Flush() => Changes.Clear();
public SyncDictionary()
{
m_Objects = new Dictionary<K, V>();
}
public SyncDictionary(IEqualityComparer<K> eq)
{
m_Objects = new Dictionary<K, V>(eq);
}
void AddOperation(Operation op, K key, V item)
{
if (IsReadOnly)
{
throw new System.InvalidOperationException("SyncDictionaries can only be modified by the server");
}
Change change = new Change
{
operation = op,
key = key,
item = item
};
Changes.Add(change);
Callback?.Invoke(op, key, item);
}
public void OnSerializeAll(NetworkWriter writer)
{
// if init, write the full list content
writer.WritePackedUInt32((uint)m_Objects.Count);
foreach (KeyValuePair<K, V> syncItem in m_Objects)
{
SerializeKey(writer, syncItem.Key);
SerializeItem(writer, syncItem.Value);
}
// all changes have been applied already
// thus the client will need to skip all the pending changes
// or they would be applied again.
// So we write how many changes are pending
writer.WritePackedUInt32((uint)Changes.Count);
}
public void OnSerializeDelta(NetworkWriter writer)
{
// write all the queued up changes
writer.WritePackedUInt32((uint)Changes.Count);
for (int i = 0; i < Changes.Count; i++)
{
Change change = Changes[i];
writer.Write((byte)change.operation);
switch (change.operation)
{
case Operation.OP_ADD:
case Operation.OP_REMOVE:
case Operation.OP_SET:
case Operation.OP_DIRTY:
SerializeKey(writer, change.key);
SerializeItem(writer, change.item);
break;
case Operation.OP_CLEAR:
break;
}
}
}
public void OnDeserializeAll(NetworkReader reader)
{
// This list can now only be modified by synchronization
IsReadOnly = true;
// if init, write the full list content
int count = (int)reader.ReadPackedUInt32();
m_Objects.Clear();
Changes.Clear();
for (int i = 0; i < count; i++)
{
K key = DeserializeKey(reader);
V obj = DeserializeItem(reader);
m_Objects.Add(key, obj);
}
// We will need to skip all these changes
// the next time the list is synchronized
// because they have already been applied
changesAhead = (int)reader.ReadPackedUInt32();
}
public void OnDeserializeDelta(NetworkReader reader)
{
// This list can now only be modified by synchronization
IsReadOnly = true;
int changesCount = (int)reader.ReadPackedUInt32();
for (int i = 0; i < changesCount; i++)
{
Operation operation = (Operation)reader.ReadByte();
// apply the operation only if it is a new change
// that we have not applied yet
bool apply = changesAhead == 0;
K key = default;
V item = default;
switch (operation)
{
case Operation.OP_ADD:
case Operation.OP_SET:
case Operation.OP_DIRTY:
key = DeserializeKey(reader);
item = DeserializeItem(reader);
if (apply)
{
m_Objects[key] = item;
}
break;
case Operation.OP_CLEAR:
if (apply)
{
m_Objects.Clear();
}
break;
case Operation.OP_REMOVE:
key = DeserializeKey(reader);
item = DeserializeItem(reader);
if (apply)
{
m_Objects.Remove(key);
}
break;
}
if (apply)
{
Callback?.Invoke(operation, key, item);
}
// we just skipped this change
else
{
changesAhead--;
}
}
}
public void Clear()
{
m_Objects.Clear();
AddOperation(Operation.OP_CLEAR, default, default);
}
public bool ContainsKey(K key) => m_Objects.ContainsKey(key);
public bool Remove(K key)
{
if (m_Objects.TryGetValue(key, out V item) && m_Objects.Remove(key))
{
AddOperation(Operation.OP_REMOVE, key, item);
return true;
}
return false;
}
public void Dirty(K index)
{
AddOperation(Operation.OP_DIRTY, index, m_Objects[index]);
}
public V this[K i]
{
get => m_Objects[i];
set
{
if (TryGetValue(i, out V val))
{
AddOperation(Operation.OP_SET, i, value);
}
else
{
AddOperation(Operation.OP_ADD, i, value);
}
m_Objects[i] = value;
}
}
public bool TryGetValue(K key, out V value) => m_Objects.TryGetValue(key, out value);
public void Add(K key, V value)
{
m_Objects.Add(key, value);
AddOperation(Operation.OP_ADD, key, value);
}
public void Add(KeyValuePair<K, V> item) => Add(item.Key, item.Value);
public bool Contains(KeyValuePair<K, V> item)
{
return TryGetValue(item.Key, out V val) && EqualityComparer<V>.Default.Equals(val, item.Value);
}
public void CopyTo(KeyValuePair<K, V>[] array, int arrayIndex)
{
if (array == null)
{
throw new System.ArgumentNullException("Array Is Null");
}
if (arrayIndex < 0 || arrayIndex > array.Length)
{
throw new System.ArgumentOutOfRangeException("Array Index Out of Range");
}
if (array.Length - arrayIndex < Count)
{
throw new System.ArgumentException("The number of items in the SyncDictionary is greater than the available space from arrayIndex to the end of the destination array");
}
int i = arrayIndex;
foreach (KeyValuePair<K,V> item in m_Objects)
{
array[i] = item;
i++;
}
}
public bool Remove(KeyValuePair<K, V> item)
{
bool result = m_Objects.Remove(item.Key);
if (result)
{
AddOperation(Operation.OP_REMOVE, item.Key, item.Value);
}
return result;
}
public IEnumerator<KeyValuePair<K, V>> GetEnumerator() => ((IDictionary<K, V>)m_Objects).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => ((IDictionary<K, V>)m_Objects).GetEnumerator();
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4b346c49cfdb668488a364c3023590e2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
namespace Mirror.Tests
{
[TestFixture]
public class SyncDictionaryTest
{
public class SyncDictionaryIntString : SyncDictionary<int, string> {}
SyncDictionaryIntString serverSyncDictionary;
SyncDictionaryIntString clientSyncDictionary;
private void SerializeAllTo<T>(T fromList, T toList) where T : SyncObject
{
NetworkWriter writer = new NetworkWriter();
fromList.OnSerializeAll(writer);
NetworkReader reader = new NetworkReader(writer.ToArray());
toList.OnDeserializeAll(reader);
}
private void SerializeDeltaTo<T>(T fromList, T toList) where T : SyncObject
{
NetworkWriter writer = new NetworkWriter();
fromList.OnSerializeDelta(writer);
NetworkReader reader = new NetworkReader(writer.ToArray());
toList.OnDeserializeDelta(reader);
fromList.Flush();
}
[SetUp]
public void SetUp()
{
serverSyncDictionary = new SyncDictionaryIntString();
clientSyncDictionary = new SyncDictionaryIntString();
// add some data to the list
serverSyncDictionary.Add(0, "Hello");
serverSyncDictionary.Add(1, "World");
serverSyncDictionary.Add(2, "!");
SerializeAllTo(serverSyncDictionary, clientSyncDictionary);
}
[Test]
public void TestInit()
{
Dictionary<int, string> comparer = new Dictionary<int, string>
{
[0] = "Hello",
[1] = "World",
[2] = "!"
};
Assert.That(clientSyncDictionary[0], Is.EqualTo("Hello"));
Assert.That(clientSyncDictionary, Is.EquivalentTo(comparer));
}
[Test]
public void TestAdd()
{
serverSyncDictionary.Add(4, "yay");
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary.ContainsKey(4), Is.EqualTo(true));
Assert.That(clientSyncDictionary[4], Is.EqualTo("yay"));
}
[Test]
public void TestClear()
{
serverSyncDictionary.Clear();
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(serverSyncDictionary, Is.EquivalentTo(new SyncDictionaryIntString()));
}
[Test]
public void TestSet()
{
serverSyncDictionary[1] = "yay";
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary.ContainsKey(1));
Assert.That(clientSyncDictionary[1], Is.EqualTo("yay"));
}
[Test]
public void TestBareSet()
{
serverSyncDictionary[4] = "yay";
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary.ContainsKey(4));
Assert.That(clientSyncDictionary[4], Is.EqualTo("yay"));
}
[Test]
public void TestBareSetNull()
{
serverSyncDictionary[4] = null;
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary[4], Is.Null);
Assert.That(clientSyncDictionary.ContainsKey(4));
}
[Test]
public void TestConsecutiveSet()
{
serverSyncDictionary[1] = "yay";
serverSyncDictionary[1] = "world";
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary[1], Is.EqualTo("world"));
}
[Test]
public void TestNullSet()
{
serverSyncDictionary[1] = null;
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary.ContainsKey(1));
Assert.That(clientSyncDictionary[1], Is.Null);
}
[Test]
public void TestRemove()
{
serverSyncDictionary.Remove(1);
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary.ContainsKey(1), Is.False);
}
[Test]
public void TestMultSync()
{
serverSyncDictionary.Add(10, "1");
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
// add some delta and see if it applies
serverSyncDictionary.Add(11, "2");
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary.ContainsKey(10));
Assert.That(clientSyncDictionary[10], Is.EqualTo("1"));
Assert.That(clientSyncDictionary.ContainsKey(11));
Assert.That(clientSyncDictionary[11], Is.EqualTo("2"));
}
[Test]
public void TestContains()
{
Assert.That(!clientSyncDictionary.Contains(new KeyValuePair<int, string>(2, "Hello")));
serverSyncDictionary[2] = "Hello";
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(clientSyncDictionary.Contains(new KeyValuePair<int, string>(2, "Hello")));
}
[Test]
public void CallbackTest()
{
bool called = false;
clientSyncDictionary.Callback += (op, index, item) =>
{
called = true;
Assert.That(op, Is.EqualTo(SyncDictionaryIntString.Operation.OP_ADD));
Assert.That(index, Is.EqualTo(3));
Assert.That(item, Is.EqualTo("yay"));
};
serverSyncDictionary.Add(3, "yay");
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(called, Is.True);
}
[Test]
public void CallbackRemoveTest()
{
bool called = false;
clientSyncDictionary.Callback += (op, key, item) =>
{
called = true;
Assert.That(op, Is.EqualTo(SyncDictionaryIntString.Operation.OP_REMOVE));
Assert.That(item, Is.EqualTo("World"));
};
serverSyncDictionary.Remove(1);
SerializeDeltaTo(serverSyncDictionary, clientSyncDictionary);
Assert.That(called, Is.True);
}
[Test]
public void CountTest()
{
Assert.That(serverSyncDictionary.Count, Is.EqualTo(3));
}
[Test]
public void ReadOnlyTest()
{
Assert.That(serverSyncDictionary.IsReadOnly, Is.False);
}
[Test]
public void DirtyTest()
{
SyncDictionaryIntString serverList = new SyncDictionaryIntString();
SyncDictionaryIntString clientList = new SyncDictionaryIntString();
// nothing to send
Assert.That(serverList.IsDirty, Is.False);
// something has changed
serverList.Add(15, "yay");
Assert.That(serverList.IsDirty, Is.True);
SerializeDeltaTo(serverList, clientList);
// data has been flushed, should go back to clear
Assert.That(serverList.IsDirty, Is.False);
}
[Test]
public void ReadonlyTest()
{
SyncDictionaryIntString serverList = new SyncDictionaryIntString();
SyncDictionaryIntString clientList = new SyncDictionaryIntString();
// data has been flushed, should go back to clear
Assert.That(clientList.IsReadOnly, Is.False);
serverList.Add(20, "yay");
serverList.Add(30, "hello");
serverList.Add(35, "world");
SerializeDeltaTo(serverList, clientList);
// client list should now lock itself, trying to modify it
// should produce an InvalidOperationException
Assert.That(clientList.IsReadOnly, Is.True);
Assert.Throws<InvalidOperationException>(() => clientList.Add(50, "fail"));
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cadf48c3662efac4181b91f5c9c88774
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,3 +1,55 @@
# SyncDictionary
Need description and code samples for SyncDictionary.
A `SyncDictionary` is an associative array containing an unordered list of key, value pairs. Keys and values can be of the following types:
- Basic type (byte, int, float, string, UInt64, etc)
- Built-in Unity math type (Vector3, Quaternion, etc)
- NetworkIdentity
- GameObject with a NetworkIdentity component attached.
- Struct with any of the above
SyncDictionaries work much like [SyncLists](SyncLists): when you make a change on the server the change is propagated to all clients and the Callback is called.
To use it, create a class that derives from `SyncDictionary` for your specific type. This is necesary because the Weaver will add methods to that class. Then add a field to your NetworkBehaviour class.
## Simple Example
```cs
using UnityEngine;
using Mirror;
public class ExamplePlayer : NetworkBehaviour
{
public class SyncDictionaryStringItem : SyncDictionary<string, Item> {}
public struct Item
{
public string name;
public int hitPoints;
public int durability;
}
public SyncDictionaryStringItem Equipment = new SyncDictionaryStringItem();
public void OnStartServer()
{
Equipment.Add("head", new Item { name = "Helmet", hitPoints = 10, durability = 20 });
Equipment.Add("body", new Item { name = "Epic Armor", hitPoints = 50, durability = 50 });
Equipment.Add("feet", new Item { name = "Sneakers", hitPoints = 3, durability = 40 });
Equipment.Add("hands", new Item { name = "Sword", hitPoints = 30, durability = 15 });
}
private void OnStartClient()
{
// Equipment is already populated with anything the server set up
// but we can subscribe to the callback in case it is updated later on
Equipment.Callback += OnEquipmentChange;
}
private void OnEquipmentChange(SyncDictionaryStringItem.Operation op, string key, Item item)
{
// equipment changed, perhaps update the gameobject
Debug.Log(op + " - " + key);
}
}
```

View File

@ -71,3 +71,59 @@ public class MyScript : NetworkBehaviour
}
}
```
## SyncDictionaries
A `SyncDictionary` is an associative array containing an unordered list of key, value pairs. Keys and values can be of the following types:
- Basic type (byte, int, float, string, UInt64, etc)
- Built-in Unity math type (Vector3, Quaternion, etc)
- NetworkIdentity
- GameObject with a NetworkIdentity component attached.
- Struct with any of the above
SyncDictionaries work much like [SyncLists](SyncLists): when you make a change on the server the change is propagated to all clients and the Callback is called.
To use it, create a class that derives from `SyncDictionary` for your specific type. This is necesary because the Weaver will add methods to that class. Then add a field to your NetworkBehaviour class.
### Simple Example
```cs
using UnityEngine;
using Mirror;
public class ExamplePlayer : NetworkBehaviour
{
public class SyncDictionaryStringItem : SyncDictionary<string, Item> {}
public struct Item
{
public string name;
public int hitPoints;
public int durability;
}
public SyncDictionaryStringItem Equipment = new SyncDictionaryStringItem();
public void OnStartServer()
{
Equipment.Add("head", new Item { name = "Helmet", hitPoints = 10, durability = 20 });
Equipment.Add("body", new Item { name = "Epic Armor", hitPoints = 50, durability = 50 });
Equipment.Add("feet", new Item { name = "Sneakers", hitPoints = 3, durability = 40 });
Equipment.Add("hands", new Item { name = "Sword", hitPoints = 30, durability = 15 });
}
private void OnStartClient()
{
// Equipment is already populated with anything the server set up
// but we can subscribe to the callback in case it is updated later on
Equipment.Callback += OnEquipmentChange;
}
private void OnEquipmentChange(SyncDictionaryStringItem.Operation op, string key, Item item)
{
// equipment changed, perhaps update the gameobject
Debug.Log(op + " - " + key);
}
}
```