Make generated and reflective document members one rule

The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).

- Accessors are named per document, so documents sharing a [CultName] or
  [CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
  properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
  or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
  declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
  re-keyed override and serializes an override marked [IgnoreMember]).
  An override that changes them is refused in both paths; an override
  that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
  generated codecs and declared resolvers; deleted. CultNetDocumentBinding
  and CultMeshSnapshots route through the untyped path with their registry.

DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-13 22:00:16 +02:00
commit 7c4bc2bdc0
12 changed files with 602 additions and 127 deletions

View file

@ -50,8 +50,8 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
return null;
}
var unkeyedMembers = new List<string>();
var members = DiscoverMembers(typeSymbol, unkeyedMembers);
var rejections = new List<string>();
var members = DiscoverMembers(typeSymbol, rejections);
var nameMember = members.FirstOrDefault(member => member.IsName);
var canConstructForDeserialization = typeSymbol.TypeKind == TypeKind.Struct ||
typeSymbol.InstanceConstructors.Any(constructor =>
@ -71,55 +71,139 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
members.Where(member => member.IndexAlias != null)
.Select(member => new IndexAccessorShape(member.IndexAlias!, member.IndexAccessorMethodName!))
.ToImmutableArray(),
members.ToImmutableArray(),
members,
canConstructForDeserialization,
hasDenseSlots,
unkeyedMembers.Select(name => UnkeyedMemberMessage(typeSymbol.Name, name)).ToImmutableArray(),
rejections.ToImmutableArray(),
typeSymbol.Locations.FirstOrDefault());
}
// The same members CultDocumentRegistry reflects: public instance fields and get/set properties, inherited included.
private static ImmutableArray<MemberShape> DiscoverMembers(INamedTypeSymbol typeSymbol, List<string> unkeyedMembers)
// Mirrors CultDocumentRegistry.DiscoverMembers step for step: public instance fields and get/set properties, most-derived
// declaration first, a property's attributes resolved up its override chain, rejections in the same order and text.
private static ImmutableArray<MemberShape> DiscoverMembers(INamedTypeSymbol typeSymbol, List<string> rejections)
{
var shaped = new List<MemberShape>();
for (var type = typeSymbol; type != null && type.SpecialType != SpecialType.System_Object; type = type.BaseType)
var documentName = typeSymbol.Name;
var documentStem = SanitizeIdentifier(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", string.Empty).Replace(".", "_"));
var candidates = new List<MemberCandidate>();
var seenRoots = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
var depth = 0;
for (var type = typeSymbol; type != null && type.SpecialType != SpecialType.System_Object; type = type.BaseType, depth++)
{
foreach (var member in type.GetMembers())
{
ITypeSymbol memberType;
if (member is IFieldSymbol field && !field.IsStatic && field.DeclaredAccessibility == Accessibility.Public)
memberType = field.Type;
else if (member is IPropertySymbol property && !property.IsStatic && !property.IsIndexer && !property.IsOverride &&
property.DeclaredAccessibility == Accessibility.Public && property.GetMethod != null && property.SetMethod != null)
memberType = property.Type;
else
continue;
if (IsIgnored(member))
continue;
var slot = GetExplicitSlot(member);
if (slot == null)
unkeyedMembers.Add(member.Name);
else
shaped.Add(MemberShapeSeed.From(member, memberType).WithSlot(slot.Value));
if (member is IFieldSymbol field && !field.IsStatic && !field.IsConst && !field.IsImplicitlyDeclared &&
field.DeclaredAccessibility == Accessibility.Public)
{
if (!IsIgnored(field))
candidates.Add(new MemberCandidate(field, field.Type, depth, !field.IsReadOnly, null));
}
else if (member is IPropertySymbol property && !property.IsStatic && !property.IsIndexer &&
property.DeclaredAccessibility == Accessibility.Public && seenRoots.Add(RootOf(property)))
{
var setter = OverrideChain(property).Select(declaration => declaration.SetMethod).FirstOrDefault(method => method != null);
if (OverrideChain(property).All(declaration => declaration.GetMethod == null) || setter == null)
continue;
var root = RootOf(property);
var divergentRoot = OverrideChain(property).Any(declaration =>
!Equals(KeyValue(declaration), KeyValue(root)) || IsIgnored(declaration) != IsIgnored(root))
? root
: null;
if (divergentRoot == null && IsIgnored(property))
continue;
candidates.Add(new MemberCandidate(property, property.Type, depth,
!setter.IsInitOnly && setter.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal,
divergentRoot));
}
}
}
return shaped.OrderBy(member => member.Slot).ToImmutableArray();
var keyed = new List<(MemberCandidate Candidate, int Slot)>();
foreach (var candidate in candidates.OrderBy(candidate => candidate.Depth).ThenBy(candidate => candidate.Member.Name, StringComparer.Ordinal))
{
var keyValue = KeyValue(candidate.Member);
if (candidate.DivergentRoot != null)
rejections.Add(DivergentOverrideMessage(documentName, Qualified(candidate.Member), Qualified(candidate.DivergentRoot)));
else if (keyValue is string)
rejections.Add(StringKeyMessage(documentName, candidate.Member.Name));
else if (keyValue is not int slot)
rejections.Add(UnkeyedMemberMessage(documentName, candidate.Member.Name));
else
{
if (!candidate.Writable)
rejections.Add(NotWritableMessage(documentName, candidate.Member.Name));
keyed.Add((candidate, slot));
}
}
foreach (var group in keyed.GroupBy(entry => entry.Slot).Where(group => group.Count() > 1).OrderBy(group => group.Key))
{
var pair = group.Take(2).ToArray();
rejections.Add(DuplicateSlotMessage(documentName, Qualified(pair[0].Candidate.Member), Qualified(pair[1].Candidate.Member), group.Key));
}
foreach (var group in keyed.GroupBy(entry => entry.Candidate.Member.Name)
.Where(group => group.Select(entry => entry.Slot).Distinct().Count() > 1)
.OrderBy(group => group.Key, StringComparer.Ordinal))
{
var pair = group.Take(2).ToArray();
rejections.Add(HiddenMemberMessage(documentName, Qualified(pair[0].Candidate.Member), Qualified(pair[1].Candidate.Member)));
}
return keyed
.Select(entry => MemberShapeSeed.From(entry.Candidate.Member, entry.Candidate.Type).WithSlot(entry.Slot, documentStem))
.OrderBy(member => member.Slot)
.ToImmutableArray();
}
// CultDocumentRegistry.UnkeyedMemberMessage throws the same text.
private static IPropertySymbol RootOf(IPropertySymbol property)
{
while (property.OverriddenProperty != null)
property = property.OverriddenProperty;
return property;
}
private static IEnumerable<IPropertySymbol> OverrideChain(IPropertySymbol property)
{
for (IPropertySymbol? declaration = property; declaration != null; declaration = declaration.OverriddenProperty)
yield return declaration;
}
private static string Qualified(ISymbol member) => member.ContainingType.Name + "." + member.Name;
// CultDocumentRegistry throws the same texts.
private static string UnkeyedMemberMessage(string documentTypeName, string memberName) =>
$"Cult document {documentTypeName} member {memberName} has no [Key]; every persisted member of a [CultDocument] type needs an explicit [Key(n)].";
private static string StringKeyMessage(string documentTypeName, string memberName) =>
$"Cult document {documentTypeName} member {memberName} has a string [Key]; string keys are not supported; use integer [Key(n)].";
private static string NotWritableMessage(string documentTypeName, string memberName) =>
$"Cult document {documentTypeName} member {memberName} is not writable; a persisted member needs a non-readonly field or a public or internal set accessor.";
private static string DuplicateSlotMessage(string documentTypeName, string first, string second, int slot) =>
$"Cult document {documentTypeName} members {first} and {second} share [Key({slot})]; every persisted member needs a distinct [Key(n)].";
private static string DivergentOverrideMessage(string documentTypeName, string member, string root) =>
$"Cult document {documentTypeName} member {member} overrides {root} with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them.";
private static object? KeyValue(ISymbol member)
{
var key = GetMemberAttribute(member, "MessagePack.KeyAttribute");
return key != null && key.ConstructorArguments.Length > 0 ? key.ConstructorArguments[0].Value : null;
}
private static string HiddenMemberMessage(string documentTypeName, string first, string second) =>
$"Cult document {documentTypeName} member {first} hides persisted member {second}; persisted member names must be unique.";
#pragma warning disable RS2008
private static readonly DiagnosticDescriptor UnkeyedMember = new(
"GCC001", "Cult document member has no [Key]", "{0}", "GameCult.Caching", DiagnosticSeverity.Error, isEnabledByDefault: true);
private static readonly DiagnosticDescriptor RejectedMember = new(
"GCC001", "Cult document member cannot be persisted", "{0}", "GameCult.Caching", DiagnosticSeverity.Error, isEnabledByDefault: true);
#pragma warning restore RS2008
private static bool IsIgnored(ISymbol member)
{
return GetAttribute(member, "MessagePack.IgnoreMemberAttribute") != null;
return GetMemberAttribute(member, "MessagePack.IgnoreMemberAttribute") != null;
}
private static AttributeData? GetAttribute(ISymbol symbol, string fullyQualifiedName)
@ -128,18 +212,12 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
attribute.AttributeClass?.ToDisplayString() == fullyQualifiedName);
}
private static int? GetExplicitSlot(ISymbol member)
// Attribute.GetCustomAttributes(inherit: true) semantics: an override without the attribute inherits its base declaration's.
private static AttributeData? GetMemberAttribute(ISymbol member, string fullyQualifiedName)
{
var keyAttribute = GetAttribute(member, "MessagePack.KeyAttribute");
if (keyAttribute == null || keyAttribute.ConstructorArguments.Length == 0)
{
return null;
}
var argument = keyAttribute.ConstructorArguments[0];
return argument.Kind == TypedConstantKind.Primitive && argument.Value is int slot
? slot
: null;
if (member is not IPropertySymbol property)
return GetAttribute(member, fullyQualifiedName);
return OverrideChain(property).Select(declaration => GetAttribute(declaration, fullyQualifiedName)).FirstOrDefault(attribute => attribute != null);
}
private static void EmitProvider(SourceProductionContext context, Compilation compilation, ImmutableArray<DocumentShape> shapes)
@ -152,11 +230,11 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
.ToArray();
foreach (var shape in shaped)
{
foreach (var message in shape.UnkeyedMemberMessages)
context.ReportDiagnostic(Diagnostic.Create(UnkeyedMember, shape.Location, message));
foreach (var message in shape.Rejections)
context.ReportDiagnostic(Diagnostic.Create(RejectedMember, shape.Location, message));
}
var documents = shaped.Where(shape => shape.UnkeyedMemberMessages.IsEmpty).ToArray();
var documents = shaped.Where(shape => shape.Rejections.IsEmpty).ToArray();
if (documents.Length == 0)
{
@ -410,13 +488,30 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
namedType.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T;
}
private sealed class MemberCandidate
{
public MemberCandidate(ISymbol member, ITypeSymbol type, int depth, bool writable, IPropertySymbol? divergentRoot)
{
Member = member;
Type = type;
Depth = depth;
Writable = writable;
DivergentRoot = divergentRoot;
}
public ISymbol Member { get; }
public ITypeSymbol Type { get; }
public int Depth { get; }
public bool Writable { get; }
public IPropertySymbol? DivergentRoot { get; }
}
private sealed class MemberShapeSeed
{
private MemberShapeSeed(
string name,
string schemaTypeName,
string typeSyntaxName,
string sortKey,
bool isName,
string? indexAlias,
bool isReference,
@ -427,7 +522,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
Name = name;
SchemaTypeName = schemaTypeName;
TypeSyntaxName = typeSyntaxName;
SortKey = sortKey;
IsName = isName;
IndexAlias = indexAlias;
IsReference = isReference;
@ -439,7 +533,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
public string Name { get; }
public string SchemaTypeName { get; }
public string TypeSyntaxName { get; }
public string SortKey { get; }
public bool IsName { get; }
public string? IndexAlias { get; }
public bool IsReference { get; }
@ -447,9 +540,11 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
public bool CanBeNull { get; }
public string? TargetSchemaName { get; }
public MemberShape WithSlot(int slot)
// Accessors are named per document: one provider class holds every document of the assembly, and documents sharing a
// base member would otherwise emit the same method twice.
public MemberShape WithSlot(int slot, string documentStem)
{
var accessorStem = SanitizeIdentifier(SortKey);
var accessorStem = documentStem + "_" + SanitizeIdentifier(Name);
return new MemberShape(
Name,
slot,
@ -467,7 +562,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
public static MemberShapeSeed From(ISymbol member, ITypeSymbol memberType)
{
var referenceAttribute = GetAttribute(member, "GameCult.Caching.CultReferenceAttribute");
var referenceAttribute = GetMemberAttribute(member, "GameCult.Caching.CultReferenceAttribute");
var explicitTarget = referenceAttribute?.ConstructorArguments.Length > 0
? referenceAttribute.ConstructorArguments[0].Value as INamedTypeSymbol
: null;
@ -478,7 +573,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
var targetSchemaName = targetType == null
? null
: GetAttribute(targetType, "GameCult.Caching.CultDocumentAttribute")?.ConstructorArguments[0].Value as string;
var indexAttribute = GetAttribute(member, "GameCult.Caching.CultIndexAttribute");
var indexAttribute = GetMemberAttribute(member, "GameCult.Caching.CultIndexAttribute");
var indexAlias = indexAttribute == null
? null
: indexAttribute.ConstructorArguments.Length == 0 || indexAttribute.ConstructorArguments[0].Value is not string alias || string.IsNullOrWhiteSpace(alias)
@ -489,8 +584,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
member.Name,
GetSchemaTypeName(memberType),
memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
BuildAccessorStem(member),
GetAttribute(member, "GameCult.Caching.CultNameAttribute") != null,
GetMemberAttribute(member, "GameCult.Caching.CultNameAttribute") != null,
indexAlias,
targetType != null || referenceAttribute != null,
many,
@ -512,13 +606,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
? namedType.TypeArguments[0] as INamedTypeSymbol
: null;
}
private static string BuildAccessorStem(ISymbol member)
{
return member.ContainingType == null
? member.Name
: member.ContainingType.Name + "_" + member.Name;
}
}
private sealed class DocumentShape
@ -534,10 +621,10 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
ImmutableArray<MemberShape> members,
bool canConstructForDeserialization,
bool hasDenseSlots,
ImmutableArray<string> unkeyedMemberMessages,
ImmutableArray<string> rejections,
Location? location)
{
UnkeyedMemberMessages = unkeyedMemberMessages;
Rejections = rejections;
Location = location;
DocumentTypeName = documentTypeName;
SchemaName = schemaName;
@ -566,7 +653,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
public ImmutableArray<MemberShape> Members { get; }
public bool CanConstructForDeserialization { get; }
public bool HasDenseSlots { get; }
public ImmutableArray<string> UnkeyedMemberMessages { get; }
public ImmutableArray<string> Rejections { get; }
public Location? Location { get; }
}

View file

@ -69,16 +69,6 @@ public static class CultDocumentMessagePackSerialization
$"Formatter resolver {resolverType.FullName} needs a public static Instance or a public parameterless constructor and must implement {nameof(IFormatterResolver)}.");
}
public static byte[] Serialize<T>(T value)
{
return MessagePackSerializer.Serialize(value, Options);
}
public static T Deserialize<T>(byte[] payload)
{
return MessagePackSerializer.Deserialize<T>(payload, Options);
}
public static byte[] SerializeUntyped(object value, Type type)
{
return SerializeUntyped(value, type, CultDocumentRegistry.Shared);

View file

@ -900,31 +900,126 @@ namespace GameCult.Caching
public int[] DefaultedMissingSlots { get; }
}
// CultDocumentMessagePackGenerator.DiscoverMembers mirrors this step for step and reports the same rejections as GCC001.
// Public instance fields and get/set properties, most-derived declaration first; GetCustomAttributes inherits a property's
// attributes up its override chain, as MessagePack's own resolver reads them.
private static IReadOnlyList<PersistedMember> DiscoverMembers(Type type)
{
var members = new List<PersistedMember>();
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
const BindingFlags declared = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public;
var candidates = new List<(MemberInfo Member, Type MemberType, int Depth, bool Writable, PropertyInfo? DivergentRoot)>();
var seenRoots = new HashSet<(Type, string)>();
var depth = 0;
for (var current = type; current != null && current != typeof(object); current = current.BaseType, depth++)
{
if (!IsIgnored(field))
members.Add(PersistedMember.FromMember(field, field.FieldType, value => field.GetValue(value), RequireKey(type, field)));
foreach (var field in current.GetFields(declared))
{
if (!IsIgnored(field))
candidates.Add((field, field.FieldType, depth, !field.IsInitOnly, null));
}
foreach (var property in current.GetProperties(declared))
{
if (property.GetIndexParameters().Length > 0 || !seenRoots.Add(RootOf(property)))
continue;
var chain = OverrideChain(property).ToArray();
var setter = chain.Select(declaration => declaration.SetMethod).FirstOrDefault(method => method != null);
if (chain.All(declaration => declaration.GetMethod == null) || setter == null)
continue;
// MessagePack's resolver reads the base declaration's [Key]/[IgnoreMember]; an override that changes them is refused.
var root = chain[chain.Length - 1];
var divergentRoot = chain.Any(declaration =>
!Equals(GetKeyValue(declaration), GetKeyValue(root)) || IsIgnored(declaration) != IsIgnored(root))
? root
: null;
if (divergentRoot == null && IsIgnored(property))
continue;
candidates.Add((property, property.PropertyType, depth, IsWritable(setter), divergentRoot));
}
}
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
var rejections = new List<string>();
var keyed = new List<(MemberInfo Member, Type MemberType, int Slot)>();
foreach (var candidate in candidates.OrderBy(candidate => candidate.Depth).ThenBy(candidate => candidate.Member.Name, StringComparer.Ordinal))
{
if (property.GetMethod != null && property.SetMethod != null && !IsIgnored(property))
members.Add(PersistedMember.FromMember(property, property.PropertyType, value => property.GetValue(value), RequireKey(type, property)));
var key = GetKeyValue(candidate.Member);
if (candidate.DivergentRoot != null)
rejections.Add(DivergentOverrideMessage(type.Name, Qualified(candidate.Member), Qualified(candidate.DivergentRoot)));
else if (key is string)
rejections.Add(StringKeyMessage(type.Name, candidate.Member.Name));
else if (key is not int slot)
rejections.Add(UnkeyedMemberMessage(type.Name, candidate.Member.Name));
else
{
if (!candidate.Writable)
rejections.Add(NotWritableMessage(type.Name, candidate.Member.Name));
keyed.Add((candidate.Member, candidate.MemberType, slot));
}
}
return members.OrderBy(member => member.Slot).ToArray();
foreach (var group in keyed.GroupBy(entry => entry.Slot).Where(group => group.Count() > 1).OrderBy(group => group.Key))
{
var pair = group.Take(2).ToArray();
rejections.Add(DuplicateSlotMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member), group.Key));
}
foreach (var group in keyed.GroupBy(entry => entry.Member.Name)
.Where(group => group.Select(entry => entry.Slot).Distinct().Count() > 1)
.OrderBy(group => group.Key, StringComparer.Ordinal))
{
var pair = group.Take(2).ToArray();
rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member)));
}
if (rejections.Count > 0)
throw new InvalidOperationException(rejections[0]);
return keyed
.Select(entry => PersistedMember.FromMember(
entry.Member,
entry.MemberType,
entry.Member is FieldInfo field ? field.GetValue : ((PropertyInfo)entry.Member).GetValue,
entry.Slot))
.OrderBy(member => member.Slot)
.ToArray();
}
// The generator reports the same text as diagnostic GCC001.
private static (Type, string) RootOf(PropertyInfo property) =>
((property.GetMethod ?? property.SetMethod)!.GetBaseDefinition().DeclaringType!, property.Name);
private static IEnumerable<PropertyInfo> OverrideChain(PropertyInfo property)
{
var root = RootOf(property);
for (var current = property.DeclaringType; current != null; current = current.BaseType)
{
var declaration = current.GetProperty(property.Name, BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (declaration != null && declaration.GetIndexParameters().Length == 0 && RootOf(declaration) == root)
yield return declaration;
}
}
private static bool IsWritable(MethodInfo setter) =>
(setter.IsPublic || setter.IsAssembly || setter.IsFamilyOrAssembly) &&
!setter.ReturnParameter.GetRequiredCustomModifiers().Any(modifier => modifier.FullName == "System.Runtime.CompilerServices.IsExternalInit");
private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name;
internal static string UnkeyedMemberMessage(string documentTypeName, string memberName) =>
$"Cult document {documentTypeName} member {memberName} has no [Key]; every persisted member of a [CultDocument] type needs an explicit [Key(n)].";
private static int RequireKey(Type documentType, MemberInfo member) =>
GetKeyValue(member) ?? throw new InvalidOperationException(UnkeyedMemberMessage(documentType.Name, member.Name));
private static string StringKeyMessage(string documentTypeName, string memberName) =>
$"Cult document {documentTypeName} member {memberName} has a string [Key]; string keys are not supported; use integer [Key(n)].";
private static string NotWritableMessage(string documentTypeName, string memberName) =>
$"Cult document {documentTypeName} member {memberName} is not writable; a persisted member needs a non-readonly field or a public or internal set accessor.";
private static string DuplicateSlotMessage(string documentTypeName, string first, string second, int slot) =>
$"Cult document {documentTypeName} members {first} and {second} share [Key({slot})]; every persisted member needs a distinct [Key(n)].";
private static string DivergentOverrideMessage(string documentTypeName, string member, string root) =>
$"Cult document {documentTypeName} member {member} overrides {root} with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them.";
private static string HiddenMemberMessage(string documentTypeName, string first, string second) =>
$"Cult document {documentTypeName} member {first} hides persisted member {second}; persisted member names must be unique.";
private static bool IsIgnored(MemberInfo member)
{
@ -935,24 +1030,19 @@ namespace GameCult.Caching
});
}
private static int? GetKeyValue(MemberInfo member)
private static object? GetKeyValue(MemberInfo member)
{
foreach (var attribute in member.GetCustomAttributes())
{
var name = attribute.GetType().FullName;
if (name == "MessagePack.KeyAttribute")
{
var property = attribute.GetType().GetProperty("IntKey");
if (property?.GetValue(attribute) is int intKey)
if (attribute.GetType().GetProperty("IntKey")?.GetValue(attribute) is int intKey)
{
return intKey;
}
var ctorArg = attribute.GetType().GetProperty("StringKey");
if (ctorArg?.GetValue(attribute) is string)
{
return null;
}
return attribute.GetType().GetProperty("StringKey")?.GetValue(attribute) as string;
}
}

View file

@ -1023,7 +1023,7 @@ namespace GameCult.Mesh
}
else
{
documents.Add(CultDocumentMessagePackSerialization.Deserialize<TDocument>(record.Payload));
documents.Add((TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload));
}
}

View file

@ -80,7 +80,8 @@ namespace GameCult.Networking
Func<byte[], T>? payloadDeserializer = null)
where T : class
{
var descriptor = (registry ?? CultDocumentRegistry.Shared).GetRequired<T>();
var documents = registry ?? CultDocumentRegistry.Shared;
var descriptor = documents.GetRequired<T>();
return new CultNetDocumentBinding(
typeof(T),
string.IsNullOrWhiteSpace(schemaId) ? descriptor.SchemaId : schemaId!,
@ -89,11 +90,11 @@ namespace GameCult.Networking
var typed = (T)document;
return payloadSerializer != null
? payloadSerializer(typed)
: CultDocumentMessagePackSerialization.Serialize(typed);
: CultDocumentMessagePackSerialization.SerializeUntyped(typed, typeof(T), documents);
},
payload => payloadDeserializer != null
? payloadDeserializer(payload)
: CultDocumentMessagePackSerialization.Deserialize<T>(payload));
: (T)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(T), payload, documents));
}
/// <summary>
@ -111,7 +112,8 @@ namespace GameCult.Networking
throw new ArgumentNullException(nameof(documentType));
}
var descriptor = (registry ?? CultDocumentRegistry.Shared).GetRequired(documentType);
var documents = registry ?? CultDocumentRegistry.Shared;
var descriptor = documents.GetRequired(documentType);
return new CultNetDocumentBinding(
documentType,
string.IsNullOrWhiteSpace(schemaId) ? descriptor.SchemaId : schemaId!,
@ -125,11 +127,11 @@ namespace GameCult.Networking
return payloadSerializer != null
? payloadSerializer(document)
: CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType);
: CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType, documents);
},
payload => payloadDeserializer != null
? payloadDeserializer(payload)
: CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload));
: CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, documents));
}
}

View file

@ -711,8 +711,8 @@ namespace GameCult.Caching.Tests
{
var reference = new CultRecordRef<NamedTestEntry>(new CultRecordKey("record-1"));
var payload = CultDocumentMessagePackSerialization.Serialize(reference);
var roundTrip = CultDocumentMessagePackSerialization.Deserialize<CultRecordRef<NamedTestEntry>>(payload);
var payload = MessagePackSerializer.Serialize(reference, CultDocumentMessagePackSerialization.Options);
var roundTrip = MessagePackSerializer.Deserialize<CultRecordRef<NamedTestEntry>>(payload, CultDocumentMessagePackSerialization.Options);
Assert.That(roundTrip.Key.Value, Is.EqualTo("record-1"));
}
@ -721,7 +721,7 @@ namespace GameCult.Caching.Tests
public void MessagePackSerialization_Rejects_InvalidPayload()
{
Assert.That(
() => CultDocumentMessagePackSerialization.Deserialize<CultRecordRef<NamedTestEntry>>(new byte[] { 0xC1 }),
() => MessagePackSerializer.Deserialize<CultRecordRef<NamedTestEntry>>(new byte[] { 0xC1 }, CultDocumentMessagePackSerialization.Options),
Throws.TypeOf<MessagePackSerializationException>());
}

View file

@ -0,0 +1,253 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Caching.MessagePack.Generator;
using MessagePack;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// Every shape compiles through the generator; its generated descriptor and codec must equal the reflective
// descriptor and MessagePack's own dynamic payload, or both paths must reject it with the same message.
public class DocumentShapeSweepTests
{
// Color sits in a namespace: global-namespace member types name differently in the generator and CultSchemaTypeNames.
private const string Prelude = "using System.Collections.Generic;\nusing GameCult.Caching;\nusing MessagePack;\nusing Shapes;\nnamespace Shapes { public enum Color { Red, Blue } }\n";
[Test]
public void FlatKeyedDocument() => Accept(@"
[CultDocument(""sweep.plain"", ""sweep.plain.v1"")]
[MessagePackObject]
public class Plain
{
[Key(0)] [CultName] public string Name { get; set; } = """";
[Key(1)] public int? Maybe;
[Key(2)] public Color Tint { get; set; }
[Key(3)] public List<string> Tags = new List<string>();
[Key(4)] public Dictionary<string, int> Counts { get; set; } = new Dictionary<string, int>();
[IgnoreMember] public int Skip { get; set; }
}
public static class Samples
{
public static object Plain() => new Plain { Name = ""p"", Maybe = 7, Tint = Color.Blue, Tags = { ""a"" }, Counts = { [""x""] = 1 }, Skip = 9 };
}", "Plain");
[Test]
public void DerivedDocument() => Accept(@"
[CultDocument(""sweep.gear"", ""sweep.gear.v1"")]
[MessagePackObject]
public class Gear { [Key(0)] public string Name { get; set; } = """"; }
[CultDocument(""sweep.weapon"", ""sweep.weapon.v1"")]
[MessagePackObject]
public sealed class Weapon : Gear { [Key(1)] public int Damage { get; set; } }
public static class Samples
{
public static object Gear() => new Gear { Name = ""helm"" };
public static object Weapon() => new Weapon { Name = ""lance"", Damage = 4 };
}", "Gear", "Weapon");
[Test]
public void NameAndIndexOnABaseSharedByTwoDocuments() => Accept(@"
[CultDocument(""sweep.gear"", ""sweep.gear.v1"")]
[MessagePackObject]
public class Gear
{
[Key(0)] [CultName] public string Name { get; set; } = """";
[Key(1)] [CultIndex(""slot"")] public string Slot { get; set; } = """";
}
[CultDocument(""sweep.weapon"", ""sweep.weapon.v1"")]
[MessagePackObject]
public sealed class Weapon : Gear { [Key(2)] public int Damage { get; set; } }
public static class Samples
{
public static object Gear() => new Gear { Name = ""helm"", Slot = ""head"" };
public static object Weapon() => new Weapon { Name = ""lance"", Slot = ""hand"", Damage = 4 };
}", "Gear", "Weapon");
[Test]
public void OverrideKeepsBaseKey()
{
var payloads = Accept(@"
[MessagePackObject]
public class OvrSameBase { [Key(0)] public virtual string Name { get; set; } = """"; [Key(2)] public virtual int Z { get; set; } }
[CultDocument(""sweep.ovr_same"", ""sweep.ovr_same.v1"")]
[MessagePackObject]
public sealed class OvrSame : OvrSameBase
{
[Key(0)] public override string Name { get; set; } = """";
public override int Z { get; set; }
[Key(1)] public int X { get; set; }
}
public static class Samples
{
public static object OvrSame() => new OvrSame { Name = ""n"", X = 3, Z = 5 };
}", "OvrSame");
// Captured from MessagePack's DynamicObjectResolver: [Name, X, Z].
Assert.That(Convert.ToHexString(payloads["OvrSame"]), Is.EqualTo("93A16E0305"));
}
// MessagePack's DynamicObjectResolver reads the base declaration: it throws "key is duplicated" for this shape.
[Test]
public void OverrideReKeyed() => Reject(@"
[MessagePackObject]
public class OvrDiffBase { [Key(0)] public virtual string Name { get; set; } = """"; }
[CultDocument(""sweep.ovr_diff"", ""sweep.ovr_diff.v1"")]
[MessagePackObject]
public sealed class OvrDiff : OvrDiffBase
{
[Key(2)] public override string Name { get; set; } = """";
[Key(1)] public int X { get; set; }
[Key(0)] public int Y { get; set; }
}",
"OvrDiff",
"Cult document OvrDiff member OvrDiff.Name overrides OvrDiffBase.Name with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them.");
// MessagePack's DynamicObjectResolver keeps the base [Key(1)] and serializes Name despite the override's [IgnoreMember].
[Test]
public void OverrideIgnored() => Reject(@"
[MessagePackObject]
public class OvrIgnoreBase { [Key(1)] public virtual string Name { get; set; } = """"; }
[CultDocument(""sweep.ovr_ignore"", ""sweep.ovr_ignore.v1"")]
[MessagePackObject]
public sealed class OvrIgnore : OvrIgnoreBase
{
[IgnoreMember] public override string Name { get; set; } = """";
[Key(0)] public int X { get; set; }
}",
"OvrIgnore",
"Cult document OvrIgnore member OvrIgnore.Name overrides OvrIgnoreBase.Name with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them.");
[Test]
public void NewHiddenField() => Reject(@"
public class NewFieldBase { [Key(0)] public string Name = ""base""; }
[CultDocument(""sweep.new_field"", ""sweep.new_field.v1"")]
[MessagePackObject]
public sealed class NewField : NewFieldBase { [Key(0)] public new string Name = """"; [Key(1)] public int X; }",
"NewField",
"Cult document NewField members NewField.Name and NewFieldBase.Name share [Key(0)]; every persisted member needs a distinct [Key(n)].");
[Test]
public void NewHiddenProperty() => Reject(@"
public class NewPropBase { [Key(0)] public string Name { get; set; } = ""base""; }
[CultDocument(""sweep.new_prop"", ""sweep.new_prop.v1"")]
[MessagePackObject]
public sealed class NewProp : NewPropBase { [Key(0)] public new string Name { get; set; } = """"; [Key(1)] public int X { get; set; } }",
"NewProp",
"Cult document NewProp members NewProp.Name and NewPropBase.Name share [Key(0)]; every persisted member needs a distinct [Key(n)].");
[Test]
public void DuplicateSlot() => Reject(@"
[CultDocument(""sweep.dup"", ""sweep.dup.v1"")]
[MessagePackObject]
public sealed class Dup { [Key(0)] public int B; [Key(0)] public int A; }",
"Dup",
"Cult document Dup members Dup.A and Dup.B share [Key(0)]; every persisted member needs a distinct [Key(n)].");
[Test]
public void InheritedPrivateSetter() => Reject(@"
public class PrivSetBase { [Key(0)] public string Name { get; private set; } = ""fixed""; }
[CultDocument(""sweep.privset"", ""sweep.privset.v1"")]
[MessagePackObject]
public sealed class PrivSetDerived : PrivSetBase { [Key(1)] public int X { get; set; } }",
"PrivSetDerived",
"Cult document PrivSetDerived member Name is not writable; a persisted member needs a non-readonly field or a public or internal set accessor.");
[Test]
public void StringKey() => Reject(@"
[CultDocument(""sweep.string_key"", ""sweep.string_key.v1"")]
[MessagePackObject]
public sealed class StringKeyed { [Key(""name"")] public string Name { get; set; } = """"; }",
"StringKeyed",
"Cult document StringKeyed member Name has a string [Key]; string keys are not supported; use integer [Key(n)].");
private static Dictionary<string, byte[]> Accept(string source, params string[] documentNames)
{
var assembly = Compile(source, out var generatorErrors);
Assert.That(generatorErrors, Is.Empty);
var samples = assembly.GetType("Samples")!;
var payloads = new Dictionary<string, byte[]>();
foreach (var name in documentNames)
{
var type = assembly.GetType(name)!;
var sample = samples.GetMethod(name)!.Invoke(null, null)!;
var generated = CultDocumentRegistry.ForTypes(new[] { type }).GetRequired(type);
var reflective = CultDocumentRegistry.BuildDescriptor(type);
Assert.That(generated.GeneratedPayloadSerializer, Is.Not.Null, $"{name}: no generated codec");
Assert.That(generated.GeneratedPayloadDeserializer, Is.Not.Null, $"{name}: no generated decoder");
var options = CultDocumentMessagePackSerialization.OptionsFor(assembly);
var dynamicBytes = MessagePackSerializer.Serialize(type, sample, options);
var generatedBytes = generated.GeneratedPayloadSerializer!(sample);
var decoded = generated.GeneratedPayloadDeserializer!(generatedBytes);
Assert.Multiple(() =>
{
Assert.That(generated.SchemaId, Is.EqualTo(reflective.SchemaId), $"{name}: schema id");
Assert.That(generated.CanonicalSchemaJson, Is.EqualTo(reflective.CanonicalSchemaJson), $"{name}: schema json");
Assert.That(Catalog(generated), Is.EqualTo(Catalog(reflective)), $"{name}: catalog members");
Assert.That(generated.NameMember, Is.EqualTo(reflective.NameMember), $"{name}: name member");
Assert.That(generated.NameAccessor?.Invoke(sample), Is.EqualTo(reflective.NameAccessor?.Invoke(sample)), $"{name}: name value");
Assert.That(Indexes(generated, sample), Is.EqualTo(Indexes(reflective, sample)), $"{name}: index values");
Assert.That(Convert.ToHexString(generatedBytes), Is.EqualTo(Convert.ToHexString(dynamicBytes)), $"{name}: payload");
Assert.That(Convert.ToHexString(generated.GeneratedPayloadSerializer(decoded)), Is.EqualTo(Convert.ToHexString(generatedBytes)), $"{name}: round trip");
Assert.That(Convert.ToHexString(generated.GeneratedPayloadSerializer(MessagePackSerializer.Deserialize(type, dynamicBytes, options)!)),
Is.EqualTo(Convert.ToHexString(dynamicBytes)), $"{name}: dynamic round trip");
});
payloads[name] = generatedBytes;
}
return payloads;
}
private static void Reject(string source, string documentName, string expected)
{
var assembly = Compile(source, out var generatorErrors);
var reflective = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.BuildDescriptor(assembly.GetType(documentName)!))!;
Assert.Multiple(() =>
{
Assert.That(generatorErrors.FirstOrDefault(), Is.EqualTo(expected), "generator");
Assert.That(reflective.Message, Is.EqualTo(expected), "registry");
});
}
private static string[] Catalog(CultDocumentDescriptor descriptor) =>
descriptor.ToCatalogEntry().Members
.Select(member => $"{member.Slot}:{member.MemberName}:{member.TypeName}:{member.IsReference}:{member.IsMany}:{member.TargetSchemaName}:{member.IsName}:{member.IndexAlias}")
.ToArray();
private static string[] Indexes(CultDocumentDescriptor descriptor, object sample) =>
descriptor.IndexAccessors.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value(sample)}").ToArray();
private static Assembly Compile(string source, out string[] generatorErrors)
{
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
.Split(Path.PathSeparator)
.Append(typeof(CultDocumentAttribute).Assembly.Location)
.Append(typeof(CultDocumentMessagePackSerialization).Assembly.Location)
.Append(typeof(KeyAttribute).Assembly.Location)
.Append(typeof(MessagePackSerializer).Assembly.Location)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(path => MetadataReference.CreateFromFile(path));
var compilation = CSharpCompilation.Create(
"Sweep_" + Guid.NewGuid().ToString("N"),
new[] { CSharpSyntaxTree.ParseText(Prelude + source) },
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable));
CSharpGeneratorDriver.Create(new CultDocumentMessagePackGenerator())
.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics);
generatorErrors = diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Select(diagnostic => diagnostic.GetMessage()).ToArray();
using var stream = new MemoryStream();
var emitted = output.Emit(stream);
Assert.That(emitted.Diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Select(diagnostic => diagnostic.ToString()), Is.Empty, "compile");
return Assembly.Load(stream.ToArray());
}
}
}

View file

@ -71,21 +71,6 @@ namespace GameCult.Caching.Tests
Assert.That(((RoutingWeapon)loaded!).Name, Is.EqualTo("lance"), "the inherited member did not round-trip");
}
[Test]
public async Task GeneratedAndReflectiveDescriptorsAgreeForDerivedDocuments()
{
var generated = CultDocumentRegistry.ForTypes(new[] { typeof(RoutingWeapon) }).GetRequired<RoutingWeapon>();
var reflective = CultDocumentRegistry.BuildDescriptor(typeof(RoutingWeapon));
Assert.That(generated.GeneratedPayloadSerializer, Is.Not.Null, "no generated codec for the derived document");
Assert.That(generated.GeneratedPayloadDeserializer, Is.Not.Null);
Assert.That(generated.SchemaId, Is.EqualTo(reflective.SchemaId));
Assert.That(generated.CanonicalSchemaJson, Is.EqualTo(reflective.CanonicalSchemaJson));
Assert.That(generated.Members.Select(member => $"{member.MemberName}@{member.Slot}"), Is.EqualTo(new[] { "Name@0", "Damage@1" }));
Assert.That(reflective.Members.Select(member => $"{member.MemberName}@{member.Slot}"), Is.EqualTo(new[] { "Name@0", "Damage@1" }));
await UpsertWeaponThroughGearHandleReloadsAsWeapon();
}
[Test]
public async Task GetByNameMatchesAssignableTypes()
{

View file

@ -121,8 +121,8 @@ namespace GameCult.Geometry.Tests
{
var chunk = SampleChunk();
var payload = CultDocumentMessagePackSerialization.Serialize(chunk);
var decoded = CultDocumentMessagePackSerialization.Deserialize<CultGeometryChunkArtifact>(payload);
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(chunk, typeof(CultGeometryChunkArtifact));
var decoded = (CultGeometryChunkArtifact)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryChunkArtifact), payload);
decoded.ChunkId.Should().Be(chunk.ChunkId);
decoded.RenderMesh.TriangleCount.Should().Be(1);
@ -150,7 +150,7 @@ namespace GameCult.Geometry.Tests
public void RustExportedDomainDocument_DecodesWithStableCultCacheKey()
{
var payload = File.ReadAllBytes(FixturePath("ragnarok-domain.msgpack"));
var domain = CultDocumentMessagePackSerialization.Deserialize<CultGeometryDomainDocument>(payload);
var domain = (CultGeometryDomainDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryDomainDocument), payload);
domain.DomainId.Should().Be("ragnarok-column");
domain.Root.Children.Should().NotBeEmpty();
@ -163,7 +163,7 @@ namespace GameCult.Geometry.Tests
public void RustExportedChunkArtifact_DecodesWithStableCultCacheKey()
{
var payload = File.ReadAllBytes(FixturePath("ragnarok-first-chunk.msgpack"));
var chunk = CultDocumentMessagePackSerialization.Deserialize<CultGeometryChunkArtifact>(payload);
var chunk = (CultGeometryChunkArtifact)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryChunkArtifact), payload);
chunk.ChunkId.Should().StartWith("chunk/");
chunk.RenderMesh.TriangleCount.Should().BeGreaterThan(0);

View file

@ -2609,12 +2609,12 @@ public sealed class CultMeshStreamingTests
var filePath = Path.Combine(directory.Path, "legacy-single-document.ccmp");
var key = new CultRecordKey("mesh-note:legacy-single-document");
var descriptor = CultDocumentRegistry.Shared.GetRequired<MeshPublicationNoteDocument>();
var payload = CultDocumentMessagePackSerialization.Serialize(new MeshPublicationNoteDocument
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(new MeshPublicationNoteDocument
{
Schema = "tests.mesh_publication_note.v1",
Text = "legacy-published",
Revision = 4
});
}, typeof(MeshPublicationNoteDocument));
File.WriteAllBytes(filePath, WriteLegacySingleDocumentSnapshot(
key.Value,

View file

@ -0,0 +1,68 @@
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Networking;
using MessagePack;
using MessagePack.Formatters;
using NUnit.Framework;
[assembly: CultCacheFormatterResolver(typeof(GameCult.Networking.Tests.BindingPairResolver))]
namespace GameCult.Networking.Tests
{
public class CultNetDocumentBindingResolverTests
{
[Test]
public void TypedBindingUsesTheDocumentAssemblyResolver()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(BindingPairDocument) });
var binding = CultNetDocumentBinding.ForDocument<BindingPairDocument>(registry);
var payload = binding.PayloadSerializer(new BindingPairDocument { Name = "p", Value = new BindingPair { A = 1.5f, B = -2f } });
var decoded = (BindingPairDocument)binding.PayloadDeserializer(payload);
Assert.That(decoded.Name, Is.EqualTo("p"));
Assert.That(decoded.Value.A, Is.EqualTo(1.5f));
Assert.That(decoded.Value.B, Is.EqualTo(-2f));
}
}
public struct BindingPair
{
public float A;
public float B;
}
[CultDocument("tests.binding_pair", "tests.binding_pair.v1")]
[MessagePackObject]
public sealed class BindingPairDocument
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public BindingPair Value;
}
public sealed class BindingPairResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new BindingPairResolver();
public IMessagePackFormatter<T>? GetFormatter<T>() =>
typeof(T) == typeof(BindingPair) ? (IMessagePackFormatter<T>)(object)PairFormatter.Instance : null;
private sealed class PairFormatter : IMessagePackFormatter<BindingPair>
{
public static readonly PairFormatter Instance = new();
public void Serialize(ref MessagePackWriter writer, BindingPair value, MessagePackSerializerOptions options)
{
writer.WriteArrayHeader(2);
writer.Write(value.A);
writer.Write(value.B);
}
public BindingPair Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
reader.ReadArrayHeader();
return new BindingPair { A = reader.ReadSingle(), B = reader.ReadSingle() };
}
}
}
}

View file

@ -3102,12 +3102,12 @@ namespace GameCult.Networking.Tests
RecordKey = key.Value,
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
PayloadEncoding = "messagepack",
Payload = CultDocumentMessagePackSerialization.Serialize(new NetworkSchemaNote
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(new NetworkSchemaNote
{
Schema = "tests.networking_note.v1",
Text = "payload-routed",
Revision = 5
})
}, typeof(NetworkSchemaNote))
}
};
@ -3182,7 +3182,7 @@ namespace GameCult.Networking.Tests
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value));
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note)));
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
}
[Test]
@ -3224,7 +3224,7 @@ namespace GameCult.Networking.Tests
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value));
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note)));
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
}
[Test]
@ -5645,7 +5645,7 @@ namespace GameCult.Networking.Tests
Assert.That(message.ChangeKind, Is.EqualTo("added"));
Assert.That(message.Document, Is.Not.Null);
Assert.That(message.Document!.SchemaId, Is.EqualTo(descriptor.SchemaId));
Assert.That(message.Document.Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note)));
Assert.That(message.Document.Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
}
[Test]