CultNet C#: a second type for one schema id is refused, typed

Soul F5. F7 (operator, 2026-09-30): "Schema aliasing sounds like a really
bad idea, I support retiring it." CultNetDocumentRegistry.Register wrote
both maps unconditionally, so the last binding for a schema id won.

- Register refuses a binding whose type already holds another schema id,
  or whose schema id another type holds, with the reference runtime's
  CultSchemaConflictException: the new binding's schema id, both document
  types, an empty record key. Nothing is bound on refusal. Binding a type
  to the schema id it holds again replaces the binding. This matches Rust
  (fc3f3ee5) and Kotlin.
- Tests: both claimant shapes in both orders with two bindings ahead of
  the holder (F6/M11), the first binding intact afterwards, and a same-type
  rebind replacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 19:49:58 +02:00
commit fa266e5829
2 changed files with 113 additions and 1 deletions

View file

@ -165,10 +165,31 @@ namespace GameCult.Networking
}
/// <summary>
/// Registers a document binding.
/// Registers a document binding. One binding owns a document type and a schema id: a binding claiming a type or a schema
/// id another binding already holds is refused with <see cref="CultSchemaConflictException"/>, naming the new binding's
/// schema id and both document types, and nothing is bound. Binding a type to the schema id it already holds replaces
/// that binding.
/// </summary>
public CultNetDocumentRegistry Register(CultNetDocumentBinding binding)
{
var holder =
_bindingsByType.TryGetValue(binding.DocumentType, out var sameType) &&
!string.Equals(sameType.SchemaId, binding.SchemaId, StringComparison.Ordinal)
? sameType
: _bindingsBySchemaId.TryGetValue(binding.SchemaId, out var sameSchema) &&
sameSchema.DocumentType != binding.DocumentType
? sameSchema
: null;
if (holder != null)
{
throw new CultSchemaConflictException(
$"CultNet schema id \"{holder.SchemaId}\" is already bound to type \"{holder.DocumentType.FullName}\"; " +
$"type \"{binding.DocumentType.FullName}\" (schema id \"{binding.SchemaId}\") cannot also be bound.",
binding.SchemaId,
new[] { holder.DocumentType.FullName ?? holder.DocumentType.Name, binding.DocumentType.FullName ?? binding.DocumentType.Name },
string.Empty);
}
_bindingsBySchemaId[binding.SchemaId] = binding;
_bindingsByType[binding.DocumentType] = binding;
return this;

View file

@ -0,0 +1,91 @@
#nullable enable
using System;
using GameCult.Caching;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." One binding owns a
// document type and a schema id, in the reference runtime as in Rust, Kotlin and TypeScript.
public sealed class CultNetDocumentRegistryConflictTests
{
private static CultNetDocumentBinding Bind<T>(string? schemaId = null) where T : class =>
CultNetDocumentBinding.ForDocument<T>(CultDocumentRegistry.Shared, schemaId);
// Two bindings stand ahead of the holder, so a conflict check that looks only at the first holder misses it.
private static CultNetDocumentRegistry RegistryHolding(CultNetDocumentBinding holder) =>
new CultNetDocumentRegistry(CultDocumentRegistry.Shared)
.Register(Bind<ConflictFillerA>())
.Register(Bind<ConflictFillerB>())
.Register(holder);
[Test]
public void A_second_type_for_one_schema_id_is_refused_typed_in_either_order()
{
var first = Bind<ConflictNote>("f5.shared");
var second = Bind<ConflictOtherNote>("f5.shared");
foreach (var (held, claimant) in new[] { (first, second), (second, first) })
{
var registry = RegistryHolding(held);
var error = Assert.Throws<CultSchemaConflictException>(() => registry.Register(claimant))!;
Assert.That(error.SchemaId, Is.EqualTo("f5.shared"));
Assert.That(error.SchemaNames, Is.EqualTo(new[] { held.DocumentType.FullName, claimant.DocumentType.FullName }));
Assert.That(error.RecordKey, Is.EqualTo(string.Empty));
Assert.That(error.Message, Does.Contain($"type \"{claimant.DocumentType.FullName}\""));
// Nothing was bound: the schema id still resolves to the first type, and the claimant's type to nothing.
Assert.That(registry.GetBySchemaId("f5.shared"), Is.SameAs(held));
Assert.That(registry.GetByDocumentType(claimant.DocumentType), Is.Null);
}
}
[Test]
public void A_type_bound_to_one_schema_id_cannot_be_bound_to_another()
{
var own = Bind<ConflictNote>();
var registry = RegistryHolding(own);
var error = Assert.Throws<CultSchemaConflictException>(() => registry.Register(Bind<ConflictNote>("f5.elsewhere")))!;
Assert.That(error.SchemaId, Is.EqualTo("f5.elsewhere"));
Assert.That(registry.GetBySchemaId("f5.elsewhere"), Is.Null);
Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(own));
}
[Test]
public void Binding_a_type_to_the_schema_id_it_holds_again_replaces_the_binding()
{
var registry = RegistryHolding(Bind<ConflictNote>());
var again = Bind<ConflictNote>();
registry.Register(again);
Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(again));
Assert.That(registry.GetBySchemaId(again.SchemaId), Is.SameAs(again));
}
[CultDocument("f5.conflict-note", "f5.conflict-note.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class ConflictNote
{
[Key(0)] public string Body = string.Empty;
}
[CultDocument("f5.conflict-other-note", "f5.conflict-other-note.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class ConflictOtherNote
{
[Key(0)] public string Body = string.Empty;
}
[CultDocument("f5.conflict-filler-a", "f5.conflict-filler-a.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class ConflictFillerA
{
[Key(0)] public string Body = string.Empty;
}
[CultDocument("f5.conflict-filler-b", "f5.conflict-filler-b.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class ConflictFillerB
{
[Key(0)] public string Body = string.Empty;
}
}
}