diff --git a/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs b/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs index 52780ea5875..faf9d5f1a27 100644 --- a/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs +++ b/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs @@ -142,6 +142,7 @@ public class ComponentNetworkGenerator : ISourceGenerator // Name = component.Name, // Count = component.Count, var getStateInit = new StringBuilder(); + var clientGetStateInit = new StringBuilder(); // eg: // component.Name = state.Name; @@ -149,13 +150,12 @@ public class ComponentNetworkGenerator : ISourceGenerator var handleStateSetters = new StringBuilder(); // Builds the string for duplicating a full component state, in preparation for applying a delta state state - // without modifying the original. Note that this will not do a proper clone of any collections, under the - // assumption that nothing should ever try to modify them. Applying the delta state should just override the - // referenced collection, not modify it. + // without modifying the original. var shallowClone = new StringBuilder(); // Delta field states var deltaGetFields = new StringBuilder(); + var clientDeltaGetFields = new StringBuilder(); var deltaHandleFields = new StringBuilder(); @@ -168,6 +168,43 @@ public class ComponentNetworkGenerator : ISourceGenerator var fieldStates = new StringBuilder(); var networkedTypes = new List(); + var usesClientCollectionCopy = false; + + void AppendShallowClone(string fieldName) + { + shallowClone.Append($@" + {fieldName} = this.{fieldName},"); + } + + void AppendCollectionClone(string fieldName, bool nullable) + { + var value = nullable + ? $"this.{fieldName} == null ? null! : new(this.{fieldName})" + : $"new(this.{fieldName})"; + shallowClone.Append($@" + {fieldName} = {value},"); + } + + string GetClientCollectionField(string fieldName, bool nullable) + { + usesClientCollectionCopy = true; + return nullable + ? $"component.{fieldName} == null ? null! : new(component.{fieldName})" + : $"new(component.{fieldName})"; + } + + string GetCollectionRefill(ITypeSymbol type, string target, string source, string indentation) + { + var named = (INamedTypeSymbol) type; + return named.ConstructedFrom.ToDisplayString(FullyQualifiedFormat) switch + { + GlobalDictionaryName => $@"foreach (var (key, value) in {source}) +{indentation} {target}.Add(key, value);", + GlobalHashSetName => $"{target}.UnionWith({source});", + GlobalListName => $"{target}.AddRange({source});", + _ => throw new InvalidOperationException($"Unsupported collection type {type}") + }; + } foreach (var (type, name) in fields) { @@ -192,6 +229,7 @@ public class ComponentNetworkGenerator : ISourceGenerator string networkedType; string getField; + string? clientGetField = null; string? cast; // TODO: Uhh I just need casts or something. var castString = typeDisplayStr.Substring(8); @@ -202,6 +240,12 @@ public class ComponentNetworkGenerator : ISourceGenerator {{ "); + clientDeltaGetFields.Append(@$" + case {Math.Pow(2, index)}: + args.State = new {deltaStateName}() + {{ + "); + deltaHandleFields.Append(@$" case {deltaStateName} {deltaStateName}_State: {{"); @@ -226,8 +270,7 @@ public class ComponentNetworkGenerator : ISourceGenerator deltaHandleFields.Append($@" component.{name} = EnsureEntity<{componentName}>({cast} {fieldHandleValue}, uid);"); - shallowClone.Append($@" - {name} = this.{name},"); + AppendShallowClone(name); deltaApply.Add($"fullState.{name} = {name};"); @@ -248,8 +291,7 @@ public class ComponentNetworkGenerator : ISourceGenerator deltaHandleFields.Append($@" component.{name} = EnsureCoordinates<{componentName}>({cast} {fieldHandleValue}, uid);"); - shallowClone.Append($@" - {name} = this.{name},"); + AppendShallowClone(name); deltaApply.Add($@"fullState.{name} = {name};"); @@ -269,8 +311,7 @@ public class ComponentNetworkGenerator : ISourceGenerator deltaHandleFields.Append($@" EnsureEntitySet<{componentName}>({cast} {fieldHandleValue}, uid, component.{name});"); - shallowClone.Append($@" - {name} = this.{name},"); + AppendCollectionClone(name, nullable); deltaApply.Add($@"fullState.{name} = {name};"); @@ -279,7 +320,7 @@ public class ComponentNetworkGenerator : ISourceGenerator networkedType = $"{GlobalNetEntityUidListName}"; stateFields.Append($@" - public {networkedType} {name} = default!;"); + public {networkedType} {name} = default!;"); getField = $"GetNetEntityList(component.{name})"; cast = $"({GlobalNetEntityUidListName})"; @@ -290,8 +331,7 @@ public class ComponentNetworkGenerator : ISourceGenerator deltaHandleFields.Append($@" EnsureEntityList<{componentName}>({cast} {fieldHandleValue}, uid, component.{name});"); - shallowClone.Append($@" - {name} = this.{name},"); + AppendCollectionClone(name, nullable); deltaApply.Add($@"fullState.{name} = {name};"); @@ -345,8 +385,7 @@ public class ComponentNetworkGenerator : ISourceGenerator EnsureEntityDictionary<{ensureGeneric}>({cast} {fieldHandleValue}, uid, component.{name});"); } - shallowClone.Append($@" - {name} = this.{name},"); + AppendCollectionClone(name, nullable); deltaApply.Add($@"fullState.{name} = {name};"); @@ -370,8 +409,7 @@ public class ComponentNetworkGenerator : ISourceGenerator deltaHandleFields.Append($@" EnsureEntityDictionary<{componentName}, {key}>({cast} {fieldHandleValue}, uid, component.{name});"); - shallowClone.Append($@" - {name} = this.{name},"); + AppendCollectionClone(name, nullable); deltaApply.Add($@"fullState.{name} = {name};"); @@ -401,8 +439,7 @@ public class ComponentNetworkGenerator : ISourceGenerator component.{name} = null!; else component.{name} = ({nullCast})({name}Value.Clone());"); - shallowClone.Append($@" - {name} = this.{name},"); + AppendShallowClone(name); deltaApply.Add($"fullState.{name} = {name} == null ? null! : {name}.Clone();"); } else @@ -411,32 +448,68 @@ public class ComponentNetworkGenerator : ISourceGenerator component.{name} = state.{name}.Clone();"); deltaHandleFields.Append($@" component.{name} = {cast}({fieldHandleValue}.Clone());"); - shallowClone.Append($@" - {name} = this.{name},"); + AppendShallowClone(name); deltaApply.Add($"fullState.{name} = {name}.Clone();"); } } else if (IsCloneType(type)) { getField = $"component.{name}"; + clientGetField = GetClientCollectionField(name, nullable); cast = $"({castString})"; var nullCast = nullable ? castString.Substring(0, castString.Length - 1) : castString; + var handleRefill = GetCollectionRefill(type, $"component.{name}", $"state.{name}", " "); + var deltaRefill = GetCollectionRefill(type, $"component.{name}", $"{name}Value", " "); - handleStateSetters.Append($@" - component.{name} = state.{name} == null ? null! : new(state.{name});"); + if (nullable) + { + handleStateSetters.Append($@" + if (state.{name} == null) + component.{name} = null!; + else if (component.{name} == null) + component.{name} = new(state.{name}); + else if (!ReferenceEquals(component.{name}, state.{name})) + {{ + component.{name}.Clear(); + {handleRefill} + }}"); - deltaHandleFields.Append($@" + deltaHandleFields.Append($@" var {name}Value = {cast} {fieldHandleValue}; if ({name}Value == null) component.{name} = null!; - else - component.{name} = new {nullCast}({name}Value);"); + else if (component.{name} == null) + component.{name} = new {nullCast}({name}Value); + else if (!ReferenceEquals(component.{name}, {name}Value)) + {{ + component.{name}.Clear(); + {deltaRefill} + }}"); + + deltaApply.Add($"fullState.{name} = {name} == null ? null! : new({name});"); + } + else + { + handleStateSetters.Append($@" + if (!ReferenceEquals(component.{name}, state.{name})) + {{ + component.{name}.Clear(); + {handleRefill} + }}"); - shallowClone.Append($@" - {name} = this.{name},"); + deltaHandleFields.Append($@" + var {name}Value = {cast} {fieldHandleValue}; + if (!ReferenceEquals(component.{name}, {name}Value)) + {{ + component.{name}.Clear(); + {deltaRefill} + }}"); + + deltaApply.Add($"fullState.{name} = new({name});"); + } - deltaApply.Add($"fullState.{name} = {name} == null ? null! : new({name});"); + AppendCollectionClone(name, nullable); } else { @@ -449,8 +522,7 @@ public class ComponentNetworkGenerator : ISourceGenerator deltaHandleFields.Append($@" component.{name} = {cast} {fieldHandleValue};"); - shallowClone.Append($@" - {name} = this.{name},"); + AppendShallowClone(name); deltaApply.Add($"fullState.{name} = {name};"); } @@ -463,14 +535,22 @@ public class ComponentNetworkGenerator : ISourceGenerator */ networkedTypes.Add(networkedType); + clientGetField ??= getField; getStateInit.Append($@" {name} = {getField},"); + clientGetStateInit.Append($@" + {name} = {clientGetField},"); + deltaGetFields.Append(@$" {name} = {getField} }}; return;"); + clientDeltaGetFields.Append(@$" {name} = {clientGetField} + }}; + return;"); + deltaHandleFields.Append(@" break; } @@ -478,6 +558,7 @@ public class ComponentNetworkGenerator : ISourceGenerator } var deltaGetState = ""; + var clientDeltaGetState = ""; var deltaInterface = ""; var deltaCompFields = ""; var deltaNetRegister = ""; @@ -540,6 +621,21 @@ public void ApplyToFullState({stateName} fullState) }} }}"; + clientDeltaGetState = @$"// Delta state + if (component is IComponentDelta delta && args.FromTick > component.CreationTick) + {{ + var aspects = EntityManager.GetModifiedAspects(component, args.FromTick); + + // Try and get a matching delta state for the relevant dirty fields, otherwise fall back to full state. + switch (aspects) + {{ + case >= DeltaAspect.Unclassified: + break;{clientDeltaGetFields} + default: + break; + }} + }}"; + deltaInterface = " : IComponentDelta"; deltaCompFields = @$"/// @@ -552,6 +648,7 @@ public void ApplyToFullState({stateName} fullState) if (!fieldDeltas) { var eventRaise = ""; + var stateSetters = TrimNewLines(handleStateSetters); if (raiseAfterAutoHandle) { eventRaise = @" @@ -563,12 +660,13 @@ public void ApplyToFullState({stateName} fullState) handleState = $@" if (args.Current is not {stateName} state) return; -{handleStateSetters}{eventRaise}"; + +{stateSetters}{eventRaise}"; } else { // Re-indent handleStateSetters so it aligns with the switch block - var stateSetters = handleStateSetters.ToString(); + var stateSetters = TrimNewLines(handleStateSetters); stateSetters = stateSetters.Replace(" ", " "); @@ -598,6 +696,24 @@ public void ApplyToFullState({stateName} fullState) } var outSb = new StringBuilder(); + var stateFieldsText = TrimNewLines(stateFields); + var getStateInitText = TrimNewLines(getStateInit); + var clientGetStateInitText = TrimNewLines(clientGetStateInit); + var cloneMethodText = TrimNewLines(cloneMethod); + var deltaGetStateText = TrimNewLines(deltaGetState); + var clientDeltaGetStateText = TrimNewLines(clientDeltaGetState); + var deltaCompFieldsText = TrimNewLines(deltaCompFields); + var fieldStatesText = TrimNewLines(fieldStates); + + var netManagerDependency = usesClientCollectionCopy + ? "[global::Robust.Shared.IoC.Dependency] private global::Robust.Shared.Network.INetManager _net = default!;" + : string.Empty; + var getStateSubscription = usesClientCollectionCopy + ? $@" if (_net.IsClient) + SubscribeLocalEvent<{componentName}, ComponentGetState>(OnGetStateClient); + else + SubscribeLocalEvent<{componentName}, ComponentGetState>(OnGetState);" + : $@" SubscribeLocalEvent<{componentName}, ComponentGetState>(OnGetState);"; outSb.Append(""" // @@ -617,57 +733,125 @@ public void ApplyToFullState({stateName} fullState) partialInfo.WriteHeader(outSb); - outSb.Append($$""" - {{deltaInterface}} - { - {{deltaCompFields}} + outSb.AppendLine(deltaInterface); + outSb.AppendLine("{"); - [System.Serializable, NetSerializable] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - [RobustAutoGenerated] - public sealed class {{stateName}} : IComponentState - { - {{stateFields}} - {{cloneMethod}} - } + if (deltaCompFieldsText.Length != 0) + { + outSb.AppendLine(deltaCompFieldsText); + outSb.AppendLine(); + } - [RobustAutoGenerated] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - public sealed class {{componentName}}_AutoNetworkSystem : EntitySystem - { - public override void Initialize() - { - {{deltaNetRegister}} - SubscribeLocalEvent<{{componentName}}, ComponentGetState>(OnGetState); - SubscribeLocalEvent<{{componentName}}, ComponentHandleState>(OnHandleState); - } + outSb.AppendLine(" [System.Serializable, NetSerializable]"); + outSb.AppendLine(" [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + outSb.AppendLine(" [RobustAutoGenerated]"); + outSb.AppendLine($" public sealed class {stateName} : IComponentState"); + outSb.AppendLine(" {"); + outSb.AppendLine(stateFieldsText); - private void OnGetState(EntityUid uid, {{componentName}} component, ref ComponentGetState args) - { - {{deltaGetState}} + if (cloneMethodText.Length != 0) + { + outSb.AppendLine(); + outSb.AppendLine(cloneMethodText); + } - // Get full state - args.State = new {{stateName}} - { - {{getStateInit}} - }; - } + outSb.AppendLine(" }"); + outSb.AppendLine(); + outSb.AppendLine(" [RobustAutoGenerated]"); + outSb.AppendLine(" [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + outSb.AppendLine($" public sealed class {componentName}_AutoNetworkSystem : EntitySystem"); + outSb.AppendLine(" {"); - private void OnHandleState(EntityUid uid, {{componentName}} component, ref ComponentHandleState args) - { - {{handleState}} - } - } + if (netManagerDependency.Length != 0) + { + outSb.AppendLine($" {netManagerDependency}"); + outSb.AppendLine(); + } + + outSb.AppendLine(" public override void Initialize()"); + outSb.AppendLine(" {"); + + if (deltaNetRegister.Length != 0) + outSb.AppendLine($" {deltaNetRegister}"); + + outSb.AppendLine(getStateSubscription); + outSb.AppendLine($" SubscribeLocalEvent<{componentName}, ComponentHandleState>(OnHandleState);"); + outSb.AppendLine(" }"); + outSb.AppendLine(); + outSb.AppendLine($" private void OnGetState(EntityUid uid, {componentName} component, ref ComponentGetState args)"); + outSb.AppendLine(" {"); - {{fieldStates}} + if (deltaGetStateText.Length != 0) + { + outSb.AppendLine(IndentFirstLine(deltaGetStateText, 12)); + outSb.AppendLine(); + } + + outSb.AppendLine(" // Get full state"); + outSb.AppendLine($" args.State = new {stateName}"); + outSb.AppendLine(" {"); + outSb.AppendLine(getStateInitText); + outSb.AppendLine(" };"); + outSb.AppendLine(" }"); + + if (usesClientCollectionCopy) + { + outSb.AppendLine(); + outSb.AppendLine($" private void OnGetStateClient(EntityUid uid, {componentName} component, ref ComponentGetState args)"); + outSb.AppendLine(" {"); + + if (clientDeltaGetStateText.Length != 0) + { + outSb.AppendLine(IndentFirstLine(clientDeltaGetStateText, 12)); + outSb.AppendLine(); } - """); + + outSb.AppendLine(" // Get full state"); + outSb.AppendLine($" args.State = new {stateName}"); + outSb.AppendLine(" {"); + outSb.AppendLine(clientGetStateInitText); + outSb.AppendLine(" };"); + outSb.AppendLine(" }"); + } + + outSb.AppendLine(); + outSb.AppendLine($" private void OnHandleState(EntityUid uid, {componentName} component, ref ComponentHandleState args)"); + outSb.AppendLine(" {"); + outSb.AppendLine(TrimNewLines(handleState)); + outSb.AppendLine(" }"); + outSb.AppendLine(" }"); + + if (fieldStatesText.Length != 0) + { + outSb.AppendLine(); + outSb.AppendLine(fieldStatesText); + } + + outSb.AppendLine("}"); partialInfo.WriteFooter(outSb); return outSb.ToString(); } + private static string TrimNewLines(StringBuilder source) + { + return source.ToString().Trim('\r', '\n'); + } + + private static string TrimNewLines(string source) + { + return source.Trim('\r', '\n'); + } + + private static string IndentFirstLine(string source, int spaces) + { + if (source.Length == 0) + return source; + + return new string(' ', spaces) + source; + } + public void Execute(GeneratorExecutionContext context) { var comp = (CSharpCompilation) context.Compilation; diff --git a/Robust.Shared.IntegrationTests/GameState/AutoNetworkingTest.cs b/Robust.Shared.IntegrationTests/GameState/AutoNetworkingTest.cs index e0a7a5fd075..43622177381 100644 --- a/Robust.Shared.IntegrationTests/GameState/AutoNetworkingTest.cs +++ b/Robust.Shared.IntegrationTests/GameState/AutoNetworkingTest.cs @@ -310,6 +310,7 @@ await server.WaitPost(() => Assert.That(state?.GetType().Name, Is.EqualTo("Field3_FieldComponentState")); } + } [RegisterComponent, NetworkedComponent, AutoGenerateComponentState] diff --git a/Robust.Shared.IntegrationTests/GameState/NoSharedReferencesTest.cs b/Robust.Shared.IntegrationTests/GameState/NoSharedReferencesTest.cs index 9313bede30e..5c17b888c14 100644 --- a/Robust.Shared.IntegrationTests/GameState/NoSharedReferencesTest.cs +++ b/Robust.Shared.IntegrationTests/GameState/NoSharedReferencesTest.cs @@ -1,10 +1,11 @@ using System; +using System.Collections.Generic; using NUnit.Framework; using Robust.Client.GameStates; using Robust.Shared.GameObjects; using Robust.Shared.GameStates; -using Robust.Shared.Network; using Robust.Shared.Serialization; +using Robust.Shared.Timing; using System.Linq; using System.Threading.Tasks; using Robust.Shared.Analyzers; @@ -27,16 +28,11 @@ public async Task ReferencesAreNotShared() { var serverOpts = new ServerIntegrationOptions { Pool = false }; var clientOpts = new ClientIntegrationOptions { Pool = false }; - var server = StartServer(serverOpts); - var client = StartClient(clientOpts); - - await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); - var netMan = client.ResolveDependency(); + await using var pair = await StartConnectedPair(serverOpts, clientOpts); + var server = pair.Server; + var client = pair.Client; var clientGameStateManager = client.ResolveDependency(); - Assert.DoesNotThrow(() => client.SetConnectTarget(server)); - client.Post(() => netMan.ClientConnect(null!, 0, null!)); - // Set up map. server.Post(() => { @@ -92,18 +88,123 @@ await server.WaitPost(() => // wait for errors. await RunTicks(); - async Task RunTicks() + Task RunTicks() => RunTicksSync(server, client, 10); + } + + [Test] + public async Task DeltaStateCollectionReferencesAreNotShared() + { + var serverOpts = new ServerIntegrationOptions { Pool = false }; + var clientOpts = new ClientIntegrationOptions { Pool = false }; + await using var pair = await StartConnectedPair(serverOpts, clientOpts); + var server = pair.Server; + var client = pair.Client; + var clientGameStateManager = client.ResolveDependency(); + + server.Post(() => + { + server.System().CreateMap(); + }); + + await RunTicks(); + + EntityUid sPlayer = default; + EntityUid cPlayer = default; + List clientValues = default!; + DeltaCollectionAutogeneratedComponent.DeltaCollectionAutogeneratedComponent_AutoState oldState = default!; + var sEntMan = server.EntMan; + + await server.WaitPost(() => + { + sPlayer = sEntMan.Spawn(); + sEntMan.AddComponent(sPlayer, new DeltaCollectionAutogeneratedComponent()); + sEntMan.AddComponent(sPlayer, new SingleDeltaCollectionAutogeneratedComponent()); + + var session = server.PlayerMan.Sessions.First(); + server.PlayerMan.SetAttachedEntity(session, sPlayer); + server.PlayerMan.JoinGame(session); + }); + + await server.WaitPost(() => + { + var comp = sEntMan.GetComponent(sPlayer); + var state = (DeltaCollectionAutogeneratedComponent.DeltaCollectionAutogeneratedComponent_AutoState) sEntMan + .GetComponentState(sEntMan.EventBus, comp, null, GameTick.Zero)!; + + Assert.That(state.Values, Is.EqualTo(comp.Values)); + Assert.That(ReferenceEquals(state.Values, comp.Values), Is.True); + }); + + await RunTicks(); + + await client.WaitPost(() => + { + var cEntMan = client.EntMan; + cPlayer = cEntMan.GetEntity(server.EntMan.GetNetEntity(sPlayer)); + Assert.That(cEntMan.TryGetComponent(cPlayer, out DeltaCollectionAutogeneratedComponent? comp)); + + var componentStates = clientGameStateManager.GetFullRep()[cEntMan.GetNetEntity(cPlayer)]; + oldState = (DeltaCollectionAutogeneratedComponent.DeltaCollectionAutogeneratedComponent_AutoState) componentStates + .First(x => x.Value is DeltaCollectionAutogeneratedComponent.DeltaCollectionAutogeneratedComponent_AutoState) + .Value!; + + Assert.That(comp!.Values, Is.EqualTo(oldState.Values)); + Assert.That(ReferenceEquals(comp.Values, oldState.Values), Is.False); + clientValues = comp.Values; + + var localState = (DeltaCollectionAutogeneratedComponent.DeltaCollectionAutogeneratedComponent_AutoState) cEntMan + .GetComponentState(cEntMan.EventBus, comp, null, GameTick.Zero)!; + + Assert.That(localState.Values, Is.EqualTo(comp.Values)); + Assert.That(ReferenceEquals(localState.Values, comp.Values), Is.False); + + Assert.That(cEntMan.TryGetComponent(cPlayer, out SingleDeltaCollectionAutogeneratedComponent? singleComp)); + var singleState = (SingleDeltaCollectionAutogeneratedComponent.SingleDeltaCollectionAutogeneratedComponent_AutoState) componentStates + .First(x => x.Value is SingleDeltaCollectionAutogeneratedComponent.SingleDeltaCollectionAutogeneratedComponent_AutoState) + .Value!; + + Assert.That(singleComp!.Values, Is.EqualTo(singleState.Values)); + Assert.That(ReferenceEquals(singleComp.Values, singleState.Values), Is.False); + + var singleLocalState = (SingleDeltaCollectionAutogeneratedComponent.SingleDeltaCollectionAutogeneratedComponent_AutoState) cEntMan + .GetComponentState(cEntMan.EventBus, singleComp, null, GameTick.Zero)!; + + Assert.That(singleLocalState.Values, Is.EqualTo(singleComp.Values)); + Assert.That(ReferenceEquals(singleLocalState.Values, singleComp.Values), Is.False); + }); + + await client.WaitPost(() => { - for (int i = 0; i < 10; i++) + var deltaState = new DeltaCollectionAutogeneratedComponent.Counter_FieldComponentState { - await server.WaitRunTicks(1); - await client.WaitRunTicks(1); - } - } - - await client.WaitPost(() => netMan.ClientDisconnect("")); - await server.WaitRunTicks(5); - await client.WaitRunTicks(5); + Counter = 1 + }; + var newState = deltaState.CreateNewFullState(oldState); + + Assert.That(newState, Is.Not.SameAs(oldState)); + Assert.That(newState.Counter, Is.EqualTo(1)); + Assert.That(newState.Values, Is.EqualTo(oldState.Values)); + Assert.That(ReferenceEquals(newState.Values, oldState.Values), Is.False); + }); + + await server.WaitPost(() => + { + var comp = sEntMan.GetComponent(sPlayer); + comp.Values.Clear(); + comp.Values.Add(4); + sEntMan.Dirty(sPlayer, comp); + }); + + await RunTicks(); + + await client.WaitPost(() => + { + var comp = client.EntMan.GetComponent(cPlayer); + Assert.That(comp.Values, Is.SameAs(clientValues)); + Assert.That(comp.Values, Is.EqualTo(new[] { 4 })); + }); + + Task RunTicks() => RunTicksSync(server, client, 10); } } @@ -116,3 +217,20 @@ public sealed partial class ExampleAutogeneratedComponent : Component [Serializable, NetSerializable] public sealed record ExampleObject(int Value); } + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true)] +public sealed partial class DeltaCollectionAutogeneratedComponent : Component +{ + [AutoNetworkedField] + public List Values = new() { 1, 2, 3 }; + + [AutoNetworkedField] + public int Counter; +} + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true)] +public sealed partial class SingleDeltaCollectionAutogeneratedComponent : Component +{ + [AutoNetworkedField] + public List Values = new() { 1, 2, 3 }; +}