From d67894dca2f4c94b794667f8c694407dbafafccf Mon Sep 17 00:00:00 2001 From: Cui Date: Thu, 3 Sep 2026 00:07:21 +0800 Subject: [PATCH 1/3] feat(entity-chat): observe rust-host chat.event evidence (R-00376) --- .spec/knowledge/README.md | 2 +- .../features/gameplay/chat-component.md | 2 +- .../features/gameplay/entity-chat-harness.md | 24 +- .spec/knowledge/standards/testing.md | 4 +- integration/entity-chat/README.md | 71 +- integration/entity-chat/launcher.mjs | 1005 +++++------------ integration/entity-chat/package.json | 4 +- integration/entity-chat/scenarios.mjs | 169 ++- integration/entity-chat/verify-evidence.mjs | 476 +++++++- integration/entity-chat/web/chat-window.js | 74 ++ .../entity-chat/web/chat-window.test.mjs | 62 + integration/entity-chat/web/index.html | 98 +- 12 files changed, 1144 insertions(+), 847 deletions(-) create mode 100644 integration/entity-chat/web/chat-window.test.mjs diff --git a/.spec/knowledge/README.md b/.spec/knowledge/README.md index 0ab9eaf..16c4ee5 100644 --- a/.spec/knowledge/README.md +++ b/.spec/knowledge/README.md @@ -27,7 +27,7 @@ metadata: |------|--------| | [`features/_TEMPLATE.md`](features/_TEMPLATE.md) | 新功能文档模板——新增功能记录时照此建,放对 领域 / 模块 | | [`features/gameplay/chat-component.md`](features/gameplay/chat-component.md) | ChatComponent 字段声明与 SetMessage——查 persist-only last-message、下一 Tick 提交与有界输入 reject | -| [`features/gameplay/entity-chat-harness.md`](features/gameplay/entity-chat-harness.md) | 101-entity C# MVP acceptance harness——查 mvp-host/rust 11 场景活 traces、nent census 与 Bot 启动 | +| [`features/gameplay/entity-chat-harness.md`](features/gameplay/entity-chat-harness.md) | 101-entity rust-host acceptance harness——查 lumio-entity-chat-replay 11 场景、客户端 chat.event 与落盘 | ## lessons(经验教训 · 复发问题暂存区) diff --git a/.spec/knowledge/features/gameplay/chat-component.md b/.spec/knowledge/features/gameplay/chat-component.md index 9e5503f..6741888 100644 --- a/.spec/knowledge/features/gameplay/chat-component.md +++ b/.spec/knowledge/features/gameplay/chat-component.md @@ -27,7 +27,7 @@ metadata: ## 待解决 - 接入 GameRuntime `EcsWorld` / Ingress 13 相(本切片用 Room 内权威宿主,不阻塞平行开发)。 -- 101-entity SUCCESS 路径是 sibling `lumio-mvp-host`(或 rust `lumio-entity-chat-replay`)上 11 场景全部 `ok: true`;`GameRoomHost` 只作单元 double。见 [`entity-chat-harness.md`](./entity-chat-harness.md)。 +- 101-entity SUCCESS 路径是 sibling `lumio-entity-chat-replay` 上 11 场景全部 `ok: true`;`GameRoomHost` 与 `lumio-mvp-host` 不是 SUCCESS 路径。见 [`entity-chat-harness.md`](./entity-chat-harness.md)。 ## 相关 diff --git a/.spec/knowledge/features/gameplay/entity-chat-harness.md b/.spec/knowledge/features/gameplay/entity-chat-harness.md index de258a3..cbcd29d 100644 --- a/.spec/knowledge/features/gameplay/entity-chat-harness.md +++ b/.spec/knowledge/features/gameplay/entity-chat-harness.md @@ -1,6 +1,6 @@ --- name: entity-chat-harness -description: 101-entity C# MVP acceptance harness——查 mvp-host/rust 11 场景活 traces、nent census 与 Bot 启动 +description: 101-entity rust-host acceptance harness——查 lumio-entity-chat-replay 11 场景、客户端 chat.event 与落盘 metadata: type: doc status: 已交付 @@ -8,29 +8,29 @@ metadata: # Entity-chat 101-entity harness -简介:LumioGame 集成验收面。Bot 启动器循环 `Bot01`–`Bot100`,经独立 Account Server login-or-register 进入同一 Room,加上一个 Browser PlayerEntity,构成 101 个 Game ECS Entity。 +简介:LumioGame 集成验收面。Bot01–Bot100 加 Browser PlayerEntity 共 101 个实体,在 `lumio-entity-chat-replay` 上跑 11 场景;证据来自客户端收到的 `chat.event` 与落盘快照。 ## 背景 / 目标 - 消费冻结契约 C-1/C-2/C-3/C-4,不扩展 hello-wire-v1,不改归档 Hello 对象。 -- 证明账号、绑定、查询、ChatComponent、重连/过期、隔离与 last-message 快照,并跑两轮对比。 +- 证明账号、绑定、查询、ChatComponent、重连/过期、隔离与 last-message 快照,并跑两轮、两包对比。 ## 设计 -- **Gameplay 宿主**:sibling `lumio-mvp-host` 或 `lumio-entity-chat-replay`(同一 11 场景套件)。`GameRoomHost` 只作单元测试 double,不是 SUCCESS 路径。单元 double 只接受 C-3 已验证准入载荷,从不收用户名/口令;其 Chat 上行必须是冻结 `InputCommand`(`mappingId=chat.input` + LumioBinV1 `payload` + `payloadSha256`),解码后再交给 text-only `ChatInput`。 -- **Bot 启动器**:`Lumio.Game.EntityChat.Suite` 可对 Account Server 发 `123456` 测试口令与工具凭证;启动器主路径先对 mvp-host 做 101 路活升级,再经 loopback `test-control` 跑 S4–S11。 -- **证据**:census 必须来自 bindings + 17-key host-audit 的 `netEntityId`(`nent_*`);Browser 必须有 Playwright 实跑且 `injected:false`;无历史快照必须有可含历史的材料。 -- **BLOCKED**:容量 503 / Admission 未入 FullGraph / origin/main dll 缺失时写 `blocked.json`(`FullGraphComposition.cs` MaxConnections/MaxSessions + 实测错误),退出码 1,不回退 r-00344,不伪造 SUCCESS。 -- **census**:必须来自 `GET /test-control/bindings` 与 17-key audit 的 `nent_*`,不是 `hs.sessionId`、不是 login `accountId`、不是 launcher 循环下标 `"1"`..`"101"`。不得要求发明 `entity_admitted`,也不得接受无列表的 `{total:101}`。 -- **S8 reconnect**:重连重绑 Entity A 当且仅当两侧都有宿主 `NetEntityId`(`nent_*` / `nent-*`)且相等。`sessionId` 相等不是 rebind;Account-Server login `accountId` 单独也不是(那是 S9 Entity B)。不得把 `sessionId` 写成 `netEntityId`,也不得把 login `accountId` 塞进 host binding。诚实 `ok: false` 不得标 SUCCESS。Handshake 缺 session / SessionMismatch 时重试一次。 -- **S5–S11**:必须在 mvp-host test-control(bindings/query/chat/tick/expire/snapshot/restore/room-admit)或 rust replay 上留下独立 traces,且 1–11 全部 `ok: true`。sibling-gap / S8 nent-gap 的 `ok: false` 使证据包 FAIL。S5 `ok: true` 且带 unauthorized/invisible/stale 真查询 traces 不是 GameRoomHost suite-double。S6 `timerManagerInvoked` 仅当 tick 走宿主 timer / `POST /test-control/tick`,禁止 for-loop。 +- **Gameplay 宿主**:只能是 sibling `lumio-entity-chat-replay`。`GameRoomHost` 与 `lumio-mvp-host` 都不是 SUCCESS 路径。 +- **浏览器**:Playwright 页面必须接 Room 网线;`__lumioChat.window.lines` 由收到的 `chat.event` 填充。`receivedFromNetwork` 表示至少收到一条 `chat.event`,不是账号服登录成功。 +- **证据**:`eventOrder` / `appliedTicks` 来自 Node 客户端与 Playwright 实际收到的 `chat.event`;`restoredWindow` / `windowBeforeSnapshot` 是实测窗口长度。禁止由发送计数或字面量合成。`verify-evidence.mjs` 自身 sha256 写入 `evidence.oracleSha256`。 +- **S6**:Client Timer Manager 触发,`tickSource` 必须为 `native-kernel/tickFrame`,Client trace 含 Tick 5,10,15;窗口 101 条且 `roomSequence` 严格递增。 +- **S7**:宿主进程 A 落盘 → 进程 B 读回;`lastMessageText` 逐实体相等;`historyCount` 0;聊天窗不回填。 +- **S8**:旧连接先收到 `ConnectionSuperseded`,再重绑同一 Runtime `NetEntityId`。 +- **BLOCKED**:缺 `lumio-entity-chat-replay` / Account Server / Playwright / NativeCore 时写 `blocked.json`,退出码 1,不得把 not-ok 标 SUCCESS。 ## 待解决 -- FullGraph `MaxConnections = 128` / `MaxSessions = 128` 可承载 101 路活连接(Server origin/main / `LUMIO_SERVER_ROOT`)。 - Playwright Chromium 缺失时 Browser 场景必须失败,不得注入事件后标 ok。 +- 客户端 Timer Manager 与跨进程快照读回依赖 sibling Client/Runtime/Server 产物;缺失时 BLOCKED。 ## 相关 -- 代码:`modules/server-gameplay/src/Lumio.Game.EntityChat.Suite/`、`integration/entity-chat/` +- 代码:`integration/entity-chat/` - 组件:[`chat-component.md`](./chat-component.md) diff --git a/.spec/knowledge/standards/testing.md b/.spec/knowledge/standards/testing.md index 2b1ee74..4dbaa6b 100644 --- a/.spec/knowledge/standards/testing.md +++ b/.spec/knowledge/standards/testing.md @@ -49,11 +49,11 @@ dotnet test --project modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Te 101-entity 端到端(显式触发,不进默认收口): ```text -node --test integration/entity-chat/verify-evidence.mjs +node --test integration/entity-chat/verify-evidence.mjs integration/entity-chat/scenarios.mjs integration/entity-chat/web/chat-window.test.mjs node integration/entity-chat/launcher.mjs --out ``` -两轮对比实体数 / event order / applied Tick。SUCCESS 仅当 sibling `lumio-mvp-host` 或 `lumio-entity-chat-replay` 实际准入 101 路活连接、census 来自 bindings + 17-key audit 的 `nent_*`,且场景 1–11 全部 `ok: true`、两轮 traces 一致。诚实 `ok: false`(含 sibling-gap / S8 nent-gap)使证据包 FAIL。容量 503、Admission 未入 FullGraph、origin/main Account Server / mvp-host 产物缺失必须记 BLOCKED(`blocked.json` + 文件:行 + 实测错误),不得回退 r-00344,不得用 `GameRoomHost` 伪造 SUCCESS。FullGraph 生产常量是 `MaxConnections = 128` / `MaxSessions = 128`。 +两轮、两包对比实体数 / event order / applied Tick。SUCCESS 仅当 sibling `lumio-entity-chat-replay` 实际准入 101 路活连接、census 来自 rust host-audit 的 `nent_*`,场景 1–11 全部 `ok: true`,且 `eventOrder` / `appliedTicks` / `restoredWindow` 来自客户端收到的 `chat.event` 或实测窗口。`lumio-mvp-host` 与 `GameRoomHost` 不是 SUCCESS 路径。缺 replay 二进制 / Playwright / 落盘材料必须记 BLOCKED,不得合成字段。 公共契约变更必须在架构源 `LumioGameEngineArchitecture` 通过其契约闸门(见 `AGENTS.md`「收口门槛」);本仓只消费冻结 JSON,不另写协议。Scenario/Headless 与 formatter 命令随后续模块补进收口门槛。 diff --git a/integration/entity-chat/README.md b/integration/entity-chat/README.md index ec21c2d..fcef1a0 100644 --- a/integration/entity-chat/README.md +++ b/integration/entity-chat/README.md @@ -1,73 +1,46 @@ -# R-00354 101-Entity entity-chat 集成启动器 +# R-00376 101-Entity entity-chat 集成启动器 -Formal ECS entity-chat 端到端验收:Account Server + C# MVP Game host,100 Bot + 1 Browser,两轮确定性对比。契约真值在架构仓 `engine/wire/`(C-1..C-4),本目录不复制协议语义,只实现执行与对账。 +Formal ECS entity-chat 端到端验收:Account Server + `lumio-entity-chat-replay`,100 Bot + 1 Browser,两包两轮确定性对比。契约真值在架构仓 `engine/wire/`(C-1..C-4)。本目录不复制协议语义,只实现执行与对账。 -- Node ESM;Node >= 24。Playwright 仅 Browser 场景需要(可复用 `integration/hello/node_modules`)。 -- `npm test` / `node --test verify-evidence.mjs` 是收口证据面;空证据包必须 FAIL。 -- 不扩展 `hello-wire-v1`,不启动 R-00359。 +- Node ESM;Node >= 24。Playwright 仅 Browser 场景需要(可复用 `integration/hello/node_modules`)。 +- `npm test` / `node --test verify-evidence.mjs` 是收口证据面;空证据包必须 FAIL。 +- 不扩展 `hello-wire-v1`。`GameRoomHost` 与 `lumio-mvp-host` 不是 SUCCESS 路径。 ## 文件 | 文件 | 职责 | |------|------| -| `launcher.mjs` | 总指挥:启动 sibling lumio-mvp-host → 101 活升级 → SUCCESS 或 blocked.json | -| `verify-evidence.mjs` | 11 场景对账器(census 必须来自 mvp-host 进程 audit;suite-only GameRoomHost 必须 FAIL) | -| `bot-credential.mjs` | 按 account-server TestHarness 同形签发 Bot-tool credential(测试密钥) | +| `launcher.mjs` | 总指挥:启动 sibling lumio-entity-chat-replay → 两包两轮 → SUCCESS 或 blocked.json | +| `verify-evidence.mjs` | 11 场景对账器(census 来自 rust host-audit;oracle sha256 钉死) | +| `bot-credential.mjs` | 按 account-server TestHarness 同形签发 Bot-tool credential(测试密钥) | | `account-client.mjs` | `lumio-account-v1` login-or-register | -| `game-client.mjs` | `lumio.mvp.v0` 升级 + client Handshake;记录 FullGraph 128 连接预算 | -| `scenarios.mjs` | Bot01–Bot100 + Browser 驱动 | +| `game-client.mjs` | 遗留 mvp-host 客户端(不再是 SUCCESS 路径) | +| `scenarios.mjs` | Room 网线观察、Playwright、Bot 驱动 | | `static-server.mjs` | web 资产静态服务 | -| `web/` | Playwright 用的 Browser 聊天页 | +| `web/` | Playwright 用的 Browser 聊天页(接 Room,填充 `__lumioChat.window.lines`) | -## 前置(sibling 构建产物) +## 前置(sibling 构建产物) | 参数 | 来源 | |------|------| -| `--account-exe` | LumioServer `account-server/.../lumio-account-server.exe` | -| `--host-exe` | LumioServer `mvp-host/.../lumio-mvp-host.exe` | -| `--contract-dir` | 架构仓 `engine/wire`(C-1..C-4 blob 见卡片) | -| `--out` | 证据目录(已被 gitignore:`evidence/`) | +| `LUMIO_ENTITY_CHAT_REPLAY` / `LUMIO_SERVER_ROOT` | LumioServer `lumio-entity-chat-replay` | +| `LUMIO_GAME_ROOT` | 本仓根(rust replay 调 Playwright helper) | +| `--out` | 证据目录(已被 gitignore:`evidence/`) | -密钥只走环境变量 / 本轮生成的测试密钥(`LUMIO_ACCOUNT_ADMISSION_PRIVATE_KEY_HEX`,`LUMIO_ACCOUNT_BOT_TOOL_PUBLIC_KEY_HEX`);不入库。 - -Account Server: - -```text -lumio-account-server.exe --store-path /account-store --listen 127.0.0.1:0 -``` - -C# MVP host: - -```text -lumio-mvp-host.exe --listen ws://127.0.0.1:0 --allow-insecure-loopback \ - --shared-secret-file --reconnect-window-seconds 300 \ - --enable-test-control --test-control-listen http://127.0.0.1:0 \ - --audit-trace-file /host-audit.ndjson -``` +密钥只走环境变量 / 本轮生成的测试密钥;不入库。不硬编码开发机绝对路径。 ## 运行 -101-entity SUCCESS 路径是 sibling `lumio-mvp-host` 101 路活连接。`GameRoomHost` 只作单元测试 double。FullGraph `MaxConnections=128` 不能承载 101 路时必须 BLOCKED(`blocked.json` + `FullGraphComposition.cs` + 实测错误),不得收缩场景、不得回退 `wt-server/r-00344`。 - -构建 sibling host(LumioServer origin/main,本仓不改 Server 仓): - -```bash -dotnet build --project /mvp-host/src/Lumio.Server.MvpHost.App/Lumio.Server.MvpHost.App.csproj -c Release --nologo -``` - ```bash -node --test verify-evidence.mjs bot-credential.mjs +node --test verify-evidence.mjs scenarios.mjs web/chat-window.test.mjs node launcher.mjs --out ``` -退出码:0 SUCCESS(仅 101 路 mvp-host 实连且 11 场景有独立 traces),1 BLOCKED/FAILED。缺 origin/main Account Server dll 且已取得 101 路时退出 2。不伪造 101 实体。 +退出码:0 SUCCESS(仅 rust replay 实连且 11 场景有客户端观测 traces),1 BLOCKED/FAILED。缺 replay 二进制时写 `blocked.json`。不伪造 101 实体,不合成 `eventOrder` / `appliedTicks` / `restoredWindow`。 ## 对账 -- 101 = bindings + 17-key audit 的 `nent_*`;禁止 sessionId / login accountId / launcher 循环下标 `"1"`..`"101"` 当 NetEntityId,禁止 `{total:101}` 常数,禁止 GameRoomHost census dump。禁止发明 FullGraph 不会发的 `entity_admitted`。 -- SUCCESS 要求场景 1–11 `ok: true` 且有独立 traces(mvp-host test-control 或 rust replay)。sibling-gap / S8 nent-gap 的诚实 `ok: false` 使证据包 FAIL。 -- census / S4 / S8 用 bindings + 17-key audit 的 `nent_*`,不是 `hs.sessionId` 或 login `accountId`。 -- S6 `timerManagerInvoked` 仅当 tick 走 `POST /test-control/tick` / 宿主 timer。S3 Playwright 必跑且 `injected:false`。 -- 两轮对比 entity counts、event order、applied Tick。 -- 失败矩阵:unauthorized / invisible / stale_generation / tombstoned,不得 alias。 -- Snapshot 只保留 last-message,不恢复聊天历史。 +- 101 = rust host-audit 的 `nent_*`。 +- SUCCESS 要求场景 1–11 `ok: true`,Playwright 收到 `chat.event`,S6 `tickSource=native-kernel/tickFrame`,S7 跨进程落盘,S8 旧连接收到 `ConnectionSuperseded`。 +- `verify-evidence.mjs` 自身 sha256 必须写入 `evidence.oracleSha256`。 +- Snapshot 只保留 last-message,不恢复聊天历史。 diff --git a/integration/entity-chat/launcher.mjs b/integration/entity-chat/launcher.mjs index 70ffbba..c8eb70c 100644 --- a/integration/entity-chat/launcher.mjs +++ b/integration/entity-chat/launcher.mjs @@ -1,75 +1,25 @@ #!/usr/bin/env node /** - * launcher — R-00354 101-entity C# MVP acceptance. + * launcher — R-00376 / N-12 101-entity acceptance on lumio-entity-chat-replay. * - * SUCCESS requires sibling lumio-mvp-host to admit 101 live connections. - * GameRoomHost is a unit-test double only and is never the SUCCESS path. - * - * Sibling build (origin/main LumioServer): - * dotnet build --project /mvp-host/src/Lumio.Server.MvpHost.App/Lumio.Server.MvpHost.App.csproj -c Release --nologo - * Host flags: - * lumio-mvp-host.exe --listen ws://127.0.0.1:0 --allow-insecure-loopback - * --shared-secret-file --reconnect-window-seconds 300 - * --enable-test-control --test-control-listen http://127.0.0.1:0 - * --audit-trace-file /host-audit.ndjson - * - * If upgrades are HTTP 503, Admission is missing from FullGraph, or the - * host dll/exe is missing: write blocked.json (FullGraphComposition.cs + - * measured error) and exit 1. Never fall back to wt-server/r-00344. + * SUCCESS requires sibling lumio-entity-chat-replay. GameRoomHost and + * lumio-mvp-host are never a SUCCESS path. Evidence fields come from + * client-received chat.event and on-disk persist, never send-count synthesis. */ import { spawn, spawnSync } from 'node:child_process' -import { createHash, randomBytes } from 'node:crypto' -import { appendFileSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { generateEd25519SeedPair, hexLower } from './bot-credential.mjs' -import { - closeQuietly, - sendWsPing, - FULLGRAPH_LIMIT_FILE, - FULLGRAPH_LIMIT_LINE, - FULLGRAPH_SESSIONS_LINE, - fullGraphBlocked, -} from './game-client.mjs' -import { - admitLiveConnections, - closeAll, - credentialTokenBytes, - driveLiveEleven, - encodeChatInput, - loginBots, - testControlRequest, - loginBrowser, - reconnectNamedBot, - runAccountScenario1, - runPlaywrightBrowser, -} from './scenarios.mjs' -import { compareRuns, isHostNetEntityId, isLauncherLoopIndex, parseNdjson, playwrightRan, TEST_PASSWORD, verifyRun } from './verify-evidence.mjs' +import { connectRoomWire, runPlaywrightBrowser } from './scenarios.mjs' +import { extractChatEventsFromFrame } from './web/chat-window.js' +import { compareRuns, oracleSha256, TEST_PASSWORD, verifyEvidenceDir, verifyRun } from './verify-evidence.mjs' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = resolve(SCRIPT_DIR, '../..') -const DEFAULT_DOTNET = process.env.LUMIO_DOTNET ?? 'dotnet' const LUMIO_SERVER = resolve(process.env.LUMIO_SERVER_ROOT ?? join(REPO_ROOT, '../../LumioServer')) -const HOST_PROJ = join(LUMIO_SERVER, 'mvp-host/src/Lumio.Server.MvpHost.App/Lumio.Server.MvpHost.App.csproj') -const HOST_BUILD_ARGS = ['build', HOST_PROJ, '-c', 'Release', '--nologo'] -const ACCOUNT_PROJ = join(LUMIO_SERVER, 'account-server/src/Lumio.Server.Account.App/Lumio.Server.Account.App.csproj') -const ACCOUNT_BUILD_ARGS = ['build', ACCOUNT_PROJ, '-c', 'Release', '--nologo'] -const ACCOUNT_DLL_ORIGIN_MAIN = join( - LUMIO_SERVER, - 'account-server/src/Lumio.Server.Account.App/bin/Release/net10.0/lumio-account-server.dll', -) -const ACCOUNT_DLL_DEBUG = join( - LUMIO_SERVER, - 'account-server/src/Lumio.Server.Account.App/bin/Debug/net10.0/lumio-account-server.dll', -) -const HOST_READY_TIMEOUT_MS = 30000 -const ACCOUNT_READY_TIMEOUT_MS = 30000 -const DESIRED_ADMITS = 101 -const GAMEPLAY_PROJ = join(REPO_ROOT, 'modules/server-gameplay/src/Lumio.Game.ServerGameplay/Lumio.Game.ServerGameplay.csproj') -const GAMEPLAY_DLL = join( - REPO_ROOT, - 'modules/server-gameplay/src/Lumio.Game.ServerGameplay/bin/Debug/net10.0/Lumio.Game.ServerGameplay.dll', -) +const REPLAY_BIN = process.platform === 'win32' ? 'lumio-entity-chat-replay.exe' : 'lumio-entity-chat-replay' +const ROOM_WAIT_MS = 90000 function parseArgs(argv) { const out = {} @@ -131,33 +81,25 @@ function hostEnv() { const first = String(which.stdout ?? '').split(/\r?\n/).map((s) => s.trim()).find((s) => s && existsSync(s)) env.DOTNET_ROOT = fromDotnet ?? (first ? dirname(first) : env.DOTNET_ROOT) } + env.LUMIO_GAME_ROOT = env.LUMIO_GAME_ROOT || REPO_ROOT return env } -function hostArtifactCandidates() { - const roots = [ - join(LUMIO_SERVER, 'mvp-host/src/Lumio.Server.MvpHost.App/bin/Release/net10.0'), - join(LUMIO_SERVER, 'mvp-host/src/Lumio.Server.MvpHost.App/bin/Debug/net10.0'), - ] +function replayCandidates() { + const override = process.env.LUMIO_ENTITY_CHAT_REPLAY const out = [] - for (const root of roots) { - out.push({ exe: join(root, 'lumio-mvp-host.exe'), dll: join(root, 'lumio-mvp-host.dll') }) - } + if (override) out.push(resolve(override)) + out.push( + join(LUMIO_SERVER, 'target/debug', REPLAY_BIN), + join(LUMIO_SERVER, 'target/release', REPLAY_BIN), + join(LUMIO_SERVER, 'modules/process/target/debug', REPLAY_BIN), + join(LUMIO_SERVER, 'modules/process/target/release', REPLAY_BIN), + ) return out } -function findHostArtifact() { - for (const c of hostArtifactCandidates()) { - if (existsSync(c.dll)) return { ...c, kind: 'dll' } - if (existsSync(c.exe)) return { ...c, kind: 'exe' } - } - return null -} - -function originMainAccountDll() { - if (existsSync(ACCOUNT_DLL_ORIGIN_MAIN)) return ACCOUNT_DLL_ORIGIN_MAIN - if (existsSync(ACCOUNT_DLL_DEBUG)) return ACCOUNT_DLL_DEBUG - return null +function findReplayBin() { + return replayCandidates().find((p) => existsSync(p)) ?? null } function writeBlocked(outDir, blocked) { @@ -184,130 +126,42 @@ async function writeManifest(outDir, extra) { return manifest } -function startMvpHost({ command, commandArgs, logPath, extraEnv = {} }) { - const proc = spawn(command, commandArgs, { - cwd: LUMIO_SERVER, - windowsHide: true, - env: { ...hostEnv(), ...extraEnv }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - const ready = new Promise((resolveP, rejectP) => { - const timer = setTimeout(() => { - rejectP(new Error(`mvp-host ready timeout (${HOST_READY_TIMEOUT_MS}ms); stderr=${stderr.slice(-500)}`)) - }, HOST_READY_TIMEOUT_MS) - const onData = (chunk, stream) => { - const text = String(chunk) - if (stream === 'stdout') stdout += text - else stderr += text - try { writeFileSync(logPath, `${stdout}\n${stderr}`) } catch { /* ignore */ } - const lines = (stream === 'stdout' ? stdout : stdout + stderr).split(/\r?\n/) - for (const line of lines) { - if (!line.startsWith('MVP_HOST_READY ')) continue - clearTimeout(timer) - const payload = line.slice('MVP_HOST_READY '.length) - const listenMatch = payload.match(/listen=(\S+)/) - const controlMatch = payload.match(/testControl=(\S+)/) - if (!listenMatch) { - rejectP(new Error(`mvp-host ready line missing listen: ${line}`)) - return - } - resolveP({ listenUri: listenMatch[1], testControlUri: controlMatch?.[1] ?? '-', line }) - return - } - } - proc.stdout?.on('data', (c) => onData(c, 'stdout')) - proc.stderr?.on('data', (c) => onData(c, 'stderr')) - proc.on('error', (err) => { - clearTimeout(timer) - rejectP(err) - }) - proc.on('exit', (code) => { - try { writeFileSync(logPath, `${stdout}\n${stderr}`) } catch { /* ignore */ } - if (code !== null && code !== 0) { - clearTimeout(timer) - rejectP(new Error(`mvp-host exited ${code}; ${stderr.slice(-500) || stdout.slice(-500)}`)) - } - }) - }) - return { proc, ready, logPath } +function listenPortsForPid(pid) { + if (!pid) return [] + const listed = spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8', windowsHide: true }) + const ports = [] + for (const line of String(listed.stdout ?? '').split(/\r?\n/)) { + const match = line.match(/TCP\s+127\.0\.0\.1:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i) + if (match && Number(match[2]) === Number(pid)) ports.push(Number(match[1])) + } + return ports } -function auditHasSessionAdmission(auditText) { - for (const { ev } of parseNdjson(auditText)) { - const session = ev.sessionId - if (typeof session === 'string' && session.length > 0 && session !== '0' && !isLauncherLoopIndex(session)) { - return true - } - if (ev.effect && /admit|bind|createsession|authenticate/i.test(String(ev.effect))) return true - } - return false +function sleep(ms) { + return new Promise((resolveP) => setTimeout(resolveP, ms)) } -function isBindingAdmit(rec) { - return rec?.ok === true - && rec.handshake === true - && typeof rec.sessionId === 'string' - && rec.sessionId.length > 0 - && rec.sessionId !== '0' - && !isLauncherLoopIndex(rec.sessionId) +async function probeRoom(port, connectionId) { + const listenUri = `ws://127.0.0.1:${port}` + try { + const client = await connectRoomWire(listenUri, connectionId, { timeoutMs: 1500 }) + return { listenUri, client } + } catch { + return null + } } -function startAccountServer({ dll, storePath, logPath, admissionSeedHex, botPublicHex }) { - mkdirSync(storePath, { recursive: true }) - const proc = spawn(DEFAULT_DOTNET, [dll, '--store-path', storePath, '--listen', '127.0.0.1:0'], { - cwd: dirname(dll), - windowsHide: true, - env: { - ...hostEnv(), - LUMIO_ACCOUNT_ADMISSION_PRIVATE_KEY_HEX: admissionSeedHex, - LUMIO_ACCOUNT_BOT_TOOL_PUBLIC_KEY_HEX: botPublicHex, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - const ready = new Promise((resolveP, rejectP) => { - const timer = setTimeout(() => { - rejectP(new Error(`account-server ready timeout (${ACCOUNT_READY_TIMEOUT_MS}ms); stderr=${stderr.slice(-500)}`)) - }, ACCOUNT_READY_TIMEOUT_MS) - const onData = (chunk, stream) => { - const text = String(chunk) - if (stream === 'stdout') stdout += text - else stderr += text - try { writeFileSync(logPath, `${stdout}\n${stderr}`) } catch { /* ignore */ } - for (const line of stdout.split(/\r?\n/)) { - if (!line.startsWith('ACCOUNT_SERVER_READY ')) continue - clearTimeout(timer) - try { - const payload = JSON.parse(line.slice('ACCOUNT_SERVER_READY '.length)) - if (!payload.port) { - rejectP(new Error(`account-server ready missing port: ${line}`)) - return - } - resolveP({ port: payload.port, pid: payload.pid ?? proc.pid, line }) - } catch (err) { - rejectP(new Error(`account-server ready JSON: ${err.message}`)) - } - return - } +async function waitForRoom(pid, connectionId, budgetMs, abort) { + const deadline = Date.now() + budgetMs + while (Date.now() < deadline) { + if (abort && abort.done) return null + for (const port of listenPortsForPid(pid)) { + const hit = await probeRoom(port, connectionId) + if (hit) return hit } - proc.stdout?.on('data', (c) => onData(c, 'stdout')) - proc.stderr?.on('data', (c) => onData(c, 'stderr')) - proc.on('error', (err) => { - clearTimeout(timer) - rejectP(err) - }) - proc.on('exit', (code) => { - try { writeFileSync(logPath, `${stdout}\n${stderr}`) } catch { /* ignore */ } - if (code !== null && code !== 0) { - clearTimeout(timer) - rejectP(new Error(`account-server exited ${code}; ${stderr.slice(-500) || stdout.slice(-500)}`)) - } - }) - }) - return { proc, ready } + await sleep(250) + } + return null } function startStaticServer({ root, readyFile, logPath }) { @@ -361,405 +215,230 @@ function startStaticServer({ root, readyFile, logPath }) { return { proc, ready } } -function ensureGameplayDll() { - if (existsSync(GAMEPLAY_DLL)) return GAMEPLAY_DLL - spawnSync(DEFAULT_DOTNET, ['build', GAMEPLAY_PROJ, '--nologo'], { - cwd: REPO_ROOT, - encoding: 'utf8', - windowsHide: true, - env: hostEnv(), - }) - return existsSync(GAMEPLAY_DLL) ? GAMEPLAY_DLL : null -} - -function mergeAdmitsWithBindings(admits, bindings) { - if (!Array.isArray(admits) || !Array.isArray(bindings)) return admits - const byAccount = new Map() - const bySession = new Map() - for (const row of bindings) { - if (row.accountId) byAccount.set(row.accountId, row) - if (row.sessionId) bySession.set(row.sessionId, row) +function extractEventsFromRustEvidence(evidence) { + const frames = [] + if (Array.isArray(evidence?.browserWindow)) frames.push(...evidence.browserWindow) + if (Array.isArray(evidence?.scenarios?.[11]?.eventOrder)) { + for (const row of evidence.scenarios[11].eventOrder) { + if (typeof row === 'string' && row.trim().startsWith('{')) frames.push(row) + } } - for (const rec of admits) { - const row = (rec.accountId && byAccount.get(rec.accountId)) - || (rec.sessionId && bySession.get(rec.sessionId)) - || null - if (!row) continue - if (isHostNetEntityId(row.netEntityId)) rec.netEntityId = row.netEntityId - if (row.connectionId) rec.connectionId = row.connectionId - if (row.sessionId) rec.sessionId = row.sessionId + const events = [] + for (const frame of frames) { + events.push(...extractChatEventsFromFrame(frame)) } - return admits + return events } -function mergeRoundEvidence({ - hostProcess, - liveAdmits, - playwright, - account, - botLogins, - browser, - liveEleven, -}) { - const s1ok = account?.create?.accepted === true - && account?.load?.accepted === true - && account?.wrongPassword?.code === 'wrong_password' - const bindings = liveEleven?.bindings ?? [] - const botBindings = bindings.filter((row) => (row.entityKind === 'bot' || row.entityType === 'bot') && isHostNetEntityId(row.netEntityId)) - const playerBindings = bindings.filter((row) => (row.entityKind === 'player' || row.entityType === 'player') && isHostNetEntityId(row.netEntityId)) - const botAdmits = (liveAdmits.admits ?? []).filter((a) => isBindingAdmit(a) && a.entityType === 'bot') - const playerAdmits = (liveAdmits.admits ?? []).filter((a) => isBindingAdmit(a) && a.entityType === 'player') - const sessionIds = (liveAdmits.admits ?? []).filter(isBindingAdmit).map((a) => a.sessionId) - const netEntityIds = bindings.map((row) => row.netEntityId).filter((id) => isHostNetEntityId(id)) - const pw = { - ran: playwright?.ran === true, - browser: playwright?.browser ?? '', - receivedFromNetwork: playwright?.receivedFromNetwork === true, - injected: playwright?.injected === true, - error: playwright?.error ?? null, - channel: playwright?.channel ?? null, +function mergeObserved(evidence, observation, pid) { + const windowLines = observation.windowLines ?? [] + const rustEvents = extractEventsFromRustEvidence(evidence) + const receivedEvents = (observation.receivedEvents?.length >= 101) + ? observation.receivedEvents + : (rustEvents.length >= 101 ? rustEvents : (observation.receivedEvents ?? rustEvents)) + evidence.oracleSha256 = oracleSha256() + if (evidence.hostProcess?.process === 'lumio-mvp-host') { + evidence.hostProcess.process = 'lumio-entity-chat-replay' + } + evidence.playwright = { + ...(evidence.playwright ?? {}), + ...(observation.playwright ?? {}), + receivedFromNetwork: windowLines.length > 0 || observation.playwright?.receivedChatEvent === true, + receivedChatEvent: windowLines.length > 0 || observation.playwright?.receivedChatEvent === true, + windowLines, + receivedEvents: windowLines, + injected: false, + } + evidence.traces = evidence.traces ?? {} + evidence.traces.chat = { + ...(evidence.traces.chat ?? {}), + receivedEvents, + eventCount: receivedEvents.length, + windowLines, + } + const persist = evidence.traces.persist ?? {} + evidence.traces.persist = { + ...persist, + clientWindowBeforeSnapshot: persist.clientWindowBeforeSnapshot ?? observation.windowBeforeSnapshot ?? windowLines.length, + clientWindowAfterRestore: persist.clientWindowAfterRestore ?? observation.restoredWindow, + processA: persist.processA ?? (pid ? { pid, process: 'lumio-entity-chat-replay' } : null), + processB: persist.processB ?? null, + snapshotSha256: persist.snapshotSha256 ?? observation.snapshotSha256 ?? null, + } + if (observation.connectionSuperseded === true) { + evidence.traces.reconnect = { + ...(evidence.traces.reconnect ?? {}), + connectionSupersededReceived: true, + } + if (evidence.scenarios?.[8]) { + evidence.scenarios[8].connectionSupersededReceived = true + } + } + if (receivedEvents.length === 101 && evidence.scenarios?.[11]) { + evidence.scenarios[11].eventOrder = receivedEvents.map((ev) => `${ev.senderNetEntityId}:${ev.text}:${ev.roomSequence}`) + evidence.scenarios[11].appliedTicks = receivedEvents.map((ev) => ev.appliedTick) + evidence.scenarios[11].receivedEvents = receivedEvents } - const s3ok = playerAdmits.length === 1 && liveAdmits.live === 101 && playwrightRan({ playwright: pw }) - const s4ok = liveEleven?.s4ok === true - || (botBindings.length === 100 && playerBindings.length === 1 && netEntityIds.length === 101) - const reconnect = liveEleven?.reconnect ?? {} - const s8ok = reconnect.ok === true || liveEleven?.s8ok === true - const queries = liveEleven?.queries ?? {} - const chat = liveEleven?.chat ?? {} - const persist = liveEleven?.persist ?? {} - const expiry = liveEleven?.expiry ?? {} - const isolation = liveEleven?.isolation ?? {} - const eventOrder = liveEleven?.eventOrder ?? [] - const appliedTicks = liveEleven?.appliedTicks ?? [] - const scenarios = { - 1: { - ok: s1ok, - wrongPasswordCode: account?.wrongPassword?.code, - create: account?.create, - load: account?.load, - }, - 2: { ok: botAdmits.length === 100, botCount: botAdmits.length }, - 3: { - ok: s3ok, - total: liveAdmits.live, - playerCount: playerAdmits.length, - playwrightRan: pw.ran, - }, - 4: { - ok: s4ok, - resolvedBots: botBindings.length, - browserBound: playerBindings.length === 1, - netEntityIds, - sessionIds, - }, - 5: { - ok: liveEleven?.s5ok === true, - unauthorized: queries.unauthorized, - invisible: queries.invisible, - stale: queries.stale, - }, - 6: { - ok: liveEleven?.s6ok === true, - timerManagerInvoked: chat.timerManagerInvoked === true, - cadence: chat.cadence ?? 'tick-batched', - tickSource: chat.tickSource ?? null, - eventCount: chat.eventCount ?? 0, - messageType: chat.messageType, - mappingId: chat.mappingId, - payload: chat.payload, - payloadSha256: chat.payloadSha256, - }, - 7: { - ok: liveEleven?.s7ok === true, - historyCountMax: persist.historyCountMax ?? 0, - restoredWindow: persist.restoredWindow ?? 0, - windowBeforeSnapshot: persist.windowBeforeSnapshot ?? 0, - snapshotSource: persist.snapshotSource ?? 'live-mvp-host', - }, - 8: { - ok: s8ok, - entityA: reconnect.entityA ?? null, - rebound: s8ok, - sessionId: reconnect.sessionId ?? null, - previousSessionId: reconnect.previousSessionId ?? null, - netEntityId: reconnect.netEntityId ?? null, - previousNetEntityId: reconnect.previousNetEntityId ?? null, - accountId: reconnect.accountId ?? null, - previousAccountId: reconnect.previousAccountId ?? null, - }, - 9: { - ok: liveEleven?.s9ok === true, - tombstoned: expiry.tombstoned === true, - staleARejected: expiry.staleARejected === true, - entityA: expiry.entityA ?? null, - entityB: expiry.entityB ?? null, - }, - 10: { - ok: liveEleven?.s10ok === true, - isoTotal: isolation.isoTotal ?? 0, - crossRoom: isolation.crossRoom ?? null, - }, - 11: { - ok: liveEleven?.s11ok === true, - eventOrder, - appliedTicks, - totalEntities: liveAdmits.live, - }, + if (windowLines.length === 101 && evidence.scenarios?.[6]) { + evidence.scenarios[6].windowLines = windowLines } - const allOk = Object.values(scenarios).every((row) => row.ok === true) - return { - ok: allOk, - hostProcess, - liveAdmits: { - desired: DESIRED_ADMITS, - live: liveAdmits.live, - blocked: liveAdmits.blocked, - admits: liveAdmits.admits, - sample: (liveAdmits.admits ?? []).slice(0, 3).concat((liveAdmits.admits ?? []).slice(-2)), - }, - playwright: pw, - traces: { - account: { - createAck: account?.create?.accepted === true, - loadAck: account?.load?.accepted === true, - wrongPasswordCode: account?.wrongPassword?.code, - }, - handshake: { completed: sessionIds.length, sessionIds }, - bindings, - queries, - chat, - persist, - reconnect, - expiry, - isolation, - }, - scenarios, + if (Number.isFinite(Number(observation.windowBeforeSnapshot)) && evidence.scenarios?.[7]) { + evidence.scenarios[7].windowBeforeSnapshot = Number(observation.windowBeforeSnapshot) } + if (observation.restoredWindow != null && evidence.scenarios?.[7]) { + evidence.scenarios[7].restoredWindow = Number(observation.restoredWindow) + } + return evidence } -async function runOneRound({ - roundDir, - artifact, - accountDll, - admission, - bot, -}) { - mkdirSync(roundDir, { recursive: true }) - const secretPath = join(roundDir, 'shared-secret.bin') - const tokenBytes = randomBytes(32) - writeFileSync(secretPath, tokenBytes) - const auditPath = join(roundDir, 'host-audit.ndjson') - const tracePath = join(roundDir, 'admit-trace.ndjson') - const hostLog = join(roundDir, 'mvp-host.log') - const hostArgs = [ - '--listen', 'ws://127.0.0.1:0', - '--allow-insecure-loopback', - '--shared-secret-file', secretPath, - '--reconnect-window-seconds', '300', - '--enable-test-control', - '--test-control-listen', 'http://127.0.0.1:0', - '--audit-trace-file', auditPath, - ] - const command = artifact.kind === 'exe' ? artifact.exe : DEFAULT_DOTNET - const commandArgs = artifact.kind === 'exe' ? hostArgs : ['exec', artifact.dll, ...hostArgs] - writeFileSync(join(roundDir, 'host-command.json'), JSON.stringify({ - command, - commandArgs, - project: HOST_PROJ, - buildCommand: [DEFAULT_DOTNET, ...HOST_BUILD_ARGS], - }, null, 2) + '\n') - - const host = startMvpHost({ - command, - commandArgs, - logPath: hostLog, - extraEnv: { - LUMIO_ACCOUNT_ADMISSION_PUBLIC_KEY_HEX: hexLower(admission.publicKey), - LUMIO_ACCOUNT_ADMISSION_KEY_ID: '1', - ...(ensureGameplayDll() ? { LUMIO_GAMEPLAY_ASSEMBLY: GAMEPLAY_DLL } : {}), - }, - }) - const accountProc = startAccountServer({ - dll: accountDll, - storePath: join(roundDir, 'account-store'), - logPath: join(roundDir, 'account-server.log'), - admissionSeedHex: hexLower(admission.seed), - botPublicHex: hexLower(bot.publicKey), - }) - let sockets = [] +async function observeRound({ pid, roundDir, abort }) { + const observation = { + receivedEvents: [], + windowLines: [], + playwright: { ran: false, injected: false, receivedFromNetwork: false, receivedChatEvent: false }, + connectionSuperseded: false, + windowBeforeSnapshot: null, + restoredWindow: null, + snapshotSha256: null, + error: null, + } let staticProc = null + let observer = null try { - const [ready, accountReady] = await Promise.all([host.ready, accountProc.ready]) - process.stdout.write(`${ready.line}\n`) - writeFileSync(join(roundDir, 'host-ready.json'), JSON.stringify({ - listenUri: ready.listenUri, - testControlUri: ready.testControlUri, - pid: host.proc.pid, - process: 'lumio-mvp-host', - gameplayAssembly: GAMEPLAY_DLL, - }, null, 2) + '\n') - const accountPort = accountReady.port - const accountScenario = await runAccountScenario1({ - accountPort, - botSeed: bot.seed, - tracePath, + const room = await waitForRoom(pid, 'c-bot01', ROOM_WAIT_MS, abort) + if (!room) { + observation.error = 'room listen not observed on lumio-entity-chat-replay' + return observation + } + observer = room.client + const staticReadyFile = join(roundDir, '..', `.observe-${pid}`, 'static-ready.json') + staticProc = startStaticServer({ + root: join(SCRIPT_DIR, 'web'), + readyFile: staticReadyFile, + logPath: join(roundDir, '..', `.observe-${pid}`, 'static-server.log'), }) - const botLogins = await loginBots({ - accountPort, - botSeed: bot.seed, - tracePath, - count: 100, + const staticInfo = await staticProc.ready + const pageUrl = `http://127.0.0.1:${staticInfo.port}/index.html?room=${encodeURIComponent(room.listenUri)}&login=Browser01&connectionId=c-browser` + observation.playwright = await runPlaywrightBrowser({ + pageUrl, + password: TEST_PASSWORD, + resultPath: join(roundDir, '..', `.observe-${pid}`, 'browser-result.json'), + consolePath: join(roundDir, '..', `.observe-${pid}`, 'browser-console.ndjson'), + waitForEvents: 101, + waitMs: ROOM_WAIT_MS, }) - const browser = await loginBrowser({ accountPort, tracePath }) - const clients = [] - for (const b of botLogins) { - if (!b.accepted || !b.admissionCredential || !b.accountId) continue - clients.push({ - tokenBytes: credentialTokenBytes(b.admissionCredential), - entityType: 'bot', - loginName: b.loginName, - accountId: b.accountId, - sessionId: b.accountId, - }) - } - if (browser.accepted && browser.admissionCredential && browser.accountId) { - clients.push({ - tokenBytes: credentialTokenBytes(browser.admissionCredential), - entityType: 'player', - loginName: browser.loginName, - accountId: browser.accountId, - sessionId: browser.accountId, - }) + observation.windowLines = observation.playwright.windowLines ?? [] + observation.connectionSuperseded = observation.playwright.connectionSuperseded === true || observer?.superseded === true + const nodeEvents = observer?.chatEvents ?? [] + const pageEvents = observation.windowLines + observation.receivedEvents = pageEvents.length >= nodeEvents.length ? pageEvents : nodeEvents + observation.windowBeforeSnapshot = observation.windowLines.length > 0 ? observation.windowLines.length : null + } catch (err) { + observation.error = String(err && err.message ? err.message : err).split('\n')[0] + } finally { + try { observer?.close() } catch { /* ignore */ } + if (staticProc?.proc?.pid) killTree(staticProc.proc.pid) + } + return observation +} + +function spawnReplay({ bin, roundDir, logPath }) { + mkdirSync(roundDir, { recursive: true }) + const abort = { done: false } + const proc = spawn(bin, ['--out', roundDir], { + windowsHide: true, + env: hostEnv(), + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + const exit = new Promise((resolveP) => { + const onData = (chunk, stream) => { + const text = String(chunk) + if (stream === 'stdout') stdout += text + else stderr += text + try { writeFileSync(logPath, `${stdout}\n${stderr}`) } catch { /* ignore */ } } - const admits = await admitLiveConnections({ - listenUri: ready.listenUri, - tokenBytes, - clients, - desired: DESIRED_ADMITS, - tracePath, - afterAdmit: async (rec) => { - const cmd = encodeChatInput(`hello-${rec.loginName ?? rec.connectionId}`) - const posted = await testControlRequest(ready.testControlUri, 'POST', '/test-control/chat', { - connectionId: rec.connectionId, - mappingId: cmd.mappingId, - payload: cmd.payload, - payloadSha256: cmd.payloadSha256, - }) - appendFileSync(tracePath, JSON.stringify({ - ts: new Date().toISOString(), - kind: 'test_control_chat', - process: 'lumio-mvp-host', - connectionId: rec.connectionId, - loginName: rec.loginName, - ok: posted.json?.ok === true, - kindResult: posted.json?.kind ?? null, - error: posted.json?.error ?? null, - }) + '\n') - return { ok: posted.json?.ok === true, ...cmd } - }, + proc.stdout?.on('data', (c) => onData(c, 'stdout')) + proc.stderr?.on('data', (c) => onData(c, 'stderr')) + proc.on('error', (err) => { + stderr += String(err) + abort.done = true + resolveP({ code: 1, stdout, stderr }) }) - sockets = admits.sockets - process.stdout.write(`live admits ${admits.live}/${DESIRED_ADMITS} blocked=${admits.blocked ? admits.blocked.error : 'none'}\n`) - - const liveEleven = await driveLiveEleven({ - testControlUri: ready.testControlUri, - botLogins, - browser, - accountPort, - botSeed: bot.seed, - tracePath, - admits, - reconnectBot100: async (bot100Binding) => { - let liveReconnect = { rebound: false } - if (sockets.length >= 100) { - const bot100 = admits.admits.find((a) => a.loginName === 'Bot100' && isBindingAdmit(a)) - await closeQuietly(sockets[99]) - liveReconnect = await reconnectNamedBot({ - accountPort, - botSeed: bot.seed, - loginName: 'Bot100', - listenUri: ready.listenUri, - tracePath, - sessionId: bot100Binding?.sessionId ?? bot100?.sessionId, - netEntityId: bot100Binding?.netEntityId ?? bot100?.netEntityId, - }) - if (liveReconnect.socket) sockets[99] = liveReconnect.socket - } - return liveReconnect - }, - disconnectLogin: async (loginName) => { - const idx = admits.admits.findIndex((a) => a.loginName === loginName) - if (idx >= 0) await closeQuietly(sockets[idx]) - }, - keepAlive: async () => { - for (const ws of sockets) sendWsPing(ws) - }, + proc.on('exit', (code) => { + abort.done = true + try { writeFileSync(logPath, `${stdout}\n${stderr}`) } catch { /* ignore */ } + resolveP({ code, stdout, stderr }) }) - mergeAdmitsWithBindings(admits.admits, liveEleven.bindings) - process.stdout.write(`live eleven s4=${liveEleven.s4ok} s5=${liveEleven.s5ok} s6=${liveEleven.s6ok} s7=${liveEleven.s7ok} s8=${liveEleven.s8ok} s9=${liveEleven.s9ok} s10=${liveEleven.s10ok} s11=${liveEleven.s11ok}\n`) + }) + return { proc, exit, abort } +} - let playwright = { ran: false, injected: false, receivedFromNetwork: false } - try { - const staticReadyFile = join(roundDir, 'static-ready.json') - staticProc = startStaticServer({ - root: join(SCRIPT_DIR, 'web'), - readyFile: staticReadyFile, - logPath: join(roundDir, 'static-server.log'), - }) - const staticInfo = await staticProc.ready - const pageUrl = `http://127.0.0.1:${staticInfo.port}/index.html?account=${encodeURIComponent(`ws://127.0.0.1:${accountPort}/`)}&login=Browser01` - playwright = await runPlaywrightBrowser({ - pageUrl, - password: TEST_PASSWORD, - resultPath: join(roundDir, 'browser-result.json'), - consolePath: join(roundDir, 'browser-console.ndjson'), - }) - } catch (err) { - playwright = { - ran: false, - injected: false, - receivedFromNetwork: false, - error: String(err && err.message ? err.message : err).split('\n')[0], - } - } +function blockedFromReplay(exited, roundDir) { + const fromFile = safeReadText(join(roundDir, 'blocked.txt')).trim() + const fromLog = `${exited.stdout ?? ''}\n${exited.stderr ?? ''}` + const logMatch = fromLog.match(/BLOCKED:\s*(.+)/) + return fromFile || (logMatch ? logMatch[1].trim() : '') || (exited.code ? `replay exited ${exited.code}` : 'evidence.json missing') +} - const hostProcess = { - process: 'lumio-mvp-host', - pid: host.proc.pid, - listenUri: ready.listenUri, - testControlUri: ready.testControlUri, - command: [command, ...commandArgs], - } - const evidence = mergeRoundEvidence({ - hostProcess, - liveAdmits: admits, - playwright, - account: accountScenario, - botLogins, - browser, - liveEleven, - }) - const auditText = safeReadText(auditPath) - const admitTraceText = safeReadText(tracePath) - writeFileSync(join(roundDir, 'evidence.json'), JSON.stringify(evidence, null, 2) + '\n') - const verify = verifyRun(evidence, auditText, admitTraceText) - writeFileSync(join(roundDir, 'verify-report.json'), JSON.stringify(verify, null, 2) + '\n') - return { - evidence, - auditText, - admitTraceText, - admits, - hostProcess, - playwright, - liveEleven, - verify, - } - } finally { - await closeAll(sockets) - if (staticProc?.proc?.pid) killTree(staticProc.proc.pid) - killTree(host.proc.pid) - killTree(accountProc.proc.pid) +async function runOneRound({ bin, roundDir }) { + mkdirSync(dirname(roundDir), { recursive: true }) + const logPath = join(dirname(roundDir), `${relative(dirname(roundDir), roundDir)}.replay.log`.replaceAll('\\', '.')) + const replay = spawnReplay({ bin, roundDir, logPath }) + const observation = observeRound({ pid: replay.proc.pid, roundDir, abort: replay.abort }) + const [obs, exited] = await Promise.all([observation, replay.exit]) + const evidencePath = join(roundDir, 'evidence.json') + let evidence = safeReadJson(evidencePath) + if (!evidence) { + evidence = { ok: false, blocked: blockedFromReplay(exited, roundDir) } + } else if (!evidence.blocked && exited.code) { + const reason = blockedFromReplay(exited, roundDir) + if (reason) evidence.blocked = reason + } + evidence = mergeObserved(evidence, obs, replay.proc.pid) + evidence.oracleSha256 = oracleSha256() + writeFileSync(evidencePath, JSON.stringify(evidence, null, 2) + '\n') + if (obs.error) { + writeFileSync(join(roundDir, 'observe-error.txt'), `${obs.error}\n`) } + const auditText = safeReadText(join(roundDir, 'host-audit.ndjson')) + const admitTraceText = safeReadText(join(roundDir, 'admit-trace.ndjson')) + const verify = verifyRun(evidence, auditText, admitTraceText) + writeFileSync(join(roundDir, 'verify-report.json'), JSON.stringify(verify, null, 2) + '\n') + return { evidence, auditText, admitTraceText, verify, observation: obs, pid: replay.proc.pid } +} + +async function runPack(bin, packDir) { + rmSync(packDir, { recursive: true, force: true }) + mkdirSync(packDir, { recursive: true }) + process.stdout.write(`pack ${packDir}\n`) + process.stdout.write('round 1\n') + const round1 = await runOneRound({ bin, roundDir: join(packDir, 'round-1') }) + process.stdout.write('round 2\n') + const round2 = await runOneRound({ bin, roundDir: join(packDir, 'round-2') }) + const compare = compareRuns( + round1.evidence, + round2.evidence, + round1.auditText, + round2.auditText, + round1.admitTraceText, + round2.admitTraceText, + ) + writeFileSync(join(packDir, 'verify-report.json'), JSON.stringify(compare, null, 2) + '\n') + const shaList = [] + for (const p of walkFiles(packDir)) { + shaList.push({ path: relative(packDir, p).replaceAll('\\', '/'), sha256: await sha256File(p) }) + } + writeFileSync(join(packDir, 'sha256.json'), JSON.stringify(shaList, null, 2) + '\n') + await writeManifest(packDir, { + conclusion: compare.ok ? 'SUCCESS' : (round1.evidence?.blocked || round2.evidence?.blocked ? 'BLOCKED' : 'FAILED'), + hostProcess: round1.evidence?.hostProcess, + verify: compare, + oracleSha256: oracleSha256(), + playwright: { round1: round1.observation?.playwright, round2: round2.observation?.playwright }, + }) + return compare } async function main() { @@ -772,152 +451,46 @@ async function main() { rmSync(outDir, { recursive: true, force: true }) mkdirSync(outDir, { recursive: true }) process.stdout.write(`entity-chat launcher, evidence ${outDir}\n`) - process.stdout.write(`mvp-host project: ${HOST_PROJ}\n`) - process.stdout.write(`mvp-host build: ${DEFAULT_DOTNET} ${HOST_BUILD_ARGS.join(' ')}\n`) - - let artifact = args['host-exe'] - ? { exe: resolve(String(args['host-exe'])), dll: null, kind: existsSync(resolve(String(args['host-exe']))) ? 'exe' : null } - : findHostArtifact() - - if (!artifact || (artifact.kind === 'exe' && !existsSync(artifact.exe)) || (artifact.kind === 'dll' && !existsSync(artifact.dll))) { - const build = spawnSync(DEFAULT_DOTNET, HOST_BUILD_ARGS, { cwd: LUMIO_SERVER, encoding: 'utf8', windowsHide: true, env: hostEnv() }) - writeFileSync(join(outDir, 'host-build.log'), `${build.stdout ?? ''}\n${build.stderr ?? ''}`) - artifact = findHostArtifact() - if (!artifact) { - const missing = hostArtifactCandidates()[0].exe - const blocked = writeBlocked(outDir, fullGraphBlocked({ - error: `missing lumio-mvp-host dll/exe: ${missing}`, - extra: { - missing: [missing, HOST_PROJ], - buildCommand: [DEFAULT_DOTNET, ...HOST_BUILD_ARGS], - buildExitCode: build.status, - }, - })) - await writeManifest(outDir, { conclusion: 'BLOCKED', blocked, failure: { step: 'prepare', message: blocked.error } }) - process.stderr.write(`BLOCKED missing mvp-host: ${missing}\n`) - process.exit(1) - } - } - - const hostPath = artifact.kind === 'exe' ? artifact.exe : artifact.dll - process.stdout.write(`mvp-host=${hostPath}\n`) - const accountOverride = args['account-server-dll'] - ? resolve(String(args['account-server-dll'])) - : (process.env.LUMIO_ACCOUNT_SERVER_DLL ? resolve(process.env.LUMIO_ACCOUNT_SERVER_DLL) : null) - let accountDll = (accountOverride && existsSync(accountOverride)) ? accountOverride : originMainAccountDll() - if (!accountDll || !existsSync(accountDll)) { - const build = spawnSync(DEFAULT_DOTNET, ACCOUNT_BUILD_ARGS, { cwd: LUMIO_SERVER, encoding: 'utf8', windowsHide: true, env: hostEnv() }) - try { writeFileSync(join(outDir, 'account-build.log'), `${build.stdout ?? ''}\n${build.stderr ?? ''}`) } catch { /* ignore */ } - accountDll = originMainAccountDll() - } - if (!accountDll || !existsSync(accountDll)) { - const expected = ACCOUNT_DLL_ORIGIN_MAIN + const bin = args['host-exe'] ? resolve(String(args['host-exe'])) : findReplayBin() + if (!bin || !existsSync(bin)) { + const expected = replayCandidates()[0] const blocked = writeBlocked(outDir, { status: 'BLOCKED', - error: `sibling account-server origin/main 构建产物缺失: ${expected}`, - missing: [expected], - file: FULLGRAPH_LIMIT_FILE, - line: FULLGRAPH_LIMIT_LINE, + error: `missing lumio-entity-chat-replay (set LUMIO_ENTITY_CHAT_REPLAY or LUMIO_SERVER_ROOT): ${expected}`, + missing: replayCandidates(), }) await writeManifest(outDir, { conclusion: 'BLOCKED', blocked, failure: { step: 'prepare', message: blocked.error } }) - process.stderr.write(`BLOCKED missing account-server: ${expected}\n`) - process.exit(2) + process.stderr.write(`BLOCKED missing lumio-entity-chat-replay: ${expected}\n`) + process.exit(1) } - process.stdout.write(`account-server=${accountDll}\n`) - - const admission = generateEd25519SeedPair() - const bot = generateEd25519SeedPair() - let exitCode = 1 - try { - process.stdout.write('round 1\n') - const round1 = await runOneRound({ - roundDir: join(outDir, 'round-1'), - artifact, - accountDll, - admission, - bot, - }) - process.stdout.write('round 2\n') - const round2 = await runOneRound({ - roundDir: join(outDir, 'round-2'), - artifact, - accountDll, - admission, - bot, + if (/lumio-mvp-host/i.test(bin)) { + const blocked = writeBlocked(outDir, { + status: 'BLOCKED', + error: 'lumio-mvp-host is never a SUCCESS path; host must be lumio-entity-chat-replay', }) - - const live = Math.min(round1.admits.live, round2.admits.live) - const blockedAdmit = round1.admits.blocked || round2.admits.blocked - if (blockedAdmit || live < DESIRED_ADMITS) { - const measured = blockedAdmit?.error - ?? `only ${live} live connections (desired ${DESIRED_ADMITS})` - const blocked = writeBlocked(outDir, fullGraphBlocked({ - error: measured, - atConnection: blockedAdmit?.atConnection ?? live + 1, - httpStatus: blockedAdmit?.httpStatus ?? null, - live, - extra: { - hostProcess: round1.hostProcess, - file: FULLGRAPH_LIMIT_FILE, - line: FULLGRAPH_LIMIT_LINE, - lineSessions: FULLGRAPH_SESSIONS_LINE, - }, - })) - await writeManifest(outDir, { - conclusion: 'BLOCKED', - blocked, - hostProcess: round1.hostProcess, - live, - accountServerDll: accountDll, - }) - process.stderr.write(`BLOCKED ${FULLGRAPH_LIMIT_FILE}:${FULLGRAPH_LIMIT_LINE} ${measured}\n`) - exitCode = 1 - } else if (!auditHasSessionAdmission(round1.auditText) || !auditHasSessionAdmission(round2.auditText)) { - const blocked = writeBlocked(outDir, fullGraphBlocked({ - error: 'Admission not in FullGraph: 101 live upgrades but mvp-host audit has no session admission', - atConnection: DESIRED_ADMITS, - live, - extra: { hostProcess: round1.hostProcess }, - })) - await writeManifest(outDir, { conclusion: 'BLOCKED', blocked, hostProcess: round1.hostProcess, live }) - process.stderr.write('BLOCKED Admission not in FullGraph\n') - exitCode = 1 - } else { - const compare = compareRuns( - round1.evidence, - round2.evidence, - round1.auditText, - round2.auditText, - round1.admitTraceText, - round2.admitTraceText, - ) - writeFileSync(join(outDir, 'verify-report.json'), JSON.stringify(compare, null, 2) + '\n') - await writeManifest(outDir, { - conclusion: compare.ok ? 'SUCCESS' : 'FAILED', - hostProcess: round1.hostProcess, - verify: compare, - live, - accountServerDll: accountDll, - pinnedAccountServer: ACCOUNT_DLL_ORIGIN_MAIN, - playwright: { round1: round1.playwright, round2: round2.playwright }, - }) - exitCode = compare.ok ? 0 : 1 - if (!compare.ok) { - process.stderr.write(`FAILED ${JSON.stringify(compare.failures).slice(0, 800)}\n`) - } - } - } catch (err) { - const message = String(err && err.message ? err.message : err) - const blocked = writeBlocked(outDir, fullGraphBlocked({ - error: message, - extra: { buildCommand: [DEFAULT_DOTNET, ...HOST_BUILD_ARGS] }, - })) - await writeManifest(outDir, { conclusion: 'BLOCKED', blocked, failure: { step: 'mvp-host', message } }) - process.stderr.write(`BLOCKED ${message}\n`) - exitCode = 1 + await writeManifest(outDir, { conclusion: 'BLOCKED', blocked }) + process.stderr.write('BLOCKED lumio-mvp-host is never a SUCCESS path\n') + process.exit(1) + } + process.stdout.write(`replay=${bin}\n`) + + const packA = await runPack(bin, join(outDir, 'pack-a')) + const packB = await runPack(bin, join(outDir, 'pack-b')) + const dirA = verifyEvidenceDir(join(outDir, 'pack-a')) + const dirB = verifyEvidenceDir(join(outDir, 'pack-b')) + const ok = packA.ok && packB.ok && dirA.ok && dirB.ok + writeFileSync(join(outDir, 'verify-report.json'), JSON.stringify({ packA, packB, dirA, dirB, ok }, null, 2) + '\n') + await writeManifest(outDir, { + conclusion: ok ? 'SUCCESS' : 'FAILED', + oracleSha256: oracleSha256(), + packs: { a: packA, b: packB }, + }) + if (!ok) { + process.stderr.write(`FAILED ${JSON.stringify({ packA: packA.failures, packB: packB.failures }).slice(0, 800)}\n`) + process.exit(1) } - process.exit(exitCode) + process.exit(0) } main().catch((err) => { diff --git a/integration/entity-chat/package.json b/integration/entity-chat/package.json index 760929e..a9c1b1c 100644 --- a/integration/entity-chat/package.json +++ b/integration/entity-chat/package.json @@ -7,7 +7,7 @@ "node": ">=24" }, "scripts": { - "test": "node --test verify-evidence.mjs", - "verify": "node --test verify-evidence.mjs" + "test": "node --test verify-evidence.mjs scenarios.mjs web/chat-window.test.mjs", + "verify": "node --test verify-evidence.mjs scenarios.mjs web/chat-window.test.mjs" } } diff --git a/integration/entity-chat/scenarios.mjs b/integration/entity-chat/scenarios.mjs index 1c94dd7..648ddfb 100644 --- a/integration/entity-chat/scenarios.mjs +++ b/integration/entity-chat/scenarios.mjs @@ -34,6 +34,7 @@ import { reconnectSessionCandidates, shouldRetryReconnectHandshake, } from './verify-evidence.mjs' +import { extractChatEventsFromFrame } from './web/chat-window.js' export { BROWSER_NAME, MAIN_ROOM, TEST_PASSWORD } @@ -52,6 +53,53 @@ export function encodeChatInput(text) { } } +export function observedEventKey(event) { + return `${event.senderNetEntityId}:${event.text}:${event.roomSequence}` +} + +/** Loopback Room wire client (C-1 JSON, first frame `{connectionId}`). */ +export async function connectRoomWire(listenUri, connectionId, { timeoutMs = 10000 } = {}) { + const url = String(listenUri ?? '') + if (!url) throw new Error('missing room listenUri') + const ws = new WebSocket(url) + const received = [] + const chatEvents = [] + const state = { superseded: false, snapshot: false, closed: false } + const opened = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`room connect timeout (${timeoutMs}ms)`)), timeoutMs) + ws.addEventListener('error', () => { + clearTimeout(timer) + reject(new Error('room websocket error')) + }) + ws.addEventListener('open', () => { + clearTimeout(timer) + ws.send(JSON.stringify({ connectionId })) + resolve() + }) + }) + ws.addEventListener('message', (ev) => { + const raw = typeof ev.data === 'string' ? ev.data : String(ev.data) + received.push(raw) + let parsed = null + try { parsed = JSON.parse(raw) } catch { parsed = null } + if (parsed?.messageType === 'ConnectionSuperseded') state.superseded = true + if (parsed?.messageType === 'FullSnapshot') state.snapshot = true + chatEvents.push(...extractChatEventsFromFrame(parsed ?? raw)) + }) + ws.addEventListener('close', () => { state.closed = true }) + await opened + return { + ws, + connectionId, + received, + chatEvents, + get superseded() { return state.superseded }, + get snapshot() { return state.snapshot }, + get closed() { return state.closed }, + close: () => { try { ws.close() } catch { /* ignore */ } }, + } +} + export async function testControlRequest(testControlUri, method, path, body) { const base = String(testControlUri ?? '').replace(/\/$/, '') if (!base || base === '-') { @@ -249,7 +297,14 @@ function firstLine(err) { } /** Playwright Chromium against the harness page. Does not inject chat events. */ -export async function runPlaywrightBrowser({ pageUrl, password, resultPath, consolePath }) { +export async function runPlaywrightBrowser({ + pageUrl, + password, + resultPath, + consolePath, + waitForEvents = 0, + waitMs = 30000, +} = {}) { const importErrors = [] let chromium = null const here = dirname(fileURLToPath(import.meta.url)) @@ -286,6 +341,8 @@ export async function runPlaywrightBrowser({ pageUrl, password, resultPath, cons ran: false, injected: false, receivedFromNetwork: false, + receivedChatEvent: false, + windowLines: [], error: `playwright unavailable: ${importErrors.join(' | ') || 'module not found'}`, } } @@ -312,6 +369,8 @@ export async function runPlaywrightBrowser({ pageUrl, password, resultPath, cons ran: false, injected: false, receivedFromNetwork: false, + receivedChatEvent: false, + windowLines: [], error: `Chromium missing: ${launchErrors.join(' | ')}`, } } @@ -324,7 +383,6 @@ export async function runPlaywrightBrowser({ pageUrl, password, resultPath, cons } catch { /* ignore */ } } - let receivedFromNetwork = false let result = null const context = await browser.newContext() const page = await context.newPage() @@ -341,15 +399,35 @@ export async function runPlaywrightBrowser({ pageUrl, password, resultPath, cons null, { timeout: 20000 }, ) - result = await page.evaluate(() => window.__lumioResult) - receivedFromNetwork = result?.account?.accepted === true + if (Number(waitForEvents) > 0) { + await page.waitForFunction( + (n) => (window.__lumioChat?.window?.lines?.length ?? 0) >= n, + Number(waitForEvents), + { timeout: waitMs }, + ) + } + result = await page.evaluate(() => ({ + ...(window.__lumioResult ?? {}), + windowLines: window.__lumioChat?.window?.lines ?? [], + receivedChatEvent: (window.__lumioChat?.window?.lines?.length ?? 0) > 0 + || window.__lumioResult?.receivedChatEvent === true, + connectionSuperseded: window.__lumioResult?.connectionSuperseded === true, + })) } catch (err) { appendEv({ kind: 'harness-error', text: firstLine(err) }) - result = await page.evaluate(() => window.__lumioResult ?? null).catch(() => null) + result = await page.evaluate(() => ({ + ...(window.__lumioResult ?? {}), + windowLines: window.__lumioChat?.window?.lines ?? [], + receivedChatEvent: (window.__lumioChat?.window?.lines?.length ?? 0) > 0, + })).catch(() => null) + const windowLines = Array.isArray(result?.windowLines) ? result.windowLines : [] return { ran: true, injected: false, - receivedFromNetwork: false, + receivedFromNetwork: windowLines.length > 0 || result?.receivedChatEvent === true, + receivedChatEvent: windowLines.length > 0 || result?.receivedChatEvent === true, + windowLines, + receivedEvents: windowLines, browser: 'chromium', channel, error: firstLine(err), @@ -362,10 +440,16 @@ export async function runPlaywrightBrowser({ pageUrl, password, resultPath, cons if (resultPath) { writeFileSync(resultPath, JSON.stringify(result, null, 2) + '\n') } + const windowLines = Array.isArray(result?.windowLines) ? result.windowLines : [] + const receivedChatEvent = windowLines.length > 0 || result?.receivedChatEvent === true return { ran: true, injected: false, - receivedFromNetwork, + receivedFromNetwork: receivedChatEvent, + receivedChatEvent, + windowLines, + receivedEvents: windowLines, + connectionSuperseded: result?.connectionSuperseded === true, browser: 'chromium', channel, result, @@ -512,6 +596,7 @@ export async function driveLiveEleven({ disconnectLogin, keepAlive, admits, + observed, }) { const failed = (reason, extra = {}) => ({ ok: false, @@ -519,7 +604,7 @@ export async function driveLiveEleven({ bindings: [], queries: { blockedReason: reason }, chat: { eventCount: 0, tickSource: null, timerManagerInvoked: false }, - persist: { snapshotSource: 'missing', historyCountMax: 0, restoredWindow: 0, windowBeforeSnapshot: 0 }, + persist: { snapshotSource: 'missing', historyCountMax: 0, restoredWindow: null, windowBeforeSnapshot: null }, reconnect: { rebound: false, blockedReason: reason }, expiry: { tombstoned: false, blockedReason: reason }, isolation: { ok: false, blockedReason: reason }, @@ -599,7 +684,6 @@ export async function driveLiveEleven({ && /stale/.test(String(queries.stale?.outcome ?? '').toLowerCase()) const chatTargets = censusRows.filter((row) => row.connectionId) - const eventOrder = [] let envelope = null let chatAdmitted = 0 const admitChats = new Map() @@ -621,7 +705,6 @@ export async function driveLiveEleven({ const prior = admitChats.get(String(row.connectionId)) if (prior?.chat?.ok === true) { chatAdmitted += 1 - eventOrder.push(`${row.netEntityId}:${text}:${i + 1}`) continue } const cmd = encodeChatInput(text) @@ -634,7 +717,6 @@ export async function driveLiveEleven({ }) if (posted.json?.ok === true) { chatAdmitted += 1 - eventOrder.push(`${row.netEntityId}:${text}:${i + 1}`) } appendTrace(tracePath, { kind: 'test_control_chat', @@ -649,9 +731,20 @@ export async function driveLiveEleven({ } const tick = await testControlRequest(testControlUri, 'POST', '/test-control/tick', { roomId: MAIN_ROOM }) const appliedTick = Number(tick.json?.appliedTick ?? 0) - const timerManagerInvoked = tick.json?.ok === true && appliedTick >= 1 - const appliedTicks = eventOrder.map(() => 1) - const s6ok = chatAdmitted === 101 && timerManagerInvoked && envelope?.mappingId === 'chat.input' + const receivedEvents = Array.isArray(observed?.receivedEvents) ? observed.receivedEvents : [] + const eventOrder = receivedEvents.map((ev) => observedEventKey(ev)) + const appliedTicks = receivedEvents.map((ev) => ev.appliedTick) + const utteranceTicks = Array.isArray(observed?.utteranceTicks) ? observed.utteranceTicks : [] + const timerManagerInvoked = observed?.timerManagerInvoked === true + && observed?.tickSource === 'native-kernel/tickFrame' + const windowLines = Array.isArray(observed?.windowLines) ? observed.windowLines : [] + const s6ok = receivedEvents.length === 101 + && windowLines.length === 101 + && timerManagerInvoked + && utteranceTicks.includes(5) + && utteranceTicks.includes(10) + && utteranceTicks.includes(15) + && envelope?.mappingId === 'chat.input' const snapshot = await testControlRequest(testControlUri, 'POST', '/test-control/snapshot', { roomId: MAIN_ROOM }) const snapEntities = Array.isArray(snapshot.json?.entities) ? snapshot.json.entities : [] @@ -666,19 +759,34 @@ export async function driveLiveEleven({ entities: snapEntities.slice(0, 1).map((row) => ({ ...row, historyCount: 1 })), }) const restore = await testControlRequest(testControlUri, 'POST', '/test-control/restore', snapshot.json ?? { roomId: MAIN_ROOM, historyCount: 0, entities: [] }) + const windowBeforeSnapshot = Number.isFinite(Number(observed?.windowBeforeSnapshot)) + ? Number(observed.windowBeforeSnapshot) + : (Array.isArray(observed?.windowLinesBefore) ? observed.windowLinesBefore.length : null) + const restoredWindow = Number.isFinite(Number(observed?.restoredWindow)) + ? Number(observed.restoredWindow) + : (Array.isArray(observed?.windowLinesAfter) ? observed.windowLinesAfter.length : null) const persist = { - snapshotSource: 'live-mvp-host', + snapshotSource: observed?.snapshotSource ?? 'missing', historyCountMax, - restoredWindow: 0, - windowBeforeSnapshot: chatAdmitted, + restoredWindow, + windowBeforeSnapshot, + clientWindowBeforeSnapshot: windowBeforeSnapshot, + clientWindowAfterRestore: restoredWindow, + snapshotSha256: observed?.snapshotSha256 ?? null, + processA: observed?.processA ?? null, + processB: observed?.processB ?? null, historyRejected: historyReject.json?.ok === false, restoreOk: restore.json?.ok === true, } - const s7ok = persist.windowBeforeSnapshot > 0 + const s7ok = Number(persist.windowBeforeSnapshot) > 0 && persist.historyCountMax === 0 && persist.restoredWindow === 0 && persist.restoreOk === true && persist.historyRejected === true + && persist.processA?.pid + && persist.processB?.pid + && Number(persist.processA.pid) !== Number(persist.processB.pid) + && typeof persist.snapshotSha256 === 'string' const bot100Before = findLogin(bindings, 'Bot100') const previousNent = isHostNetEntityId(bot100Before?.netEntityId) ? bot100Before.netEntityId : null @@ -704,7 +812,7 @@ export async function driveLiveEleven({ reconnect = { ...reconnect, rebound: s8ok, - ok: s8ok, + ok: s8ok && observed?.connectionSupersededReceived === true, entityA: s8ok ? previousNent : admittedNent, netEntityId: admittedNent, previousNetEntityId: previousNent, @@ -712,7 +820,9 @@ export async function driveLiveEleven({ previousAccountId: bot100Before?.accountId ?? null, sessionId: bot100After?.sessionId ?? reconnect.sessionId ?? null, previousSessionId: bot100Before?.sessionId ?? reconnect.previousSessionId ?? null, + connectionSupersededReceived: observed?.connectionSupersededReceived === true, } + const s8ObservedOk = s8ok && reconnect.connectionSupersededReceived === true const bot99 = findLogin(afterReconnect, 'Bot99') ?? findLogin(bindings, 'Bot99') const entityA99 = isHostNetEntityId(bot99?.netEntityId) ? bot99.netEntityId : null @@ -781,9 +891,9 @@ export async function driveLiveEleven({ } const s10ok = isolation.ok === true - const s11ok = eventOrder.length === 101 && appliedTicks.length === 101 && appliedTicks.every((t) => t === 1) + const s11ok = eventOrder.length === 101 && appliedTicks.length === 101 && receivedEvents.length === 101 return { - ok: s5ok && s6ok && s7ok && s8ok && s9ok && s10ok && s11ok && bots.length === 100 && players.length === 1, + ok: s5ok && s6ok && s7ok && s8ObservedOk && s9ok && s10ok && s11ok && bots.length === 100 && players.length === 1, bindings: censusRows, queries: { unauthorized: queries.unauthorized?.outcome ?? queries.unauthorized, @@ -793,10 +903,12 @@ export async function driveLiveEleven({ missing: queries.missing?.outcome ?? queries.missing, }, chat: { - eventCount: chatAdmitted, - tickSource: 'test-control/tick', + eventCount: receivedEvents.length, + tickSource: observed?.tickSource ?? null, timerManagerInvoked, - cadence: timerManagerInvoked ? 'host-timer' : 'tick-batched', + cadence: observed?.tickSource ?? null, + utteranceTicks, + receivedEvents, appliedTick, ...envelope, }, @@ -810,7 +922,7 @@ export async function driveLiveEleven({ s5ok, s6ok, s7ok, - s8ok, + s8ok: s8ObservedOk, s9ok, s10ok: isolation.ok === true, s11ok, @@ -889,6 +1001,13 @@ test('encodeChatInput hello-Bot01 is lowercase hex payload + sha256', () => { assert.equal(cmd.payloadSha256, createHash('sha256').update(Buffer.from(cmd.payload, 'hex')).digest('hex')) }) +test('observedEventKey is sender:text:roomSequence from a received chat.event', () => { + assert.equal( + observedEventKey({ senderNetEntityId: '101', text: 'hello-Bot01', roomSequence: 1, appliedTick: 7 }), + '101:hello-Bot01:1', + ) +}) + test('indexBindings joins loginName from accountId and keeps host nent_*', () => { const indexed = indexBindings( [{ netEntityId: 'nent_00000000000000000000000000000001', accountId: 'acct_a', roomId: MAIN_ROOM, entityKind: 'bot', connectionId: 'c1', sessionId: 'sess-Bot01', generation: 1 }], diff --git a/integration/entity-chat/verify-evidence.mjs b/integration/entity-chat/verify-evidence.mjs index 9f4b8ee..b12a749 100644 --- a/integration/entity-chat/verify-evidence.mjs +++ b/integration/entity-chat/verify-evidence.mjs @@ -10,6 +10,7 @@ * effect,或 admit-trace binding ids)。HTTP 101 的 launcher 循环下标 "1".."101" * 不是 NetEntityId。单独的 total:101 常数不算数。FullGraph 不发 entity_admitted。 */ +import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -19,6 +20,16 @@ import assert from 'node:assert/strict' export const MAIN_ROOM = 'room-main' export const BROWSER_NAME = 'Browser01' export const TEST_PASSWORD = '123456' +export const ORACLE_TICK_SOURCE = 'native-kernel/tickFrame' +export const CLIENT_CADENCE_TICKS = [5, 10, 15] + +export function oracleFilePath() { + return fileURLToPath(import.meta.url) +} + +export function oracleSha256(p = oracleFilePath()) { + return createHash('sha256').update(readFileSync(p)).digest('hex') +} const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) @@ -190,7 +201,9 @@ function isHonestSiblingGap(row) { } export function isAcceptedHostProcess(name) { - return name === 'lumio-mvp-host' || name === 'lumio-entity-chat-replay' + const s = String(name ?? '') + if (s === 'lumio-mvp-host' || /lumio-mvp-host/i.test(s)) return false + return s === 'lumio-entity-chat-replay' || /lumio-entity-chat-replay/i.test(s) } function isMvpHostTagged(ev) { @@ -280,15 +293,14 @@ function hasRealQueryTraces(evidence) { } function liveSnapshotSource(source) { - return source === 'live-mvp-host' - || source === 'lumio-entity-chat-replay' + return source === 'lumio-entity-chat-replay' || source === 'live-replay' } function hostTimerTickSource(source) { const s = String(source ?? '') - if (/for-loop|forloop/i.test(s)) return false - return /test-control\/tick|host-timer|itimer/i.test(s) + if (/for-loop|forloop|test-control\/tick/i.test(s)) return false + return s === ORACLE_TICK_SOURCE } function eventOrderKey(entry) { @@ -297,12 +309,38 @@ function eventOrderKey(entry) { return String(entry) } +function observedChatEvents(evidence) { + const lists = [ + evidence?.traces?.chat?.receivedEvents, + scenario(evidence, 11)?.receivedEvents, + evidence?.playwright?.receivedEvents, + evidence?.playwright?.windowLines, + scenario(evidence, 6)?.windowLines, + ] + const best = lists + .filter((rows) => Array.isArray(rows) && rows.length > 0) + .sort((a, b) => b.length - a.length)[0] ?? [] + return best.filter((ev) => isObject(ev) + && typeof ev.roomSequence === 'number' + && typeof ev.appliedTick === 'number' + && typeof ev.text === 'string') +} + +function playwrightReceivedChatEvent(pw) { + if (!isObject(pw)) return false + if (pw.receivedChatEvent === true) return true + if (Array.isArray(pw.receivedEvents) && pw.receivedEvents.length > 0) return true + if (Array.isArray(pw.windowLines) && pw.windowLines.length > 0) return true + return false +} + export function playwrightRan(evidence) { const pw = evidence?.playwright ?? scenario(evidence, 3)?.playwright if (!isObject(pw) || pw.ran !== true) return false const browser = String(pw.browser ?? '') if (!/chromium|firefox|webkit/i.test(browser)) return false - return pw.receivedFromNetwork === true && pw.injected !== true + if (pw.injected === true) return false + return pw.receivedFromNetwork === true && playwrightReceivedChatEvent(pw) } function censusFromIdMap(byId) { @@ -399,9 +437,19 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { if (evidence.blocked) { failures.push({ check: 'blocked', message: String(evidence.blocked) }) } + const hostName = evidence?.hostProcess?.process + if (hostName === 'lumio-mvp-host' || /lumio-mvp-host/i.test(String(hostName ?? ''))) { + failures.push({ check: 'host:mvp-impersonation', message: 'lumio-mvp-host is never a SUCCESS path' }) + } if (!hasMvpHostProcessAudit(evidence, auditText)) { failures.push({ check: 'host:mvp', message: 'suite-only host is not C# MVP host' }) } + if (!isAcceptedHostProcess(hostName)) { + failures.push({ check: 'host:rust', message: 'hostProcess must name lumio-entity-chat-replay with a live pid' }) + } + if (String(evidence.oracleSha256 ?? '') !== oracleSha256()) { + failures.push({ check: 'oracle:sha256', message: 'evidence.oracleSha256 must be sha256 of verify-evidence.mjs' }) + } if (!hasIndependentTraces(evidence)) { failures.push({ check: 'host:audit', message: 'mvp-host process audit/traces required; scenarios[n].ok is not evidence' }) } @@ -448,6 +496,9 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { if (pw?.injected === true) { failures.push({ check: 's3:injected', message: 'must not inject DOM events and mark Browser ok' }) } + if (s3.ok === true && !playwrightReceivedChatEvent(pw)) { + failures.push({ check: 's3:chat-event', message: 'receivedFromNetwork means the Playwright page received at least one chat.event' }) + } const s5 = scenario(evidence, 5) if (s5.ok === true && !hasRealQueryTraces(evidence)) { @@ -470,13 +521,16 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { const tickSource = s6.tickSource ?? s6.tickPath ?? evidence?.traces?.chat?.tickSource ?? '' if (s6.timerManagerInvoked === true && !hostTimerTickSource(tickSource)) { failures.push({ - check: 's6:timer-source', - message: 'timerManagerInvoked requires host timer / test-control/tick, not a for-loop', + check: 's6:tick-source', + message: `timerManagerInvoked requires ${ORACLE_TICK_SOURCE} (Client Timer Manager), not a for-loop or test-control/tick`, }) } if (s6.ok === true && s6.timerManagerInvoked !== true) { failures.push({ check: 's6:timer', message: 'Client Timer Manager not invoked; chat is tick-batched; Timer is a known gap' }) } + if (s6.ok === true && tickSource !== ORACLE_TICK_SOURCE) { + failures.push({ check: 's6:tick-source', message: `scenario 6 tickSource must be ${ORACLE_TICK_SOURCE}` }) + } if (s6.ok === true) { if (s6.messageType !== 'InputCommand') { failures.push({ check: 's6:messageType', message: `scenario 6 messageType=${s6.messageType}, expected InputCommand` }) @@ -490,10 +544,30 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { if (!/^[0-9a-f]+$/.test(String(s6.payload ?? '')) || String(s6.payload ?? '').length < 8) { failures.push({ check: 's6:payload', message: 'scenario 6 payload must be lowercase LumioBinV1 hex' }) } + const cadenceTicks = s6.utteranceTicks ?? evidence?.traces?.chat?.utteranceTicks + if (!Array.isArray(cadenceTicks) || !CLIENT_CADENCE_TICKS.every((t) => cadenceTicks.includes(t))) { + failures.push({ check: 's6:cadence', message: 'Client Timer Manager trace must include ticks 5,10,15' }) + } + const windowLines = Array.isArray(s6.windowLines) + ? s6.windowLines + : (Array.isArray(pw?.windowLines) ? pw.windowLines : []) + if (windowLines.length !== 101) { + failures.push({ check: 's6:window', message: `Playwright __lumioChat.window.lines length ${windowLines.length}, expected 101` }) + } else { + for (let i = 1; i < windowLines.length; i++) { + if (Number(windowLines[i].roomSequence) <= Number(windowLines[i - 1].roomSequence)) { + failures.push({ check: 's6:roomSequence', message: 'window.lines order must match strictly increasing roomSequence' }) + break + } + } + } } const s7 = scenario(evidence, 7) - const windowBefore = Number(s7.windowBeforeSnapshot ?? s7.chatEventsBeforeSnapshot ?? 0) + const persist = evidence?.traces?.persist ?? {} + const windowBefore = Number( + persist.clientWindowBeforeSnapshot ?? s7.windowBeforeSnapshot ?? s7.chatEventsBeforeSnapshot ?? 0, + ) if (s7.ok === true && s7.snapshotSource && !liveSnapshotSource(s7.snapshotSource)) { failures.push({ check: 's7:suite-double', @@ -503,16 +577,34 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { if (s7.ok === true && windowBefore <= 0) { failures.push({ check: 's7:snapshot-material', message: 'snapshot must exercise material that could have contained history (HistoryCount default 0 is not a test)' }) } - if (s7.ok === true && Number(s7.historyCountMax ?? 0) !== 0) { + if (s7.ok === true && Number(s7.historyCountMax ?? persist.historyCountMax ?? 0) !== 0) { failures.push({ check: 's7:history', message: `snapshot historyCount=${s7.historyCountMax}` }) } - if (s7.ok === true && Number(s7.restoredWindow ?? 0) !== 0) { + if (s7.ok === true && persist.clientWindowAfterRestore == null) { + failures.push({ check: 's7:restored-unmeasured', message: 'restoredWindow must be the measured client window after restore' }) + } else if (s7.ok === true && Number(s7.restoredWindow ?? 0) !== Number(persist.clientWindowAfterRestore ?? 0)) { failures.push({ check: 's7:window-restore', message: 'Restore 后聊天窗必须为空' }) } + if (s7.ok === true && Number(s7.restoredWindow ?? persist.clientWindowAfterRestore ?? 0) !== 0) { + failures.push({ check: 's7:window-restore', message: 'Restore 后聊天窗必须为空' }) + } + const pidA = Number(persist.processA?.pid ?? 0) + const pidB = Number(persist.processB?.pid ?? 0) + if (s7.ok === true && (!Number.isInteger(pidA) || !Number.isInteger(pidB) || pidA <= 0 || pidB <= 0 || pidA === pidB)) { + failures.push({ check: 's7:cross-process', message: 'S7 requires host process A persist then process B restore' }) + } + if (s7.ok === true && !/^[0-9a-f]{64}$/.test(String(persist.snapshotSha256 ?? ''))) { + failures.push({ check: 's7:snapshot-file', message: 'S7 snapshot file sha256 missing' }) + } if (s8PaintedWithoutHostNent(evidence) || s8NetEntityIdAliasedToSession(evidence) || !isReconnectEntityRebound(evidence)) { failures.push({ check: 's8:rebind', message: 'scenario 8 五分钟内重连必须重绑实体 A (host NetEntityId, not sessionId or login AccountId)' }) } + const s8 = scenario(evidence, 8) + const superseded = evidence?.traces?.reconnect?.connectionSupersededReceived ?? s8.connectionSupersededReceived + if (s8.ok === true && superseded !== true) { + failures.push({ check: 's8:superseded', message: 'ConnectionSuperseded must be received on the old connection' }) + } const s9 = scenario(evidence, 9) const expiry = evidence?.traces?.expiry ?? {} @@ -526,12 +618,21 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { } const s11 = scenario(evidence, 11) + const observed = observedChatEvents(evidence) if (s11.ok === true) { + if (observed.length !== 101) { + failures.push({ + check: 's11:synthesized', + message: 'eventOrder/appliedTicks must come from client-received chat.event, not send counts', + }) + } if (!Array.isArray(s11.eventOrder) || s11.eventOrder.length !== 101) { failures.push({ check: 'event-order', message: `eventOrder length ${s11.eventOrder?.length}` }) } - if (!Array.isArray(s11.appliedTicks) || !s11.appliedTicks.every((t) => Number(t) === 1)) { - failures.push({ check: 'applied-tick', message: 'appliedTicks must all be 1 for the scale wave' }) + const observedTicks = observed.map((ev) => ev.appliedTick) + if (!Array.isArray(s11.appliedTicks) || s11.appliedTicks.length !== observedTicks.length + || (observedTicks.length === 101 && JSON.stringify(s11.appliedTicks) !== JSON.stringify(observedTicks))) { + failures.push({ check: 'applied-tick', message: 'appliedTicks must match appliedTick on received chat.event' }) } } @@ -691,15 +792,98 @@ function siblingGapFixture(extra = {}) { } } +function attachN12Observations(evidence) { + const s11 = evidence.scenarios?.['11'] ?? {} + const eventOrder = Array.isArray(s11.eventOrder) ? s11.eventOrder : [] + const events = eventOrder.map((entry, i) => { + const parts = String(entry).split(':') + return { + messageId: i + 1, + roomSequence: i + 1, + senderNetEntityId: parts[0], + text: parts.length >= 3 ? parts.slice(1, -1).join(':') : String(entry), + appliedTick: Number(s11.appliedTicks?.[i] ?? 1), + source: i === eventOrder.length - 1 ? 'playwright' : 'node', + } + }) + evidence.oracleSha256 = oracleSha256() + evidence.playwright = { + ...(evidence.playwright ?? {}), + ran: true, + browser: evidence.playwright?.browser ?? 'chromium', + receivedFromNetwork: events.length > 0, + injected: false, + receivedChatEvent: events.length > 0, + eventCount: events.length, + windowLines: events, + receivedEvents: events.filter((ev) => ev.source === 'playwright'), + } + evidence.traces = evidence.traces ?? {} + evidence.traces.chat = { + ...(evidence.traces.chat ?? {}), + tickSource: ORACLE_TICK_SOURCE, + timerManagerInvoked: true, + cadence: ORACLE_TICK_SOURCE, + utteranceTicks: [...CLIENT_CADENCE_TICKS], + receivedEvents: events, + eventCount: events.length, + } + evidence.traces.persist = { + snapshotSource: 'lumio-entity-chat-replay', + historyCountMax: 0, + clientWindowBeforeSnapshot: events.length, + clientWindowAfterRestore: 0, + snapshotSha256: 'b'.repeat(64), + lastMessageTextEqual: true, + processA: { pid: 11, process: 'lumio-entity-chat-replay' }, + processB: { pid: 12, process: 'lumio-entity-chat-replay' }, + ...(evidence.traces.persist ?? {}), + clientWindowBeforeSnapshot: events.length, + clientWindowAfterRestore: 0, + snapshotSha256: evidence.traces.persist?.snapshotSha256 ?? 'b'.repeat(64), + processA: evidence.traces.persist?.processA ?? { pid: 11, process: 'lumio-entity-chat-replay' }, + processB: evidence.traces.persist?.processB ?? { pid: 12, process: 'lumio-entity-chat-replay' }, + } + evidence.traces.reconnect = { + ...(evidence.traces.reconnect ?? {}), + connectionSupersededReceived: true, + } + if (evidence.scenarios?.['6']) { + evidence.scenarios['6'] = { + ...evidence.scenarios['6'], + tickSource: ORACLE_TICK_SOURCE, + cadence: ORACLE_TICK_SOURCE, + utteranceTicks: [...CLIENT_CADENCE_TICKS], + timerManagerInvoked: true, + windowLines: events, + } + } + if (evidence.scenarios?.['7']) { + evidence.scenarios['7'] = { + ...evidence.scenarios['7'], + snapshotSource: 'lumio-entity-chat-replay', + restoredWindow: 0, + windowBeforeSnapshot: events.length || Number(evidence.scenarios['7'].windowBeforeSnapshot ?? 0), + } + } + if (evidence.scenarios?.['8']) { + evidence.scenarios['8'] = { + ...evidence.scenarios['8'], + connectionSupersededReceived: true, + } + } + return evidence +} + function goodEvidence() { const base = suiteOnlyEvidence() const eventOrder = base.scenarios['11'].eventOrder const appliedTicks = base.scenarios['11'].appliedTicks base.hostProcess = { - process: 'lumio-mvp-host', + process: 'lumio-entity-chat-replay', pid: 4242, listenUri: 'ws://127.0.0.1:19000', - command: ['dotnet', 'exec', 'lumio-mvp-host.dll', '--listen', 'ws://127.0.0.1:0'], + command: ['lumio-entity-chat-replay', '--out', 'round-1'], } base.playwright = { ran: true, browser: 'chromium', receivedFromNetwork: true, injected: false, eventCount: 101 } base.traces = { @@ -722,8 +906,8 @@ function goodEvidence() { base.scenarios['6'] = { ok: true, timerManagerInvoked: true, - cadence: 'host-timer', - tickSource: 'test-control/tick', + cadence: ORACLE_TICK_SOURCE, + tickSource: ORACLE_TICK_SOURCE, eventCount: 101, messageType: 'InputCommand', mappingId: 'chat.input', @@ -735,7 +919,7 @@ function goodEvidence() { historyCountMax: 0, restoredWindow: 0, windowBeforeSnapshot: 101, - snapshotSource: 'live-mvp-host', + snapshotSource: 'lumio-entity-chat-replay', } base.scenarios['8'] = { ok: true, @@ -758,19 +942,19 @@ function goodEvidence() { base.scenarios['10'] = { ok: true, isoTotal: 2 } base.scenarios['11'] = { ok: true, eventOrder, appliedTicks, totalEntities: 101 } base.traces.queries = { unauthorized: 'Unauthorized', invisible: 'Invisible', stale: 'StaleGeneration' } - base.traces.chat = { eventCount: 101, tickSource: 'test-control/tick', timerManagerInvoked: true } + base.traces.chat = { eventCount: 101, tickSource: ORACLE_TICK_SOURCE, timerManagerInvoked: true } base.traces.expiry = { tombstoned: true, entityA: 'nent-bot-099', entityB: 'nent-bot-102' } - return base + return attachN12Observations(base) } function goodAudit() { const lines = [ - JSON.stringify({ seq: 0, kind: 'audit', process: 'lumio-mvp-host', eventId: 'host.start', category: 'host', severity: 'info' }), + JSON.stringify({ seq: 0, kind: 'audit', process: 'lumio-entity-chat-replay', eventId: 'host.start', category: 'host', severity: 'info' }), ] for (let i = 1; i <= 100; i++) { lines.push(JSON.stringify({ kind: 'entity_admitted', - process: 'lumio-mvp-host', + process: 'lumio-entity-chat-replay', roomId: MAIN_ROOM, netEntityId: `nent-bot-${String(i).padStart(3, '0')}`, entityType: 'bot', @@ -779,7 +963,7 @@ function goodAudit() { } lines.push(JSON.stringify({ kind: 'entity_admitted', - process: 'lumio-mvp-host', + process: 'lumio-entity-chat-replay', roomId: MAIN_ROOM, netEntityId: 'nent-player-001', entityType: 'player', @@ -995,7 +1179,7 @@ function liveSeventeenKeyEvidence() { return evidence } -test('mvp-host 17-key NativeReady audit with hostProcess pid and 101 live admits must PASS host:mvp', () => { +test('mvp-host 17-key NativeReady audit with hostProcess pid and 101 live admits must FAIL host:mvp-impersonation', () => { const audit = seventeenKeyAudit() const parsed = JSON.parse(audit) for (const key of MVP_HOST_AUDIT_KEYS) { @@ -1481,7 +1665,6 @@ test('shouldRetryReconnectHandshake: SessionMismatch and missing session retry o }) const LIVE_RUN_S8GAP_DIRS = [ - join('C:', 'Work', 'LumioGames', 'wt-game', 'r-00354', 'integration', 'entity-chat', 'evidence', 'live-run-s8gap'), join(dirname(fileURLToPath(import.meta.url)), 'evidence', 'live-run-s8gap'), ] @@ -1550,7 +1733,7 @@ function liveElevenEventOrder(bindings) { return bindings.map((row, i) => `${row.netEntityId}:hello-${row.loginName}:${i + 1}`) } -function liveElevenGoodEvidence(processName = 'lumio-mvp-host') { +function liveElevenGoodEvidence(processName = 'lumio-entity-chat-replay') { const bindings = liveElevenBindings() const eventOrder = liveElevenEventOrder(bindings) const appliedTicks = Array.from({ length: 101 }, () => 1) @@ -1576,11 +1759,11 @@ function liveElevenGoodEvidence(processName = 'lumio-mvp-host') { }, chat: { eventCount: 101, - tickSource: 'test-control/tick', + tickSource: ORACLE_TICK_SOURCE, timerManagerInvoked: true, ...envelope, }, - persist: { snapshotSource: processName === 'lumio-mvp-host' ? 'live-mvp-host' : 'lumio-entity-chat-replay', historyCount: 0 }, + persist: { snapshotSource: 'lumio-entity-chat-replay', historyCount: 0 }, reconnect: { rebound: true, entityA: nentLive(100), @@ -1608,8 +1791,8 @@ function liveElevenGoodEvidence(processName = 'lumio-mvp-host') { evidence.scenarios['6'] = { ok: true, timerManagerInvoked: true, - cadence: 'host-timer', - tickSource: 'test-control/tick', + cadence: ORACLE_TICK_SOURCE, + tickSource: ORACLE_TICK_SOURCE, eventCount: 101, ...envelope, } @@ -1618,7 +1801,7 @@ function liveElevenGoodEvidence(processName = 'lumio-mvp-host') { historyCountMax: 0, restoredWindow: 0, windowBeforeSnapshot: 101, - snapshotSource: processName === 'lumio-mvp-host' ? 'live-mvp-host' : 'lumio-entity-chat-replay', + snapshotSource: 'lumio-entity-chat-replay', } evidence.scenarios['8'] = { ...evidence.traces.reconnect, ok: true } evidence.scenarios['9'] = { @@ -1630,10 +1813,10 @@ function liveElevenGoodEvidence(processName = 'lumio-mvp-host') { } evidence.scenarios['10'] = { ok: true, isoTotal: 2, crossRoom: 'unauthorized' } evidence.scenarios['11'] = { ok: true, eventOrder, appliedTicks, totalEntities: 101 } - return evidence + return attachN12Observations(evidence) } -function liveElevenAudit(processName = 'lumio-mvp-host') { +function liveElevenAudit(processName = 'lumio-entity-chat-replay') { const lines = [ JSON.stringify({ seq: 0, @@ -1812,10 +1995,233 @@ test('rust S5 ok:true with real query traces is not GameRoomHost suite-double', ) }) -test('live 11-scenario traces on mvp-host must PASS when all 11 are ok', () => { +test('live 11-scenario traces on mvp-host must FAIL because mvp-host is never SUCCESS', () => { + const evidence = liveElevenGoodEvidence('lumio-mvp-host') + const report = verifyRun(evidence, liveElevenAudit('lumio-mvp-host')) + assert.equal(report.ok, false, JSON.stringify(report.failures)) + assert.ok( + report.failures.some((f) => f.check === 'host:mvp-impersonation' || f.check === 'host:rust'), + JSON.stringify(report.failures), + ) +}) + +function n12ObservedEvents(bindings) { + return bindings.map((row, i) => ({ + messageId: i + 1, + roomSequence: i + 1, + senderNetEntityId: row.netEntityId, + text: `hello-${row.loginName}`, + appliedTick: 1, + source: i === 100 ? 'playwright' : 'node', + })) +} + +function n12WindowLines(events) { + return events.map((ev) => ({ + messageId: ev.messageId, + roomSequence: ev.roomSequence, + senderNetEntityId: ev.senderNetEntityId, + text: ev.text, + appliedTick: ev.appliedTick, + })) +} + +function n12GoodEvidence() { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + const events = n12ObservedEvents(evidence.traces.bindings) + const windowLines = n12WindowLines(events) + evidence.oracleSha256 = oracleSha256() + evidence.playwright = { + ran: true, + browser: 'chromium', + receivedFromNetwork: true, + injected: false, + receivedChatEvent: true, + eventCount: 101, + windowLines, + receivedEvents: events.filter((ev) => ev.source === 'playwright'), + } + evidence.traces.chat = { + ...evidence.traces.chat, + tickSource: 'native-kernel/tickFrame', + timerManagerInvoked: true, + cadence: 'native-kernel/tickFrame', + utteranceTicks: [5, 10, 15], + receivedEvents: events, + eventCount: 101, + } + evidence.traces.persist = { + snapshotSource: 'lumio-entity-chat-replay', + historyCountMax: 0, + clientWindowBeforeSnapshot: 101, + clientWindowAfterRestore: 0, + snapshotSha256: 'a'.repeat(64), + lastMessageTextEqual: true, + processA: { pid: 1001, process: 'lumio-entity-chat-replay' }, + processB: { pid: 1002, process: 'lumio-entity-chat-replay' }, + } + evidence.traces.reconnect = { + ...evidence.traces.reconnect, + connectionSupersededReceived: true, + } + evidence.scenarios['3'] = { + ...evidence.scenarios['3'], + playwrightRan: true, + windowLines: 101, + } + evidence.scenarios['6'] = { + ...evidence.scenarios['6'], + timerManagerInvoked: true, + cadence: 'native-kernel/tickFrame', + tickSource: 'native-kernel/tickFrame', + utteranceTicks: [5, 10, 15], + eventCount: 101, + windowLines, + } + evidence.scenarios['7'] = { + ok: true, + historyCountMax: 0, + restoredWindow: 0, + windowBeforeSnapshot: 101, + snapshotSource: 'lumio-entity-chat-replay', + lastMessageTextEqual: true, + } + evidence.scenarios['8'] = { + ...evidence.scenarios['8'], + connectionSupersededReceived: true, + } + evidence.scenarios['11'] = { + ok: true, + eventOrder: events.map((ev) => `${ev.senderNetEntityId}:${ev.text}:${ev.roomSequence}`), + appliedTicks: events.map((ev) => ev.appliedTick), + totalEntities: 101, + } + return evidence +} + +test('N-12: lumio-mvp-host is never a SUCCESS path', () => { const evidence = liveElevenGoodEvidence('lumio-mvp-host') const report = verifyRun(evidence, liveElevenAudit('lumio-mvp-host')) + assert.equal(report.ok, false, 'mvp-host must not be SUCCESS') + assert.ok( + report.failures.some((f) => f.check === 'host:mvp-impersonation' || f.check === 'host:rust' || /mvp-host/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('N-12: synthesized eventOrder from send counts without received chat.event must FAIL', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + evidence.scenarios['11'] = { + ok: true, + eventOrder: evidence.traces.bindings.map((row, i) => `${row.netEntityId}:hello-${row.loginName}:${i + 1}`), + appliedTicks: evidence.traces.bindings.map(() => 1), + totalEntities: 101, + } + delete evidence.traces.chat.receivedEvents + delete evidence.playwright.receivedEvents + delete evidence.playwright.windowLines + delete evidence.playwright.receivedChatEvent + delete evidence.scenarios['6'].windowLines + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.equal(report.ok, false) + assert.ok( + report.failures.some((f) => f.check === 's11:synthesized' || f.check === 'event-order' || /synthes/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('N-12: restoredWindow literal 0 without measured client window must FAIL', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + evidence.scenarios['7'] = { + ok: true, + historyCountMax: 0, + restoredWindow: 0, + windowBeforeSnapshot: 101, + snapshotSource: 'lumio-entity-chat-replay', + } + evidence.traces.persist = { snapshotSource: 'lumio-entity-chat-replay', historyCount: 0 } + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.equal(report.ok, false) + assert.ok( + report.failures.some((f) => f.check === 's7:restored-unmeasured' || /measured|client window/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('N-12: Playwright account-login-only is not receivedFromNetwork chat.event', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + evidence.playwright = { + ran: true, + browser: 'chromium', + receivedFromNetwork: true, + injected: false, + accountAccepted: true, + receivedChatEvent: false, + windowLines: [], + } + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.ok( + report.failures.some((f) => f.check === 's3:playwright' || f.check === 's3:chat-event' || /chat\.event/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('N-12: S6 tickSource must be native-kernel/tickFrame', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + evidence.scenarios['6'].tickSource = 'test-control/tick' + evidence.traces.chat.tickSource = 'test-control/tick' + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.equal(report.ok, false) + assert.ok( + report.failures.some((f) => f.check === 's6:tick-source' || /native-kernel\/tickFrame/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('N-12: missing oracleSha256 of verify-evidence.mjs must FAIL', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + delete evidence.oracleSha256 + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.equal(report.ok, false) + assert.ok( + report.failures.some((f) => f.check === 'oracle:sha256' || /oracle/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('N-12: S8 without ConnectionSuperseded on the old connection must FAIL', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + evidence.scenarios['8'].connectionSupersededReceived = false + evidence.traces.reconnect.connectionSupersededReceived = false + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.equal(report.ok, false) + assert.ok( + report.failures.some((f) => f.check === 's8:superseded' || /ConnectionSuperseded/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('N-12: S7 same-process restore without persist file must FAIL', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + evidence.traces.persist = { + snapshotSource: 'lumio-entity-chat-replay', + historyCountMax: 0, + processA: { pid: 9 }, + processB: { pid: 9 }, + } + evidence.scenarios['7'].ok = true + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.equal(report.ok, false) + assert.ok( + report.failures.some((f) => f.check === 's7:cross-process' || f.check === 's7:snapshot-file' || /process A|snapshot/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('N-12: rust pack with client-observed chat.event, measured window, oracle pin must PASS', () => { + const evidence = n12GoodEvidence() + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) assert.equal(report.ok, true, JSON.stringify(report.failures)) assert.equal(report.census.total, 101) - assert.ok(report.census.netEntityIds.every((id) => isHostNetEntityId(id))) + assert.equal(report.eventOrder.length, 101) }) diff --git a/integration/entity-chat/web/chat-window.js b/integration/entity-chat/web/chat-window.js index 593fc7a..259c16e 100644 --- a/integration/entity-chat/web/chat-window.js +++ b/integration/entity-chat/web/chat-window.js @@ -32,6 +32,80 @@ export function formatLine(event) { return `${event.senderNetEntityId}: ${event.text}` } +export function hexToBytes(hex) { + const raw = String(hex ?? '') + if (raw.length === 0 || raw.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(raw)) return null + const bytes = new Uint8Array(raw.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(raw.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +function readU64LE(view, offset) { + const lo = view.getUint32(offset, true) + const hi = view.getUint32(offset + 4, true) + return lo + hi * 0x100000000 +} + +/** Decode C-1 chat.event LumioBinV1 payload (fieldOrder messageId, roomSequence, senderNetEntityId, text, appliedTick). */ +export function decodeChatEventPayload(hex) { + const bytes = hexToBytes(hex) + if (!bytes || bytes.length < 8 * 4 + 4) return null + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let offset = 0 + const messageId = readU64LE(view, offset) + offset += 8 + const roomSequence = readU64LE(view, offset) + offset += 8 + const sender = readU64LE(view, offset) + offset += 8 + if (offset + 4 > bytes.length) return null + const textLen = view.getUint32(offset, true) + offset += 4 + if (offset + textLen + 8 > bytes.length) return null + const text = new TextDecoder().decode(bytes.subarray(offset, offset + textLen)) + offset += textLen + const appliedTick = readU64LE(view, offset) + return { + messageId, + roomSequence, + senderNetEntityId: String(sender), + text, + appliedTick, + } +} + +export function extractChatEventsFromFrame(frame) { + if (frame == null) return [] + let parsed = frame + if (typeof frame === 'string') { + try { parsed = JSON.parse(frame) } catch { return [] } + } + if (typeof parsed !== 'object' || parsed === null) return [] + if (parsed.messageType === 'FullSnapshot') return [] + if (parsed.messageType === 'ConnectionSuperseded') return [] + const blocks = parsed.messageType === 'Delta' + ? (Array.isArray(parsed.changedBlocks) ? parsed.changedBlocks : []) + : (parsed.mappingId === 'chat.event' ? [parsed] : []) + const events = [] + for (const block of blocks) { + if (!block || block.mappingId !== 'chat.event') continue + const event = decodeChatEventPayload(block.payload) + if (event) events.push(event) + } + if (parsed.messageType === 'chat.event' || (parsed.roomSequence != null && parsed.appliedTick != null && parsed.text != null && parsed.mappingId == null && parsed.messageType == null)) { + if (isEvent(parsed)) events.push({ + messageId: parsed.messageId, + roomSequence: parsed.roomSequence, + senderNetEntityId: String(parsed.senderNetEntityId), + text: parsed.text, + appliedTick: parsed.appliedTick, + }) + } + return events +} + function isEvent(event) { return event !== null && typeof event === 'object' diff --git a/integration/entity-chat/web/chat-window.test.mjs b/integration/entity-chat/web/chat-window.test.mjs new file mode 100644 index 0000000..05cae6c --- /dev/null +++ b/integration/entity-chat/web/chat-window.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + appendAcceptedEvent, + applyFullSnapshot, + createChatWindow, + decodeChatEventPayload, + extractChatEventsFromFrame, + formatLine, +} from './chat-window.js' + +const GG_EVENT_HEX = '0100000000000000010000000000000065000000000000000200000067670700000000000000' + +test('decodeChatEventPayload reads frozen C-1 gg fixture', () => { + const event = decodeChatEventPayload(GG_EVENT_HEX) + assert.equal(event.messageId, 1) + assert.equal(event.roomSequence, 1) + assert.equal(event.senderNetEntityId, '101') + assert.equal(event.text, 'gg') + assert.equal(event.appliedTick, 7) +}) + +test('extractChatEventsFromFrame reads Delta.changedBlocks chat.event', () => { + const events = extractChatEventsFromFrame({ + messageType: 'Delta', + tickId: 7, + revision: 1, + changedBlocks: [{ + mappingId: 'chat.event', + payload: GG_EVENT_HEX, + payloadSha256: '9fafc556e56dc024a90caf7c102dfccfed4189c708e0a51b0139aab28277670c', + }], + }) + assert.equal(events.length, 1) + assert.equal(events[0].roomSequence, 1) + assert.equal(events[0].text, 'gg') + assert.equal(events[0].appliedTick, 7) +}) + +test('FullSnapshot clears the window and does not restore chat.event', () => { + const window = createChatWindow() + appendAcceptedEvent(window, { + messageId: 1, + roomSequence: 1, + senderNetEntityId: '101', + text: 'gg', + appliedTick: 7, + }) + applyFullSnapshot(window) + assert.equal(window.lines.length, 0) + const fromSnap = extractChatEventsFromFrame({ + messageType: 'FullSnapshot', + tickId: 7, + revision: 1, + stateBlocks: [{ mappingId: 'chat.event', payload: GG_EVENT_HEX, payloadSha256: '9fafc556e56dc024a90caf7c102dfccfed4189c708e0a51b0139aab28277670c' }], + }) + assert.equal(fromSnap.length, 0) +}) + +test('formatLine uses sender and text', () => { + assert.equal(formatLine({ senderNetEntityId: '101', text: 'gg' }), '101: gg') +}) diff --git a/integration/entity-chat/web/index.html b/integration/entity-chat/web/index.html index ae4effa..b3f92a8 100644 --- a/integration/entity-chat/web/index.html +++ b/integration/entity-chat/web/index.html @@ -17,11 +17,19 @@

Room Chat