diff --git a/.spec/knowledge/features/rust-entity-chat-host.md b/.spec/knowledge/features/rust-entity-chat-host.md index cffdb41..132b887 100644 --- a/.spec/knowledge/features/rust-entity-chat-host.md +++ b/.spec/knowledge/features/rust-entity-chat-host.md @@ -19,7 +19,8 @@ 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`)。默认 `MaxSnapshotBytes=4096` 只能装下约 6 个聊天实体;101 实体 Capture 为 Retryable 时不得把 `restoredWindow: 0` / `processB=null` 写成 S7 ok。 - **发现**:外部产物经 `LUMIO_*` 环境变量与仓根相对路径;缺失即 BLOCKED,不硬编码开发机绝对路径。 @@ -27,7 +28,7 @@ ADR-056:Rust 宿主是接力交付面,只托管与传输。Room 世界是 Ru ## 待解决 -- 完整 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。 diff --git a/entity-chat-host/src/Lumio.Server.EntityChat.HostEntry/HostEntry.cs b/entity-chat-host/src/Lumio.Server.EntityChat.HostEntry/HostEntry.cs index 8fa4cae..4cb5730 100644 --- a/entity-chat-host/src/Lumio.Server.EntityChat.HostEntry/HostEntry.cs +++ b/entity-chat-host/src/Lumio.Server.EntityChat.HostEntry/HostEntry.cs @@ -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); @@ -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); } @@ -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)) @@ -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? listed = ListedBinding(roomId, netEntityId); + if (listed is not null) + { + payload["binding"] = listed; + } + } + } if (bindings is Array array) { @@ -559,6 +579,69 @@ private static (int, byte[]) FromBindingResult(object result) return (EntrySuccess, Json(payload)); } + private static Dictionary? 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 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 BindingDict(object binding) { Type type = binding.GetType(); diff --git a/modules/process/src/entity_chat/browser.rs b/modules/process/src/entity_chat/browser.rs index 010c258..6bc9ef3 100644 --- a/modules/process/src/entity_chat/browser.rs +++ b/modules/process/src/entity_chat/browser.rs @@ -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"); @@ -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() { @@ -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"), @@ -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 diff --git a/modules/process/src/entity_chat/clr.rs b/modules/process/src/entity_chat/clr.rs index 32b3926..7979605 100644 --- a/modules/process/src/entity_chat/clr.rs +++ b/modules/process/src/entity_chat/clr.rs @@ -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, @@ -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(()) @@ -201,13 +203,21 @@ impl RuntimeSurface for ClrGameplay { room_id: &str, net_entity_id: &str, ) -> Option { - 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 { @@ -215,7 +225,7 @@ impl RuntimeSurface for ClrGameplay { "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, })) { diff --git a/modules/process/src/entity_chat/envelope.rs b/modules/process/src/entity_chat/envelope.rs index 9f64fae..53554e0 100644 --- a/modules/process/src/entity_chat/envelope.rs +++ b/modules/process/src/entity_chat/envelope.rs @@ -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::() { + 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::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 { diff --git a/modules/process/src/entity_chat/host.rs b/modules/process/src/entity_chat/host.rs index 1c5d1d8..f287a48 100644 --- a/modules/process/src/entity_chat/host.rs +++ b/modules/process/src/entity_chat/host.rs @@ -10,7 +10,9 @@ use lumio_host_runtime::{ }; use super::admission::{is_bot_namespace, verify_admission, AdmissionPayload}; -use super::envelope::{connection_superseded_json, net_entity_id_to_u64, InputCommand}; +use super::envelope::{ + connection_superseded_json, net_entity_id_to_u64, normalize_net_entity_id, InputCommand, +}; use super::runtime::BoundEntityKind; use super::runtime::{ AttributeQueryScope, ChatOperation, PersistRecord, QueryResult, RebindMode, RuntimeAdmit, @@ -136,7 +138,7 @@ struct Session { net_entity_id: String, entity_type: BoundEntityKind, generation: u64, - egress: Option, + egresses: Vec, } struct Inner { @@ -150,7 +152,7 @@ struct Inner { sessions: HashMap, account_sessions: HashMap, expire_watch: HashMap, - pending_egress: HashMap, + pending_egress: HashMap>, tick_id: u64, } @@ -352,9 +354,27 @@ impl EntityChatHost { room_id: String, net_entity_id: String, ) -> Option { + let net_entity_id = normalize_net_entity_id(&net_entity_id); self.on_owner(move |inner| inner.try_resolve_by_net_entity_id(&room_id, &net_entity_id)) } + /// Count live Room WS observers for a connection (harness wait). + #[must_use] + pub fn wire_observer_count(&self, connection_id: String) -> usize { + self.on_owner(move |inner| { + inner + .sessions + .get(&connection_id) + .map(|session| session.egresses.len()) + .unwrap_or(0) + + inner + .pending_egress + .get(&connection_id) + .map(Vec::len) + .unwrap_or(0) + }) + } + /// C-2 attribute query forwarded to Runtime. #[must_use] pub fn query_attribute(&self, request: AttributeQueryRequest) -> QueryResult { @@ -481,7 +501,7 @@ impl Inner { let new_generation = binding.connection_generation; let net_u64 = net_entity_id_to_u64(&binding.net_entity_id).unwrap_or(0); if let Some(old) = self.sessions.remove(old_id) { - if let Some(egress) = &old.egress { + for egress in &old.egresses { let _ = egress.send_text(connection_superseded_json(net_u64, new_generation)); let _ = egress.close(); } @@ -509,7 +529,10 @@ impl Inner { return RoomAdmitResult::reject("runtime_failure"); } let session_id = session_id_for(&payload.login_name, reconnected || takeover); - let egress = self.pending_egress.remove(connection_id); + let egresses = self + .pending_egress + .remove(connection_id) + .unwrap_or_default(); let session = Session { connection_id: connection_id.to_owned(), session_id: session_id.clone(), @@ -519,7 +542,7 @@ impl Inner { net_entity_id: runtime_binding.net_entity_id.clone(), entity_type: runtime_binding.entity_type, generation: runtime_binding.connection_generation, - egress, + egresses, }; let binding = ConnectionBinding::from_runtime(runtime_binding, session_id); self.account_sessions @@ -534,7 +557,7 @@ impl Inner { return false; }; self.account_sessions.remove(&session.account_id); - if let Some(egress) = &session.egress { + for egress in &session.egresses { let _ = egress.close(); } let _ = self.runtime.disconnect(connection_id); @@ -609,18 +632,16 @@ impl Inner { tick } - fn broadcast(&self, room_id: &str, frames: &[Vec]) { - for session in self.sessions.values() { + fn broadcast(&mut self, room_id: &str, frames: &[Vec]) { + for session in self.sessions.values_mut() { if session.room_id != room_id { continue; } - let Some(egress) = &session.egress else { - continue; - }; - for frame in frames { - let text = String::from_utf8_lossy(frame).into_owned(); - let _ = egress.send_text(text); - } + session.egresses.retain(|egress| { + frames + .iter() + .all(|frame| egress.send_text(String::from_utf8_lossy(frame).into_owned())) + }); } } @@ -629,9 +650,25 @@ impl Inner { return; }; let room_id = session.room_id.clone(); - let Some(egress) = session.egress.clone() else { + let egresses = session.egresses.clone(); + if egresses.is_empty() { + return; + } + let bytes = self.runtime.build_full_snapshot(&room_id, self.tick_id, 0); + if bytes.is_empty() { + return; + } + let text = String::from_utf8_lossy(&bytes).into_owned(); + for egress in &egresses { + let _ = egress.send_text(text.clone()); + } + } + + fn send_full_snapshot_to(&mut self, connection_id: &str, egress: &WireSender) { + let Some(session) = self.sessions.get(connection_id) else { return; }; + let room_id = session.room_id.clone(); let bytes = self.runtime.build_full_snapshot(&room_id, self.tick_id, 0); if bytes.is_empty() { return; @@ -645,11 +682,16 @@ impl Inner { connection_id, egress, } => { - if let Some(session) = self.sessions.get_mut(&connection_id) { - session.egress = Some(egress); - self.send_full_snapshot(&connection_id); + if self.sessions.contains_key(&connection_id) { + if let Some(session) = self.sessions.get_mut(&connection_id) { + session.egresses.push(egress.clone()); + } + self.send_full_snapshot_to(&connection_id, &egress); } else { - self.pending_egress.insert(connection_id, egress); + self.pending_egress + .entry(connection_id) + .or_default() + .push(egress); } } WireEvent::Input { @@ -660,10 +702,8 @@ impl Inner { let _ = self.admit_chat_input(&connection_id, &envelope); } } - WireEvent::Closed { connection_id } => { - if let Some(session) = self.sessions.get_mut(&connection_id) { - session.egress = None; - } + WireEvent::Closed { .. } => { + // One socket close must not drop other c-browser observers (Playwright + harness). } } } @@ -682,9 +722,16 @@ impl Inner { room_id: &str, net_entity_id: &str, ) -> Option { + let id = normalize_net_entity_id(net_entity_id); let runtime = self .runtime - .resolve_by_net_entity_id(room_id, net_entity_id)?; + .resolve_by_net_entity_id(room_id, &id) + .or_else(|| { + self.runtime + .list_bindings(room_id) + .into_iter() + .find(|row| normalize_net_entity_id(&row.net_entity_id) == id) + })?; Some(EntityResolution { net_entity_id: runtime.net_entity_id, room_id: runtime.room_id, @@ -697,7 +744,7 @@ impl Inner { self.runtime.query_attribute(&RuntimeQuery { caller_scope: request.caller_scope, room_id: request.room_id.clone(), - net_entity_id: request.net_entity_id.clone(), + net_entity_id: normalize_net_entity_id(&request.net_entity_id), attribute_id: request.attribute_id.clone(), connection_generation: request.connection_generation, }) diff --git a/modules/process/src/entity_chat/mod.rs b/modules/process/src/entity_chat/mod.rs index 571d7a1..fdb7c0d 100644 --- a/modules/process/src/entity_chat/mod.rs +++ b/modules/process/src/entity_chat/mod.rs @@ -37,7 +37,9 @@ pub use admission::{ }; pub use clr::{ClrGameplay, ClrGameplayConfig}; pub use discover::{discover, ReplayArtifacts}; -pub use envelope::{CommandBlock, InputCommand, CHAT_INPUT_MAPPING, MESSAGE_TYPE}; +pub use envelope::{ + normalize_net_entity_id, CommandBlock, InputCommand, CHAT_INPUT_MAPPING, MESSAGE_TYPE, +}; pub use host::{ AdmitTrace, AttributeQueryRequest, ConnectionBinding, EntityChatHost, EntityResolution, RoomAdmitResult, RoomCensus, DISPATCH_EXPIRE, DISPATCH_TICK, diff --git a/modules/process/src/entity_chat/suite.rs b/modules/process/src/entity_chat/suite.rs index 31ee1ba..75105eb 100644 --- a/modules/process/src/entity_chat/suite.rs +++ b/modules/process/src/entity_chat/suite.rs @@ -3,7 +3,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; @@ -290,22 +291,34 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { let process_name = replay_process_name(); let census_payload = census_payload(&admits); let host_audit = host_audit(&process_name, &admits, MAIN_ROOM); - let playwright = capture_browser_login(&account.uri(), TEST_PASSWORD, out_dir); - let playwright_ran = playwright.playwright_ran(); - scenarios.insert( - "3".to_owned(), - json!({ - "ok": browser_ok && full.total == 101 && full.bot_count == 100 && full.player_count == 1 && playwright_ran, - "total": full.total, - "botCount": full.bot_count, - "playerCount": full.player_count, - "playwrightRan": playwright_ran, - "loginAccepted": browser_login.accepted, - "loginError": browser_login.error_code, - "verifyError": browser_verify, - "admitError": browser_admit_code, - }), - ); + let listen_uri = host.listen_uri(); + let mut browser_wire = RoomClient::connect(&listen_uri, "c-browser").ok(); + if let Some(client) = browser_wire.as_mut() { + let _ = client.recv_text(); + } + let before_observers = host.wire_observer_count("c-browser".to_owned()); + let account_uri = account.uri(); + let out_dir_pw = out_dir.to_path_buf(); + let listen_pw = listen_uri.clone(); + let pw_thread = super::browser::game_root().ok().map(|_| { + thread::spawn(move || { + capture_browser_login( + &account_uri, + Some(&listen_pw), + TEST_PASSWORD, + &out_dir_pw, + 101, + ) + }) + }); + if pw_thread.is_some() { + let _ = wait_for_wire_observers( + &host, + "c-browser", + before_observers.saturating_add(1), + Duration::from_secs(25), + ); + } let mut resolved = 0; for (connection, _) in &connections { @@ -329,6 +342,23 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { let Some(browser_binding) = host.try_self_lookup("c-browser".to_owned()) else { blocked = blocked.or(Some("browser connection was not bound".to_owned())); + let playwright = match pw_thread { + Some(handle) => handle + .join() + .unwrap_or_else(|_| super::browser::PlaywrightCapture::failed("playwright thread")), + None => super::browser::PlaywrightCapture::failed("browser connection was not bound"), + }; + scenarios.insert( + "3".to_owned(), + json!({ + "ok": false, + "playwrightRan": playwright.playwright_ran(), + "loginAccepted": browser_login.accepted, + "loginError": browser_login.error_code, + "verifyError": browser_verify, + "admitError": browser_admit_code, + }), + ); let evidence = json!({ "ok": false, "blocked": blocked, @@ -360,7 +390,7 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { caller_scope: AttributeQueryScope::ClientReplica, room_id: MAIN_ROOM.to_owned(), net_entity_id: browser_binding.net_entity_id.clone(), - attribute_id: "EntityIdentity.restrictedFlag".to_owned(), + attribute_id: "EntityIdentity.claimedMark".to_owned(), connection_generation: None, }); let missing = host.query_attribute(AttributeQueryRequest { @@ -401,10 +431,6 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { ); let mut first_envelope: Option = None; - let mut browser_wire = RoomClient::connect(&host.listen_uri(), "c-browser").ok(); - if let Some(client) = browser_wire.as_mut() { - let _ = client.recv_text(); - } let mut pending_chats = 0usize; let mut tick = RuntimeTick::default(); let mut received = Vec::new(); @@ -445,6 +471,28 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { let first_block = first_envelope .as_ref() .and_then(|envelope| envelope.commands.first()); + let playwright = match pw_thread { + Some(handle) => handle + .join() + .unwrap_or_else(|_| super::browser::PlaywrightCapture::failed("playwright thread")), + None => super::browser::PlaywrightCapture::failed("BLOCKED: LUMIO_GAME_ROOT is not set"), + }; + let playwright_ran = playwright.playwright_ran(); + let browser_room_observed = chat_events.len() == 101; + scenarios.insert( + "3".to_owned(), + json!({ + "ok": browser_ok && full.total == 101 && full.bot_count == 100 && full.player_count == 1 && playwright_ran && browser_room_observed, + "total": full.total, + "botCount": full.bot_count, + "playerCount": full.player_count, + "playwrightRan": playwright_ran, + "loginAccepted": browser_login.accepted, + "loginError": browser_login.error_code, + "verifyError": browser_verify, + "admitError": browser_admit_code, + }), + ); scenarios.insert( "6".to_owned(), json!({ @@ -797,6 +845,24 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { evidence } +fn wait_for_wire_observers( + host: &EntityChatHost, + connection: &str, + min: usize, + budget: Duration, +) -> bool { + let deadline = Instant::now() + budget; + loop { + if host.wire_observer_count(connection.to_owned()) >= min { + return true; + } + if Instant::now() >= deadline { + return host.wire_observer_count(connection.to_owned()) >= min; + } + thread::sleep(Duration::from_millis(50)); + } +} + fn is_chat_event_delta(frame: &str) -> bool { frame.contains("\"messageType\":\"Delta\"") && frame.contains("\"mappingId\":\"chat.event\"") } diff --git a/modules/process/tests/common/mod.rs b/modules/process/tests/common/mod.rs index d62621c..f15b08b 100644 --- a/modules/process/tests/common/mod.rs +++ b/modules/process/tests/common/mod.rs @@ -6,9 +6,9 @@ use std::sync::{Arc, Mutex}; use lumio_host_runtime::{KernelError, KernelFired, KernelHandle, KernelTimer, TimerMode}; use lumio_server_process::entity_chat::{ - AttributeQueryOutcome, BoundEntityKind, ChatOperation, PersistRecord, QueryResult, RebindMode, - RuntimeAdmit, RuntimeBinding, RuntimeQuery, RuntimeSurface, RuntimeTick, - MAX_CHAT_INPUTS_PER_TICK, + normalize_net_entity_id, AttributeQueryOutcome, AttributeQueryScope, BoundEntityKind, + ChatOperation, PersistRecord, QueryResult, RebindMode, RuntimeAdmit, RuntimeBinding, + RuntimeQuery, RuntimeSurface, RuntimeTick, MAX_CHAT_INPUTS_PER_TICK, }; pub const DISPATCH_EXPIRE: u32 = 1; @@ -316,10 +316,11 @@ impl RuntimeSurface for ScriptedRuntime { } fn expire(&mut self, net_entity_id: &str) -> Result<(), String> { - self.expire_calls.push(net_entity_id.to_owned()); - if let Some(occupancy) = self.entities.remove(net_entity_id) { + let net_entity_id = normalize_net_entity_id(net_entity_id); + self.expire_calls.push(net_entity_id.clone()); + if let Some(occupancy) = self.entities.remove(&net_entity_id) { self.tombstoned - .insert(net_entity_id.to_owned(), occupancy.binding.room_id); + .insert(net_entity_id.clone(), occupancy.binding.room_id); self.retained .retain(|_, row| row.binding.net_entity_id != net_entity_id); } @@ -335,7 +336,8 @@ impl RuntimeSurface for ScriptedRuntime { room_id: &str, net_entity_id: &str, ) -> Option { - let occupancy = self.entities.get(net_entity_id)?; + let net_entity_id = normalize_net_entity_id(net_entity_id); + let occupancy = self.entities.get(&net_entity_id)?; if occupancy.binding.room_id != room_id { return None; } @@ -343,20 +345,21 @@ impl RuntimeSurface for ScriptedRuntime { } fn query_attribute(&mut self, request: &RuntimeQuery) -> QueryResult { + let net_entity_id = normalize_net_entity_id(&request.net_entity_id); if let Some(planted) = self.planted_query.get(&( request.room_id.clone(), - request.net_entity_id.clone(), + net_entity_id.clone(), request.attribute_id.clone(), )) { return planted.clone(); } - if let Some(room) = self.tombstoned.get(&request.net_entity_id) { + if let Some(room) = self.tombstoned.get(&net_entity_id) { if room != &request.room_id { return QueryResult::request_error("cross_room_reference"); } return QueryResult::fail(AttributeQueryOutcome::Tombstoned); } - let Some(occupancy) = self.entities.get(&request.net_entity_id) else { + let Some(occupancy) = self.entities.get(&net_entity_id) else { return QueryResult::fail(AttributeQueryOutcome::NonExistent); }; if occupancy.binding.room_id != request.room_id { @@ -367,6 +370,11 @@ impl RuntimeSurface for ScriptedRuntime { return QueryResult::fail(AttributeQueryOutcome::StaleGeneration); } } + if request.caller_scope == AttributeQueryScope::ClientReplica + && request.attribute_id == "EntityIdentity.claimedMark" + { + return QueryResult::fail(AttributeQueryOutcome::Unauthorized); + } QueryResult::ok(occupancy.binding.entity_type.as_str().to_owned(), 0, 0) } diff --git a/modules/process/tests/entity_chat_architecture.rs b/modules/process/tests/entity_chat_architecture.rs index a5149d5..6b71944 100644 --- a/modules/process/tests/entity_chat_architecture.rs +++ b/modules/process/tests/entity_chat_architecture.rs @@ -134,6 +134,79 @@ fn suite_connection_superseded_received_must_not_copy_takeover() { ); } +#[test] +fn suite_attaches_c_browser_room_ws_before_chat_burst() { + let text = + fs::read_to_string(process_root().join("src/entity_chat/suite.rs")).expect("suite.rs"); + let browser = text + .find("RoomClient::connect(&listen_uri, \"c-browser\")") + .or_else(|| text.find("RoomClient::connect(&host.listen_uri(), \"c-browser\")")) + .expect("suite must attach c-browser as a RoomClient"); + let burst = text + .find("for (connection, name) in &connections") + .expect("chat burst loop"); + assert!( + browser < burst, + "c-browser Room WS must be attached before the 101 chat burst" + ); +} + +#[test] +fn suite_playwright_ran_requires_browser_room_observation() { + let suite = + fs::read_to_string(process_root().join("src/entity_chat/suite.rs")).expect("suite.rs"); + let browser = + fs::read_to_string(process_root().join("src/entity_chat/browser.rs")).expect("browser.rs"); + assert!( + browser.contains("room=") || suite.contains("room="), + "Playwright page URL must include the Room listen URI so the browser joins before chats" + ); + assert!( + suite.contains("playwright_ran") + && (suite.contains("received_from_network") + || suite.contains("receivedFromNetwork") + || suite.contains("playwright_ran()")), + "S3 playwrightRan must come from Playwright Room observation, not account-login-only" + ); + assert!( + !suite.contains("\"playwrightRan\": true"), + "must not hard-code playwrightRan true" + ); +} + +#[test] +fn suite_unauthorized_query_uses_claimed_mark_not_undeclared_flag() { + let text = + fs::read_to_string(process_root().join("src/entity_chat/suite.rs")).expect("suite.rs"); + assert!( + text.contains("EntityIdentity.claimedMark"), + "S5 unauthorized must query Runtime claim-scoped EntityIdentity.claimedMark" + ); + assert!( + !text.contains("EntityIdentity.restrictedFlag"), + "restrictedFlag is undeclared and maps to RequestError, not contract Unauthorized" + ); +} + +#[test] +fn host_entry_resolve_forwards_ok_entity_as_binding() { + let path = process_root() + .parent() + .expect("modules") + .parent() + .expect("repo") + .join("entity-chat-host/src/Lumio.Server.EntityChat.HostEntry/HostEntry.cs"); + let text = fs::read_to_string(&path).expect("HostEntry.cs"); + assert!( + text.contains("ListBindings") && text.contains("ResolveByNetEntityId"), + "Resolve OkEntity has no Binding; HostEntry must attach the listed ConnectionBinding" + ); + assert!( + text.contains("x32") || text.contains("NormalizeNetEntityId"), + "Resolve must accept C-1 u64 and Runtime 32-hex NetEntityId" + ); +} + #[test] fn suite_schedules_kernel_tick_every_max_chat_inputs() { let text = diff --git a/modules/process/tests/entity_chat_host.rs b/modules/process/tests/entity_chat_host.rs index 3296198..94158fc 100644 --- a/modules/process/tests/entity_chat_host.rs +++ b/modules/process/tests/entity_chat_host.rs @@ -178,15 +178,6 @@ fn isolation_rejects_cross_room_query() { #[test] fn attribute_query_is_forwarded_to_runtime() { let runtime = SharedRuntime::new(); - { - let mut guard = runtime.lock(); - guard.plant_query( - "room-main", - "pending", - "EntityIdentity.restrictedFlag", - QueryResult::fail(AttributeQueryOutcome::Unauthorized), - ); - } let (host, keys) = host_with(runtime.clone()); let _ = host.admit( "room-main".to_owned(), @@ -203,7 +194,7 @@ fn attribute_query_is_forwarded_to_runtime() { runtime.lock().plant_query( "room-main", &binding.net_entity_id, - "EntityIdentity.restrictedFlag", + "EntityIdentity.claimedMark", QueryResult::fail(AttributeQueryOutcome::Unauthorized), ); let ok = host.query_attribute(AttributeQueryRequest { @@ -224,7 +215,7 @@ fn attribute_query_is_forwarded_to_runtime() { caller_scope: AttributeQueryScope::ClientReplica, room_id: "room-main".to_owned(), net_entity_id: binding.net_entity_id.clone(), - attribute_id: "EntityIdentity.restrictedFlag".to_owned(), + attribute_id: "EntityIdentity.claimedMark".to_owned(), connection_generation: None, }); let missing = host.query_attribute(AttributeQueryRequest { @@ -368,3 +359,49 @@ fn sixty_five_chat_inputs_one_tick_empty_delta_is_not_success() { ), } } + +#[test] +fn claimed_mark_client_replica_is_contract_unauthorized() { + let (host, keys) = host_with(SharedRuntime::new()); + let _ = host.admit( + "room-main".to_owned(), + "c-browser".to_owned(), + credential(&keys, "Browser01", false), + ); + let binding = host.must_self("c-browser"); + let unauthorized = host.query_attribute(AttributeQueryRequest { + caller_scope: AttributeQueryScope::ClientReplica, + room_id: "room-main".to_owned(), + net_entity_id: binding.net_entity_id, + attribute_id: "EntityIdentity.claimedMark".to_owned(), + connection_generation: None, + }); + assert_eq!( + unauthorized.outcome, + AttributeQueryOutcome::Unauthorized, + "claim-scoped claimedMark without a claim is contract Unauthorized, not {:?}", + unauthorized.outcome + ); +} + +#[test] +fn resolve_accepts_runtime_32_hex_and_c1_u64() { + let (host, keys) = host_with(SharedRuntime::new()); + let bot = host.admit( + "room-main".to_owned(), + "c-bot01".to_owned(), + credential(&keys, "Bot01", true), + ); + let hex = bot.binding.expect("binding").net_entity_id; + assert_eq!(hex.len(), 32); + assert!(host + .try_resolve_by_net_entity_id("room-main".to_owned(), hex.clone()) + .is_some()); + let as_u64 = u64::from_str_radix(&hex, 16).expect("runtime 32-hex is a u64"); + let resolved = host.try_resolve_by_net_entity_id("room-main".to_owned(), as_u64.to_string()); + assert!( + resolved.is_some(), + "C-1 u64 decimal {as_u64} must resolve the Runtime 32-hex {hex}" + ); + assert_eq!(resolved.expect("row").net_entity_id, hex); +} diff --git a/modules/process/tests/entity_chat_wire.rs b/modules/process/tests/entity_chat_wire.rs index 07f4299..cfd813c 100644 --- a/modules/process/tests/entity_chat_wire.rs +++ b/modules/process/tests/entity_chat_wire.rs @@ -212,3 +212,43 @@ fn runtime_snapshot_failure_does_not_send_host_minted_empty_full_snapshot() { client.received ); } + +#[test] +fn second_c_browser_attach_still_receives_room_delta() { + let keys = generate_keys(); + let host = EntityChatHost::new( + RECONNECT_WINDOW_MS, + SharedClock::test(), + Box::new(SharedRuntime::new()), + Box::new(TestKernel::new()), + ADMISSION_KEY_ID, + keys.public.to_vec(), + 1_000, + ); + let admit = host.admit( + "room-main".to_owned(), + "c-browser".to_owned(), + credential(&keys, "Browser01", false), + ); + assert!(admit.accepted); + let mut first = RoomClient::connect(&host.listen_uri(), "c-browser").expect("first"); + let _ = first.recv_text(); + let mut second = RoomClient::connect(&host.listen_uri(), "c-browser").expect("second"); + let _ = second.recv_text(); + let _ = host.admit_chat_input( + "c-browser".to_owned(), + InputCommand::from_chat_text("hello-browser"), + ); + let tick = host.run_tick("room-main".to_owned()); + assert!(tick.ok); + let first_frame = first.recv_text().expect("first delta"); + let second_frame = second.recv_text().expect("second delta"); + assert!( + first_frame.contains("\"mappingId\":\"chat.event\""), + "first c-browser observer must keep receiving, got {first_frame}" + ); + assert!( + second_frame.contains("\"mappingId\":\"chat.event\""), + "Playwright-style second c-browser attach must also receive, got {second_frame}" + ); +} diff --git a/modules/process/tests/run_playwright_browser.mjs b/modules/process/tests/run_playwright_browser.mjs index d01e543..3966333 100644 --- a/modules/process/tests/run_playwright_browser.mjs +++ b/modules/process/tests/run_playwright_browser.mjs @@ -48,6 +48,8 @@ try { password: args.password, resultPath: args['result-path'], consolePath: args['console-path'], + waitForEvents: Number(args['wait-for-events'] ?? 0), + waitMs: Number(args['wait-ms'] ?? 90000), }) process.stdout.write(JSON.stringify(result) + '\n') } catch (err) {