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
5 changes: 3 additions & 2 deletions .spec/knowledge/features/rust-entity-chat-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ ADR-056:Rust 宿主是接力交付面,只托管与传输。Room 世界是 Ru
- **会话表**:只保存 `connection ↔ Runtime 绑定句柄` 与 `sess-*` 会话号。`NetEntityId` 由 Runtime 身份表发号(32 位小写 hex)。
- **Runtime 消费**:CoreCLR `entity-chat-host` 转发 `Admit` / `Disconnect` / `Rebind` / `Expire` / `QueryAttribute` / `BuildFullSnapshot` / `BuildDelta` / `CapturePersist` / `RestorePersist`。恢复路径不 `Admit`、不新建 Active 绑定。
- **定时**:host-runtime 是 NativeCore ABI 适配层。五分钟断线保留走 `wallClock` one-shot;Tick 走 `tickFrame` repeating。删除 `expire_due` 轮询。
- **Room 网线**:loopback WebSocket。准入/重连发送 Runtime `BuildFullSnapshot`(含 `stateBlocks`);每 Tick 把 `BuildDelta` 字节广播给本 Room 连接;顶号先发 `ConnectionSuperseded` 再关旧连接。S8 证据 `connectionSupersededReceived` 只来自旧 `RoomClient` 收帧,不得用宿主 `takeover` 布尔冒充。
- **Room 网线**:loopback WebSocket。准入/重连发送 Runtime `BuildFullSnapshot`(含 `stateBlocks`);每 Tick 把 `BuildDelta` 字节广播给本 Room 连接;同一 `connectionId` 可有多个观察者(Playwright + harness),后连者不得顶掉先连者的 egress。顶号先发 `ConnectionSuperseded` 再关旧连接。S8 证据 `connectionSupersededReceived` 只来自旧 `RoomClient` 收帧,不得用宿主 `takeover` 布尔冒充。S3 在 101 条 `chat.input` 之前挂上 `c-browser` Room WS;`playwrightRan` 只在浏览器真正从网线收到 Room 帧时为 true。
- **解析 / 查询**:`ResolveByNetEntityId` 接受 Runtime 32-hex 与 C-1 u64;HostEntry 把 Runtime `OkEntity`(无 Binding)补成列出的五元组。S5 unauthorized 走声明过的 claim-scoped `EntityIdentity.claimedMark`(`restrictedFlag` 未声明 → `RequestError`,不得冒充 Unauthorized)。
- **Tick 分批**:Runtime `ChatCommandRuntime.RunTick` 经 `ChatIngressWorld` 默认 `EcsBudget.MaxChangeEntries=128`。每条 `chat.input` 写两个 ChatComponent 字段,单 Tick 最多 64 条;超过则 `Command reservation budget exceeded`、Runtime `_faulted`、`BuildDelta` 为 `changedBlocks:[]`。宿主按 `MAX_CHAT_INPUTS_PER_TICK` 穿插 `tickFrame`,不自建第二份事件队列。
- **Persist**:`CapturePersist` / `RestorePersist` 走 Runtime 公开面(`RestorePersist` 的第二参是 `ReadOnlyMemory<byte>`)。默认 `MaxSnapshotBytes=4096` 只能装下约 6 个聊天实体;101 实体 Capture 为 Retryable 时不得把 `restoredWindow: 0` / `processB=null` 写成 S7 ok。
- **发现**:外部产物经 `LUMIO_*` 环境变量与仓根相对路径;缺失即 BLOCKED,不硬编码开发机绝对路径。
- **复跑**:`lumio-entity-chat-replay` 两轮;`manifest.conclusion=SUCCESS` 只在 Game `verify-evidence.mjs` oracle 通过之后写。`--restore-snapshot` 供 S7 进程 B 单独启 CLR 恢复。

## 待解决

- 完整 101 实体 acceptance 依赖 Runtime / NativeCore / Game 产物路径;缺失时测试以 BLOCKED 失败而非跳过。
- 完整 101 实体 acceptance 依赖 Runtime / NativeCore / Game 产物路径;缺失时测试以 BLOCKED 失败而非跳过。S3 的 Playwright Room 观察同样依赖 `LUMIO_GAME_ROOT`。
- Runtime `ChatIngressWorld.Create` 默认预算装不下 101 实体 Persist;S7 跨进程恢复待 Runtime 放大 `MaxSnapshotBytes`。
- `mvp-host/` 仍冻结,归 N-13。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ private static (int, byte[]) Expire(JsonElement root)
return (EntrySuccess, Fail("invalid_request"));
}

id = NormalizeNetEntityId(id);
object result = BindingType!.GetMethod("Expire", new[] { typeof(string) })!
.Invoke(Bindings, new object[] { id })!;
return FromBindingResult(result);
Expand Down Expand Up @@ -259,7 +260,10 @@ private static (int, byte[]) Resolve(JsonElement root)
return (EntrySuccess, Fail("invalid_request"));
}

object result = BindingType!.GetMethod("ResolveByNetEntityId")!
id = NormalizeNetEntityId(id);
object result = BindingType!.GetMethod(
"ResolveByNetEntityId",
new[] { typeof(string), typeof(string), typeof(ulong?), typeof(string) })!
.Invoke(Bindings, new object?[] { room, id, null, "server-authoritative" })!;
return FromBindingResult(result);
}
Expand All @@ -275,7 +279,10 @@ private static (int, byte[]) Query(JsonElement root)
object request = Activator.CreateInstance(requestType)!;
requestType.GetProperty("CallerScope")!.SetValue(request, ReadString(root, "callerScope"));
requestType.GetProperty("RoomId")!.SetValue(request, ReadString(root, "roomId"));
requestType.GetProperty("NetEntityId")!.SetValue(request, ReadString(root, "netEntityId"));
string? netEntityId = ReadString(root, "netEntityId");
requestType.GetProperty("NetEntityId")!.SetValue(
request,
string.IsNullOrEmpty(netEntityId) ? netEntityId : NormalizeNetEntityId(netEntityId));
requestType.GetProperty("AttributeId")!.SetValue(request, ReadString(root, "attributeId"));
if (root.TryGetProperty("connectionGeneration", out JsonElement gen) && gen.ValueKind == JsonValueKind.Number
&& gen.TryGetUInt64(out ulong generation))
Expand Down Expand Up @@ -539,6 +546,19 @@ private static (int, byte[]) FromBindingResult(object result)
{
payload["binding"] = BindingDict(binding);
}
else if (outcome == "ok")
{
string? netEntityId = type.GetProperty("NetEntityId")!.GetValue(result) as string;
string? roomId = type.GetProperty("RoomId")!.GetValue(result) as string;
if (!string.IsNullOrEmpty(netEntityId) && !string.IsNullOrEmpty(roomId))
{
Dictionary<string, object?>? listed = ListedBinding(roomId, netEntityId);
if (listed is not null)
{
payload["binding"] = listed;
}
}
}

if (bindings is Array array)
{
Expand All @@ -559,6 +579,69 @@ private static (int, byte[]) FromBindingResult(object result)
return (EntrySuccess, Json(payload));
}

private static Dictionary<string, object?>? ListedBinding(string roomId, string netEntityId)
{
if (Bindings is null || BindingType is null)
{
return null;
}

object listed = BindingType.GetMethod("ListBindings", new[] { typeof(string) })!
.Invoke(Bindings, new object[] { roomId })!;
object? rows = listed.GetType().GetProperty("Bindings")!.GetValue(listed);
if (rows is not Array array)
{
return null;
}

string want = NormalizeNetEntityId(netEntityId);
foreach (object row in array)
{
Dictionary<string, object?> dict = BindingDict(row);
if (dict["netEntityId"] is string got
&& string.Equals(NormalizeNetEntityId(got), want, StringComparison.Ordinal))
{
return dict;
}
}

return null;
}

private static string NormalizeNetEntityId(string id)
{
string lower = id.Trim().ToLowerInvariant();
if (lower.Length == 32)
{
bool hex = true;
foreach (char c in lower)
{
if (!Uri.IsHexDigit(c))
{
hex = false;
break;
}
}

if (hex)
{
return lower;
}
}

if (ulong.TryParse(lower, NumberStyles.None, CultureInfo.InvariantCulture, out ulong dec))
{
return dec.ToString("x32", CultureInfo.InvariantCulture);
}

if (ulong.TryParse(lower, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out ulong hexValue))
{
return hexValue.ToString("x32", CultureInfo.InvariantCulture);
}

return lower;
}

private static Dictionary<string, object?> BindingDict(object binding)
{
Type type = binding.GetType();
Expand Down
14 changes: 12 additions & 2 deletions modules/process/src/entity_chat/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ pub fn run_playwright_browser(
password: &str,
result_path: &Path,
console_path: &Path,
wait_for_events: u32,
) -> PlaywrightCapture {
let wrapper =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/run_playwright_browser.mjs");
Expand All @@ -205,6 +206,8 @@ pub fn run_playwright_browser(
.arg(result_path)
.arg("--console-path")
.arg(console_path)
.arg("--wait-for-events")
.arg(wait_for_events.to_string())
.env(
"LUMIO_GAME_ROOT",
match game_root() {
Expand Down Expand Up @@ -260,8 +263,10 @@ pub fn run_playwright_browser(
#[must_use]
pub fn capture_browser_login(
account_uri: &str,
room_uri: Option<&str>,
password: &str,
out_dir: &Path,
wait_for_events: u32,
) -> PlaywrightCapture {
let web = match game_root() {
Ok(root) => root.join("integration/entity-chat/web"),
Expand All @@ -275,16 +280,21 @@ pub fn capture_browser_login(
Ok(server) => server,
Err(error) => return PlaywrightCapture::failed(&error),
};
let page_url = format!(
"http://127.0.0.1:{}/index.html?account={}&login=Browser01",
let mut page_url = format!(
"http://127.0.0.1:{}/index.html?account={}&login=Browser01&connectionId=c-browser",
static_server.port,
encode_query_component(account_uri)
);
if let Some(room) = room_uri {
page_url.push_str("&room=");
page_url.push_str(&encode_query_component(room));
}
let capture = run_playwright_browser(
&page_url,
password,
&out_dir.join("browser-result.json"),
&out_dir.join("browser-console.ndjson"),
wait_for_events,
);
drop(static_server);
capture
Expand Down
26 changes: 18 additions & 8 deletions modules/process/src/entity_chat/clr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde_json::{json, Value};
use crate::runtime_bridge::{BridgeError, ClrBridge, ClrStart};
use crate::sdk_loader;

use super::envelope::normalize_net_entity_id;
use super::runtime::BoundEntityKind;
use super::runtime::{
ChatOperation, PersistRecord, QueryResult, RebindMode, RuntimeAdmit, RuntimeBinding,
Expand Down Expand Up @@ -178,6 +179,7 @@ impl RuntimeSurface for ClrGameplay {
}

fn expire(&mut self, net_entity_id: &str) -> Result<(), String> {
let net_entity_id = normalize_net_entity_id(net_entity_id);
let value = self.call(json!({ "op": "expire", "netEntityId": net_entity_id }))?;
if value.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(())
Expand All @@ -201,21 +203,29 @@ impl RuntimeSurface for ClrGameplay {
room_id: &str,
net_entity_id: &str,
) -> Option<RuntimeBinding> {
self.call(json!({
"op": "resolve",
"roomId": room_id,
"netEntityId": net_entity_id
}))
.ok()
.and_then(|value| value.get("binding").and_then(binding_from))
let net_entity_id = normalize_net_entity_id(net_entity_id);
if let Some(binding) = self
.call(json!({
"op": "resolve",
"roomId": room_id,
"netEntityId": net_entity_id
}))
.ok()
.and_then(|value| value.get("binding").and_then(binding_from))
{
return Some(binding);
}
self.list_bindings(room_id)
.into_iter()
.find(|row| normalize_net_entity_id(&row.net_entity_id) == net_entity_id)
}

fn query_attribute(&mut self, request: &RuntimeQuery) -> QueryResult {
match self.call(json!({
"op": "query",
"callerScope": request.caller_scope.as_runtime_str(),
"roomId": request.room_id,
"netEntityId": request.net_entity_id,
"netEntityId": normalize_net_entity_id(&request.net_entity_id),
"attributeId": request.attribute_id,
"connectionGeneration": request.connection_generation,
})) {
Expand Down
25 changes: 24 additions & 1 deletion modules/process/src/entity_chat/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,33 @@ pub fn connection_superseded_json(net_entity_id: u64, new_generation: u64) -> St
.to_string()
}

/// Runtime issues 32-char lowercase hex of a u64 sequence.
/// C-1 `NetEntityId` is the same u64 (decimal or shorter hex on some clients).
#[must_use]
pub fn normalize_net_entity_id(net_entity_id: &str) -> String {
let lower = net_entity_id.trim().to_ascii_lowercase();
if lower.len() == 32
&& lower
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
{
return lower;
}
if !lower.is_empty() && lower.bytes().all(|b| b.is_ascii_digit()) {
if let Ok(value) = lower.parse::<u64>() {
return format!("{value:032x}");
}
}
if let Ok(value) = u64::from_str_radix(&lower, 16) {
return format!("{value:032x}");
}
lower
}

/// Runtime issues 32-char lowercase hex of a u64 sequence.
#[must_use]
pub fn net_entity_id_to_u64(net_entity_id: &str) -> Option<u64> {
u64::from_str_radix(net_entity_id, 16).ok()
u64::from_str_radix(&normalize_net_entity_id(net_entity_id), 16).ok()
}

fn is_lower_sha256(value: &str) -> bool {
Expand Down
Loading
Loading