From c99d2e2afdbb6d9f257b135f345d758fb0fb61c6 Mon Sep 17 00:00:00 2001 From: Go1c <50826835+Go1c@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:19:12 +0800 Subject: [PATCH 1/3] fix(replica): do not gate room chat.event on receiver sender AOI Room-scoped chat.event is a C-1 ordered room broadcast. Receiver sender admission, InAoi, and tombstone only affect Attribute Query, so independent clients keep the same (MessageId, roomSequence) stream. --- ...om-chat-event-not-gated-by-receiver-aoi.md | 16 ++++++ .spec/decisions/README.md | 1 + .../features/replica/replica-world-chat.md | 3 +- modules/replica/README.md | 2 +- modules/replica/src/Internal/GameplayCodec.cs | 2 - modules/replica/src/Public/ReplicaWorld.cs | 22 +------- .../tests/Fault/ReplicaMalformedChatTests.cs | 31 ++++++----- .../Unit/ReplicaChatPresentationTests.cs | 54 +++++++++++++++++++ 8 files changed, 92 insertions(+), 39 deletions(-) create mode 100644 .spec/decisions/0006-room-chat-event-not-gated-by-receiver-aoi.md diff --git a/.spec/decisions/0006-room-chat-event-not-gated-by-receiver-aoi.md b/.spec/decisions/0006-room-chat-event-not-gated-by-receiver-aoi.md new file mode 100644 index 0000000..f622cc0 --- /dev/null +++ b/.spec/decisions/0006-room-chat-event-not-gated-by-receiver-aoi.md @@ -0,0 +1,16 @@ +# 0006 · Room 范围 chat.event 不以接收方 sender 投影 / AOI 为投递闸 + +- 日期:2026-09-01 +- 状态:生效 + +## 背景 + +C-1 `chat.event` 的 `dimensions.visibility` 为 `room`,由服务端盖章后经 Delta 可靠有序广播到本 Room。C-2 的实体存在性、`InAoi` 与 tombstone 是 Attribute Query 结局,不是聊天投递闸。若 Stage 用接收方 ReplicaWorld 的 sender 记录拒帧,同一 Delta 会在两端产生不同 `(MessageId, roomSequence)` 流。 + +## 决策 + +已解码的 Room `chat.event` 只强制 C-1 接收方规则(payload hash、block kind、序号单调、text 上限)。接收方是否已 Admit 发送者、发送者是否 `InAoi` 或 tombstoned,只影响本连接 Query,不决定是否入窗。 + +## 后果 + +聊天窗可以显示尚未投影、当前 AOI 外或已 tombstone 的发送者 NetEntityId。两端可见集不一致时,独立 ReplicaWorld 仍对同一 Delta 得到相同的 `(MessageId, roomSequence)` 序列。 diff --git a/.spec/decisions/README.md b/.spec/decisions/README.md index 132f91e..d995b36 100644 --- a/.spec/decisions/README.md +++ b/.spec/decisions/README.md @@ -33,3 +33,4 @@ | [`0003`](0003-a1-client-wss-access-landing-sites.md) | A1 客户端接入的三项落点:凭据随创建请求、Envelope 构造留在组装根、WSS 进既有 connection 工程 | 生效 | | [`0004`](0004-architecture-source-readonly-mirror.md) | 架构源发布物以整目录只读镜像消费,硬校验与上游同步拆成两条独立检查 | 生效 | | [`0005`](0005-chat-event-netentityid-string-bridge.md) | Chat event sender NetEntityId 以十进制字符串桥接到 C-2 不透明身份 | 生效 | +| [`0006`](0006-room-chat-event-not-gated-by-receiver-aoi.md) | Room 范围 chat.event 不以接收方 sender 投影 / AOI 为投递闸 | 生效 | diff --git a/.spec/knowledge/features/replica/replica-world-chat.md b/.spec/knowledge/features/replica/replica-world-chat.md index 3ffd397..5d2405a 100644 --- a/.spec/knowledge/features/replica/replica-world-chat.md +++ b/.spec/knowledge/features/replica/replica-world-chat.md @@ -13,11 +13,12 @@ metadata: ## 设计 - **绑定与查询**:准入写入 C-2 五元组;`SelfLookup` 与 `QueryAttribute` 只读本连接副本。client-replica 仅可读 `replication=replicated` 且当前可见的 AttributeId;persist-only / server-only 返回 `invisible`。 -- **聊天呈现**:`chat.event` 仅经 Delta 追加到客户端聊天窗(MessageId、Room sequence、sender NetEntityId、text)。FullSnapshot 清空窗口,不回放历史。畸形/未授权事件在 Stage 拒绝,零可见突变。 +- **聊天呈现**:`chat.event` 仅经 Delta 追加到客户端聊天窗(MessageId、Room sequence、sender NetEntityId、text)。C-1 `visibility=room` 只校验信封/摘要/序号/text 上限;接收方是否已 Admit 发送者、发送者是否 InAoi / tombstoned 只影响 Attribute Query,不决定是否入窗。FullSnapshot 清空窗口,不回放历史。畸形信封在 Stage 拒绝,零可见突变。 - **消费者**:`ReplicaChatConsumer` 区分 Browser 与 Bot;二者不得共享 World/Entity 引用。浏览器静态页 `modules/web/chat/` 只渲染已接受事件,不扩展 hello-wire-v1。 - **契约**:字段真值是架构仓 C-1 / C-2 JSON。测试定位 `origin/main` 文件,本仓不内嵌协议副本。 ## 相关 - [`0005`](../../../decisions/0005-chat-event-netentityid-string-bridge.md) +- [`0006`](../../../decisions/0006-room-chat-event-not-gated-by-receiver-aoi.md) - [`replica` 模块 README](../../../../modules/replica/README.md) diff --git a/modules/replica/README.md b/modules/replica/README.md index d15fc11..af8afb6 100644 --- a/modules/replica/README.md +++ b/modules/replica/README.md @@ -16,7 +16,7 @@ - 维护客户端 Baseline、SnapshotId、ReplicationRevision、Sequence 和 Mapping Hash 视图。 - 使用生成 Mapping 将 Server Component 投影到允许的 Client Component/Field。 - 维护 `NetEntityId -> LocalEntityId` 映射、Destroy Tombstone 和 provisional ID 确认重映射。 -- 为每个连接维护独立 `ReplicaWorld`:准入绑定、可见实体投影、client-replica Attribute Query、以及仅经权威事务提交后追加的 Room 聊天呈现。 +- 为每个连接维护独立 `ReplicaWorld`:准入绑定、可见实体投影、client-replica Attribute Query、以及仅经权威事务提交后追加的 Room 聊天呈现(投递闸是 C-1 信封/序号,不是接收方 sender AOI)。 - 检测 Gap、未知 Baseline、旧 Revision、重复/迟到 Delta、Mapping 不匹配和 Tombstone 冲突。 - 输出 Apply Result、BaselineAck/DeltaAck 或明确的 ResyncRequest 原因。 diff --git a/modules/replica/src/Internal/GameplayCodec.cs b/modules/replica/src/Internal/GameplayCodec.cs index 559ab68..221d4fa 100644 --- a/modules/replica/src/Internal/GameplayCodec.cs +++ b/modules/replica/src/Internal/GameplayCodec.cs @@ -16,8 +16,6 @@ internal static class GameplayReject public const string BlockOrderViolation = "block_order_violation"; public const string StateBlockKindMismatch = "state_block_kind_mismatch"; public const string ChatTextTooLong = "chat_text_too_long"; - public const string Unauthorized = "unauthorized"; - public const string Tombstoned = "tombstoned"; } internal static class GameplayMappings diff --git a/modules/replica/src/Public/ReplicaWorld.cs b/modules/replica/src/Public/ReplicaWorld.cs index 7a50fd2..364cd1d 100644 --- a/modules/replica/src/Public/ReplicaWorld.cs +++ b/modules/replica/src/Public/ReplicaWorld.cs @@ -228,6 +228,7 @@ internal bool TryValidateAuthority(in ReplicaStageRequest request, out string re } DecodedChatEvent chat = block.ChatEvent; + // Room-scoped chat.event: C-1 envelope/sequence only. Receiver AOI/admission does not gate delivery. bool sequenceOk = _lastRoomSequence == 0UL ? chat.RoomSequence > 0UL : chat.RoomSequence == _lastRoomSequence + 1UL; @@ -237,27 +238,6 @@ internal bool TryValidateAuthority(in ReplicaStageRequest request, out string re _lastRejectCode = rejectCode; return false; } - - if (!_entities.TryGetValue(chat.SenderNetEntityId, out EntityRecord sender)) - { - rejectCode = GameplayReject.Unauthorized; - _lastRejectCode = rejectCode; - return false; - } - - if (sender.Tombstoned) - { - rejectCode = GameplayReject.Tombstoned; - _lastRejectCode = rejectCode; - return false; - } - - if (!sender.InAoi) - { - rejectCode = GameplayReject.Unauthorized; - _lastRejectCode = rejectCode; - return false; - } } _lastRejectCode = string.Empty; diff --git a/modules/replica/tests/Fault/ReplicaMalformedChatTests.cs b/modules/replica/tests/Fault/ReplicaMalformedChatTests.cs index f22fe35..62146e2 100644 --- a/modules/replica/tests/Fault/ReplicaMalformedChatTests.cs +++ b/modules/replica/tests/Fault/ReplicaMalformedChatTests.cs @@ -67,50 +67,53 @@ public void RoomSequenceRegressionDoesNotAppend() } [Fact] - public void TombstonedSenderEventDoesNotAppend() + public void TombstonedSenderRoomEventStillAppends() { ReplicaChatConsumer consumer = GameplayWireFixtures.CreateConsumer(ReplicaClientKind.Browser); Assert.True(GameplayWireFixtures.AdmitRoom( consumer.World, extras: new[] { GameplayWireFixtures.Entity("101", "bot", "room-01", 1, 1, 0, tombstoned: true) }).Accepted); Assert.True(GameplayWireFixtures.CommitEmptySnapshot(consumer.Replica)); - ReplicaStageStatus staged = GameplayWireFixtures.StageJson( + Assert.True(GameplayWireFixtures.CommitJson( consumer.Replica, ReplicaUpdateKind.Delta, GameplayWireFixtures.ContractChatDelta(), 2, 10, 0, - 1, - out _); - Assert.Equal(ReplicaStageStatus.Rejected, staged); - Assert.Empty(consumer.ChatWindow); - Assert.Equal("tombstoned", consumer.World.LastRejectCode); + 1)); + Assert.Single(consumer.ChatWindow); + Assert.Equal(1UL, consumer.ChatWindow[0].MessageId); + Assert.Equal(1UL, consumer.ChatWindow[0].RoomSequence); + Assert.Equal("101", consumer.ChatWindow[0].SenderNetEntityId); Assert.Equal( ReplicaQueryStatus.Tombstoned, consumer.World.QueryAttribute(new ReplicaAttributeQuery("client-replica", "room-01", "101", "EntityIdentity.entityType")).Status); } [Fact] - public void InvisibleSenderEventDoesNotAppend() + public void InvisibleSenderRoomEventStillAppends() { ReplicaChatConsumer consumer = GameplayWireFixtures.CreateConsumer(ReplicaClientKind.Browser); Assert.True(GameplayWireFixtures.AdmitRoom( consumer.World, extras: new[] { GameplayWireFixtures.Entity("101", "bot", "room-01", 1, 1, 0, inAoi: false) }).Accepted); Assert.True(GameplayWireFixtures.CommitEmptySnapshot(consumer.Replica)); - ReplicaStageStatus staged = GameplayWireFixtures.StageJson( + Assert.True(GameplayWireFixtures.CommitJson( consumer.Replica, ReplicaUpdateKind.Delta, GameplayWireFixtures.ContractChatDelta(), 2, 10, 0, - 1, - out _); - Assert.Equal(ReplicaStageStatus.Rejected, staged); - Assert.Empty(consumer.ChatWindow); - Assert.Equal("unauthorized", consumer.World.LastRejectCode); + 1)); + Assert.Single(consumer.ChatWindow); + Assert.Equal(1UL, consumer.ChatWindow[0].MessageId); + Assert.Equal(1UL, consumer.ChatWindow[0].RoomSequence); + Assert.Equal("101", consumer.ChatWindow[0].SenderNetEntityId); + Assert.Equal( + ReplicaQueryStatus.Invisible, + consumer.World.QueryAttribute(new ReplicaAttributeQuery("client-replica", "room-01", "101", "EntityIdentity.entityType")).Status); } private static void AssertRejected( diff --git a/modules/replica/tests/Unit/ReplicaChatPresentationTests.cs b/modules/replica/tests/Unit/ReplicaChatPresentationTests.cs index 3d7957e..71f4aec 100644 --- a/modules/replica/tests/Unit/ReplicaChatPresentationTests.cs +++ b/modules/replica/tests/Unit/ReplicaChatPresentationTests.cs @@ -72,6 +72,60 @@ public void TwoClientsReceiveIdenticalMessageIdAndRoomSequenceWithoutSharedRefer Assert.Equal("101", botWindow[1].SenderNetEntityId); } + [Fact] + public void TwoClientsReceiveIdenticalChatStreamWhenSenderAdmissionAndAoiDiverge() + { + ReplicaChatConsumer browser = GameplayWireFixtures.CreateConsumer(ReplicaClientKind.Browser); + ReplicaChatConsumer botOutOfAoi = GameplayWireFixtures.CreateConsumer(ReplicaClientKind.Bot); + ReplicaChatConsumer botWithoutSender = GameplayWireFixtures.CreateConsumer(ReplicaClientKind.Bot); + ReplicaVisibleEntity inAoiSender = GameplayWireFixtures.Entity("101", "bot", "room-01", 1, 1, 0); + ReplicaVisibleEntity outOfAoiSender = GameplayWireFixtures.Entity("101", "bot", "room-01", 1, 1, 0, inAoi: false); + Assert.True(GameplayWireFixtures.AdmitRoom(browser.World, extras: new[] { inAoiSender }).Accepted); + Assert.True(GameplayWireFixtures.AdmitRoom(botOutOfAoi.World, "2", "player", extras: new[] { outOfAoiSender }).Accepted); + Assert.True(GameplayWireFixtures.AdmitRoom(botWithoutSender.World, "3", "player").Accepted); + Assert.True(GameplayWireFixtures.CommitEmptySnapshot(browser.Replica)); + Assert.True(GameplayWireFixtures.CommitEmptySnapshot(botOutOfAoi.Replica)); + Assert.True(GameplayWireFixtures.CommitEmptySnapshot(botWithoutSender.Replica)); + + (string payload2, string sha2) = GameplayWireFixtures.EncodeChatEvent(2, 2, 101, "hi", 8); + string first = GameplayWireFixtures.ContractChatDelta(); + string second = GameplayWireFixtures.ChatDelta(payload2, sha2, 8, 2); + + Assert.True(GameplayWireFixtures.CommitJson(browser.Replica, ReplicaUpdateKind.Delta, first, 2, 10, 0, 1)); + Assert.True(GameplayWireFixtures.CommitJson(botOutOfAoi.Replica, ReplicaUpdateKind.Delta, first, 2, 10, 0, 1)); + Assert.True(GameplayWireFixtures.CommitJson(botWithoutSender.Replica, ReplicaUpdateKind.Delta, first, 2, 10, 0, 1)); + Assert.True(GameplayWireFixtures.CommitJson(browser.Replica, ReplicaUpdateKind.Delta, second, 3, 10, 1, 2)); + Assert.True(GameplayWireFixtures.CommitJson(botOutOfAoi.Replica, ReplicaUpdateKind.Delta, second, 3, 10, 1, 2)); + Assert.True(GameplayWireFixtures.CommitJson(botWithoutSender.Replica, ReplicaUpdateKind.Delta, second, 3, 10, 1, 2)); + + IReadOnlyList browserWindow = browser.ChatWindow; + IReadOnlyList outOfAoiWindow = botOutOfAoi.ChatWindow; + IReadOnlyList missingSenderWindow = botWithoutSender.ChatWindow; + Assert.NotSame(browser.Replica, botOutOfAoi.Replica); + Assert.NotSame(browser.World, botWithoutSender.World); + Assert.Equal(2, browserWindow.Count); + Assert.Equal( + browserWindow.Select(line => (line.MessageId, line.RoomSequence)).ToArray(), + outOfAoiWindow.Select(line => (line.MessageId, line.RoomSequence)).ToArray()); + Assert.Equal( + browserWindow.Select(line => (line.MessageId, line.RoomSequence)).ToArray(), + missingSenderWindow.Select(line => (line.MessageId, line.RoomSequence)).ToArray()); + Assert.Equal("101", outOfAoiWindow[0].SenderNetEntityId); + Assert.Equal("101", missingSenderWindow[1].SenderNetEntityId); + Assert.Equal( + ReplicaQueryStatus.Ok, + browser.World.QueryAttribute( + new ReplicaAttributeQuery("client-replica", "room-01", "101", "EntityIdentity.entityType")).Status); + Assert.Equal( + ReplicaQueryStatus.Invisible, + botOutOfAoi.World.QueryAttribute( + new ReplicaAttributeQuery("client-replica", "room-01", "101", "EntityIdentity.entityType")).Status); + Assert.Equal( + ReplicaQueryStatus.NonExistent, + botWithoutSender.World.QueryAttribute( + new ReplicaAttributeQuery("client-replica", "room-01", "101", "EntityIdentity.entityType")).Status); + } + [Fact] public void FullSnapshotClearsChatWindowAndDoesNotRestoreHistory() { From 00061f4707dea17bd052606c5433a461fb50e59e Mon Sep 17 00:00:00 2001 From: Go1c <50826835+Go1c@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:31:04 +0800 Subject: [PATCH 2/3] fix(toolchain): align SDK pin copies to global.json 10.0.111 disable --- eng/verify-toolchain.ps1 | 2 +- eng/verify-toolchain.sh | 2 +- global.json | 2 +- .../Toolchain/ToolchainPolicyTests.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/verify-toolchain.ps1 b/eng/verify-toolchain.ps1 index 3e2406a..e77faa0 100644 --- a/eng/verify-toolchain.ps1 +++ b/eng/verify-toolchain.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = "Stop" $Root = Split-Path -Parent $PSScriptRoot Set-Location $Root dotnet --info -if ((Get-Content "./global.json" -Raw) -notmatch '"version":\s*"10.0.400"') { throw "SDK pin missing" } +if ((Get-Content "./global.json" -Raw) -notmatch '"version":\s*"10.0.111"') { throw "SDK pin missing" } dotnet restore --locked-mode dotnet format --verify-no-changes --no-restore dotnet build -c Release --no-restore diff --git a/eng/verify-toolchain.sh b/eng/verify-toolchain.sh index f3eef61..1d7b154 100644 --- a/eng/verify-toolchain.sh +++ b/eng/verify-toolchain.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" dotnet --info -grep -q '10.0.400' global.json +grep -q '10.0.111' global.json dotnet restore --locked-mode dotnet format --verify-no-changes --no-restore dotnet build -c Release --no-restore diff --git a/global.json b/global.json index 43554fc..ff1c69a 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "version": "10.0.111", - "rollForward": "latestPatch", + "rollForward": "disable", "allowPrerelease": false } } diff --git a/tests/Lumio.Client.ArchitectureTests/Toolchain/ToolchainPolicyTests.cs b/tests/Lumio.Client.ArchitectureTests/Toolchain/ToolchainPolicyTests.cs index 8954d01..28ef016 100644 --- a/tests/Lumio.Client.ArchitectureTests/Toolchain/ToolchainPolicyTests.cs +++ b/tests/Lumio.Client.ArchitectureTests/Toolchain/ToolchainPolicyTests.cs @@ -12,7 +12,7 @@ public void GlobalJsonPinsSdkAndDisablesRollForward() Assert.True(File.Exists(path), "global.json must exist"); using var doc = JsonDocument.Parse(File.ReadAllText(path)); var sdk = doc.RootElement.GetProperty("sdk"); - Assert.Equal("10.0.400", sdk.GetProperty("version").GetString()); + Assert.Equal("10.0.111", sdk.GetProperty("version").GetString()); Assert.Equal("disable", sdk.GetProperty("rollForward").GetString()); Assert.False(sdk.GetProperty("allowPrerelease").GetBoolean()); } From 47d1a348a0a045d211eeea35aa35c838a5bda30c Mon Sep 17 00:00:00 2001 From: Go1c <50826835+Go1c@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:31:28 +0800 Subject: [PATCH 3/3] fix(toolchain): restore SDK pin 10.0.400 disable for pin gate --- eng/verify-toolchain.ps1 | 2 +- eng/verify-toolchain.sh | 2 +- global.json | 2 +- .../Toolchain/ToolchainPolicyTests.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/verify-toolchain.ps1 b/eng/verify-toolchain.ps1 index e77faa0..3e2406a 100644 --- a/eng/verify-toolchain.ps1 +++ b/eng/verify-toolchain.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = "Stop" $Root = Split-Path -Parent $PSScriptRoot Set-Location $Root dotnet --info -if ((Get-Content "./global.json" -Raw) -notmatch '"version":\s*"10.0.111"') { throw "SDK pin missing" } +if ((Get-Content "./global.json" -Raw) -notmatch '"version":\s*"10.0.400"') { throw "SDK pin missing" } dotnet restore --locked-mode dotnet format --verify-no-changes --no-restore dotnet build -c Release --no-restore diff --git a/eng/verify-toolchain.sh b/eng/verify-toolchain.sh index 1d7b154..f3eef61 100644 --- a/eng/verify-toolchain.sh +++ b/eng/verify-toolchain.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" dotnet --info -grep -q '10.0.111' global.json +grep -q '10.0.400' global.json dotnet restore --locked-mode dotnet format --verify-no-changes --no-restore dotnet build -c Release --no-restore diff --git a/global.json b/global.json index ff1c69a..8aaa898 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.111", + "version": "10.0.400", "rollForward": "disable", "allowPrerelease": false } diff --git a/tests/Lumio.Client.ArchitectureTests/Toolchain/ToolchainPolicyTests.cs b/tests/Lumio.Client.ArchitectureTests/Toolchain/ToolchainPolicyTests.cs index 28ef016..8954d01 100644 --- a/tests/Lumio.Client.ArchitectureTests/Toolchain/ToolchainPolicyTests.cs +++ b/tests/Lumio.Client.ArchitectureTests/Toolchain/ToolchainPolicyTests.cs @@ -12,7 +12,7 @@ public void GlobalJsonPinsSdkAndDisablesRollForward() Assert.True(File.Exists(path), "global.json must exist"); using var doc = JsonDocument.Parse(File.ReadAllText(path)); var sdk = doc.RootElement.GetProperty("sdk"); - Assert.Equal("10.0.111", sdk.GetProperty("version").GetString()); + Assert.Equal("10.0.400", sdk.GetProperty("version").GetString()); Assert.Equal("disable", sdk.GetProperty("rollForward").GetString()); Assert.False(sdk.GetProperty("allowPrerelease").GetBoolean()); }