Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)` 序列。
1 change: 1 addition & 0 deletions .spec/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 为投递闸 | 生效 |
3 changes: 2 additions & 1 deletion .spec/knowledge/features/replica/replica-world-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"sdk": {
"version": "10.0.111",
"rollForward": "latestPatch",
"version": "10.0.400",
"rollForward": "disable",
"allowPrerelease": false
}
}
2 changes: 1 addition & 1 deletion modules/replica/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 原因。

Expand Down
2 changes: 0 additions & 2 deletions modules/replica/src/Internal/GameplayCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 1 addition & 21 deletions modules/replica/src/Public/ReplicaWorld.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
31 changes: 17 additions & 14 deletions modules/replica/tests/Fault/ReplicaMalformedChatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
54 changes: 54 additions & 0 deletions modules/replica/tests/Unit/ReplicaChatPresentationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReplicaChatLine> browserWindow = browser.ChatWindow;
IReadOnlyList<ReplicaChatLine> outOfAoiWindow = botOutOfAoi.ChatWindow;
IReadOnlyList<ReplicaChatLine> 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()
{
Expand Down
Loading