CultCache: [CultIndex] is unique at commit and load; grouping and selection declarations become [CultRole]
RefuseIndexSharing in Resolve judges every admission; _indexes holds one key per value; cacheless writes and moved-file writes of unique-indexed types are refused. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
44149afb7b
commit
46a40e774b
19 changed files with 460 additions and 245 deletions
|
|
@ -44,7 +44,7 @@ using MessagePack;
|
|||
public sealed class Item
|
||||
{
|
||||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
[Key(1)] [CultIndex] public string Category = string.Empty;
|
||||
[Key(1)] public string Category = string.Empty;
|
||||
[Key(2)] public int Value;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -885,6 +885,10 @@ namespace GameCult.Caching
|
|||
return indexes.BySchemaId.ContainsKey(schemaId) || indexes.ByCompatibleSchemaId.ContainsKey(schemaId);
|
||||
}
|
||||
|
||||
// Whether the type registered under this schema id declares a [CultIndex]: its records are written through a cache, which judges uniqueness.
|
||||
internal bool HoldsUniqueIndex(string schemaId) =>
|
||||
_indexes.BySchemaId.TryGetValue(schemaId, out var descriptor) && descriptor.IndexAccessors.Count > 0;
|
||||
|
||||
// A persisted entry a local entry reads whole: identical content, or compatible shapes that leave no persisted slot ignored.
|
||||
internal static bool ReadsWhole(CultSchemaCatalogEntry persisted, CultSchemaCatalogEntry local) =>
|
||||
string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal) ||
|
||||
|
|
@ -1006,7 +1010,7 @@ namespace GameCult.Caching
|
|||
throw new InvalidOperationException(ids.Problems[0]);
|
||||
var nameMember = members.FirstOrDefault(member => member.IsName);
|
||||
var indexAccessors = members
|
||||
.Where(member => member.IndexAlias != null)
|
||||
.Where(member => member.IsUnique)
|
||||
.ToDictionary(
|
||||
member => member.IndexAlias!,
|
||||
member => member.Getter,
|
||||
|
|
@ -1481,6 +1485,13 @@ namespace GameCult.Caching
|
|||
rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member)));
|
||||
}
|
||||
|
||||
// A member names its role once: [CultIndex] already is a role.
|
||||
foreach (var entry in keyed.OrderBy(entry => entry.Member.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (entry.Member.GetCustomAttribute<CultIndexAttribute>() != null && entry.Member.GetCustomAttribute<CultRoleAttribute>() != null)
|
||||
rejections.Add($"Cult document {type.Name} member {Qualified(entry.Member)} declares both [CultIndex] and [CultRole]; [CultIndex] already names its role.");
|
||||
}
|
||||
|
||||
// D10: two differently-named members that independently declare the same index alias. A
|
||||
// hiding member sharing one slot is already caught above; this is two distinct members whose
|
||||
// alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.
|
||||
|
|
@ -1580,14 +1591,19 @@ namespace GameCult.Caching
|
|||
|
||||
private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name;
|
||||
|
||||
// The member's role alias, from [CultIndex] or [CultRole] (the member name when the alias is blank).
|
||||
// Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration
|
||||
// refuses exactly the shapes the accessor it builds afterward could not have walked anyway.
|
||||
private static string? ResolveIndexAlias(MemberInfo member)
|
||||
{
|
||||
var indexAttribute = member.GetCustomAttribute<CultIndexAttribute>();
|
||||
if (indexAttribute == null)
|
||||
string? alias;
|
||||
if (member.GetCustomAttribute<CultIndexAttribute>() is { } index)
|
||||
alias = index.Alias;
|
||||
else if (member.GetCustomAttribute<CultRoleAttribute>() is { } role)
|
||||
alias = role.Alias;
|
||||
else
|
||||
return null;
|
||||
return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias;
|
||||
return string.IsNullOrWhiteSpace(alias) ? member.Name : alias;
|
||||
}
|
||||
|
||||
private static bool IsCultRecordRefType(Type type) =>
|
||||
|
|
@ -1854,6 +1870,7 @@ namespace GameCult.Caching
|
|||
public int Slot { get; set; }
|
||||
public bool IsName { get; set; }
|
||||
public string? IndexAlias { get; set; }
|
||||
public bool IsUnique { get; set; }
|
||||
public bool IsReference { get; set; }
|
||||
public bool IsMany { get; set; }
|
||||
public string? TargetSchemaName { get; set; }
|
||||
|
|
@ -1896,6 +1913,7 @@ namespace GameCult.Caching
|
|||
Slot = slot,
|
||||
IsName = member.GetCustomAttribute<CultNameAttribute>() != null,
|
||||
IndexAlias = ResolveIndexAlias(member),
|
||||
IsUnique = member.GetCustomAttribute<CultIndexAttribute>() != null,
|
||||
IsReference = isReference,
|
||||
IsMany = many,
|
||||
TargetSchemaName = targetSchemaName,
|
||||
|
|
@ -2200,8 +2218,8 @@ namespace GameCult.Caching
|
|||
private readonly List<(CacheBackingStore Store, Type[] Homes)> _stores = new();
|
||||
private readonly Dictionary<string, CultStoredDocument> _entries = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Type, Dictionary<string, string>> _names = new();
|
||||
// Every key holding a value (unordered): the index is truthful about duplicates, so removing one holder never hides another.
|
||||
private readonly Dictionary<(Type Type, string Alias), Dictionary<string, List<string>>> _indexes = new();
|
||||
// One key per value: RefuseIndexSharing decides it, Apply only records it.
|
||||
private readonly Dictionary<(Type Type, string Alias), Dictionary<string, string>> _indexes = new();
|
||||
private readonly Dictionary<Type, string> _globals = new();
|
||||
private readonly CultCodec? _codec;
|
||||
// Keys holding a variant: derived from the entries by Apply, never decided anywhere else.
|
||||
|
|
@ -2671,16 +2689,12 @@ namespace GameCult.Caching
|
|||
.SelectMany(pair => pair.Value.TryGetValue(name, out var key) ? new[] { key } : Array.Empty<string>()));
|
||||
}
|
||||
|
||||
// A value held by one record finds it. Records of one type sharing a value (plain duplicates) find the holder with the
|
||||
// smallest key (ordinal), whatever order they were written or loaded in: the choice is a function of the held set, so
|
||||
// it is the same live, after a flush and after a reopen, and removing that holder finds the next.
|
||||
// A [CultIndex] value names one record of its type; blank values are not indexed.
|
||||
public T? GetByIndex<T>(string alias, string value) where T : class
|
||||
{
|
||||
return Single<T>($"index {alias}='{value}'", () => _indexes
|
||||
.Where(pair => pair.Key.Alias == alias && typeof(T).IsAssignableFrom(pair.Key.Type))
|
||||
.SelectMany(pair => pair.Value.TryGetValue(value, out var keys)
|
||||
? new[] { keys.Aggregate((best, next) => string.CompareOrdinal(next, best) < 0 ? next : best) }
|
||||
: Array.Empty<string>()));
|
||||
.SelectMany(pair => pair.Value.TryGetValue(value, out var key) ? new[] { key } : Array.Empty<string>()));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -3270,12 +3284,10 @@ namespace GameCult.Caching
|
|||
{
|
||||
var plan = ResolveVariants(admitted, evicted, source);
|
||||
Project(plan);
|
||||
// The variant rules are judged only when a variant is involved.
|
||||
RefuseIndexSharing(plan, evicted);
|
||||
// The variant name rule is judged only when a variant is involved.
|
||||
if (_variantKeys.Count != 0 || admitted.Any(stored => stored.Variant != null))
|
||||
{
|
||||
RefuseVariantNameSharing(plan);
|
||||
RefuseVariantIndexSharing(plan, evicted);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
|
@ -3472,62 +3484,41 @@ namespace GameCult.Caching
|
|||
}
|
||||
}
|
||||
|
||||
// The one index rule, judged on the commit's result whatever the write order: a variant may not share an indexed
|
||||
// value with another record. It reads the truthful held index (every holder of a value), minus the records this
|
||||
// commit replaces or removes, plus the records it lands. Plain-vs-plain sharing is not this rule's business.
|
||||
// A unique index maps a value to one key at lookup; a variant sharing it would be found in place of its
|
||||
// holder or lose the value, and the store would refuse to reopen.
|
||||
private void RefuseVariantIndexSharing(VariantPlan plan, IReadOnlyList<CultStoredDocument> evicted)
|
||||
// The one index rule, judged on the commit's result whatever the write order: within one document type and one
|
||||
// [CultIndex] alias, no two records hold one non-blank value. The holders of a value are the held key (unless this
|
||||
// admission replaces or evicts it) plus every landing key projecting it; two distinct holders refuse, naming both
|
||||
// keys and the index and never the value. One pass over what lands, one lookup per (index, value).
|
||||
private void RefuseIndexSharing(VariantPlan plan, IReadOnlyList<CultStoredDocument> evicted)
|
||||
{
|
||||
var landing = plan.Admitted.Concat(plan.Dependents).ToList();
|
||||
if (landing.All(stored => stored.Descriptor.IndexAccessors.Count == 0))
|
||||
return;
|
||||
var replaced = new HashSet<string>(
|
||||
plan.Admitted.Concat(evicted).Concat(plan.Dependents).Select(stored => stored.Key.Value), StringComparer.Ordinal);
|
||||
var landingByKey = landing.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal);
|
||||
|
||||
bool IsVariant(string key) =>
|
||||
landingByKey.TryGetValue(key, out var landed) ? landed.Variant != null : _variantKeys.Contains(key);
|
||||
|
||||
// One pass over what lands, one lookup per (index, value): the rule is linear in the records landing.
|
||||
var landedValues = new List<(CultStoredDocument Stored, (Type Type, string Alias) Index, string Value)>();
|
||||
var landed = new Dictionary<((Type Type, string Alias) Index, string Value), List<string>>();
|
||||
var schemaNames = landing.Select(stored => stored.Descriptor).GroupBy(descriptor => descriptor.DocumentType)
|
||||
.ToDictionary(group => group.Key, group => group.First().SchemaName);
|
||||
foreach (var stored in landing)
|
||||
{
|
||||
foreach (var (alias, value) in plan.Projections[stored.Key.Value].Indexes)
|
||||
{
|
||||
var index = (stored.Descriptor.DocumentType, alias);
|
||||
landedValues.Add((stored, index, value));
|
||||
if (!landed.TryGetValue((index, value), out var landedKeys))
|
||||
landed[(index, value)] = landedKeys = new List<string>();
|
||||
landedKeys.Add(stored.Key.Value);
|
||||
var slot = ((stored.Descriptor.DocumentType, alias), value);
|
||||
if (!landed.TryGetValue(slot, out var keys))
|
||||
landed[slot] = keys = new List<string>();
|
||||
keys.Add(stored.Key.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var holdersOf = new Dictionary<((Type Type, string Alias) Index, string Value), (string[] All, string[] Variants)>();
|
||||
foreach (var (stored, index, value) in landedValues)
|
||||
foreach (var ((index, value), landedKeys) in landed.OrderBy(pair => pair.Key.Index.Alias, StringComparer.Ordinal))
|
||||
{
|
||||
if (!holdersOf.TryGetValue((index, value), out var known))
|
||||
{
|
||||
var all = (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKeys)
|
||||
? heldKeys.Where(heldKey => !replaced.Contains(heldKey))
|
||||
: Enumerable.Empty<string>())
|
||||
.Concat(landed[(index, value)])
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
holdersOf[(index, value)] = known = (all, all.Where(IsVariant).ToArray());
|
||||
}
|
||||
|
||||
var self = stored.Key.Value;
|
||||
var holder = known.All.FirstOrDefault(candidate => candidate != self);
|
||||
if (stored.Variant != null && holder != null)
|
||||
var holders = landedKeys.AsEnumerable();
|
||||
if (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKey) && !replaced.Contains(heldKey))
|
||||
holders = holders.Append(heldKey);
|
||||
var distinct = holders.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).Take(2).ToArray();
|
||||
if (distinct.Length > 1)
|
||||
throw new InvalidOperationException(
|
||||
$"Variant {self} and {holder} both hold index {index.Alias} '{value}'; a variant must override its indexed member.");
|
||||
var variantHolder = known.Variants.FirstOrDefault(candidate => candidate != self);
|
||||
if (stored.Variant == null && variantHolder != null)
|
||||
throw new InvalidOperationException(
|
||||
$"Record {self} and variant {variantHolder} both hold index {index.Alias} '{value}'; " +
|
||||
"a variant may not share an indexed value with another record.");
|
||||
$"Records {distinct[0]} and {distinct[1]} ({schemaNames[index.Type]}) " +
|
||||
$"both hold unique index '{index.Alias}'; an index value names one record.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3633,7 +3624,7 @@ namespace GameCult.Caching
|
|||
if (projection.Name != null)
|
||||
MapOf(_names, type)[projection.Name] = stored.Key.Value;
|
||||
foreach (var (alias, value) in projection.Indexes)
|
||||
AddHolder(_indexes, (type, alias), value, stored.Key.Value);
|
||||
MapOf(_indexes, (type, alias))[value] = stored.Key.Value;
|
||||
}
|
||||
|
||||
// Documents are mutable, so a name or index value may have changed since it was indexed: drop by key.
|
||||
|
|
@ -3645,26 +3636,7 @@ namespace GameCult.Caching
|
|||
if (_names.TryGetValue(type, out var names))
|
||||
RemoveKey(names, stored.Key.Value);
|
||||
foreach (var pair in _indexes.Where(pair => pair.Key.Type == type))
|
||||
RemoveHolder(pair.Value, stored.Key.Value);
|
||||
}
|
||||
|
||||
private static void AddHolder(Dictionary<(Type Type, string Alias), Dictionary<string, List<string>>> indexes, (Type, string) index, string value, string key)
|
||||
{
|
||||
if (!indexes.TryGetValue(index, out var map))
|
||||
indexes[index] = map = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
if (!map.TryGetValue(value, out var keys))
|
||||
map[value] = keys = new List<string>();
|
||||
keys.Remove(key);
|
||||
keys.Add(key);
|
||||
}
|
||||
|
||||
private static void RemoveHolder(Dictionary<string, List<string>> map, string key)
|
||||
{
|
||||
foreach (var pair in map.ToArray())
|
||||
{
|
||||
if (pair.Value.Remove(key) && pair.Value.Count == 0)
|
||||
map.Remove(pair.Key);
|
||||
}
|
||||
RemoveKey(pair.Value, stored.Key.Value);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> MapOf<TKey>(Dictionary<TKey, Dictionary<string, string>> maps, TKey key)
|
||||
|
|
@ -4648,13 +4620,17 @@ namespace GameCult.Caching
|
|||
/// <param name="holdsIds">Whether the record's payload holds element ids, or null when the caller cannot tell.</param>
|
||||
/// <param name="registry">The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read. It never decides ownership: the answer is the same lent or not. <paramref name="catalogEntry"/> alone owns the record at the key when it declares the stored id (its own or one it lists compatible), or when it is the type that reads the stored entry and reads it whole, so an older id of its own schema name it reads whole is its own and is restamped. Everything else is refused with the file left as it was: a record of another registered type included, whatever the registry holds, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry.</param>
|
||||
/// <remarks>The call holds the store's gate while it runs. User code it runs must not enter a cache or another store; that throws <see cref="InvalidOperationException"/>.</remarks>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache.</exception>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache. Also thrown when the record's type declares a <see cref="CultIndexAttribute"/> (when the registry holds that type): its records are written through a cache.</exception>
|
||||
/// <exception cref="CultStoreUnreadableException">The file at the path is not a store this runtime can read.</exception>
|
||||
public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null)
|
||||
{
|
||||
if (record == null) throw new ArgumentNullException(nameof(record));
|
||||
if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry));
|
||||
Cacheless(registry, () => WriteOne(record, catalogEntry, holdsIds));
|
||||
Cacheless(registry, () =>
|
||||
{
|
||||
RefuseUniqueIndexedCacheless(AttachedRegistry, catalogEntry.SchemaId);
|
||||
WriteOne(record, catalogEntry, holdsIds);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -4671,7 +4647,7 @@ namespace GameCult.Caching
|
|||
/// <param name="storedAt">The stored-at stamp, or null for now.</param>
|
||||
/// <param name="registry">The registry that describes and encodes the document, or null for <see cref="CultDocumentRegistry.Shared"/>. The document type's entry is taken from it, and the registry decides the header for copied records it can read. It never decides ownership, so the answer is the same for any registry that holds the type: the document's entry owns a record whose id it declares (its own or one it lists compatible), and a record of an older id of its own schema name that it reads whole; everything else is refused, another registered type's record included, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry.</param>
|
||||
/// <remarks>The call holds the store's gate while it runs. User code it runs (property getters while the document is encoded, constructors while a copied record is read) must not enter a cache or another store; that throws <see cref="InvalidOperationException"/>, carried by the encoder's wrapper when the code ran inside it.</remarks>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache.</exception>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache. Also thrown when the record's type declares a <see cref="CultIndexAttribute"/> (when the registry holds that type): its records are written through a cache.</exception>
|
||||
/// <exception cref="CultStoreUnreadableException">The file at the path is not a store this runtime can read.</exception>
|
||||
/// <exception cref="CultElementIdException">The document holds an element id or list a cache write refuses.</exception>
|
||||
public void WriteDocument<TDocument>(CultRecordKey key, TDocument document, string? storedAt = null, CultDocumentRegistry? registry = null)
|
||||
|
|
@ -4680,6 +4656,7 @@ namespace GameCult.Caching
|
|||
if (document == null) throw new ArgumentNullException(nameof(document));
|
||||
var resolvedRegistry = registry ?? CultDocumentRegistry.Shared;
|
||||
var catalogEntry = resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry();
|
||||
RefuseUniqueIndexedCacheless(resolvedRegistry, catalogEntry.SchemaId);
|
||||
Cacheless(resolvedRegistry, () =>
|
||||
{
|
||||
var ids = CultElementIds.Plan(typeof(TDocument), document, key.Value, deterministic: false);
|
||||
|
|
@ -4705,6 +4682,14 @@ namespace GameCult.Caching
|
|||
});
|
||||
}
|
||||
|
||||
// A unique index is judged against every held record, which a cacheless write does not hold.
|
||||
private static void RefuseUniqueIndexedCacheless(CultDocumentRegistry? registry, string schemaId)
|
||||
{
|
||||
if (registry != null && registry.HoldsUniqueIndex(schemaId))
|
||||
throw new InvalidOperationException(
|
||||
"A record of a type with a unique index is written through a cache; a cacheless write cannot judge uniqueness.");
|
||||
}
|
||||
|
||||
private void WriteOne(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds)
|
||||
{
|
||||
using (AcquireLock(wait: true))
|
||||
|
|
@ -4727,7 +4712,7 @@ namespace GameCult.Caching
|
|||
RefuseUndeclaredOnDisk(durable, upserts, removals, checks);
|
||||
if (upserts.Count == 0 && removals.Count == 0 && (checks.Any() || StagedChecks.Count > 0))
|
||||
return Array.Empty<(CultStagedRecord, string)>();
|
||||
RefuseWriteIntoMovedVariantStore(durable, upserts, removals);
|
||||
RefuseWriteIntoMovedStore(durable, upserts, removals);
|
||||
var durableRecords = durable?.Snapshot.Records ?? Array.Empty<CultPersistedRecord>();
|
||||
// A record written over one the store holds is stored at a storedAt later than the one it replaces, whoever wrote that.
|
||||
var replaced = durableRecords.ToDictionary(record => record.Key, record => record.StoredAt, StringComparer.Ordinal);
|
||||
|
|
@ -4812,10 +4797,11 @@ namespace GameCult.Caching
|
|||
return false;
|
||||
}
|
||||
|
||||
private void RefuseWriteIntoMovedVariantStore(Durable? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals)
|
||||
private void RefuseWriteIntoMovedStore(Durable? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals)
|
||||
{
|
||||
var records = durable?.Snapshot.Records ?? Array.Empty<CultPersistedRecord>();
|
||||
if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null))
|
||||
if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null) &&
|
||||
!upserts.Any(staged => AttachedRegistry?.HoldsUniqueIndex(staged.Entry.SchemaId) == true))
|
||||
return;
|
||||
var changed = new HashSet<string>(StringComparer.Ordinal);
|
||||
var versions = records.ToDictionary(record => record.Key, record => durable!.Version(record.Key), StringComparer.Ordinal);
|
||||
|
|
@ -4852,9 +4838,9 @@ namespace GameCult.Caching
|
|||
},
|
||||
inner => new CultWriteConflictException(
|
||||
Cache == null
|
||||
? $"{this} holds a variant and was written by a writer with no CultCache, which cannot resolve variants; nothing was written. " +
|
||||
? $"{this} holds a variant or a unique index and was written by a writer with no CultCache, which cannot resolve variants; nothing was written. " +
|
||||
$"Records it would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that differ from what the writer has read: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}."
|
||||
: $"{this} changed since this cache last read it, and it holds a variant, which resolves against other records; nothing was written. " +
|
||||
: $"{this} changed since this cache last read it, and it holds a variant or a record with a unique index, which resolve against other records; nothing was written. " +
|
||||
$"Records this write would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that changed: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}. " +
|
||||
(inner == null
|
||||
? "The cache reloaded them; retry the write against what the store holds now."
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ namespace GameCult.Caching
|
|||
{
|
||||
}
|
||||
|
||||
// A unique lookup: within one document type, a non-blank value names one record. Also filterable by CultNet selection.
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
|
||||
public sealed class CultIndexAttribute : Attribute
|
||||
{
|
||||
|
|
@ -49,6 +50,19 @@ namespace GameCult.Caching
|
|||
public string? Alias { get; }
|
||||
}
|
||||
|
||||
// Names the member's selection and reference role (the alias, or the member name). Implies nothing about uniqueness:
|
||||
// many records may hold one value, and a role member is never indexed for lookup.
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
|
||||
public sealed class CultRoleAttribute : Attribute
|
||||
{
|
||||
public CultRoleAttribute(string? alias = null)
|
||||
{
|
||||
Alias = alias;
|
||||
}
|
||||
|
||||
public string? Alias { get; }
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultReferenceAttribute : Attribute
|
||||
{
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ namespace GameCult.Geometry
|
|||
public string DomainId { get; set; } = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string RootKey { get; set; } = string.Empty;
|
||||
|
||||
[Key(2)]
|
||||
|
|
@ -164,7 +164,7 @@ namespace GameCult.Geometry
|
|||
public CultRecordRef<CultGeometryDomainDocument> DomainKey { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string WorkerGroup { get; set; } = string.Empty;
|
||||
|
||||
[Key(3)]
|
||||
|
|
@ -326,7 +326,7 @@ namespace GameCult.Geometry
|
|||
public CultRecordRef<CultGeometrySelectedCutManifest> CutKey { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string SelectedCutId { get; set; } = string.Empty;
|
||||
|
||||
[Key(3)]
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ namespace GameCult.Mesh
|
|||
public sealed class CultMeshBodyPublicationDocument
|
||||
{
|
||||
[Key(0), CultName] public string BodyId { get; set; } = string.Empty;
|
||||
[Key(1), CultIndex] public string ProducerId { get; set; } = string.Empty;
|
||||
[Key(1), CultRole] public string ProducerId { get; set; } = string.Empty;
|
||||
[Key(2)] public string SchemaId { get; set; } = string.Empty;
|
||||
[Key(3)] public int LayoutVersion { get; set; }
|
||||
[Key(4)] public long ByteSize { get; set; }
|
||||
|
|
|
|||
|
|
@ -142,17 +142,17 @@ namespace GameCult.Mesh
|
|||
|
||||
/// <summary>Artifact kind, such as asset, build, or package.</summary>
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string Kind { get; set; } = CultMeshCdnArtifactKinds.Asset;
|
||||
|
||||
/// <summary>Caller-defined artifact version.</summary>
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>SHA-256 hash of the full materialized artifact.</summary>
|
||||
[Key(3)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Full materialized artifact size.</summary>
|
||||
|
|
|
|||
|
|
@ -193,12 +193,12 @@ namespace GameCult.Mesh
|
|||
|
||||
/// <summary>Caller-defined package version.</summary>
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tool that authored the package, such as Brokkr Blender.</summary>
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string SourceTool { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Source scene name or path.</summary>
|
||||
|
|
@ -207,12 +207,12 @@ namespace GameCult.Mesh
|
|||
|
||||
/// <summary>Source Blender collection name.</summary>
|
||||
[Key(4)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string SourceCollection { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Stable hash of graph structure and referenced assets.</summary>
|
||||
[Key(5)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Package creation timestamp in round-trip UTC format.</summary>
|
||||
|
|
|
|||
|
|
@ -20,14 +20,14 @@ namespace GameCult.Mesh
|
|||
/// Stable fact identifier.
|
||||
/// </summary>
|
||||
[Key(0)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string FactId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Shard containing the simulation.
|
||||
/// </summary>
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string ShardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -40,21 +40,21 @@ namespace GameCult.Mesh
|
|||
/// Simulation frame or tick.
|
||||
/// </summary>
|
||||
[Key(3)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public long Frame { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject the fact is about.
|
||||
/// </summary>
|
||||
[Key(4)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string SubjectId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Fact kind, such as hit, collision, or ownership.
|
||||
/// </summary>
|
||||
[Key(5)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string ClaimKind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -226,8 +226,8 @@ namespace GameCult.Caching.Tests
|
|||
public void DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName()
|
||||
{
|
||||
var type = Document("DupAlias");
|
||||
Field(type, "First", typeof(string), Key(0), IndexAttr("shared"));
|
||||
Field(type, "Second", typeof(string), Key(1), IndexAttr("shared"));
|
||||
Field(type, "First", typeof(string), Key(0), RoleAttr("shared"));
|
||||
Field(type, "Second", typeof(string), Key(1), RoleAttr("shared"));
|
||||
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
||||
|
||||
Assert.That(message, Does.Contain("DupAlias"));
|
||||
|
|
@ -236,7 +236,7 @@ namespace GameCult.Caching.Tests
|
|||
Assert.That(message, Does.Contain("shared"));
|
||||
}
|
||||
|
||||
// R-AL/SM-6: a plain data member's bare name (it declares no [CultIndex], so its role in
|
||||
// R-AL/SM-6: a plain data member's bare name (it declares no [CultRole], so its role in
|
||||
// ReferencesOf/ReferenceMembers falls back to its own MemberName) colliding with a reference
|
||||
// member's explicit alias. D10 above only groups members that declare an alias and misses
|
||||
// this; before the fix this registered without error and then every hop through the
|
||||
|
|
@ -246,7 +246,7 @@ namespace GameCult.Caching.Tests
|
|||
{
|
||||
var type = Document("OwnerCollision");
|
||||
Field(type, "Owner", typeof(string), Key(0));
|
||||
Field(type, "Ref", typeof(CultRecordRef<FixtureLeafA>), Key(1), IndexAttr("Owner"));
|
||||
Field(type, "Ref", typeof(CultRecordRef<FixtureLeafA>), Key(1), RoleAttr("Owner"));
|
||||
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
||||
|
||||
Assert.That(message, Does.Contain("OwnerCollision"));
|
||||
|
|
@ -295,8 +295,8 @@ namespace GameCult.Caching.Tests
|
|||
private static CustomAttributeBuilder Key(int slot) =>
|
||||
new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot });
|
||||
|
||||
private static CustomAttributeBuilder IndexAttr(string alias) =>
|
||||
new(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias });
|
||||
private static CustomAttributeBuilder RoleAttr(string alias) =>
|
||||
new(typeof(CultRoleAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias });
|
||||
|
||||
private static CustomAttributeBuilder ReferenceAttr(Type? targetType, bool many) =>
|
||||
new(typeof(CultReferenceAttribute).GetConstructor(new[] { typeof(Type), typeof(bool) })!, new object?[] { targetType, many });
|
||||
|
|
@ -308,7 +308,7 @@ namespace GameCult.Caching.Tests
|
|||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("mass")]
|
||||
[CultRole("mass")]
|
||||
public float Mass;
|
||||
}
|
||||
|
||||
|
|
@ -325,7 +325,7 @@ namespace GameCult.Caching.Tests
|
|||
public sealed class FixtureLeafB : FixtureMiddle
|
||||
{
|
||||
[Key(2)]
|
||||
[CultIndex("weight")]
|
||||
[CultRole("weight")]
|
||||
public decimal? Weight;
|
||||
}
|
||||
|
||||
|
|
@ -341,19 +341,19 @@ namespace GameCult.Caching.Tests
|
|||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("big_integer")]
|
||||
[CultRole("big_integer")]
|
||||
public long BigInteger;
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex("trimmed_decimal")]
|
||||
[CultRole("trimmed_decimal")]
|
||||
public decimal TrimmedDecimal;
|
||||
|
||||
[Key(3)]
|
||||
[CultIndex("wide_double")]
|
||||
[CultRole("wide_double")]
|
||||
public double WideDouble;
|
||||
|
||||
[Key(4)]
|
||||
[CultIndex("tiny_double")]
|
||||
[CultRole("tiny_double")]
|
||||
public double TinyDouble;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -394,7 +394,7 @@ namespace GameCult.Caching.Tests
|
|||
public class Gear
|
||||
{
|
||||
[Key(0)] [CultName] public string Name { get; set; } = "";
|
||||
[Key(1)] [CultIndex("slot")] public string Slot { get; set; } = "";
|
||||
[Key(1)] [CultRole("slot")] public string Slot { get; set; } = "";
|
||||
}
|
||||
|
||||
[CultDocument("shape.weapon", "shape.weapon.v1")]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ namespace GameCult.Caching.Tests
|
|||
// two ways), an alias whose resolved type would depend on scan order, or a type a test needs to itself.
|
||||
internal static class EmittedDocumentTypes
|
||||
{
|
||||
internal readonly record struct Field(string Name, Type Type, int Key, bool IsName = false);
|
||||
internal readonly record struct Field(string Name, Type Type, int Key, bool IsName = false, string? Index = null, string? Role = null);
|
||||
|
||||
internal static Type Emit(string typeName, string schemaName, string schemaVersion, Field[]? fields = null, string[]? compatibleSchemaIds = null)
|
||||
{
|
||||
|
|
@ -37,6 +37,10 @@ namespace GameCult.Caching.Tests
|
|||
builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { field.Key }));
|
||||
if (field.IsName)
|
||||
builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultNameAttribute).GetConstructor(Type.EmptyTypes)!, Array.Empty<object>()));
|
||||
if (field.Index != null)
|
||||
builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object[] { field.Index }));
|
||||
if (field.Role != null)
|
||||
builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultRoleAttribute).GetConstructor(new[] { typeof(string) })!, new object[] { field.Role }));
|
||||
}
|
||||
|
||||
return type.CreateType()!;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ using MessagePack;
|
|||
public sealed class Item
|
||||
{
|
||||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
[Key(1)] [CultIndex] public string Category = string.Empty;
|
||||
[Key(1)] public string Category = string.Empty;
|
||||
[Key(2)] public int Value;
|
||||
}
|
||||
|
||||
|
|
|
|||
313
tests/GameCult.Caching.Tests/UniqueIndexTests.cs
Normal file
313
tests/GameCult.Caching.Tests/UniqueIndexTests.cs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
// variants:ruling:unique-indexes. Within one document type and one [CultIndex] alias, no two held records hold one
|
||||
// non-blank value; CultCache.Resolve judges every commit and every load, and a refusal never echoes the value.
|
||||
public class UniqueIndexTests
|
||||
{
|
||||
private const string Canary = "CANARY-7f3a";
|
||||
|
||||
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[]
|
||||
{
|
||||
typeof(UqItem), typeof(UqTwin), typeof(UqTagged)
|
||||
});
|
||||
|
||||
// Emitted, so CultDocumentRegistry.Shared never scans them (one schema declared two ways, and a member Shared would refuse).
|
||||
// The same schema name, version and shape, once as a role and once as a unique index: a role store holds the pair, the index type refuses it.
|
||||
private static readonly Type UqLoadRole = EmittedDocumentTypes.Emit(
|
||||
"UqLoadRole", "tests.uq_load", "tests.uq_load.v1", new[] { new EmittedDocumentTypes.Field("Code", typeof(string), 0, Role: "code") });
|
||||
private static readonly Type UqLoadIndex = EmittedDocumentTypes.Emit(
|
||||
"UqLoadIndex", "tests.uq_load", "tests.uq_load.v1", new[] { new EmittedDocumentTypes.Field("Code", typeof(string), 0, Index: "code") });
|
||||
private static readonly Type UqBoth = EmittedDocumentTypes.Emit(
|
||||
"UqBoth", "tests.uq_both", "tests.uq_both.v1", new[] { new EmittedDocumentTypes.Field("Both", typeof(string), 0, Index: "x", Role: "x") });
|
||||
|
||||
private static object Load(Type type, string code) => EmittedDocumentTypes.New(type, ("Code", code));
|
||||
|
||||
private string _directory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void CreateDirectory()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidx-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void DeleteDirectory()
|
||||
{
|
||||
if (Directory.Exists(_directory))
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private string PathOf(string name) => Path.Combine(_directory, name);
|
||||
|
||||
private static CultCache Open(string path, CultDocumentRegistry? registry = null) =>
|
||||
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry ?? Registry });
|
||||
|
||||
private static CultRecordKey Key(string name) => new(name);
|
||||
|
||||
private static UqItem Item(string? sku) => new() { Sku = sku };
|
||||
|
||||
private static InvalidOperationException Refused(TestDelegate write) => Assert.Throws<InvalidOperationException>(write)!;
|
||||
|
||||
[Test]
|
||||
public void ABatchLeavingTwoRecordsWithOneValueIsRefusedNamingBothKeysAndTheIndex()
|
||||
{
|
||||
var path = PathOf("items.cc");
|
||||
using var cache = Open(path);
|
||||
cache.Commit(batch => batch.Upsert(typeof(UqItem), Item("keep"), Key("seed")));
|
||||
var before = File.ReadAllBytes(path);
|
||||
|
||||
var refused = Refused(() => cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(UqItem), Item(Canary), Key("zeta"));
|
||||
batch.Upsert(typeof(UqItem), Item(Canary), Key("alpha"));
|
||||
}));
|
||||
|
||||
Assert.That(refused.Message, Does.Contain("alpha").And.Contain("zeta").And.Contain("sku").And.Contain("tests.uq_item"));
|
||||
Assert.That(refused.Message.IndexOf("alpha", StringComparison.Ordinal), Is.LessThan(refused.Message.IndexOf("zeta", StringComparison.Ordinal)), "keys in ordinal order");
|
||||
Assert.That(refused.Message, Does.Not.Contain(Canary), "the value is never echoed");
|
||||
Assert.That(cache.Get(Key("alpha")), Is.Null);
|
||||
Assert.That(cache.Get(Key("zeta")), Is.Null);
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the store is byte-identical");
|
||||
Assert.That(cache.GetByIndex<UqItem>("sku", Canary), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AWriteOfAHeldValueAtAnotherKeyIsRefused()
|
||||
{
|
||||
using var cache = Open(PathOf("items.cc"));
|
||||
await cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("first"));
|
||||
|
||||
var single = Assert.ThrowsAsync<InvalidOperationException>(() => cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("second")))!;
|
||||
Assert.That(single.Message, Does.Contain("first").And.Contain("second").And.Contain("sku").And.Not.Contain(Canary));
|
||||
var batched = Refused(() => cache.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("third"))));
|
||||
Assert.That(batched.Message, Does.Contain("first").And.Contain("third"));
|
||||
|
||||
Assert.That(cache.Get(Key("second")), Is.Null);
|
||||
Assert.That(cache.Get(Key("third")), Is.Null);
|
||||
Assert.That(cache.GetByIndex<UqItem>("sku", Canary), Is.Not.Null);
|
||||
|
||||
await cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("first"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SwappingTwoValuesInOneCommitLandsAndBothAreFound()
|
||||
{
|
||||
var path = PathOf("items.cc");
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(UqItem), Item("one"), Key("a"));
|
||||
batch.Upsert(typeof(UqItem), Item("two"), Key("b"));
|
||||
});
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(UqItem), Item("two"), Key("a"));
|
||||
batch.Upsert(typeof(UqItem), Item("one"), Key("b"));
|
||||
});
|
||||
|
||||
Assert.That(cache.GetStored(Key("a"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "two")));
|
||||
Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "one")));
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.GetStored(Key("a"))!.Document, Is.SameAs(reopened.GetByIndex<UqItem>("sku", "two")));
|
||||
Assert.That(reopened.GetStored(Key("b"))!.Document, Is.SameAs(reopened.GetByIndex<UqItem>("sku", "one")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AValueMovesWhenItsHolderIsRemovedOrChangedInTheSameCommit()
|
||||
{
|
||||
using var cache = Open(PathOf("items.cc"));
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(UqItem), Item("one"), Key("a"));
|
||||
batch.Upsert(typeof(UqItem), Item("two"), Key("b"));
|
||||
});
|
||||
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Remove(Key("a"));
|
||||
batch.Upsert(typeof(UqItem), Item("one"), Key("c"));
|
||||
});
|
||||
Assert.That(cache.GetStored(Key("c"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "one")), "removed holder released its value");
|
||||
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(UqItem), Item("three"), Key("b"));
|
||||
batch.Upsert(typeof(UqItem), Item("two"), Key("d"));
|
||||
});
|
||||
Assert.That(cache.GetStored(Key("d"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "two")), "changed holder released its value");
|
||||
Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "three")));
|
||||
Assert.That(cache.GetByIndex<UqItem>("sku", "one")!.Sku, Is.EqualTo("one"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BlankValuesNeverConflict()
|
||||
{
|
||||
var path = PathOf("items.cc");
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(UqItem), Item(null), Key("a"));
|
||||
batch.Upsert(typeof(UqItem), Item(""), Key("b"));
|
||||
batch.Upsert(typeof(UqItem), Item(" "), Key("c"));
|
||||
batch.Upsert(typeof(UqItem), Item(null), Key("d"));
|
||||
});
|
||||
Assert.That(cache.GetByIndex<UqItem>("sku", ""), Is.Null);
|
||||
Assert.That(cache.GetByIndex<UqItem>("sku", " "), Is.Null);
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.Get(Key("d")), Is.Not.Null, "a store of blank values reopens");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TwoTypesMayShareAnAliasAndAValue()
|
||||
{
|
||||
using var cache = Open(PathOf("items.cc"));
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(UqItem), Item("same"), Key("item"));
|
||||
batch.Upsert(typeof(UqTwin), new UqTwin { Sku = "same" }, Key("twin"));
|
||||
});
|
||||
|
||||
Assert.That(cache.GetByIndex<UqItem>("sku", "same"), Is.Not.Null);
|
||||
Assert.That(cache.GetByIndex<UqTwin>("sku", "same"), Is.Not.Null);
|
||||
Assert.That(Registry.GetRequired<UqItem>().DeclaredMembers.Single().IndexAlias, Is.EqualTo("sku"), "a unique index addresses selection as a role does");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RoleMembersMayShareValuesAndStaySelectable()
|
||||
{
|
||||
var path = PathOf("tagged.cc");
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "weapon" }, Key("a"));
|
||||
batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "weapon" }, Key("b"));
|
||||
});
|
||||
Assert.That(cache.GetByIndex<UqTagged>("kind", "weapon"), Is.Null, "a role is not a lookup");
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.Get(Key("b")), Is.Not.Null, "a role pair reopens");
|
||||
var member = Registry.GetRequired<UqTagged>().DeclaredMembers.Single(candidate => candidate.MemberName == nameof(UqTagged.Kind));
|
||||
Assert.That(member.IndexAlias, Is.EqualTo("kind"), "the role still supplies the selection address");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AStoreHoldingDuplicatesRefusesToOpenNamingBothKeysAndTheIndex()
|
||||
{
|
||||
var path = PathOf("load.cc");
|
||||
var roleRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadRole });
|
||||
using (var writer = Open(path, roleRegistry))
|
||||
{
|
||||
writer.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(UqLoadRole, Load(UqLoadRole, Canary), Key("m"));
|
||||
batch.Upsert(UqLoadRole, Load(UqLoadRole, Canary), Key("k"));
|
||||
});
|
||||
}
|
||||
|
||||
var before = File.ReadAllBytes(path);
|
||||
var indexRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadIndex });
|
||||
var refused = Refused(() => Open(path, indexRegistry).Dispose());
|
||||
|
||||
Assert.That(refused.Message, Does.Contain("k").And.Contain("m").And.Contain("code").And.Not.Contain(Canary));
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is untouched");
|
||||
|
||||
using var cache = new CultCache(indexRegistry);
|
||||
var store = new SingleFileMessagePackBackingStore(path);
|
||||
Refused(() => cache.AddBackingStore(store));
|
||||
Assert.That(cache.BackingStores, Is.Empty, "the refused store is not attached");
|
||||
Assert.That(cache.Get(Key("k")), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AWriteOntoAMovedFileIsAConflictAndTheRetryIsRefused()
|
||||
{
|
||||
var path = PathOf("items.cc");
|
||||
using var seed = Open(path);
|
||||
seed.Commit(batch => batch.Upsert(typeof(UqItem), Item("base"), Key("a")));
|
||||
using var second = Open(path);
|
||||
using (var first = Open(path))
|
||||
first.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("b")));
|
||||
|
||||
var conflict = Assert.Throws<CultWriteConflictException>(
|
||||
() => second.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("c"))))!;
|
||||
Assert.That(conflict.Message, Does.Contain("unique index"));
|
||||
Assert.That(second.Get(Key("b")), Is.Not.Null, "the moved record is loaded");
|
||||
|
||||
var retry = Refused(() => second.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("c"))));
|
||||
Assert.That(retry.Message, Does.Contain("b").And.Contain("c").And.Not.Contain(Canary));
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.Get(Key("c")), Is.Null, "the file never holds the pair");
|
||||
Assert.That(reopened.Get(Key("b")), Is.Not.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CachelessWritesOfAUniqueIndexedTypeAreRefused()
|
||||
{
|
||||
var path = PathOf("items.cc");
|
||||
using (var cache = Open(path))
|
||||
cache.Commit(batch => batch.Upsert(typeof(UqItem), Item("a"), Key("a")));
|
||||
var before = File.ReadAllBytes(path);
|
||||
var store = new SingleFileMessagePackBackingStore(path);
|
||||
|
||||
Refused(() => store.WriteDocument(Key("x"), Item(Canary), registry: Registry));
|
||||
var entry = Registry.GetRequired<UqItem>().ToCatalogEntry();
|
||||
Refused(() => store.WriteRecord(
|
||||
new CultPersistedRecord { Key = "y", SchemaId = entry.SchemaId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0x90 } },
|
||||
entry,
|
||||
registry: Registry));
|
||||
|
||||
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is byte-identical");
|
||||
store.WriteDocument(Key("t"), new UqTagged { Kind = "k" }, registry: Registry);
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.Get(Key("t")), Is.Not.Null, "a role-only type still writes cacheless");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AMemberWithBothAttributesIsRefusedAtRegistration()
|
||||
{
|
||||
var refused = Refused(() => CultDocumentRegistry.ForTypes(new[] { UqBoth }));
|
||||
Assert.That(refused.Message, Does.Contain("Both").And.Contain("[CultIndex]").And.Contain("[CultRole]"));
|
||||
}
|
||||
|
||||
[CultDocument("tests.uq_item", "tests.uq_item.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class UqItem
|
||||
{
|
||||
[Key(0)] [CultIndex("sku")] public string? Sku { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("tests.uq_twin", "tests.uq_twin.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class UqTwin
|
||||
{
|
||||
[Key(0)] [CultIndex("sku")] public string? Sku { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("tests.uq_tagged", "tests.uq_tagged.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
public sealed class UqTagged
|
||||
{
|
||||
[Key(0)] [CultRole("kind")] public string? Kind { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -580,7 +580,7 @@ namespace GameCult.Caching.Tests
|
|||
{
|
||||
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big")
|
||||
})));
|
||||
Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Contain("l1"));
|
||||
Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Not.Contain("l1"), "the value is not echoed");
|
||||
|
||||
var explicitly = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
|
||||
{
|
||||
|
|
@ -631,7 +631,7 @@ namespace GameCult.Caching.Tests
|
|||
SeedBig(cache);
|
||||
|
||||
var refused = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other)));
|
||||
Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("laser big"));
|
||||
Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Not.Contain("laser big"));
|
||||
|
||||
var single = Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => cache.UpsertAsync(Other("laser big"), new CultRecordHandle<VariantGear>(other)))!;
|
||||
|
|
@ -660,7 +660,7 @@ namespace GameCult.Caching.Tests
|
|||
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other));
|
||||
|
||||
var refused = Refused(() => SeedBig(cache));
|
||||
Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Contain("laser big"));
|
||||
Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Not.Contain("laser big"));
|
||||
Assert.That(cache.Get(BigKey), Is.Null, "nothing landed");
|
||||
|
||||
// The same batch, either staging order: the result is what is judged.
|
||||
|
|
@ -685,8 +685,9 @@ namespace GameCult.Caching.Tests
|
|||
}));
|
||||
Assert.That(sameBatchVariantFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker"));
|
||||
|
||||
// Plain-vs-plain sharing is not this rule's business.
|
||||
Assert.DoesNotThrow(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin"))));
|
||||
// Plain-vs-plain sharing is the same rule.
|
||||
var plain = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin"))));
|
||||
Assert.That(plain.Message, Does.Contain("twin").And.Contain(BaseKey.Value));
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
|
|
@ -819,7 +820,7 @@ namespace GameCult.Caching.Tests
|
|||
using var a = Open(path);
|
||||
using (var b = Open(path))
|
||||
b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z));
|
||||
a.Commit(batch => batch.Upsert(typeof(VariantGear), Other("yc"), new CultRecordKey("y")));
|
||||
a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "yc" }, new CultRecordKey("y")));
|
||||
|
||||
var conflict = Assert.Throws<CultWriteConflictException>(() => SeedBig(a))!;
|
||||
|
||||
|
|
@ -1100,22 +1101,8 @@ namespace GameCult.Caching.Tests
|
|||
Assert.That(reloaded.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
|
||||
}
|
||||
|
||||
// ---- the held index is truthful: every holder of a value, so no rule judges a variant against a lossy view ----
|
||||
|
||||
private static VariantGear Named(string name, string code) => new() { Name = name, Power = 1, Code = code };
|
||||
|
||||
private static CultRecordKey[] SharedCodePair(CultCache cache)
|
||||
{
|
||||
var p1 = new CultRecordKey("p1");
|
||||
var p2 = new CultRecordKey("p2");
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
batch.Upsert(typeof(VariantGear), Named("p1", "x"), p1);
|
||||
batch.Upsert(typeof(VariantGear), Named("p2", "x"), p2);
|
||||
});
|
||||
return new[] { p1, p2 };
|
||||
}
|
||||
|
||||
private static void VariantOnCode(CultCache cache, string code) =>
|
||||
cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
|
||||
{
|
||||
|
|
@ -1123,73 +1110,6 @@ namespace GameCult.Caching.Tests
|
|||
cache.Override<VariantGear>(nameof(VariantGear.Code), code)
|
||||
}));
|
||||
|
||||
[Test]
|
||||
public void RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable()
|
||||
{
|
||||
var path = PathOf("gear.cc");
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
var pair = SharedCodePair(cache);
|
||||
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"), "the smallest key");
|
||||
|
||||
cache.Remove(pair[0]);
|
||||
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p2"), "removing the holder finds the other one");
|
||||
|
||||
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p1", "x"), pair[0]));
|
||||
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"));
|
||||
cache.Remove(pair[1]);
|
||||
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"), "removing the other changes nothing");
|
||||
cache.Remove(pair[0]);
|
||||
Assert.That(cache.GetByIndex<VariantGear>("code", "x"), Is.Null);
|
||||
cache.BackingStores[0].PushAll();
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.GetByIndex<VariantGear>("code", "x"), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinWasRemovedIsRefusedAndTheStoreReopens()
|
||||
{
|
||||
var path = PathOf("gear.cc");
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
SeedBase(cache);
|
||||
var pair = SharedCodePair(cache);
|
||||
cache.Remove(pair[1]);
|
||||
|
||||
var refused = Refused(() => VariantOnCode(cache, "x"));
|
||||
Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("code").And.Contain("'x'"));
|
||||
Assert.That(cache.Get(BigKey), Is.Null, "nothing landed");
|
||||
cache.BackingStores[0].PushAll();
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.Get(BigKey), Is.Null);
|
||||
Assert.That(reopened.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinChangedItsValueIsRefusedAndTheStoreReopens()
|
||||
{
|
||||
var path = PathOf("gear.cc");
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
SeedBase(cache);
|
||||
var pair = SharedCodePair(cache);
|
||||
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p2", "y"), pair[1]));
|
||||
|
||||
var refused = Refused(() => VariantOnCode(cache, "x"));
|
||||
Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("'x'"));
|
||||
Assert.DoesNotThrow(() => VariantOnCode(cache, "z"), "an unshared value is fine");
|
||||
cache.BackingStores[0].PushAll();
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.GetByIndex<VariantGear>("code", "z")!.Name, Is.EqualTo("laser big"));
|
||||
Assert.That(reopened.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"));
|
||||
}
|
||||
|
||||
// ---- a merge onto the file is judged on the set the file will hold ----
|
||||
|
||||
[Test]
|
||||
|
|
@ -1218,7 +1138,7 @@ namespace GameCult.Caching.Tests
|
|||
var conflict = Assert.Throws<CultWriteConflictException>(Write)!;
|
||||
Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value));
|
||||
var refused = Refused(Write);
|
||||
Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared"), "the retry is judged by Resolve");
|
||||
Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Not.Contain("shared"), "the retry is judged by Resolve");
|
||||
Assert.That(b.Get(other), Is.Null, "nothing landed in memory");
|
||||
}
|
||||
|
||||
|
|
@ -1408,24 +1328,24 @@ namespace GameCult.Caching.Tests
|
|||
{
|
||||
var path = PathOf("gear.cc");
|
||||
var other = new CultRecordKey("other");
|
||||
var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantGear) });
|
||||
var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantOther) });
|
||||
using (var seed = Open(path))
|
||||
SeedBase(seed);
|
||||
|
||||
using (var a = Open(path))
|
||||
using (var b = Open(path, registry: narrow))
|
||||
{
|
||||
a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "foreign" }, new CultRecordKey("foreign")));
|
||||
a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("foreign", "foreign-code"), new CultRecordKey("foreign")));
|
||||
Assert.That(b.Commit(batch =>
|
||||
{
|
||||
batch.Expect(other, null);
|
||||
batch.Upsert(typeof(VariantGear), Named("other", "fine"), other);
|
||||
batch.Upsert(typeof(VariantOther), new VariantOther { Name = "fine" }, other);
|
||||
}), Is.True);
|
||||
}
|
||||
|
||||
using var reopened = Open(path);
|
||||
Assert.That(reopened.Get<VariantGear>(other), Is.Not.Null);
|
||||
Assert.That(reopened.Get<VariantOther>(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge");
|
||||
Assert.That(reopened.Get<VariantOther>(other), Is.Not.Null);
|
||||
Assert.That(reopened.Get<VariantGear>(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -1448,28 +1368,6 @@ namespace GameCult.Caching.Tests
|
|||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlainDuplicatesFindTheSmallestKeyLiveAfterAFlushAndAfterAReopen()
|
||||
{
|
||||
var path = PathOf("gear.cc");
|
||||
using (var cache = Open(path))
|
||||
{
|
||||
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("nb", "x"), new CultRecordKey("b")));
|
||||
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a")));
|
||||
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "live");
|
||||
cache.BackingStores[0].PushAll();
|
||||
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "after a flush");
|
||||
}
|
||||
|
||||
using (var reopened = Open(path))
|
||||
{
|
||||
Assert.That(reopened.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "after a reopen");
|
||||
reopened.Remove(new CultRecordKey("a"));
|
||||
reopened.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a")));
|
||||
Assert.That(reopened.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "rewriting the smaller key does not change the choice");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADroppedSlotIsReportedOncePerRecord()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -493,11 +493,11 @@ namespace GameCult.Networking.Tests
|
|||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("mass")]
|
||||
[CultRole("mass")]
|
||||
public float Mass;
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex("kind")]
|
||||
[CultRole("kind")]
|
||||
public string Kind = string.Empty;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -581,7 +581,7 @@ namespace GameCult.Networking.Tests
|
|||
return instance;
|
||||
}
|
||||
|
||||
// A plain data member "Owner" with no [CultIndex] (its role falls back to its bare name)
|
||||
// A plain data member "Owner" with no [CultRole] (its role falls back to its bare name)
|
||||
// and a reference member whose alias is explicitly "Owner" - the exact shape Soul probed.
|
||||
var ownerMember = MakeMember(typeof(GuardProbeShape).GetField(nameof(GuardProbeShape.Owner))!,
|
||||
typeof(string), 0, indexAlias: null, isReference: false, targetType: null);
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ namespace GameCult.Networking.Tests
|
|||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("kind")]
|
||||
[CultRole("kind")]
|
||||
public string Kind = string.Empty;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ namespace GameCult.Networking.Tests
|
|||
public sealed class SurvivorNumericLeaf
|
||||
{
|
||||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
[Key(1)] [CultIndex("level")] public int Level;
|
||||
[Key(1)] [CultRole("level")] public int Level;
|
||||
}
|
||||
|
||||
[CultDocument("cultnet.selection-tests.survivor_string_leaf", "cultnet.selection-tests.survivor_string_leaf.v1")]
|
||||
|
|
@ -70,7 +70,7 @@ namespace GameCult.Networking.Tests
|
|||
public sealed class SurvivorStringLeaf
|
||||
{
|
||||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
[Key(1)] [CultIndex("level")] public string Level = string.Empty;
|
||||
[Key(1)] [CultRole("level")] public string Level = string.Empty;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
|
@ -173,7 +173,7 @@ namespace GameCult.Networking.Tests
|
|||
// The declared index alias ("linkRole") differs from the member name ("Link") - the exact
|
||||
// shape section 2's role-naming rule exists for, and no prior fixture had one.
|
||||
[Key(1)]
|
||||
[CultIndex("linkRole")]
|
||||
[CultRole("linkRole")]
|
||||
[CultReference(typeof(SurvivorAliasTarget))]
|
||||
public CultRecordRef<SurvivorAliasTarget> Link;
|
||||
}
|
||||
|
|
@ -408,7 +408,7 @@ namespace GameCult.Networking.Tests
|
|||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("hopRole")]
|
||||
[CultRole("hopRole")]
|
||||
[CultReference(typeof(SurvivorAliasTarget))]
|
||||
public CultRecordRef<SurvivorAliasTarget> Link;
|
||||
}
|
||||
|
|
@ -431,12 +431,12 @@ namespace GameCult.Networking.Tests
|
|||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("zLastRole")]
|
||||
[CultRole("zLastRole")]
|
||||
[CultReference(typeof(SurvivorAliasTarget))]
|
||||
public CultRecordRef<SurvivorAliasTarget> RefZ;
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex("aFirstRole")]
|
||||
[CultRole("aFirstRole")]
|
||||
[CultReference(typeof(SurvivorAliasTarget))]
|
||||
public CultRecordRef<SurvivorAliasTarget> RefA;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -586,11 +586,11 @@ namespace GameCult.Networking.Tests
|
|||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("kind")]
|
||||
[CultRole("kind")]
|
||||
public string Kind = string.Empty;
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex("mass")]
|
||||
[CultRole("mass")]
|
||||
public float Mass;
|
||||
}
|
||||
|
||||
|
|
@ -631,7 +631,7 @@ namespace GameCult.Networking.Tests
|
|||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex("narrow_ref")]
|
||||
[CultRole("narrow_ref")]
|
||||
[CultReference(typeof(ParityLeafA))]
|
||||
public CultRecordRef<ParityLeafA> NarrowRef;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue