From e1b78ce9788a51a47ef4d5c3d1c776a283c110bf Mon Sep 17 00:00:00 2001 From: Cui Date: Thu, 3 Sep 2026 05:06:05 +0800 Subject: [PATCH] feat(entity-chat): spawn Client Bot.Host; S6 ticks from ClientTimerManager (R-00374) --- .../features/rust-entity-chat-host.md | 1 + .../bot_startup_hook/StartupHook.cs | 433 +++++++++++++++++ modules/process/src/entity_chat/bots.rs | 459 ++++++++++++++++++ modules/process/src/entity_chat/mod.rs | 2 + modules/process/src/entity_chat/suite.rs | 107 +++- .../process/tests/entity_chat_acceptance.rs | 18 +- .../process/tests/entity_chat_architecture.rs | 84 +++- modules/process/tests/entity_chat_wire.rs | 33 ++ 8 files changed, 1112 insertions(+), 25 deletions(-) create mode 100644 modules/process/src/entity_chat/bot_startup_hook/StartupHook.cs create mode 100644 modules/process/src/entity_chat/bots.rs diff --git a/.spec/knowledge/features/rust-entity-chat-host.md b/.spec/knowledge/features/rust-entity-chat-host.md index 132b887..4e3ea31 100644 --- a/.spec/knowledge/features/rust-entity-chat-host.md +++ b/.spec/knowledge/features/rust-entity-chat-host.md @@ -22,6 +22,7 @@ ADR-056:Rust 宿主是接力交付面,只托管与传输。Room 世界是 Ru - **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`,不自建第二份事件队列。 +- **Client Bot**:S6 发言由 suite spawn `Lumio.Client.Bot.Host`(`LUMIO_BOT_HOST` / `LUMIO_CLIENT_ROOT` 或仓根相对 `LumioClient` 兄弟,缺失 BLOCKED)。Bot 进程经 Client Timer Manager drain NativeCore `tickFrame`(`native-kernel/tickFrame`,utteranceTicks 含 5/10/15),再把 `chat.input` 发上 Room WS。禁止 `host.admit_chat_input` 冒充 101 条 Bot 发言,禁止把常量 `[5,10,15]` 写成证据。 - **Persist**:`CapturePersist` / `RestorePersist` 走 Runtime 公开面(`RestorePersist` 的第二参是 `ReadOnlyMemory`)。默认 `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 恢复。 diff --git a/modules/process/src/entity_chat/bot_startup_hook/StartupHook.cs b/modules/process/src/entity_chat/bot_startup_hook/StartupHook.cs new file mode 100644 index 0000000..da93ed5 --- /dev/null +++ b/modules/process/src/entity_chat/bot_startup_hook/StartupHook.cs @@ -0,0 +1,433 @@ +using System.Net.WebSockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Lumio.Client.Bot; + +/// +/// Loaded into Lumio.Client.Bot.Host via DOTNET_STARTUP_HOOKS. +/// Drains production (native tickFrame) and +/// submits pre-encoded chat.input envelopes on the Room WebSocket. +/// No-op unless LUMIO_BOT_FLEET_SPEC is set, so a bare Bot.Host still runs. +/// +internal static class StartupHook +{ + public static void Initialize() + { + string? specPath = Environment.GetEnvironmentVariable("LUMIO_BOT_FLEET_SPEC"); + if (string.IsNullOrWhiteSpace(specPath)) + { + return; + } + + int code = 2; + try + { + code = Run(specPath); + } + catch (Exception ex) + { + TryWriteBlocked(specPath, ex.ToString()); + code = 2; + } + + Environment.Exit(code); + } + + private static int Run(string specPath) + { + FleetSpec spec = JsonSerializer.Deserialize( + File.ReadAllText(specPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidOperationException("fleet spec missing"); + + if (string.IsNullOrWhiteSpace(spec.EngineNative) || !File.Exists(spec.EngineNative)) + { + WriteTrace(spec, false, "", Array.Empty(), 0, "BLOCKED: LUMIO_ENGINE_NATIVE missing for Client Timer Manager"); + return 2; + } + + var sockets = new List(spec.Bots.Count); + try + { + Uri room = new Uri(spec.RoomUri.TrimEnd('/') + "/"); + foreach (BotSpec bot in spec.Bots) + { + var ws = new ClientWebSocket(); + ws.ConnectAsync(room, CancellationToken.None).GetAwaiter().GetResult(); + SendText(ws, "{\"connectionId\":\"" + bot.ConnectionId + "\"}"); + DrainInBackground(ws); + sockets.Add(ws); + } + + using var abi = new NativeTickFrameAbi(spec.EngineNative); + using var timer = new ClientTimerManager(abi); + if (!timer.ScheduleBotChatCadence()) + { + WriteTrace(spec, false, "", Array.Empty(), 0, "BLOCKED: ClientTimerManager.ScheduleBotChatCadence failed"); + return 2; + } + + ulong advanceTo = spec.AdvanceToTick == 0 ? 15UL : spec.AdvanceToTick; + IReadOnlyList dues = timer.Advance(advanceTo); + ulong[] ticks = timer.Trace.UtteranceTicks.ToArray(); + bool invoked = ticks.Length > 0; + string source = invoked ? "native-kernel/tickFrame" : ""; + + int sent = 0; + int n = spec.Bots.Count; + int parts = Math.Max(dues.Count, 1); + for (int p = 0; p < dues.Count; p++) + { + int start = p * n / parts; + int end = (p + 1) * n / parts; + for (int i = start; i < end; i++) + { + SendText(sockets[i], spec.Bots[i].Envelope); + sent++; + if (!string.IsNullOrWhiteSpace(spec.SentPath)) + { + File.WriteAllText(spec.SentPath, sent.ToString()); + } + } + + Thread.Sleep(400); + } + + WriteTrace(spec, invoked && sent == n, source, ticks, sent, null); + return invoked && sent == n ? 0 : 2; + } + finally + { + foreach (ClientWebSocket ws in sockets) + { + try + { + ws.Dispose(); + } + catch (Exception) + { + } + } + } + } + + private static void SendText(ClientWebSocket ws, string text) + { + byte[] bytes = Encoding.UTF8.GetBytes(text); + ws.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + + private static void DrainInBackground(ClientWebSocket ws) + { + _ = Task.Run(async () => + { + var buffer = new byte[65536]; + try + { + while (ws.State == WebSocketState.Open) + { + await ws.ReceiveAsync(buffer, CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception) + { + } + }); + } + + private static void WriteTrace( + FleetSpec spec, + bool ok, + string tickSource, + ulong[] utteranceTicks, + int submitted, + string? blocked) + { + var body = new Dictionary + { + ["ok"] = ok, + ["tickSource"] = tickSource, + ["cadence"] = tickSource, + ["utteranceTicks"] = utteranceTicks.Select(tick => (long)tick).ToArray(), + ["timerManagerInvoked"] = utteranceTicks.Length > 0, + ["submitted"] = submitted, + ["process"] = "Lumio.Client.Bot.Host", + ["pid"] = Environment.ProcessId, + ["blocked"] = blocked, + }; + string json = JsonSerializer.Serialize(body); + if (!string.IsNullOrWhiteSpace(spec.TracePath)) + { + string? dir = Path.GetDirectoryName(spec.TracePath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText(spec.TracePath, json + "\n"); + } + } + + private static void TryWriteBlocked(string specPath, string reason) + { + try + { + FleetSpec? spec = JsonSerializer.Deserialize( + File.ReadAllText(specPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (spec != null) + { + WriteTrace(spec, false, "", Array.Empty(), 0, reason); + } + } + catch (Exception) + { + } + } + + private sealed class FleetSpec + { + public string RoomUri { get; set; } = ""; + + public string EngineNative { get; set; } = ""; + + public string TracePath { get; set; } = ""; + + public string SentPath { get; set; } = ""; + + public ulong AdvanceToTick { get; set; } + + public List Bots { get; set; } = new List(); + } + + private sealed class BotSpec + { + public string ConnectionId { get; set; } = ""; + + public string Envelope { get; set; } = ""; + } +} + +internal sealed class NativeTickFrameAbi : INativeTimerAbi, IDisposable +{ + private const int Success = 0; + private const int DrainRecordBytes = 40; + + private readonly IntPtr _module; + private readonly CreateManagerFn _createManager; + private readonly DestroyManagerFn _destroyManager; + private readonly RegisterDispatchFn _registerDispatch; + private readonly RegisterScopeFn _registerScope; + private readonly CreateSlotFn _createSlot; + private readonly BindSlotFn _bindSlot; + private readonly ScheduleRepeatingFn _scheduleRepeating; + private readonly AdvanceFn _advance; + private readonly DrainFn _drain; + + public NativeTickFrameAbi(string nativePath) + { + _module = LoadLibraryW(nativePath); + if (_module == IntPtr.Zero) + { + throw new InvalidOperationException("LoadLibraryW failed for engine native"); + } + + IntPtr proc = GetProcAddress(_module, "lumio_engine_get_api_v1"); + if (proc == IntPtr.Zero) + { + throw new InvalidOperationException("lumio_engine_get_api_v1 missing"); + } + + var getApi = Marshal.GetDelegateForFunctionPointer(proc); + int rc = getApi(1, out IntPtr table); + if (rc != Success || table == IntPtr.Zero) + { + throw new InvalidOperationException("lumio_engine_get_api_v1 status " + rc); + } + + uint size = (uint)Marshal.ReadInt32(table, 4); + if (size < 200) + { + throw new InvalidOperationException("native ABI struct_size missing timer slots"); + } + + _createManager = Fn(table, 88); + _destroyManager = Fn(table, 96); + _registerDispatch = Fn(table, 104); + _registerScope = Fn(table, 112); + _createSlot = Fn(table, 128); + _bindSlot = Fn(table, 136); + _scheduleRepeating = Fn(table, 160); + _advance = Fn(table, 176); + _drain = Fn(table, 192); + } + + public int CreateManager(uint mode, out IntPtr manager) + { + return _createManager(mode, out manager); + } + + public int DestroyManager(IntPtr manager) + { + return _destroyManager(manager); + } + + public int RegisterDispatch(IntPtr manager, uint dispatchId) + { + return _registerDispatch(manager, dispatchId); + } + + public int RegisterScope(IntPtr manager, ulong scopeId, uint scopeKind, out uint generation) + { + return _registerScope(manager, scopeId, scopeKind, out generation); + } + + public int CreateSlot(IntPtr manager, out IntPtr slot) + { + return _createSlot(manager, out slot); + } + + public int BindSlot(IntPtr manager, IntPtr slot, uint dispatchId) + { + return _bindSlot(manager, slot, dispatchId); + } + + public int ScheduleRepeating( + IntPtr manager, + ulong scopeId, + uint scopeKind, + uint scopeGeneration, + ulong firstDue, + ulong interval, + IntPtr slot, + out NativeTimerHandle handle) + { + int rc = _scheduleRepeating( + manager, + scopeId, + scopeKind, + scopeGeneration, + firstDue, + interval, + slot, + out TimerHandleAbi abi); + handle = new NativeTimerHandle(abi.Index, abi.Generation, abi.Context); + return rc; + } + + public int Advance(IntPtr manager, ulong toTick) + { + return _advance(manager, toTick); + } + + public int Drain(IntPtr manager, Span records, out int count) + { + count = 0; + int cap = records.Length; + IntPtr buf = IntPtr.Zero; + try + { + if (cap > 0) + { + buf = Marshal.AllocHGlobal(checked(cap * DrainRecordBytes)); + } + + int status = _drain(manager, buf, (uint)cap, out uint nativeCount); + count = (int)nativeCount; + if (status != Success || buf == IntPtr.Zero) + { + return status; + } + + int copy = Math.Min(count, cap); + for (int i = 0; i < copy; i++) + { + IntPtr row = buf + (i * DrainRecordBytes); + ulong due = unchecked((ulong)Marshal.ReadInt64(row, 16)); + ulong seq = unchecked((ulong)Marshal.ReadInt64(row, 24)); + uint dispatch = unchecked((uint)Marshal.ReadInt32(row, 32)); + records[i] = new NativeTimerDrainRecord(due, seq, dispatch); + } + + return status; + } + finally + { + if (buf != IntPtr.Zero) + { + Marshal.FreeHGlobal(buf); + } + } + } + + public void Dispose() + { + } + + private static T Fn(IntPtr table, int offset) + where T : Delegate + { + IntPtr ptr = Marshal.ReadIntPtr(table, offset); + if (ptr == IntPtr.Zero) + { + throw new InvalidOperationException("null timer slot at +" + offset); + } + + return Marshal.GetDelegateForFunctionPointer(ptr); + } + + [DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryW(string path); + + [DllImport("kernel32", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)] + private static extern IntPtr GetProcAddress(IntPtr module, string name); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int GetApiV1(uint version, out IntPtr table); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int CreateManagerFn(uint mode, out IntPtr manager); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int DestroyManagerFn(IntPtr manager); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int RegisterDispatchFn(IntPtr manager, uint dispatchId); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int RegisterScopeFn(IntPtr manager, ulong scopeId, uint scopeKind, out uint generation); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int CreateSlotFn(IntPtr manager, out IntPtr slot); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int BindSlotFn(IntPtr manager, IntPtr slot, uint dispatchId); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int ScheduleRepeatingFn( + IntPtr manager, + ulong scopeId, + uint scopeKind, + uint scopeGeneration, + ulong firstDue, + ulong interval, + IntPtr slot, + out TimerHandleAbi handle); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int AdvanceFn(IntPtr manager, ulong toTick); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int DrainFn(IntPtr manager, IntPtr records, uint capacity, out uint count); + + [StructLayout(LayoutKind.Sequential)] + private struct TimerHandleAbi + { + public uint Index; + public uint Generation; + public ulong Context; + } +} diff --git a/modules/process/src/entity_chat/bots.rs b/modules/process/src/entity_chat/bots.rs new file mode 100644 index 0000000..0a8344a --- /dev/null +++ b/modules/process/src/entity_chat/bots.rs @@ -0,0 +1,459 @@ +//! Discover and spawn `Lumio.Client.Bot.Host`. S6 cadence is ClientTimerManager drain. + +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +use super::envelope::InputCommand; + +/// Observed Client Timer Manager drain from a Bot.Host process. +#[derive(Debug, Clone, Default)] +pub struct ClientBotTrace { + pub tick_source: String, + pub utterance_ticks: Vec, + pub timer_manager_invoked: bool, + pub submitted: u32, + pub pid: u32, + pub blocked: Option, +} + +/// Env lookup used by discovery. Process env in production; map in unit tests. +pub trait BotHostEnv { + /// Reads one environment variable. + /// + /// # Errors + /// + /// Returns [`std::env::VarError`] when the name is unset or invalid. + fn var(&self, name: &str) -> Result; +} + +struct StdEnv; + +impl BotHostEnv for StdEnv { + fn var(&self, name: &str) -> Result { + std::env::var(name) + } +} + +/// Locates `Lumio.Client.Bot.Host` via `LUMIO_BOT_HOST` / `LUMIO_CLIENT_ROOT` or +/// a `LumioClient` sibling of this repo. Missing is BLOCKED. +/// +/// # Errors +/// +/// Returns a BLOCKED reason when no host dll/exe/csproj can be found. +pub fn discover_bot_host() -> Result { + discover_bot_host_in(&StdEnv, &process_repo_root()) +} + +pub(crate) fn discover_bot_host_in(env: &dyn BotHostEnv, repo: &Path) -> Result { + if let Ok(raw) = env.var("LUMIO_BOT_HOST") { + let path = PathBuf::from(raw); + if path.is_file() { + return Ok(path); + } + if path.is_dir() { + if let Some(found) = bot_host_in_dir(&path) { + return Ok(found); + } + } + return Err(format!( + "BLOCKED: LUMIO_BOT_HOST missing: {}", + path.display() + )); + } + + let mut roots = Vec::new(); + if let Ok(root) = env.var("LUMIO_CLIENT_ROOT") { + roots.push(PathBuf::from(root)); + } + if let Some(parent) = repo.parent() { + roots.push(parent.join("LumioClient")); + if let Some(grand) = parent.parent() { + roots.push(grand.join("LumioClient")); + } + } + for root in roots { + if !root.is_dir() { + continue; + } + if let Some(found) = bot_host_under_client(&root) { + return Ok(found); + } + let csproj = root.join("modules/bot/host/Lumio.Client.Bot.Host.csproj"); + if csproj.is_file() { + return Ok(csproj); + } + } + Err( + "BLOCKED: Lumio.Client.Bot.Host not found (set LUMIO_CLIENT_ROOT or LUMIO_BOT_HOST)" + .to_owned(), + ) +} + +/// Builds Bot.Host when discovery returned a csproj; otherwise returns the file. +/// +/// # Errors +/// +/// Returns BLOCKED when `dotnet build` fails or the output dll is missing. +pub fn ensure_bot_host_executable(path: &Path, dotnet: &str) -> Result { + let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + if ext.eq_ignore_ascii_case("csproj") { + return build_bot_host(path, dotnet); + } + if path.is_file() { + return Ok(path.to_path_buf()); + } + Err(format!( + "BLOCKED: Lumio.Client.Bot.Host missing: {}", + path.display() + )) +} + +/// Spawns `Lumio.Client.Bot.Host` so ClientTimerManager can drain native tickFrame. +/// +/// # Errors +/// +/// Returns BLOCKED when the host, hook, native ABI, or trace is missing. +pub fn run_client_bot_fleet( + bot_host: &Path, + engine_native: &Path, + room_uri: &str, + envelopes: &[(String, InputCommand)], + out_dir: &Path, + dotnet: &str, + mut on_progress: F, +) -> Result +where + F: FnMut(u32), +{ + std::fs::create_dir_all(out_dir).map_err(|error| error.to_string())?; + let host = ensure_bot_host_executable(bot_host, dotnet)?; + let bot_dll = bot_assembly_beside(&host)?; + let hook = compile_startup_hook(out_dir, &bot_dll, dotnet)?; + let spec_path = out_dir.join("fleet-spec.json"); + let trace_path = out_dir.join("timer-trace.json"); + let sent_path = out_dir.join("sent.txt"); + let spec = json!({ + "roomUri": room_uri, + "engineNative": engine_native.display().to_string(), + "tracePath": trace_path.display().to_string(), + "sentPath": sent_path.display().to_string(), + "advanceToTick": 15, + "bots": envelopes.iter().map(|(connection, envelope)| { + json!({ + "connectionId": connection, + "envelope": envelope.to_json(), + }) + }).collect::>(), + }); + std::fs::write( + &spec_path, + serde_json::to_string_pretty(&spec).map_err(|error| error.to_string())? + "\n", + ) + .map_err(|error| error.to_string())?; + + let stdout_path = out_dir.join("bot-host.stdout"); + let stderr_path = out_dir.join("bot-host.stderr"); + let stdout = File::create(&stdout_path).map_err(|error| error.to_string())?; + let stderr = File::create(&stderr_path).map_err(|error| error.to_string())?; + let mut command = bot_host_command(dotnet, &host); + command + .env("DOTNET_STARTUP_HOOKS", &hook) + .env("LUMIO_BOT_FLEET_SPEC", &spec_path) + .env("LUMIO_ENGINE_NATIVE", engine_native) + .env("DOTNET_NOLOGO", "1") + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + let mut child = command + .spawn() + .map_err(|error| format!("BLOCKED: spawn Lumio.Client.Bot.Host: {error}"))?; + let deadline = Instant::now() + Duration::from_secs(60); + loop { + on_progress(read_sent(&sent_path)); + if trace_path.is_file() { + break; + } + match child.try_wait() { + Ok(Some(status)) => { + if !trace_path.is_file() { + return Err(format!( + "BLOCKED: Lumio.Client.Bot.Host exited {status} without ClientTimerManager trace{}", + tail_logs(&stdout_path, &stderr_path) + )); + } + break; + } + Ok(None) => {} + Err(error) => { + return Err(format!("BLOCKED: Lumio.Client.Bot.Host wait: {error}")); + } + } + if Instant::now() >= deadline { + let _ = child.kill(); + return Err(format!( + "BLOCKED: Lumio.Client.Bot.Host timed out waiting for ClientTimerManager drain{}", + tail_logs(&stdout_path, &stderr_path) + )); + } + thread::sleep(Duration::from_millis(50)); + } + let _ = child.wait(); + on_progress(read_sent(&sent_path)); + parse_trace(&trace_path) +} + +fn process_repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn bot_host_under_client(root: &Path) -> Option { + bot_host_in_dir(&root.join("modules/bot/host/bin/Debug/net10.0")) + .or_else(|| bot_host_in_dir(&root.join("modules/bot/host/bin/Release/net10.0"))) +} + +fn bot_host_in_dir(dir: &Path) -> Option { + first_existing(&[ + dir.join("Lumio.Client.Bot.Host.dll"), + dir.join("Lumio.Client.Bot.Host.exe"), + ]) +} + +fn first_existing(candidates: &[PathBuf]) -> Option { + candidates.iter().find(|path| path.is_file()).cloned() +} + +fn build_bot_host(csproj: &Path, dotnet: &str) -> Result { + let status = Command::new(dotnet) + .arg("build") + .arg(csproj) + .arg("-c") + .arg("Debug") + .arg("--nologo") + .status() + .map_err(|error| format!("BLOCKED: dotnet build Lumio.Client.Bot.Host: {error}"))?; + if !status.success() { + return Err(format!( + "BLOCKED: dotnet build Lumio.Client.Bot.Host failed: {status}" + )); + } + let dir = csproj.parent().unwrap_or(csproj); + bot_host_in_dir(&dir.join("bin/Debug/net10.0")) + .or_else(|| bot_host_in_dir(&dir.join("bin/Release/net10.0"))) + .ok_or_else(|| "BLOCKED: Lumio.Client.Bot.Host.dll missing after dotnet build".to_owned()) +} + +fn bot_assembly_beside(host: &Path) -> Result { + let dir = host + .parent() + .ok_or_else(|| "BLOCKED: Lumio.Client.Bot.Host has no directory".to_owned())?; + let dll = dir.join("Lumio.Client.Bot.dll"); + if dll.is_file() { + Ok(dll) + } else { + Err("BLOCKED: Lumio.Client.Bot.dll missing beside Bot.Host".to_owned()) + } +} + +fn hook_source() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/entity_chat/bot_startup_hook/StartupHook.cs") +} + +fn compile_startup_hook(out_dir: &Path, bot_dll: &Path, dotnet: &str) -> Result { + let source = hook_source(); + if !source.is_file() { + return Err(format!( + "BLOCKED: Bot.Host startup hook source missing: {}", + source.display() + )); + } + let hook_dir = out_dir.join("bot-hook"); + std::fs::create_dir_all(&hook_dir).map_err(|error| error.to_string())?; + std::fs::copy(&source, hook_dir.join("StartupHook.cs")).map_err(|error| error.to_string())?; + let hint = bot_dll.display().to_string().replace('\\', "/"); + let csproj = format!( + r#" + + net10.0 + enable + enable + false + Lumio.EntityChat.BotStartupHook + + + + + {hint} + true + + + +"# + ); + std::fs::write(hook_dir.join("BotHook.csproj"), csproj).map_err(|error| error.to_string())?; + let output = Command::new(dotnet) + .arg("build") + .arg("BotHook.csproj") + .arg("-c") + .arg("Debug") + .arg("--nologo") + .current_dir(&hook_dir) + .output() + .map_err(|error| format!("BLOCKED: compile Bot.Host startup hook: {error}"))?; + if !output.status.success() { + return Err(format!( + "BLOCKED: compile Bot.Host startup hook failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let dll = hook_dir + .join("bin/Debug/net10.0/Lumio.EntityChat.BotStartupHook.dll") + .canonicalize() + .unwrap_or_else(|_| hook_dir.join("bin/Debug/net10.0/Lumio.EntityChat.BotStartupHook.dll")); + if dll.is_file() { + Ok(dll) + } else { + Err("BLOCKED: Bot.Host startup hook dll missing after build".to_owned()) + } +} + +fn bot_host_command(dotnet: &str, host: &Path) -> Command { + let ext = host.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + if ext.eq_ignore_ascii_case("dll") { + let mut command = Command::new(dotnet); + command.arg("exec").arg(host); + command + } else { + Command::new(host) + } +} + +fn read_sent(path: &Path) -> u32 { + std::fs::read_to_string(path) + .ok() + .and_then(|text| text.trim().parse().ok()) + .unwrap_or(0) +} + +fn tail_logs(stdout_path: &Path, stderr_path: &Path) -> String { + let stdout = std::fs::read_to_string(stdout_path).unwrap_or_default(); + let stderr = std::fs::read_to_string(stderr_path).unwrap_or_default(); + let mut logs = String::new(); + if !stdout.trim().is_empty() { + logs.push_str(" stdout="); + logs.push_str(stdout.trim()); + } + if !stderr.trim().is_empty() { + logs.push_str(" stderr="); + logs.push_str(stderr.trim()); + } + logs +} + +fn parse_trace(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|error| format!("BLOCKED: ClientTimerManager trace missing: {error}"))?; + let value: Value = serde_json::from_str(&text) + .map_err(|error| format!("BLOCKED: ClientTimerManager trace is not JSON: {error}"))?; + let utterance_ticks = value + .get("utteranceTicks") + .and_then(Value::as_array) + .map(|rows| rows.iter().filter_map(Value::as_u64).collect::>()) + .unwrap_or_default(); + let tick_source = value + .get("tickSource") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + let blocked = value + .get("blocked") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(str::to_owned); + if let Some(reason) = blocked.clone() { + return Err(reason); + } + Ok(ClientBotTrace { + timer_manager_invoked: value + .get("timerManagerInvoked") + .and_then(Value::as_bool) + .unwrap_or(false) + && tick_source == "native-kernel/tickFrame" + && !utterance_ticks.is_empty(), + tick_source, + utterance_ticks, + submitted: u32::try_from(value.get("submitted").and_then(Value::as_u64).unwrap_or(0)) + .unwrap_or(u32::MAX), + pid: u32::try_from(value.get("pid").and_then(Value::as_u64).unwrap_or(0)).unwrap_or(0), + blocked, + }) +} + +#[cfg(test)] +mod tests { + use super::{discover_bot_host_in, BotHostEnv}; + use std::collections::HashMap; + use std::fs; + + struct MapEnv(HashMap); + + impl BotHostEnv for MapEnv { + fn var(&self, name: &str) -> Result { + self.0 + .get(name) + .cloned() + .ok_or(std::env::VarError::NotPresent) + } + } + + #[test] + fn missing_client_bot_host_is_blocked() { + let repo = tempfile::tempdir().expect("tmp"); + let err = discover_bot_host_in(&MapEnv(HashMap::new()), repo.path()).unwrap_err(); + assert!(err.starts_with("BLOCKED:"), "{err}"); + assert!( + err.contains("LUMIO_CLIENT_ROOT") || err.contains("LUMIO_BOT_HOST"), + "{err}" + ); + } + + #[test] + fn lumio_bot_host_file_is_discovered() { + let tmp = tempfile::tempdir().expect("tmp"); + let host = tmp.path().join("Lumio.Client.Bot.Host.dll"); + fs::write(&host, []).expect("touch"); + let mut env = HashMap::new(); + env.insert( + "LUMIO_BOT_HOST".to_owned(), + host.to_string_lossy().into_owned(), + ); + let found = discover_bot_host_in(&MapEnv(env), tmp.path()).expect("discover"); + assert_eq!(found, host); + } + + #[test] + fn lumio_client_root_csproj_is_discovered() { + let tmp = tempfile::tempdir().expect("tmp"); + let csproj = tmp + .path() + .join("modules/bot/host/Lumio.Client.Bot.Host.csproj"); + fs::create_dir_all(csproj.parent().expect("dir")).expect("dirs"); + fs::write(&csproj, "").expect("csproj"); + let mut env = HashMap::new(); + env.insert( + "LUMIO_CLIENT_ROOT".to_owned(), + tmp.path().to_string_lossy().into_owned(), + ); + let found = discover_bot_host_in(&MapEnv(env), tmp.path()).expect("discover"); + assert_eq!(found, csproj); + } +} diff --git a/modules/process/src/entity_chat/mod.rs b/modules/process/src/entity_chat/mod.rs index fdb7c0d..d1eebd6 100644 --- a/modules/process/src/entity_chat/mod.rs +++ b/modules/process/src/entity_chat/mod.rs @@ -20,6 +20,7 @@ mod account; mod admission; +mod bots; mod browser; mod clr; mod crypto; @@ -35,6 +36,7 @@ pub use admission::{ generate_keys, issue_admission_credential, issue_bot_tool_credential, verify_admission, AdmissionPayload, Ed25519KeyPair, }; +pub use bots::{discover_bot_host, run_client_bot_fleet, ClientBotTrace}; pub use clr::{ClrGameplay, ClrGameplayConfig}; pub use discover::{discover, ReplayArtifacts}; pub use envelope::{ diff --git a/modules/process/src/entity_chat/suite.rs b/modules/process/src/entity_chat/suite.rs index 75105eb..5d65544 100644 --- a/modules/process/src/entity_chat/suite.rs +++ b/modules/process/src/entity_chat/suite.rs @@ -13,6 +13,7 @@ use serde_json::{json, Value}; use super::account::{login_or_register, AccountServerProcess}; use super::admission::{generate_keys, issue_bot_tool_credential, verify_admission}; +use super::bots::{discover_bot_host, run_client_bot_fleet, ClientBotTrace}; use super::browser::capture_browser_login; use super::clr::{ClrGameplay, ClrGameplayConfig}; use super::crypto::hex_lower; @@ -430,33 +431,94 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { }), ); - let mut first_envelope: Option = None; + let bot_host = match discover_bot_host() { + Ok(path) => path, + Err(reason) => { + let playwright = match pw_thread { + Some(handle) => handle.join().unwrap_or_else(|_| { + super::browser::PlaywrightCapture::failed("playwright thread") + }), + None => super::browser::PlaywrightCapture::failed(&reason), + }; + let evidence = json!({ + "ok": false, + "blocked": reason, + "hostProcess": host_process_payload(&process_name, &host.listen_uri()), + "playwright": playwright.to_json(), + "accountServer": account_meta(&options.account_server_dll, &account), + "census": census_payload, + "scenarios": scenarios, + }); + write_evidence(out_dir, &evidence, &host_audit); + return evidence; + } + }; + let engine_native = match options.clr.as_ref() { + Some(config) if config.engine_native.is_file() => config.engine_native.clone(), + _ => { + return write_blocked(out_dir, "BLOCKED: LUMIO_ENGINE_NATIVE is not set"); + } + }; + let envelopes: Vec<(String, InputCommand)> = connections + .iter() + .map(|(connection, name)| { + ( + connection.clone(), + InputCommand::from_chat_text(&format!("hello-{name}")), + ) + }) + .collect(); + let first_envelope = envelopes.first().map(|(_, envelope)| envelope.clone()); let mut pending_chats = 0usize; + let mut last_ticked = 0u32; let mut tick = RuntimeTick::default(); let mut received = Vec::new(); - for (connection, name) in &connections { - let command = InputCommand::from_chat_text(&format!("hello-{name}")); - if first_envelope.is_none() { - first_envelope = Some(command.clone()); - } - let _ = host.admit_chat_input(connection.clone(), command); - pending_chats += 1; - if pending_chats >= MAX_CHAT_INPUTS_PER_TICK { - tick = host.schedule_room_tick(MAIN_ROOM.to_owned(), 1); - drain_chat_event_deltas(&mut browser_wire, &mut received); - pending_chats = 0; + let fleet_dir = out_dir.join("client-bots"); + let bot_trace = match run_client_bot_fleet( + &bot_host, + &engine_native, + &listen_uri, + &envelopes, + &fleet_dir, + &options.dotnet, + |sent| { + pending_chats = sent.saturating_sub(last_ticked) as usize; + if pending_chats >= MAX_CHAT_INPUTS_PER_TICK { + tick = host.schedule_room_tick(MAIN_ROOM.to_owned(), 1); + drain_chat_event_deltas(&mut browser_wire, &mut received); + last_ticked = sent; + pending_chats = 0; + } + }, + ) { + Ok(trace) => trace, + Err(reason) => { + blocked = blocked.or(Some(reason)); + ClientBotTrace::default() } + }; + if let Some(client) = browser_wire.as_mut() { + let _ = client.send_text(&InputCommand::from_chat_text("hello-browser").to_json()); } - let _ = host.admit_chat_input( - "c-browser".to_owned(), - InputCommand::from_chat_text("hello-browser"), - ); - pending_chats += 1; + pending_chats = pending_chats.saturating_add(1); if pending_chats > 0 { tick = host.schedule_room_tick(MAIN_ROOM.to_owned(), 1); drain_chat_event_deltas(&mut browser_wire, &mut received); } - let timer_ok = tick.ok && tick.applied_tick >= 1; + for _ in 0..6 { + if received.len() >= 101 { + break; + } + tick = host.schedule_room_tick(MAIN_ROOM.to_owned(), 1); + drain_chat_event_deltas(&mut browser_wire, &mut received); + } + let timer_ok = bot_trace.timer_manager_invoked + && bot_trace.tick_source == "native-kernel/tickFrame" + && bot_trace.utterance_ticks.contains(&5) + && bot_trace.utterance_ticks.contains(&10) + && bot_trace.utterance_ticks.contains(&15) + && tick.ok + && tick.applied_tick >= 1; let chat_events: Vec = received .iter() .filter(|frame| is_chat_event_delta(frame)) @@ -500,8 +562,9 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { "eventCount": chat_events.len(), "appliedTick": tick.applied_tick, "timerManagerInvoked": timer_ok, - "cadence": if timer_ok { "kernel:tickFrame" } else { "tick-batched" }, - "tickSource": if timer_ok { "kernel:tickFrame" } else { "tick-batched" }, + "cadence": bot_trace.tick_source, + "tickSource": bot_trace.tick_source, + "utteranceTicks": bot_trace.utterance_ticks, "messageType": first_envelope.as_ref().map(|envelope| envelope.message_type.as_str()), "mappingId": first_block.map(|block| block.mapping_id.as_str()), "payload": first_block.map(|block| block.payload.as_str()), @@ -816,8 +879,10 @@ async fn run_round_async(options: &SuiteOptions, out_dir: &Path) -> Value { "queries": query_traces, "chat": { "eventCount": chat_events.len(), - "tickSource": if timer_ok { "kernel:tickFrame" } else { "tick-batched" }, + "tickSource": bot_trace.tick_source, "timerManagerInvoked": timer_ok, + "utteranceTicks": bot_trace.utterance_ticks, + "botHostPid": bot_trace.pid, "messageType": first_envelope.as_ref().map(|envelope| envelope.message_type.as_str()), "mappingId": first_block.map(|block| block.mapping_id.as_str()), "payloadSha256": first_block.map(|block| block.payload_sha256.as_str()), diff --git a/modules/process/tests/entity_chat_acceptance.rs b/modules/process/tests/entity_chat_acceptance.rs index b9e7df7..f900890 100644 --- a/modules/process/tests/entity_chat_acceptance.rs +++ b/modules/process/tests/entity_chat_acceptance.rs @@ -102,10 +102,26 @@ fn assert_identical_suite_stamps(evidence: &Value) { tick_l.contains("tickframe") || tick_l.contains("kernel") || tick_l.contains("native"), "S6 tickSource must be kernel tickFrame, got {tick_source:?}" ); + assert_eq!( + s6.get("tickSource").and_then(Value::as_str), + Some("native-kernel/tickFrame") + ); assert_eq!( s6.get("cadence").and_then(Value::as_str), - Some("kernel:tickFrame") + Some("native-kernel/tickFrame") ); + let cadence_ticks = s6 + .get("utteranceTicks") + .or_else(|| evidence.pointer("/traces/chat/utteranceTicks")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for needed in [5_u64, 10, 15] { + assert!( + cadence_ticks.iter().any(|tick| tick.as_u64() == Some(needed)), + "S6 utteranceTicks must include {needed} from Client Timer Manager drain, got {cadence_ticks:?}" + ); + } let s7 = evidence.pointer("/scenarios/7").unwrap_or(&empty); let source = s7 diff --git a/modules/process/tests/entity_chat_architecture.rs b/modules/process/tests/entity_chat_architecture.rs index 6b71944..24eaf0c 100644 --- a/modules/process/tests/entity_chat_architecture.rs +++ b/modules/process/tests/entity_chat_architecture.rs @@ -143,11 +143,12 @@ fn suite_attaches_c_browser_room_ws_before_chat_burst() { .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"); + .find("run_client_bot_fleet(") + .or_else(|| text.find("spawn_client_bot_host(")) + .expect("chat burst must spawn Client Bot.Host, not a host-admit loop"); assert!( browser < burst, - "c-browser Room WS must be attached before the 101 chat burst" + "c-browser Room WS must be attached before Client Bot utterances" ); } @@ -222,6 +223,83 @@ fn suite_schedules_kernel_tick_every_max_chat_inputs() { ); } +#[test] +fn suite_discovers_client_bot_host_via_env_or_sibling() { + let bots = fs::read_to_string(process_root().join("src/entity_chat/bots.rs")).expect("bots.rs"); + let suite = + fs::read_to_string(process_root().join("src/entity_chat/suite.rs")).expect("suite.rs"); + let blob = format!("{bots}\n{suite}"); + assert!( + blob.contains("LUMIO_CLIENT_ROOT") && blob.contains("LUMIO_BOT_HOST"), + "Client Bot.Host must be discovered via LUMIO_CLIENT_ROOT / LUMIO_BOT_HOST" + ); + assert!( + blob.contains("LumioClient") && blob.contains("Lumio.Client.Bot.Host"), + "missing Bot.Host must fall back to a repo-relative sibling, never a hardcoded machine path" + ); +} + +#[test] +fn suite_spawns_lumio_client_bot_host() { + let bots = fs::read_to_string(process_root().join("src/entity_chat/bots.rs")).expect("bots.rs"); + assert!( + bots.contains("Lumio.Client.Bot.Host") + && (bots.contains("DOTNET_STARTUP_HOOKS") || bots.contains("dotnet")), + "suite must spawn Lumio.Client.Bot.Host as a child process" + ); + assert!( + bots.contains("ClientTimerManager"), + "spawned Bot.Host must drain ClientTimerManager, not a second timer" + ); +} + +#[test] +fn suite_s6_tick_source_is_native_kernel_tick_frame() { + let suite = + fs::read_to_string(process_root().join("src/entity_chat/suite.rs")).expect("suite.rs"); + assert!( + suite.contains("native-kernel/tickFrame"), + "S6 tickSource must be native-kernel/tickFrame from Client Timer Manager" + ); + assert!( + !suite.contains("\"tickSource\": if timer_ok { \"kernel:tickFrame\""), + "must not impersonate Client Timer Manager with host kernel:tickFrame" + ); +} + +#[test] +fn suite_s6_utterance_ticks_come_from_client_timer_drain() { + let suite = + fs::read_to_string(process_root().join("src/entity_chat/suite.rs")).expect("suite.rs"); + let bots = fs::read_to_string(process_root().join("src/entity_chat/bots.rs")).expect("bots.rs"); + let blob = format!("{suite}\n{bots}"); + assert!( + blob.contains("utteranceTicks") || blob.contains("utterance_ticks"), + "S6 must record Client Timer Manager utteranceTicks" + ); + assert!( + !suite.contains("vec![5, 10, 15]") + && !suite.contains("vec![5,10,15]") + && !suite.contains("[5, 10, 15]") + && !suite.contains("[5,10,15]"), + "must not hard-code Client Timer ticks 5,10,15 in suite evidence" + ); +} + +#[test] +fn suite_chat_burst_does_not_host_admit_bot_utterances() { + let text = + fs::read_to_string(process_root().join("src/entity_chat/suite.rs")).expect("suite.rs"); + assert!( + !text.contains("admit_chat_input(connection.clone()"), + "101 bot chats must come from Client Bot.Host over Room WS, not host.admit_chat_input" + ); + assert!( + text.contains("write_blocked") && text.contains("discover_bot_host"), + "missing Client Bot.Host must BLOCKED rather than skip" + ); +} + #[test] fn owned_sources_have_no_hardcoded_dev_machine_paths() { let mut hits = Vec::new(); diff --git a/modules/process/tests/entity_chat_wire.rs b/modules/process/tests/entity_chat_wire.rs index cfd813c..e7d44ac 100644 --- a/modules/process/tests/entity_chat_wire.rs +++ b/modules/process/tests/entity_chat_wire.rs @@ -65,6 +65,39 @@ fn admit_sends_full_snapshot_with_state_blocks_to_the_client() { ); } +#[test] +fn room_client_chat_input_over_wire_then_tick_sends_chat_event_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-bot01".to_owned(), + credential(&keys, "Bot01", true), + ); + assert!(admit.accepted); + let mut client = RoomClient::connect(&host.listen_uri(), "c-bot01").expect("connect"); + let _ = client.recv_text(); + client + .send_text(&InputCommand::from_chat_text("hello-Bot01").to_json()) + .expect("wire chat.input"); + std::thread::sleep(std::time::Duration::from_millis(80)); + let tick = host.run_tick("room-main".to_owned()); + assert!(tick.ok, "kernel tickFrame must run, got {tick:?}"); + let frame = client.recv_text().expect("delta"); + assert!( + frame.contains("\"mappingId\":\"chat.event\""), + "Room WS chat.input must become chat.event, got {frame}" + ); +} + #[test] fn admit_chat_input_then_tick_sends_chat_event_delta_to_room_client() { let keys = generate_keys();