diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 68b9cb49..58aede9d 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -857,9 +857,11 @@ namespace GameCult.Mesh /// /// Resolves one selection for a typed convenience call: explicit - /// win; otherwise an explicit, non-empty means no schema filter; - /// otherwise the selection defaults to the document type's own schema. The one owner of that - /// rule (docs/cultnet-selection-cut.md, section 4). + /// win; otherwise an explicit - including one that lowers to an + /// empty array - means no schema filter; otherwise the selection defaults to the document + /// type's own schema. R-R (2026-09-22): an empty is a real filter + /// (answers nothing), not "no filter", so it must not fall through to the schema default + /// either. The one owner of that rule (docs/cultnet-selection-cut.md, section 4). /// internal static CultNetSelection ResolveDefaultSelection( IReadOnlyList? schemaIds, diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 84871469..de275adf 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -283,14 +283,32 @@ namespace GameCult.Networking CultNetDocumentMessageOptions? options = null) { if (cache == null) throw new ArgumentNullException(nameof(cache)); + var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys); var selection = new CultNetSelection { Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds), - Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys), + Keys = lowKeys, Projection = CultNetSelectionProjections.Document }; - var page = SelectAll(cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, options); + // R-R: v0's pre-cut CreateRawSnapshotResponse (b3d9cf7) iterated the requested record keys + // in the order the caller sent them (a HashSet built from the array, enumerated in + // insertion order). The evaluator's own tiebreak (schema then key) only applies when the + // caller did not name keys. + Func ordinalOf; + if (lowKeys is { Length: > 0 }) + { + var order = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < lowKeys.Length; i++) + order.TryAdd(lowKeys[i], i); + ordinalOf = (_, key) => order.TryGetValue(key.Value, out var index) ? index : lowKeys.Length; + } + else + { + ordinalOf = static (_, _) => 0; + } + + var page = SelectAll(cache, selection, ordinalOf, asOf: 0, options); return new CultNetSnapshotResponseRawMessage { diff --git a/src/GameCult.Networking/CultNetSelection.cs b/src/GameCult.Networking/CultNetSelection.cs index 2ae88305..ed4ad9b0 100644 --- a/src/GameCult.Networking/CultNetSelection.cs +++ b/src/GameCult.Networking/CultNetSelection.cs @@ -210,17 +210,26 @@ namespace GameCult.Networking /// public static class CultNetV0SelectionLowering { - /// Lowers one v0 filter list; see the type summary. + /// + /// Lowers one v0 filter list; see the type summary. R-R (2026-09-22): a null list means + /// "no filter" and stays null; a non-null list, even one that filters down to nothing, + /// stays a real (possibly empty) array. Collapsing an explicit empty list to "no filter" was + /// wrong - the pre-cut reference (CultNetDatabase.CreateShardSnapshotResponse at + /// b3d9cf7) tests filter?.RecordKeys != null, not a length, so + /// recordKeys: [] always answered empty. The evaluator already honours that distinction + /// (CultNetSelectionEvaluator: a non-null Keys array excludes every row when it + /// is empty); only this lowering used to erase it before the selection ever reached the + /// evaluator. + /// public static string[]? Lower(IReadOnlyList? values) { - if (values is not { Count: > 0 }) + if (values == null) return null; - var filtered = values + return values .Where(value => !string.IsNullOrWhiteSpace(value)) .Distinct(StringComparer.Ordinal) .ToArray(); - return filtered.Length == 0 ? null : filtered; } } diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index f829b369..906f564f 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2746,9 +2746,8 @@ public sealed class CultMeshStreamingTests (await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key"); - // MESH-KEY-Loosening (docs/cultnet-selection-cut.md, section 4/S2-4): the recordKey match is - // exact (ordinal), not case-insensitive. A case-variant key must not win even when it appears - // first in the response. + // The recordKey match is exact (ordinal), not case-insensitive. A case-variant key must not + // win even when it appears first in the response. var caseVariantKey = key.Value.ToUpperInvariant(); var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot( _ => Task.FromResult(new CultNetSnapshotResponseRawMessage @@ -2794,13 +2793,14 @@ public sealed class CultMeshStreamingTests (await caseVariantKeyHandle.LatestAsync()).Text.Should().Be("right-case-key"); } - // MESH-DEFAULT-Loosening (docs/cultnet-selection-cut.md, section 4/S2-7): ResolveDefaultSelection's - // no-schema-filter branch must trigger on a non-empty recordKeys, not merely a non-null one. An - // explicit empty recordKeys array is a v0-compatible list that lowers to null (rule 1's v0 - // lowering), so it does not suppress the type's own schema default - the wire must carry - // [T.SchemaId], not schemas=null. + // R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22) corrects the earlier commit-2 + // ruling this test used to pin: an explicit empty recordKeys array is a real filter, not "no + // filter". ResolveDefaultSelection must not fall back to the type's own schema default just + // because the caller's recordKeys happened to be empty - the wire must carry recordKeys=[] and + // no schema default, and the answer is empty, exactly as v0 answered before this cut + // (CultNetDatabase.CreateShardSnapshotResponse at b3d9cf7 tests RecordKeys != null, not a length). [Test] - public async Task FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema() + public async Task FetchDocumentsAsync_WithExplicitEmptyRecordKeys_AnswersEmpty() { var cache = new CultCache(); var key = new CultRecordKey("mesh-note:empty-keys-default"); @@ -2828,11 +2828,11 @@ public sealed class CultMeshStreamingTests var documents = await session.FetchDocumentsAsync(recordKeys: Array.Empty()); - documents.Should().ContainSingle().Which.Text.Should().Be("empty-keys-default"); + documents.Should().BeEmpty(); requests.Should().ContainSingle(); - requests[0].SchemaIds.Should().ContainSingle().Which - .Should().Be(CultDocumentRegistry.Shared.GetRequired().SchemaId); - requests[0].RecordKeys.Should().BeNull(); + requests[0].SchemaIds.Should().BeNull(); + requests[0].RecordKeys.Should().NotBeNull(); + requests[0].RecordKeys.Should().BeEmpty(); } [Test] diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 7784b02c..6d28c2b7 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -3284,6 +3284,63 @@ namespace GameCult.Networking.Tests Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload)); } + // R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0 lowering preserves v0's + // pre-cut answers exactly. recordKeys: [] answers empty - it does not lower to "no filter". + [Test] + public async Task CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForDocument( + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + await cache.AddAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@test", PasswordHash = "x", Username = "A" }); + + var request = registry.CreateSnapshotRequest("request-empty", recordKeys: Array.Empty()); + var response = registry.CreateRawSnapshotResponse(cache, "snapshot-empty", request); + + Assert.That(response.Documents, Is.Empty); + } + + // R-R: v0's pre-cut CreateRawSnapshotResponse iterated the requested record keys in the order + // the caller sent them, not in schema-and-key sort order. This pins that order survives the + // move to the shared evaluator. + [Test] + public async Task CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForDocument( + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + + var players = new[] { "zeta", "alpha", "mid" } + .Select(name => new PlayerData { PlayerId = Guid.NewGuid(), Email = $"{name}@test", PasswordHash = "x", Username = name }) + .ToArray(); + var handles = new List>(); + foreach (var player in players) + handles.Add(await cache.AddAsync(player)); + + // Request in an order that matches neither the insertion order nor the ordinal-sorted + // record-key order, so a schema-and-key sort would visibly reorder the response. + var requestedOrder = new[] { handles[2].Key.Value, handles[0].Key.Value, handles[1].Key.Value }; + var request = registry.CreateSnapshotRequest("request-order", recordKeys: requestedOrder); + var response = registry.CreateRawSnapshotResponse(cache, "snapshot-order", request); + + Assert.That(response.Documents.Select(d => d.RecordKey), Is.EqualTo(requestedOrder)); + } + + [Test] + public void CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty() + { + Assert.That(CultNetV0SelectionLowering.Lower(null), Is.Null); + Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty()), Is.Not.Null); + Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty()), Is.Empty); + Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Not.Null); + Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Empty); + Assert.That(CultNetV0SelectionLowering.Lower(new[] { "b", "a", "b" }), Is.EqualTo(new[] { "b", "a" })); + } + [Test] public async Task CultNetDatabase_PutGet_AndWatchByIndex_Uses_PrimaryShard() {