R-R fixup: v0's empty-answers-empty must not reach the v1 door

The first attempt at R-R built a CultNetSelection with an empty (non-null)
Keys/Schemas array straight from the lowered v0 filter and handed it to the
shared evaluator. CultNetSelectionValidation.ValidateShape refuses exactly
that shape ("selection.keys is present and empty"), because for the v1 typed
door an empty list is a caller mistake. v0 and v1 read the same emptiness
differently on purpose (v0: answer nothing; v1: refuse), so v0 must decide
its answer before the selection ever reaches Validate.

CultNetDocumentRegistry.CreateRawSnapshotResponse and
CultNetDatabase.CreateShardSnapshotResponse now short-circuit to an empty
document set when the lowered schemas or keys list is present-and-empty,
without constructing a CultNetSelection or calling into the evaluator at all.

Caught by the test added in the previous commit
(CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty),
which failed with CultNetSelectionInvalidException before this fix. Verified
on Yggdrasil: dotnet test tests/GameCult.Networking.Tests -c Release, all
green (217 passed, 2 skipped by design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 19:15:22 +02:00
commit 1afbaed775
2 changed files with 35 additions and 3 deletions

View file

@ -614,10 +614,27 @@ namespace GameCult.Networking
ThrowIfDisposed();
if (shard == null) throw new ArgumentNullException(nameof(shard));
var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds);
var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys);
// R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not
// reach the v1 door, which refuses an empty selection.schemas/keys outright.
if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 })
{
return new CultNetSnapshotResponseRawMessage
{
MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId,
Documents = Array.Empty<CultNetRawDocumentRecord>(),
ShardId = shard.ShardId,
ShardEpoch = shard.Epoch,
ShardLogSequence = GetLatestMutationLogSequence(shard.ShardId)
};
}
var selection = new CultNetSelection
{
Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds),
Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys),
Schemas = lowSchemas,
Keys = lowKeys,
Projection = CultNetSelectionProjections.Document
};
bool RowFilter(CultDocumentDescriptor descriptor, CultRecordKey key) =>

View file

@ -283,10 +283,25 @@ namespace GameCult.Networking
CultNetDocumentMessageOptions? options = null)
{
if (cache == null) throw new ArgumentNullException(nameof(cache));
var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds);
var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys);
// R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not
// reach the v1 door, which refuses an empty selection.schemas/keys outright
// (CultNetSelectionValidation.ValidateShape). v0 and v1 disagree about what an empty list
// means, so v0 decides this before the v1 evaluator ever sees the selection.
if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 })
{
return new CultNetSnapshotResponseRawMessage
{
MessageId = RequireNonEmpty(messageId, nameof(messageId)),
Documents = Array.Empty<CultNetRawDocumentRecord>()
};
}
var selection = new CultNetSelection
{
Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds),
Schemas = lowSchemas,
Keys = lowKeys,
Projection = CultNetSelectionProjections.Document
};