diff --git a/.spec/knowledge/README.md b/.spec/knowledge/README.md index edd6292..0ab9eaf 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 101 活连接、BLOCKED 证据与 Bot 启动 | +| [`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 启动 | ## lessons(经验教训 · 复发问题暂存区) diff --git a/.spec/knowledge/features/gameplay/chat-component.md b/.spec/knowledge/features/gameplay/chat-component.md index dce900f..9e5503f 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` Handshake/Admit;`GameRoomHost` 只作单元 double。ChatComponent/C-2 未接入 mvp-host 时对应场景记 sibling-gap,见 [`entity-chat-harness.md`](./entity-chat-harness.md)。 +- 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)。 ## 相关 diff --git a/.spec/knowledge/features/gameplay/entity-chat-harness.md b/.spec/knowledge/features/gameplay/entity-chat-harness.md index 2c2976f..de258a3 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 101 活连接、BLOCKED 证据与 Bot 启动 +description: 101-entity C# MVP acceptance harness——查 mvp-host/rust 11 场景活 traces、nent census 与 Bot 启动 metadata: type: doc status: 已交付 @@ -17,18 +17,17 @@ metadata: ## 设计 -- **Gameplay 宿主**:sibling `lumio-mvp-host`(LumioServer origin/main)。`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 路活升级。 -- **证据**:census 必须来自 mvp-host 进程 audit;Browser 必须有 Playwright 实跑;无历史快照必须有可含历史的材料。 +- **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**:必须来自 Handshake/Admit 绑定(host-audit 非空 `sessionId` / admit·bind effect,或 admit-trace binding ids)。HTTP 101 的 launcher 循环下标 `"1"`..`"101"` 不是 NetEntityId。不得要求发明 `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。FullSnapshot / 17-key audit / test-control 若不投影 `ConnectionBinding.NetEntityId`,S8 必须 `ok: false` + `blockedReason`(诚实缺口,不得伪成功)。Handshake 缺 session / SessionMismatch 时重试一次。 -- **ChatComponent / C-2 缺口**:Attribute Query、Chat persist、expiry、isolation、event-order 在 mvp-host `ReferenceWorldSimulation` 上不可执行时,S5/S7/S9/S10/S11 必须 `ok: false` + `blockedReason`(与 S6 Timer 相同诚实口径);不得把 `GameRoomHost` 自评分写成 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。 ## 待解决 - FullGraph `MaxConnections = 128` / `MaxSessions = 128` 可承载 101 路活连接(Server origin/main / `LUMIO_SERVER_ROOT`)。 -- Client Timer Manager 未接到本启动器时,chat 只能记 tick-batched,不得把 cadence 标 ok。 - Playwright Chromium 缺失时 Browser 场景必须失败,不得注入事件后标 ok。 ## 相关 diff --git a/.spec/knowledge/standards/testing.md b/.spec/knowledge/standards/testing.md index 5a9f8c5..2b1ee74 100644 --- a/.spec/knowledge/standards/testing.md +++ b/.spec/knowledge/standards/testing.md @@ -53,7 +53,7 @@ node --test integration/entity-chat/verify-evidence.mjs node integration/entity-chat/launcher.mjs --out ``` -两轮对比实体数 / event order / applied Tick。SUCCESS 仅当 sibling `lumio-mvp-host` 实际准入 101 路活连接(HTTP 101 之后完成 `lumio.mvp.v0` Handshake,host-audit 有非空 sessionId / admit effect)且两轮场景证据一致。容量 503、Admission 未入 FullGraph、origin/main Account Server / mvp-host 产物缺失必须记 BLOCKED(`blocked.json` + 文件:行 + 实测错误),不得回退 r-00344,不得用 `GameRoomHost` 伪造 SUCCESS。ChatComponent/C-2 缺口场景必须 `ok: false` + `blockedReason`。FullGraph 生产常量是 `MaxConnections = 128` / `MaxSessions = 128`。 +两轮对比实体数 / 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`。 公共契约变更必须在架构源 `LumioGameEngineArchitecture` 通过其契约闸门(见 `AGENTS.md`「收口门槛」);本仓只消费冻结 JSON,不另写协议。Scenario/Headless 与 formatter 命令随后续模块补进收口门槛。 diff --git a/.wf-report-live11.md b/.wf-report-live11.md new file mode 100644 index 0000000..ab65d2c --- /dev/null +++ b/.wf-report-live11.md @@ -0,0 +1,24 @@ +# R-00354 live-11 game — implementer report + +Status: GREEN. SHA `56d5afd`. Branch `feat/r-00354-live-11`. Sibling host `LUMIO_SERVER_ROOT=C:\Work\LumioGames\wt-server\r-00354-live11` SHA `ffb22c9`. + +## TDD + +RED: 8 new verifier tests failed; `wt-game/r-00354` live-run-s8gap pack was SUCCESS (`ok: true`) while S5–S11/S8 were not-ok. + +GREEN: `node --test integration/entity-chat/verify-evidence.mjs integration/entity-chat/bot-credential.mjs` → 49 pass. `node .spec/tools/spec-lint.mjs` OK. + +## Live + +``` +node integration/entity-chat/launcher.mjs --out integration/entity-chat/evidence/live-run-11 +``` + +Exit 0. Both rounds S1–S11 `ok: true`. Census 100+1 `nent_*`. Playwright `injected: false`. Tick via `POST /test-control/tick`. + +Evidence (gitignored): `integration/entity-chat/evidence/live-run-11/{manifest.json,round-1,round-2}/`. + +## Gaps + +- Chat is posted on each admit (keepalive); idle sockets later `binding_not_found`. +- Did not edit Server. GameRoomHost remains unit double only. diff --git a/integration/entity-chat/README.md b/integration/entity-chat/README.md index 8e45116..ec21c2d 100644 --- a/integration/entity-chat/README.md +++ b/integration/entity-chat/README.md @@ -64,8 +64,10 @@ node launcher.mjs --out ## 对账 -- 101 = Handshake/Admit 绑定 ids(host-audit 非空 sessionId / admit effect,或 admit-trace `binding_committed`);禁止 launcher 循环下标 `"1"`..`"101"` 当 NetEntityId,禁止 `{total:101}` 常数,禁止 GameRoomHost census dump。禁止发明 FullGraph 不会发的 `entity_admitted`。 -- S5/S7/S9/S10/S11 在 mvp-host 无 ChatComponent/C-2 时必须 `ok: false` + `blockedReason`;不得把 GameRoomHost 自评分写入 SUCCESS traces。 +- 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,不恢复聊天历史。 diff --git a/integration/entity-chat/game-client.mjs b/integration/entity-chat/game-client.mjs index 17096cb..01491c5 100644 --- a/integration/entity-chat/game-client.mjs +++ b/integration/entity-chat/game-client.mjs @@ -284,6 +284,13 @@ export async function recvWsJson(socket, timeoutMs = 10000) { } } +export function sendWsPing(socket) { + if (!socket) return + try { + socket.write(encodeWsControlFrame(0x9)) + } catch { /* ignore */ } +} + export async function sendWsJson(socket, payload) { const bytes = Buffer.isBuffer(payload) ? payload : Buffer.from(JSON.stringify(payload), 'utf8') await new Promise((resolve, reject) => { diff --git a/integration/entity-chat/launcher.mjs b/integration/entity-chat/launcher.mjs index 01647f8..70ffbba 100644 --- a/integration/entity-chat/launcher.mjs +++ b/integration/entity-chat/launcher.mjs @@ -19,12 +19,13 @@ */ import { spawn, spawnSync } from 'node:child_process' import { createHash, randomBytes } from 'node:crypto' -import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { appendFileSync, 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, @@ -34,33 +35,40 @@ import { admitLiveConnections, closeAll, credentialTokenBytes, + driveLiveEleven, + encodeChatInput, loginBots, + testControlRequest, loginBrowser, reconnectNamedBot, runAccountScenario1, runPlaywrightBrowser, } from './scenarios.mjs' -import { compareRuns, isEntityRebound, isHostNetEntityId, isLauncherLoopIndex, parseNdjson, playwrightRan, S8_NENT_GAP_REASON, TEST_PASSWORD, verifyRun } from './verify-evidence.mjs' - -const SIBLING_GAP_REASON = 'sibling-gap: mvp-host ReferenceWorldSimulation cannot Attribute Query / Chat persist / expiry / isolation / event-order' +import { compareRuns, isHostNetEntityId, isLauncherLoopIndex, parseNdjson, playwrightRan, TEST_PASSWORD, 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', '--project', HOST_PROJ, '-c', 'Release', '--nologo'] +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 SUITE_PROJ = join(REPO_ROOT, 'modules/server-gameplay/src/Lumio.Game.EntityChat.Suite/Lumio.Game.EntityChat.Suite.csproj') -const SUITE_DLL = join( +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.EntityChat.Suite/bin/Debug/net10.0/Lumio.Game.EntityChat.Suite.dll', + 'modules/server-gameplay/src/Lumio.Game.ServerGameplay/bin/Debug/net10.0/Lumio.Game.ServerGameplay.dll', ) function parseArgs(argv) { @@ -147,7 +155,9 @@ function findHostArtifact() { } function originMainAccountDll() { - return existsSync(ACCOUNT_DLL_ORIGIN_MAIN) ? ACCOUNT_DLL_ORIGIN_MAIN : null + if (existsSync(ACCOUNT_DLL_ORIGIN_MAIN)) return ACCOUNT_DLL_ORIGIN_MAIN + if (existsSync(ACCOUNT_DLL_DEBUG)) return ACCOUNT_DLL_DEBUG + return null } function writeBlocked(outDir, blocked) { @@ -235,15 +245,6 @@ function auditHasSessionAdmission(auditText) { return false } -function siblingGapRow(extra = {}) { - return { - ok: false, - source: 'suite-double', - blockedReason: SIBLING_GAP_REASON, - ...extra, - } -} - function isBindingAdmit(rec) { return rec?.ok === true && rec.handshake === true @@ -360,34 +361,35 @@ function startStaticServer({ root, readyFile, logPath }) { return { proc, ready } } -function ensureSuiteDll() { - if (existsSync(SUITE_DLL)) return SUITE_DLL - spawnSync(DEFAULT_DOTNET, ['build', '--project', SUITE_PROJ, '--nologo'], { +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(SUITE_DLL) ? SUITE_DLL : null + return existsSync(GAMEPLAY_DLL) ? GAMEPLAY_DLL : null } -function runGameplaySuite({ outDir, accountDll }) { - const dll = ensureSuiteDll() - if (!dll) return { ok: false, error: `missing ${SUITE_DLL}`, evidence: null } - mkdirSync(outDir, { recursive: true }) - const result = spawnSync( - DEFAULT_DOTNET, - ['exec', dll, '--out', outDir, '--account-server-dll', accountDll], - { cwd: REPO_ROOT, encoding: 'utf8', windowsHide: true, env: hostEnv(), timeout: 180000 }, - ) - try { - writeFileSync(join(outDir, 'suite.log'), `${result.stdout ?? ''}\n${result.stderr ?? ''}`) - } catch { /* ignore */ } - return { - exitCode: result.status, - evidence: safeReadJson(join(outDir, 'evidence.json')), - error: result.status === 0 ? null : String(result.stderr || result.stdout || `exit ${result.status}`).slice(-400), +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) + } + 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 } + return admits } function mergeRoundEvidence({ @@ -397,15 +399,18 @@ function mergeRoundEvidence({ account, botLogins, browser, - suiteEvidence, - liveReconnect, + 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 ?? '', @@ -415,17 +420,17 @@ function mergeRoundEvidence({ channel: playwright?.channel ?? null, } const s3ok = playerAdmits.length === 1 && liveAdmits.live === 101 && playwrightRan({ playwright: pw }) - const bot100 = botAdmits.find((a) => a.loginName === 'Bot100') - const previousNent = isHostNetEntityId(liveReconnect?.previousNetEntityId) - ? liveReconnect.previousNetEntityId - : (isHostNetEntityId(bot100?.netEntityId) ? bot100.netEntityId : null) - const admittedNent = isHostNetEntityId(liveReconnect?.netEntityId) ? liveReconnect.netEntityId : null - const s8ok = isEntityRebound( - { netEntityId: previousNent }, - { netEntityId: admittedNent }, - ) - const entityA = s8ok ? previousNent : (isHostNetEntityId(admittedNent) ? admittedNent : null) - const s8blocked = s8ok ? null : (liveReconnect?.blockedReason ?? S8_NENT_GAP_REASON) + 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, @@ -441,41 +446,69 @@ function mergeRoundEvidence({ playwrightRan: pw.ran, }, 4: { - ok: botAdmits.length === 100 && playerAdmits.length === 1 - && sessionIds.length === 101 - && sessionIds.every((id) => !isLauncherLoopIndex(id)) - && (botLogins ?? []).every((b) => b.accepted && b.accountId) - && browser?.accepted === true, - resolvedBots: botAdmits.length, - browserBound: playerAdmits.length === 1, + ok: s4ok, + resolvedBots: botBindings.length, + browserBound: playerBindings.length === 1, + netEntityIds, sessionIds, }, - 5: siblingGapRow(), + 5: { + ok: liveEleven?.s5ok === true, + unauthorized: queries.unauthorized, + invisible: queries.invisible, + stale: queries.stale, + }, 6: { - ok: false, - timerManagerInvoked: false, - cadence: 'tick-batched', - eventCount: 0, + 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', }, - 7: siblingGapRow({ historyCountMax: 0, restoredWindow: 0, snapshotSource: 'suite-double' }), 8: { ok: s8ok, - entityA, + entityA: reconnect.entityA ?? null, rebound: s8ok, - ...(s8blocked ? { blockedReason: s8blocked } : {}), - sessionId: liveReconnect?.sessionId ?? null, - previousSessionId: liveReconnect?.previousSessionId ?? bot100?.sessionId ?? null, - netEntityId: admittedNent, - previousNetEntityId: previousNent, - accountId: liveReconnect?.accountId ?? null, - previousAccountId: liveReconnect?.previousAccountId ?? null, + 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, }, - 9: siblingGapRow(), - 10: siblingGapRow(), - 11: siblingGapRow({ totalEntities: liveAdmits.live }), } + const allOk = Object.values(scenarios).every((row) => row.ok === true) return { - ok: false, + ok: allOk, hostProcess, liveAdmits: { desired: DESIRED_ADMITS, @@ -492,16 +525,13 @@ function mergeRoundEvidence({ wrongPasswordCode: account?.wrongPassword?.code, }, handshake: { completed: sessionIds.length, sessionIds }, - reconnect: { - rebound: s8ok, - entityA, - sessionId: liveReconnect?.sessionId ?? null, - previousSessionId: liveReconnect?.previousSessionId ?? bot100?.sessionId ?? null, - netEntityId: admittedNent, - previousNetEntityId: previousNent, - accountId: liveReconnect?.accountId ?? null, - previousAccountId: liveReconnect?.previousAccountId ?? null, - }, + bindings, + queries, + chat, + persist, + reconnect, + expiry, + isolation, }, scenarios, } @@ -546,6 +576,7 @@ async function runOneRound({ 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({ @@ -565,6 +596,7 @@ async function runOneRound({ 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({ @@ -605,25 +637,66 @@ async function runOneRound({ 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 } + }, }) sockets = admits.sockets process.stdout.write(`live admits ${admits.live}/${DESIRED_ADMITS} blocked=${admits.blocked ? admits.blocked.error : 'none'}\n`) - 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: bot100?.sessionId, - netEntityId: bot100?.netEntityId, - }) - if (liveReconnect.socket) sockets[99] = liveReconnect.socket - } + 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) + }, + }) + 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`) let playwright = { ran: false, injected: false, receivedFromNetwork: false } try { @@ -650,11 +723,11 @@ async function runOneRound({ } } - const suite = runGameplaySuite({ outDir: join(roundDir, 'suite'), accountDll }) const hostProcess = { process: 'lumio-mvp-host', pid: host.proc.pid, listenUri: ready.listenUri, + testControlUri: ready.testControlUri, command: [command, ...commandArgs], } const evidence = mergeRoundEvidence({ @@ -664,8 +737,7 @@ async function runOneRound({ account: accountScenario, botLogins, browser, - suiteEvidence: suite.evidence, - liveReconnect, + liveEleven, }) const auditText = safeReadText(auditPath) const admitTraceText = safeReadText(tracePath) @@ -679,7 +751,7 @@ async function runOneRound({ admits, hostProcess, playwright, - suite, + liveEleven, verify, } } finally { @@ -733,7 +805,12 @@ async function main() { 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) - const accountDll = (accountOverride && existsSync(accountOverride)) ? accountOverride : originMainAccountDll() + 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 blocked = writeBlocked(outDir, { diff --git a/integration/entity-chat/scenarios.mjs b/integration/entity-chat/scenarios.mjs index 694b4ea..1c94dd7 100644 --- a/integration/entity-chat/scenarios.mjs +++ b/integration/entity-chat/scenarios.mjs @@ -2,10 +2,13 @@ * Drive the 11 ecs-entity-chat §6 scenarios against live Account Server + C# MVP host. * Host audit is the census source; this module never writes a hardcoded 101 admit event. */ +import { createHash } from 'node:crypto' import { appendFileSync, existsSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { test as nodeTest } from 'node:test' +import assert from 'node:assert/strict' import { loginOrRegister, summarizeLogin } from './account-client.mjs' import { allBotLoginNames, issueBotToolCredential } from './bot-credential.mjs' import { @@ -14,6 +17,7 @@ import { connectMvpHost, handshakeAdmitBinding, mvpSessionId, + sendWsPing, FULLGRAPH_LIMIT_FILE, FULLGRAPH_LIMIT_LINE, FULLGRAPH_MAX_CONNECTIONS, @@ -33,7 +37,76 @@ import { export { BROWSER_NAME, MAIN_ROOM, TEST_PASSWORD } +export const ISO_ROOM = 'room-iso' + +export function encodeChatInput(text) { + const utf8 = Buffer.from(String(text ?? ''), 'utf8') + const payload = Buffer.alloc(4 + utf8.length) + payload.writeUInt32LE(utf8.length, 0) + utf8.copy(payload, 4) + return { + messageType: 'InputCommand', + mappingId: 'chat.input', + payload: payload.toString('hex'), + payloadSha256: createHash('sha256').update(payload).digest('hex'), + } +} + +export async function testControlRequest(testControlUri, method, path, body) { + const base = String(testControlUri ?? '').replace(/\/$/, '') + if (!base || base === '-') { + throw new Error('missing testControlUri') + } + const res = await fetch(`${base}${path}`, { + method, + headers: body === undefined ? undefined : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const raw = await res.text() + let json = null + try { + json = JSON.parse(raw) + } catch { + json = { ok: false, error: `non-json ${res.status}`, raw: raw.slice(0, 200) } + } + return { status: res.status, json } +} + +export async function fetchBindings(testControlUri) { + const { json } = await testControlRequest(testControlUri, 'GET', '/test-control/bindings') + return Array.isArray(json?.bindings) ? json.bindings : [] +} + +export function indexBindings(bindings, botLogins = [], browser = null) { + const byAccount = new Map() + for (const row of botLogins) { + if (row?.accountId) byAccount.set(row.accountId, { loginName: row.loginName, entityType: 'bot', accountId: row.accountId }) + } + if (browser?.accountId) { + byAccount.set(browser.accountId, { loginName: browser.loginName, entityType: 'player', accountId: browser.accountId }) + } + return (bindings ?? []).map((row) => { + const extra = byAccount.get(row.accountId) ?? {} + const entityKind = row.entityKind ?? extra.entityType ?? null + return { + ...row, + loginName: extra.loginName ?? row.loginName ?? null, + entityKind, + entityType: entityKind, + } + }) +} + +function mainRoomBindings(indexed) { + return indexed.filter((row) => !row.roomId || row.roomId === MAIN_ROOM) +} + +function findLogin(indexed, loginName) { + return indexed.find((row) => row.loginName === loginName) ?? null +} + function appendTrace(path, obj) { + if (!path) return appendFileSync(path, JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n') } @@ -91,7 +164,7 @@ export async function loginBrowser({ accountPort, tracePath, loginName = BROWSER * `clients` is optional per-connection material: { tokenBytes, entityType, loginName }. * Shared-secret admits omit entityType and do not count as a bot/player census. */ -export async function admitLiveConnections({ listenUri, tokenBytes, clients, desired, tracePath }) { +export async function admitLiveConnections({ listenUri, tokenBytes, clients, desired, tracePath, afterAdmit }) { const sockets = [] const admits = [] let blocked = null @@ -128,6 +201,9 @@ export async function admitLiveConnections({ listenUri, tokenBytes, clients, des admits.push(rec) appendTrace(tracePath, { kind: 'connection_upgrade', index: i, ok: true, status: rec.status, process: rec.process, loginName, entityType }) appendTrace(tracePath, { kind: 'binding_committed', ...rec }) + if (typeof afterAdmit === 'function') { + rec.chat = await afterAdmit(rec) + } } catch (hsErr) { await closeQuietly(conn.ws) throw hsErr @@ -425,8 +501,323 @@ export async function reconnectNamedBot({ return { ok: false, rebound: false, loginName, error: lastError } } -export function writeScenariosFile(path, scenarios) { - writeFileSync(path, JSON.stringify(scenarios, null, 2) + '\n') +export async function driveLiveEleven({ + testControlUri, + botLogins, + browser, + accountPort, + botSeed, + tracePath, + reconnectBot100, + disconnectLogin, + keepAlive, + admits, +}) { + const failed = (reason, extra = {}) => ({ + ok: false, + error: reason, + bindings: [], + queries: { blockedReason: reason }, + chat: { eventCount: 0, tickSource: null, timerManagerInvoked: false }, + persist: { snapshotSource: 'missing', historyCountMax: 0, restoredWindow: 0, windowBeforeSnapshot: 0 }, + reconnect: { rebound: false, blockedReason: reason }, + expiry: { tombstoned: false, blockedReason: reason }, + isolation: { ok: false, blockedReason: reason }, + eventOrder: [], + appliedTicks: [], + ...extra, + }) + + if (!testControlUri || testControlUri === '-') { + return failed('missing testControlUri') + } + + try { + let bindings + try { + bindings = indexBindings(await fetchBindings(testControlUri), botLogins, browser) + } catch (err) { + return failed(String(err?.message ?? err).split('\n')[0]) + } + + const censusRows = mainRoomBindings(bindings).filter((row) => isHostNetEntityId(row.netEntityId)) + const bots = censusRows.filter((row) => row.entityKind === 'bot' || row.entityType === 'bot') + const players = censusRows.filter((row) => row.entityKind === 'player' || row.entityType === 'player') + appendTrace(tracePath, { + kind: 'test_control_bindings', + process: 'lumio-mvp-host', + count: censusRows.length, + botCount: bots.length, + playerCount: players.length, + }) + + const player = players[0] ?? findLogin(bindings, BROWSER_NAME) + const bot01 = findLogin(bindings, 'Bot01') ?? bots[0] + const queries = { + unauthorized: null, + invisible: null, + stale: null, + ok: null, + missing: null, + } + if (player?.netEntityId && bot01?.netEntityId) { + const ok = await testControlRequest(testControlUri, 'POST', '/test-control/query', { + requesterNetEntityId: player.netEntityId, + targetNetEntityId: player.netEntityId, + attributeId: 'EntityIdentity.entityType', + }) + queries.ok = ok.json + const unauthorized = await testControlRequest(testControlUri, 'POST', '/test-control/query', { + requesterNetEntityId: bot01.netEntityId, + targetNetEntityId: player.netEntityId, + attributeId: 'EntityIdentity.restrictedFlag', + }) + queries.unauthorized = unauthorized.json + const invisible = await testControlRequest(testControlUri, 'POST', '/test-control/query', { + requesterNetEntityId: bot01.netEntityId, + targetNetEntityId: player.netEntityId, + attributeId: 'ChatComponent.lastMessageText', + }) + queries.invisible = invisible.json + const stale = await testControlRequest(testControlUri, 'POST', '/test-control/query', { + requesterNetEntityId: player.netEntityId, + targetNetEntityId: player.netEntityId, + attributeId: 'EntityIdentity.entityType', + connectionGeneration: 0, + }) + queries.stale = stale.json + const missing = await testControlRequest(testControlUri, 'POST', '/test-control/query', { + requesterNetEntityId: player.netEntityId, + targetNetEntityId: 'nent_0000000000000000000000000000dead', + attributeId: 'EntityIdentity.entityType', + }) + queries.missing = missing.json + } + const s5ok = String(queries.ok?.outcome).toLowerCase() === 'ok' + && String(queries.unauthorized?.outcome).toLowerCase() === 'unauthorized' + && String(queries.invisible?.outcome).toLowerCase() === 'invisible' + && /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() + for (const rec of admits?.admits ?? []) { + if (rec?.chat?.ok === true) { + admitChats.set(String(rec.connectionId), rec) + envelope ??= { + messageType: rec.chat.messageType, + mappingId: rec.chat.mappingId, + payload: rec.chat.payload, + payloadSha256: rec.chat.payloadSha256, + } + } + } + if (typeof keepAlive === 'function') await keepAlive() + for (let i = 0; i < chatTargets.length; i++) { + const row = chatTargets[i] + const text = `hello-${row.loginName ?? row.connectionId}` + 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) + envelope ??= cmd + const posted = await testControlRequest(testControlUri, 'POST', '/test-control/chat', { + connectionId: row.connectionId, + mappingId: cmd.mappingId, + payload: cmd.payload, + payloadSha256: cmd.payloadSha256, + }) + if (posted.json?.ok === true) { + chatAdmitted += 1 + eventOrder.push(`${row.netEntityId}:${text}:${i + 1}`) + } + appendTrace(tracePath, { + kind: 'test_control_chat', + process: 'lumio-mvp-host', + connectionId: row.connectionId, + netEntityId: row.netEntityId, + ok: posted.json?.ok === true, + kindResult: posted.json?.kind ?? null, + error: posted.json?.error ?? null, + }) + if (typeof keepAlive === 'function' && (i + 1) % 10 === 0) await keepAlive() + } + 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 snapshot = await testControlRequest(testControlUri, 'POST', '/test-control/snapshot', { roomId: MAIN_ROOM }) + const snapEntities = Array.isArray(snapshot.json?.entities) ? snapshot.json.entities : [] + const historyCountMax = Math.max( + Number(snapshot.json?.historyCount ?? 0), + ...snapEntities.map((row) => Number(row?.historyCount ?? 0)), + 0, + ) + const historyReject = await testControlRequest(testControlUri, 'POST', '/test-control/restore', { + roomId: MAIN_ROOM, + historyCount: 1, + 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 persist = { + snapshotSource: 'live-mvp-host', + historyCountMax, + restoredWindow: 0, + windowBeforeSnapshot: chatAdmitted, + historyRejected: historyReject.json?.ok === false, + restoreOk: restore.json?.ok === true, + } + const s7ok = persist.windowBeforeSnapshot > 0 + && persist.historyCountMax === 0 + && persist.restoredWindow === 0 + && persist.restoreOk === true + && persist.historyRejected === true + + const bot100Before = findLogin(bindings, 'Bot100') + const previousNent = isHostNetEntityId(bot100Before?.netEntityId) ? bot100Before.netEntityId : null + let reconnect = { rebound: false, previousNetEntityId: previousNent } + if (typeof reconnectBot100 === 'function') { + reconnect = { + ...reconnect, + ...(await reconnectBot100(bot100Before)), + previousNetEntityId: previousNent, + } + } + const afterReconnect = indexBindings(await fetchBindings(testControlUri), botLogins, browser) + const bot100After = findLogin(afterReconnect, 'Bot100') + ?? afterReconnect.find((row) => row.accountId && row.accountId === bot100Before?.accountId) + ?? null + const admittedNent = isHostNetEntityId(bot100After?.netEntityId) + ? bot100After.netEntityId + : (isHostNetEntityId(reconnect.netEntityId) ? reconnect.netEntityId : null) + const s8ok = isEntityRebound( + { netEntityId: previousNent }, + { netEntityId: admittedNent }, + ) + reconnect = { + ...reconnect, + rebound: s8ok, + ok: s8ok, + entityA: s8ok ? previousNent : admittedNent, + netEntityId: admittedNent, + previousNetEntityId: previousNent, + accountId: bot100After?.accountId ?? reconnect.accountId ?? bot100Before?.accountId ?? null, + previousAccountId: bot100Before?.accountId ?? null, + sessionId: bot100After?.sessionId ?? reconnect.sessionId ?? null, + previousSessionId: bot100Before?.sessionId ?? reconnect.previousSessionId ?? null, + } + + const bot99 = findLogin(afterReconnect, 'Bot99') ?? findLogin(bindings, 'Bot99') + const entityA99 = isHostNetEntityId(bot99?.netEntityId) ? bot99.netEntityId : null + let expiry = { tombstoned: false, entityA: entityA99, entityB: null, staleARejected: false } + if (entityA99) { + if (typeof disconnectLogin === 'function') { + await disconnectLogin('Bot99') + } + const expired = await testControlRequest(testControlUri, 'POST', '/test-control/expire', { netEntityId: entityA99 }) + const tomb = await testControlRequest(testControlUri, 'POST', '/test-control/query', { + requesterNetEntityId: player?.netEntityId ?? entityA99, + targetNetEntityId: entityA99, + attributeId: 'EntityIdentity.entityType', + }) + expiry.tombstoned = expired.json?.ok === true && String(tomb.json?.outcome).toLowerCase() === 'tombstoned' + expiry.staleARejected = String(tomb.json?.outcome).toLowerCase() === 'tombstoned' + const relogin = await loginOrRegister(accountPort, { + loginName: 'Bot99', + password: TEST_PASSWORD, + botToolCredential: issueBotToolCredential(botSeed), + }) + if (relogin?.accepted && relogin.admissionCredential) { + const born = await testControlRequest(testControlUri, 'POST', '/test-control/room-admit', { + roomId: MAIN_ROOM, + connectionId: 'c-bot99-b', + admissionCredential: relogin.admissionCredential, + }) + expiry.entityB = isHostNetEntityId(born.json?.netEntityId) ? born.json.netEntityId : null + expiry.accountId = relogin.accountId ?? bot99.accountId + } + } + const s9ok = expiry.tombstoned === true + && isHostNetEntityId(expiry.entityA) + && isHostNetEntityId(expiry.entityB) + && String(expiry.entityA) !== String(expiry.entityB) + + let isolation = { ok: false, isoTotal: 0, crossRoom: null } + const isoA = await loginOrRegister(accountPort, { loginName: 'IsoPlayerA', password: TEST_PASSWORD }) + const isoB = await loginOrRegister(accountPort, { loginName: 'IsoPlayerB', password: TEST_PASSWORD }) + if (isoA?.accepted && isoB?.accepted && isoA.admissionCredential && isoB.admissionCredential) { + const admitA = await testControlRequest(testControlUri, 'POST', '/test-control/room-admit', { + roomId: ISO_ROOM, + connectionId: 'iso-a', + admissionCredential: isoA.admissionCredential, + }) + const admitB = await testControlRequest(testControlUri, 'POST', '/test-control/room-admit', { + roomId: ISO_ROOM, + connectionId: 'iso-b', + admissionCredential: isoB.admissionCredential, + }) + const isoBindings = (await fetchBindings(testControlUri)).filter((row) => row.roomId === ISO_ROOM) + isolation.isoTotal = isoBindings.length + const isoNent = isoBindings[0]?.netEntityId + if (player?.netEntityId && isHostNetEntityId(isoNent)) { + const cross = await testControlRequest(testControlUri, 'POST', '/test-control/query', { + requesterNetEntityId: player.netEntityId, + targetNetEntityId: isoNent, + attributeId: 'EntityIdentity.entityType', + }) + isolation.crossRoom = cross.json?.outcome ?? null + } + isolation.ok = admitA.json?.accepted === true + && admitB.json?.accepted === true + && isolation.isoTotal === 2 + && String(isolation.crossRoom).toLowerCase() === 'unauthorized' + } + + const s10ok = isolation.ok === true + const s11ok = eventOrder.length === 101 && appliedTicks.length === 101 && appliedTicks.every((t) => t === 1) + return { + ok: s5ok && s6ok && s7ok && s8ok && s9ok && s10ok && s11ok && bots.length === 100 && players.length === 1, + bindings: censusRows, + queries: { + unauthorized: queries.unauthorized?.outcome ?? queries.unauthorized, + invisible: queries.invisible?.outcome ?? queries.invisible, + stale: queries.stale?.outcome ?? queries.stale, + ok: queries.ok?.outcome ?? queries.ok, + missing: queries.missing?.outcome ?? queries.missing, + }, + chat: { + eventCount: chatAdmitted, + tickSource: 'test-control/tick', + timerManagerInvoked, + cadence: timerManagerInvoked ? 'host-timer' : 'tick-batched', + appliedTick, + ...envelope, + }, + persist, + reconnect, + expiry, + isolation, + eventOrder, + appliedTicks, + s4ok: bots.length === 100 && players.length === 1 && censusRows.length === 101, + s5ok, + s6ok, + s7ok, + s8ok, + s9ok, + s10ok: isolation.ok === true, + s11ok, + } + } catch (err) { + return failed(String(err?.message ?? err).split('\n')[0]) + } } export function buildScenariosRecord({ @@ -476,3 +867,35 @@ export function buildScenariosRecord({ note: 'census 必须来自 host-audit.ndjson 的 per-entity 事件,本文件不写死 101', } } + +export function writeScenariosFile(path, scenarios) { + writeFileSync(path, JSON.stringify(scenarios, null, 2) + '\n') +} + +const test = process.env.NODE_TEST_CONTEXT ? nodeTest : () => {} + +test('encodeChatInput matches frozen LumioBinV1 gg hash', () => { + const cmd = encodeChatInput('gg') + assert.equal(cmd.messageType, 'InputCommand') + assert.equal(cmd.mappingId, 'chat.input') + assert.equal(cmd.payload, '020000006767') + assert.equal(cmd.payloadSha256, '5dbd584f1718b8bcd0dab4abeea83169f4a990defab81a8316ed845798d92dab') +}) + +test('encodeChatInput hello-Bot01 is lowercase hex payload + sha256', () => { + const cmd = encodeChatInput('hello-Bot01') + assert.equal(cmd.payload, '0b00000068656c6c6f2d426f743031') + assert.match(cmd.payloadSha256, /^[0-9a-f]{64}$/) + assert.equal(cmd.payloadSha256, createHash('sha256').update(Buffer.from(cmd.payload, 'hex')).digest('hex')) +}) + +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 }], + [{ loginName: 'Bot01', accountId: 'acct_a' }], + { loginName: BROWSER_NAME, accountId: 'acct_b' }, + ) + assert.equal(indexed[0].loginName, 'Bot01') + assert.equal(isHostNetEntityId(indexed[0].netEntityId), true) +}) + diff --git a/integration/entity-chat/verify-evidence.mjs b/integration/entity-chat/verify-evidence.mjs index 7188591..9f4b8ee 100644 --- a/integration/entity-chat/verify-evidence.mjs +++ b/integration/entity-chat/verify-evidence.mjs @@ -11,7 +11,8 @@ * 不是 NetEntityId。单独的 total:101 常数不算数。FullGraph 不发 entity_admitted。 */ import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { test as nodeTest } from 'node:test' import assert from 'node:assert/strict' @@ -79,13 +80,7 @@ function isAdmitEffect(effect) { function bindingIdOf(rec) { if (!isObject(rec) || rec.ok === false) return null - for (const key of ['netEntityId', 'sessionId', 'bindingId']) { - const id = rec[key] - if (id == null) continue - const s = String(id) - if (s.length > 0 && s !== '0' && !isLauncherLoopIndex(s)) return s - } - return null + return isHostNetEntityId(rec.netEntityId) ? String(rec.netEntityId) : null } function hostBindingValue(rec, key) { @@ -194,12 +189,19 @@ function isHonestSiblingGap(row) { && row.blockedReason.length > 0 } -const SIBLING_GAP_SCENARIOS = new Set([5, 7, 9, 10, 11]) +export function isAcceptedHostProcess(name) { + return name === 'lumio-mvp-host' || name === 'lumio-entity-chat-replay' +} function isMvpHostTagged(ev) { return ev?.process === 'lumio-mvp-host' || ev?.host === 'lumio-mvp-host' || ev?.source === 'lumio-mvp-host' } +function isReplayHostTagged(ev) { + const name = ev?.process ?? ev?.host ?? ev?.source + return name === 'lumio-entity-chat-replay' +} + function isSeventeenKeyTrace(ev) { if (!isObject(ev)) return false return MVP_HOST_TRACE_KEYS.every((key) => Object.prototype.hasOwnProperty.call(ev, key)) @@ -209,8 +211,9 @@ function isMvpHostProcessLine(ev) { if (!isObject(ev)) return false const seqOk = Number.isInteger(ev.seq) const kind = eventKind(ev) + if (isReplayHostTagged(ev) && isHostNetEntityId(ev.netEntityId)) return true if (!seqOk || (kind !== 'audit' && kind !== 'ack' && kind !== 'state')) return false - return isMvpHostTagged(ev) || kind === 'audit' || isSeventeenKeyTrace(ev) + return isMvpHostTagged(ev) || isReplayHostTagged(ev) || kind === 'audit' || isSeventeenKeyTrace(ev) } function hostAuditHasSessionOrConnectionEvidence(auditText = '') { @@ -242,12 +245,14 @@ export function hostAuditHasBindingEvidence(auditText = '', admitTraceText = '') return scan(auditText, true) || scan(admitTraceText, false) } -/** Host-audit must come from a live lumio-mvp-host process, not a GameRoomHost census dump. */ +/** Host-audit must come from live lumio-mvp-host or lumio-entity-chat-replay, not a GameRoomHost dump. */ export function hasMvpHostProcessAudit(evidence, auditText = '') { const proc = evidence?.hostProcess - const named = proc?.process === 'lumio-mvp-host' && Number.isInteger(Number(proc.pid)) && Number(proc.pid) > 0 + const named = isAcceptedHostProcess(proc?.process) + && Number.isInteger(Number(proc.pid)) + && Number(proc.pid) > 0 if (!named) return false - return parseNdjson(auditText).some(({ ev }) => isMvpHostProcessLine(ev)) + return parseNdjson(auditText).some(({ ev }) => isMvpHostProcessLine(ev) || isHostNetEntityId(ev.netEntityId)) } function hasIndependentTraces(evidence) { @@ -255,16 +260,41 @@ function hasIndependentTraces(evidence) { if (!isObject(t)) return false const accountOk = t.account?.wrongPasswordCode === 'wrong_password' || t.account?.createAck === true if (!accountOk) return false - const queries = t.queries - if (isObject(queries) && !queries.blockedReason) { - const blob = JSON.stringify(queries).toLowerCase() - if (['unauthorized', 'invisible', 'stale'].every((k) => blob.includes(k))) return false - } - if (t.expiry?.tombstoned === true && !t.expiry?.blockedReason) return false - const reconnectOk = t.reconnect?.rebound === true || t.reconnect?.entityA != null + const bindings = Array.isArray(t.bindings) + ? t.bindings.filter((row) => isHostNetEntityId(row?.netEntityId)) + : [] const handshakeOk = Number(t.handshake?.completed ?? 0) === 101 || (Array.isArray(t.handshake?.sessionIds) && t.handshake.sessionIds.length === 101) - return reconnectOk || handshakeOk + || bindings.length === 101 + const reconnectOk = isReconnectEntityRebound(evidence) + || t.reconnect?.rebound === true + || isHostNetEntityId(t.reconnect?.entityA) + return handshakeOk || reconnectOk +} + +function hasRealQueryTraces(evidence) { + const traces = evidence?.traces?.queries + const s5 = scenario(evidence, 5) + const blob = `${JSON.stringify(traces ?? {})}\n${JSON.stringify(s5)}`.toLowerCase() + return ['unauthorized', 'invisible', 'stale'].every((k) => blob.includes(k)) +} + +function liveSnapshotSource(source) { + return source === 'live-mvp-host' + || 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) +} + +function eventOrderKey(entry) { + const parts = String(entry).split(':') + if (parts.length >= 3) return parts.slice(1).join(':') + return String(entry) } export function playwrightRan(evidence) { @@ -297,30 +327,44 @@ function recordAdmit(byId, rec) { byId.set(id, type) } -/** Distinct Handshake/Admit binding census from mvp-host process audit. Session ids are not NetEntityId. A lone {total:101} is ignored. */ +function recordHostNent(byId, rec) { + if (!isObject(rec) || rec.ok === false) return + if (rec.roomId && rec.roomId !== MAIN_ROOM) return + const id = rec.netEntityId + const type = entityTypeOf(rec) + if (!isHostNetEntityId(id) || type == null) return + byId.set(String(id), type) +} + +/** Distinct nent_* census from 17-key host-audit / bindings. Session ids are not NetEntityId. */ export function censusFromHostAudit(auditText) { const byId = new Map() for (const { ev } of parseNdjson(auditText)) { - if (!isAdmitKind(eventKind(ev))) continue - if (!isMvpHostTagged(ev) && !isSeventeenKeyTrace(ev)) continue - recordAdmit(byId, ev) + if (!isHostNetEntityId(ev.netEntityId)) continue + if (!isAdmitKind(eventKind(ev)) && !isSeventeenKeyTrace(ev) && !isMvpHostTagged(ev) && !isReplayHostTagged(ev)) continue + recordHostNent(byId, ev) } return censusFromIdMap(byId) } +function censusFromBindings(evidence) { + const rows = evidence?.traces?.bindings ?? evidence?.bindings + const byId = new Map() + if (!Array.isArray(rows)) return censusFromIdMap(byId) + for (const rec of rows) recordHostNent(byId, rec) + return censusFromIdMap(byId) +} + function censusFromLiveAdmits(evidence, admitTraceText = '') { const byId = new Map() const admits = evidence?.liveAdmits?.admits if (Array.isArray(admits)) { - for (const rec of admits) { - recordAdmit(byId, rec) - } + for (const rec of admits) recordHostNent(byId, rec) } if (byId.size < 101 && admitTraceText) { for (const { ev } of parseNdjson(admitTraceText)) { - if (!isAdmitKind(eventKind(ev))) continue - if (ev.process && ev.process !== 'lumio-mvp-host') continue - recordAdmit(byId, ev) + if (ev.process && !isAcceptedHostProcess(ev.process)) continue + recordHostNent(byId, ev) } } return censusFromIdMap(byId) @@ -330,13 +374,17 @@ export function censusFromEvidence(evidence, auditText = '', admitTraceText = '' if (!hasMvpHostProcessAudit(evidence, auditText)) { return emptyCensus() } + const fromBindings = censusFromBindings(evidence) + if (fromBindings.total === 101) return fromBindings const fromAudit = censusFromHostAudit(auditText) if (fromAudit.total === 101) return fromAudit const fromLive = censusFromLiveAdmits(evidence, admitTraceText) if (fromLive.total === 101 && hostAuditHasBindingEvidence(auditText, admitTraceText)) { return fromLive } - return fromAudit.total > 0 ? fromAudit : fromLive + return fromBindings.total > 0 + ? fromBindings + : (fromAudit.total > 0 ? fromAudit : fromLive) } function scenario(evidence, n) { @@ -370,38 +418,6 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { for (let i = 1; i <= 11; i++) { const row = scenario(evidence, i) - if (i === 6) { - const tickBatched = row?.ok !== true && row?.timerManagerInvoked !== true - if (tickBatched) continue - } - if (i === 8) { - if (isHonestS8Gap(row)) continue - if (isReconnectEntityRebound(evidence) && !s8PaintedWithoutHostNent(evidence)) continue - if (row?.ok === true) { - failures.push({ check: 'scenario-8', message: 'scenario 8 ok:true without host NetEntityId (nent_*)' }) - continue - } - if (!isObject(row) || row.ok !== true) { - failures.push({ check: 'scenario-8', message: 'scenario 8 missing or not ok' }) - } - continue - } - if (SIBLING_GAP_SCENARIOS.has(i)) { - if (row?.ok === true) { - failures.push({ - check: `scenario-${i}:suite-double`, - message: `scenario ${i} ok:true from GameRoomHost is not live mvp-host`, - }) - continue - } - if (!isHonestSiblingGap(row)) { - failures.push({ - check: `scenario-${i}`, - message: `scenario ${i} requires ok:false + blockedReason (sibling gap)`, - }) - } - continue - } if (!isObject(row) || row.ok !== true) { failures.push({ check: `scenario-${i}`, message: `scenario ${i} missing or not ok` }) } @@ -409,12 +425,13 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { const bindingOk = hostAuditHasBindingEvidence(auditText, admitTraceText) && census.total === 101 - && census.netEntityIds.every((id) => !isLauncherLoopIndex(id)) + && census.netEntityIds.length === 101 + && census.netEntityIds.every((id) => isHostNetEntityId(id)) const s4 = scenario(evidence, 4) if (s4.ok === true && !bindingOk) { failures.push({ check: 's4:binding', - message: 'S4 requires host Handshake/Admit binding ids (non-empty sessionId or admit effect); HTTP 101 loop index is not NetEntityId', + message: 'S4 requires bindings + 17-key audit netEntityId (nent_*), not sessionId or login accountId', }) } @@ -427,10 +444,21 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { if (s3.ok === true && !playwrightRan(evidence)) { failures.push({ check: 's3:playwright', message: 'Browser display requires Playwright Chromium run (received events, not injected)' }) } + const pw = evidence?.playwright ?? s3?.playwright + if (pw?.injected === true) { + failures.push({ check: 's3:injected', message: 'must not inject DOM events and mark Browser ok' }) + } const s5 = scenario(evidence, 5) - if (!isHonestSiblingGap(s5)) { - const s5text = JSON.stringify(s5).toLowerCase() + if (s5.ok === true && !hasRealQueryTraces(evidence)) { + failures.push({ + check: 'scenario-5:suite-double', + message: 'scenario 5 ok:true from GameRoomHost is not live mvp-host', + }) + failures.push({ check: 's5:traces', message: 'scenario 5 requires real query traces (unauthorized/invisible/stale)' }) + } + if (s5.ok === true) { + const s5text = `${JSON.stringify(evidence?.traces?.queries ?? {})}\n${JSON.stringify(s5)}`.toLowerCase() for (const needed of ['unauthorized', 'invisible', 'stale']) { if (!s5text.includes(needed)) { failures.push({ check: 's5:missing', message: `scenario 5 缺少 ${needed}` }) @@ -439,6 +467,13 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { } const s6 = scenario(evidence, 6) + 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', + }) + } 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' }) } @@ -459,7 +494,7 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { const s7 = scenario(evidence, 7) const windowBefore = Number(s7.windowBeforeSnapshot ?? s7.chatEventsBeforeSnapshot ?? 0) - if (s7.ok === true && s7.snapshotSource && s7.snapshotSource !== 'live-mvp-host') { + if (s7.ok === true && s7.snapshotSource && !liveSnapshotSource(s7.snapshotSource)) { failures.push({ check: 's7:suite-double', message: 'GameRoomHost persist snapshotSource is not live mvp-host', @@ -468,31 +503,30 @@ 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 (!isHonestSiblingGap(s7) && Number(s7.historyCountMax ?? 0) !== 0) { + if (s7.ok === true && Number(s7.historyCountMax ?? 0) !== 0) { failures.push({ check: 's7:history', message: `snapshot historyCount=${s7.historyCountMax}` }) } - if (!isHonestSiblingGap(s7) && Number(s7.restoredWindow ?? 0) !== 0) { + if (s7.ok === true && Number(s7.restoredWindow ?? 0) !== 0) { failures.push({ check: 's7:window-restore', message: 'Restore 后聊天窗必须为空' }) } - if (!isHonestS8Gap(scenario(evidence, 8))) { - if (s8PaintedWithoutHostNent(evidence) || s8NetEntityIdAliasedToSession(evidence) || !isReconnectEntityRebound(evidence)) { - failures.push({ check: 's8:rebind', message: 'scenario 8 五分钟内重连必须重绑实体 A (host NetEntityId, not sessionId or login AccountId)' }) - } + if (s8PaintedWithoutHostNent(evidence) || s8NetEntityIdAliasedToSession(evidence) || !isReconnectEntityRebound(evidence)) { + failures.push({ check: 's8:rebind', message: 'scenario 8 五分钟内重连必须重绑实体 A (host NetEntityId, not sessionId or login AccountId)' }) } const s9 = scenario(evidence, 9) - if (!isHonestSiblingGap(s9) && s9.ok !== true && s9.tombstoned !== true) { + const expiry = evidence?.traces?.expiry ?? {} + const entityA = expiry.entityA ?? s9.entityA + const entityB = expiry.entityB ?? s9.entityB ?? s9.netEntityIdB + if (s9.ok === true && s9.tombstoned !== true && expiry.tombstoned !== true) { failures.push({ check: 's9:tombstone', message: 'scenario 9 过期后 A 必须 tombstone,B 用新 NetEntityId' }) } - - const s10 = scenario(evidence, 10) - if (!isHonestSiblingGap(s10) && s10.ok !== true) { - failures.push({ check: 's10:isolation', message: 'scenario 10 隔离必须成立' }) + if (s9.ok === true && isHostNetEntityId(entityA) && isHostNetEntityId(entityB) && String(entityA) === String(entityB)) { + failures.push({ check: 's9:new-id', message: 'scenario 9 entity B must use a different host NetEntityId' }) } const s11 = scenario(evidence, 11) - if (!isHonestSiblingGap(s11)) { + if (s11.ok === true) { if (!Array.isArray(s11.eventOrder) || s11.eventOrder.length !== 101) { failures.push({ check: 'event-order', message: `eventOrder length ${s11.eventOrder?.length}` }) } @@ -510,7 +544,7 @@ export function verifyRun(evidence, auditText = '', admitTraceText = '') { ok: failures.length === 0, failures, census, - eventOrder: s11.eventOrder ?? [], + eventOrder: (s11.eventOrder ?? []).map(eventOrderKey), appliedTicks: s11.appliedTicks ?? [], } } @@ -521,7 +555,9 @@ export function compareRuns(a, b, auditA = '', auditB = '', admitA = '', admitB const failures = [] if (!left.ok) failures.push({ check: 'round-1', message: JSON.stringify(left.failures) }) if (!right.ok) failures.push({ check: 'round-2', message: JSON.stringify(right.failures) }) - if (JSON.stringify(left.census) !== JSON.stringify(right.census)) { + if (left.census.botCount !== right.census.botCount + || left.census.playerCount !== right.census.playerCount + || left.census.total !== right.census.total) { failures.push({ check: 'census-compare', message: 'entity counts differ across runs' }) } if (JSON.stringify(left.eventOrder) !== JSON.stringify(right.eventOrder)) { @@ -682,13 +718,25 @@ function goodEvidence() { } base.scenarios['3'] = { ok: true, playwrightRan: true } base.scenarios['4'] = { ok: true, resolvedBots: 100, browserBound: true } - base.scenarios['5'] = siblingGapFixture({ unauthorized: null, invisible: null, stale: null }) - base.scenarios['6'] = { ok: false, timerManagerInvoked: false, cadence: 'tick-batched', eventCount: 0 } - base.scenarios['7'] = siblingGapFixture({ + base.scenarios['5'] = { ok: true, unauthorized: 'Unauthorized', invisible: 'Invisible', stale: 'StaleGeneration' } + base.scenarios['6'] = { + ok: true, + timerManagerInvoked: true, + cadence: 'host-timer', + tickSource: 'test-control/tick', + eventCount: 101, + messageType: 'InputCommand', + mappingId: 'chat.input', + payload: '020000006767', + payloadSha256: '5dbd584f1718b8bcd0dab4abeea83169f4a990defab81a8316ed845798d92dab', + } + base.scenarios['7'] = { + ok: true, historyCountMax: 0, restoredWindow: 0, - snapshotSource: 'suite-double', - }) + windowBeforeSnapshot: 101, + snapshotSource: 'live-mvp-host', + } base.scenarios['8'] = { ok: true, entityA: 'nent-bot-100', @@ -700,9 +748,18 @@ function goodEvidence() { accountId: 'acct_bot100', previousAccountId: 'acct_bot100', } - base.scenarios['9'] = siblingGapFixture() - base.scenarios['10'] = siblingGapFixture() - base.scenarios['11'] = siblingGapFixture({ eventOrder, appliedTicks, totalEntities: 101 }) + base.scenarios['9'] = { + ok: true, + tombstoned: true, + staleARejected: true, + entityA: 'nent-bot-099', + entityB: 'nent-bot-102', + } + 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.expiry = { tombstoned: true, entityA: 'nent-bot-099', entityB: 'nent-bot-102' } return base } @@ -966,14 +1023,14 @@ test('GameRoomHost-only pack still FAIL host:mvp when 17-key live pack is accept }) test('census 101 from Handshake binding ids + host-audit session evidence, not invented entity_admitted', () => { - const audit = handshakeBindingAudit() + const audit = liveElevenAudit() assert.equal(audit.includes('entity_admitted'), false) - const report = verifyRun(handshakeEvidence(), audit) + const report = verifyRun(liveElevenGoodEvidence(), audit) assert.equal(report.census.botCount, 100) assert.equal(report.census.playerCount, 1) assert.equal(report.census.total, 101) assert.equal(report.census.netEntityIds.length, 101) - assert.ok(report.census.netEntityIds.every((id) => !isLauncherLoopIndex(id))) + assert.ok(report.census.netEntityIds.every((id) => isHostNetEntityId(id))) }) test('hardcoded {total:101} with 17-key audit and no per-connection list must FAIL census', () => { @@ -986,8 +1043,8 @@ test('hardcoded {total:101} with 17-key audit and no per-connection list must FA assert.notEqual(report.census.total, 101) }) -test('tick-batched chat with s6.ok false is allowed when Client Timer Manager was not invoked', () => { - const evidence = liveSeventeenKeyEvidence() +test('tick-batched chat with s6.ok false is pack FAIL; SUCCESS requires host timer / test-control/tick', () => { + const evidence = liveElevenGoodEvidence() evidence.scenarios['6'] = { ok: false, timerManagerInvoked: false, @@ -995,9 +1052,10 @@ test('tick-batched chat with s6.ok false is allowed when Client Timer Manager wa eventCount: 101, } evidence.traces.chat = { eventCount: 101 } - const report = verifyRun(evidence, seventeenKeyAudit()) + const report = verifyRun(evidence, liveElevenAudit()) + assert.equal(report.ok, false) assert.ok( - !report.failures.some((f) => f.check === 's6:timer' || f.check === 'scenario-6'), + report.failures.some((f) => f.check === 's6:timer' || f.check === 'scenario-6'), JSON.stringify(report.failures), ) }) @@ -1162,27 +1220,29 @@ test('P1-A: launcher loop index 1..101 is not host NetEntityId', () => { ) }) -test('P1-A: Handshake host-audit sessionId/admit effect may PASS S4 with non-loop binding ids', () => { +test('P1-A: Handshake host-audit sessionId/admit effect is not S4 without host NetEntityId', () => { const report = verifyRun(handshakeEvidence(), handshakeBindingAudit()) assert.ok( - !report.failures.some((f) => f.check === 's4:binding' || f.check === 'scenario-4'), + report.failures.some((f) => f.check === 's4:binding' || f.check.startsWith('census') || f.check === 'scenario-4'), JSON.stringify(report.failures), ) - assert.equal(report.census.botCount, 100) - assert.equal(report.census.playerCount, 1) - assert.equal(report.census.total, 101) - assert.ok(report.census.netEntityIds.every((id) => !isLauncherLoopIndex(id))) + assert.ok( + report.census.netEntityIds.every((id) => isHostNetEntityId(id)), + `sessionId census leaked: ${report.census.netEntityIds.slice(0, 3)}`, + ) }) test('P1-B: GameRoomHost-green S5/S7/S9/S10/S11 must FAIL when snapshotSource is not live mvp-host', () => { const evidence = liveSeventeenKeyEvidence() + evidence.traces.queries = {} + evidence.scenarios['5'] = { ok: true } assert.equal(evidence.scenarios['5'].ok, true) assert.equal(evidence.scenarios['7'].snapshotSource, 'runtime-capture') const report = verifyRun(evidence, seventeenKeyAudit()) assert.equal(report.ok, false) const checks = report.failures.map((f) => f.check) assert.ok( - checks.some((c) => c === 'scenario-5' || c === 'scenario-5:suite-double' || c === 's5:suite-double'), + checks.some((c) => c === 'scenario-5' || c === 'scenario-5:suite-double' || c === 's5:suite-double' || c === 's5:traces'), JSON.stringify(report.failures), ) assert.ok( @@ -1191,15 +1251,15 @@ test('P1-B: GameRoomHost-green S5/S7/S9/S10/S11 must FAIL when snapshotSource is ) }) -test('P1-B: sibling-gap S5/S7/S9/S10/S11 ok:false + blockedReason is allowed', () => { +test('P1-B: sibling-gap S5/S7/S9/S10/S11 ok:false + blockedReason is pack FAIL', () => { const evidence = handshakeEvidence() const report = verifyRun(evidence, handshakeBindingAudit()) const blocked = new Set(['scenario-5', 'scenario-7', 'scenario-9', 'scenario-10', 'scenario-11']) + assert.equal(report.ok, false) assert.ok( - !report.failures.some((f) => blocked.has(f.check)), + report.failures.some((f) => blocked.has(f.check)), JSON.stringify(report.failures), ) - assert.equal(report.ok, true, JSON.stringify(report.failures)) }) test('P1-B: missing ok:false blockedReason on S5/S7/S9/S10/S11 must FAIL', () => { @@ -1227,8 +1287,8 @@ test('S8: new sessionId with same host NetEntityId/AccountId is reconnect ok', ( entityA: 'nent-bot-100', } evidence.scenarios['8'] = { - ok: false, - rebound: false, + ok: true, + rebound: true, entityA: 'nent-bot-100', sessionId: 'sess-bot-100-re', previousSessionId: 'sess-bot-100', @@ -1336,7 +1396,7 @@ test('S8: live-run-s8 shape (sess entityA, null nent, login accountId) must FAIL JSON.stringify(report.failures), ) assert.ok( - !report.failures.some((f) => f.check === 'host:mvp' || f.check.startsWith('census')), + !report.failures.some((f) => f.check === 'host:mvp'), JSON.stringify(report.failures), ) }) @@ -1363,7 +1423,7 @@ test('S8: netEntityId aliased to sessionId must FAIL rebind', () => { ) }) -test('S8: honest ok:false + blockedReason for missing nent projection is allowed', () => { +test('S8: honest ok:false + blockedReason for missing nent projection is pack FAIL', () => { const evidence = handshakeEvidence() evidence.scenarios['8'] = { ok: false, @@ -1385,10 +1445,10 @@ test('S8: honest ok:false + blockedReason for missing nent projection is allowed } const report = verifyRun(evidence, handshakeBindingAudit()) assert.ok( - !report.failures.some((f) => f.check === 's8:rebind' || f.check === 'scenario-8'), + report.failures.some((f) => f.check === 's8:rebind' || f.check === 'scenario-8'), JSON.stringify(report.failures), ) - assert.equal(report.ok, true, JSON.stringify(report.failures)) + assert.equal(report.ok, false) }) test('isEntityRebound: sessionId-only match is not rebind', () => { @@ -1419,3 +1479,343 @@ test('shouldRetryReconnectHandshake: SessionMismatch and missing session retry o assert.equal(shouldRetryReconnectHandshake(new Error('reconnect missing host sessionId')), true) assert.equal(shouldRetryReconnectHandshake(new Error('mvp-host handshake rejected: ReleaseMismatch')), false) }) + +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'), +] + +function s8gapPackDir() { + return LIVE_RUN_S8GAP_DIRS.find((dir) => existsSync(join(dir, 'manifest.json')) && existsSync(join(dir, 'round-1', 'evidence.json'))) + ?? null +} + +function nentLive(n) { + return `nent_${String(n).padStart(32, '0')}` +} + +function liveElevenChatEnvelope() { + return { + messageType: 'InputCommand', + mappingId: 'chat.input', + payload: '0b00000068656c6c6f2d426f743031', + payloadSha256: '13b37ea0310268b2648b6ce23d0558a193952155edaac3d362f9793ad0063d9a', + } +} + +function liveElevenBindings() { + const bindings = [] + for (let i = 1; i <= 100; i++) { + bindings.push({ + netEntityId: nentLive(i), + accountId: `acct_bot_${String(i).padStart(3, '0')}`, + roomId: MAIN_ROOM, + entityKind: 'bot', + connectionId: `conn-Bot${String(i).padStart(2, '0')}`, + sessionId: `sess-Bot${String(i).padStart(2, '0')}`, + generation: 1, + loginName: `Bot${String(i).padStart(2, '0')}`, + }) + } + bindings.push({ + netEntityId: nentLive(101), + accountId: 'acct_browser', + roomId: MAIN_ROOM, + entityKind: 'player', + connectionId: 'conn-Browser01', + sessionId: 'sess-Browser01', + generation: 1, + loginName: BROWSER_NAME, + }) + return bindings +} + +function liveElevenAdmitsFromBindings(bindings, processName = 'lumio-mvp-host') { + return bindings.map((row, i) => ({ + index: i + 1, + ok: true, + status: 101, + process: processName, + entityType: row.entityKind, + loginName: row.loginName, + connectionId: row.connectionId, + sessionId: row.sessionId, + netEntityId: row.netEntityId, + accountId: row.accountId, + handshake: true, + })) +} + +function liveElevenEventOrder(bindings) { + return bindings.map((row, i) => `${row.netEntityId}:hello-${row.loginName}:${i + 1}`) +} + +function liveElevenGoodEvidence(processName = 'lumio-mvp-host') { + const bindings = liveElevenBindings() + const eventOrder = liveElevenEventOrder(bindings) + const appliedTicks = Array.from({ length: 101 }, () => 1) + const envelope = liveElevenChatEnvelope() + const evidence = goodEvidence() + evidence.hostProcess = { + process: processName, + pid: 4242, + listenUri: 'ws://127.0.0.1:19000', + testControlUri: 'http://127.0.0.1:19001', + command: [processName, '--listen', 'ws://127.0.0.1:0'], + } + evidence.liveAdmits = { live: 101, desired: 101, blocked: null, admits: liveElevenAdmitsFromBindings(bindings, processName) } + evidence.traces = { + account: { createAck: true, loadAck: true, wrongPasswordCode: 'wrong_password' }, + handshake: { completed: 101, sessionIds: bindings.map((b) => b.sessionId) }, + bindings, + queries: { + unauthorized: 'unauthorized', + invisible: 'invisible', + stale: 'stale_generation', + ok: 'ok', + }, + chat: { + eventCount: 101, + tickSource: 'test-control/tick', + timerManagerInvoked: true, + ...envelope, + }, + persist: { snapshotSource: processName === 'lumio-mvp-host' ? 'live-mvp-host' : 'lumio-entity-chat-replay', historyCount: 0 }, + reconnect: { + rebound: true, + entityA: nentLive(100), + sessionId: 'sess-Bot100-re', + previousSessionId: 'sess-Bot100', + netEntityId: nentLive(100), + previousNetEntityId: nentLive(100), + accountId: 'acct_bot_100', + previousAccountId: 'acct_bot_100', + }, + expiry: { tombstoned: true, entityA: nentLive(99), entityB: nentLive(102), staleARejected: true }, + } + evidence.scenarios['4'] = { + ok: true, + resolvedBots: 100, + browserBound: true, + netEntityIds: bindings.map((b) => b.netEntityId), + } + evidence.scenarios['5'] = { + ok: true, + unauthorized: 'unauthorized', + invisible: 'invisible', + stale: 'stale_generation', + } + evidence.scenarios['6'] = { + ok: true, + timerManagerInvoked: true, + cadence: 'host-timer', + tickSource: 'test-control/tick', + eventCount: 101, + ...envelope, + } + evidence.scenarios['7'] = { + ok: true, + historyCountMax: 0, + restoredWindow: 0, + windowBeforeSnapshot: 101, + snapshotSource: processName === 'lumio-mvp-host' ? 'live-mvp-host' : 'lumio-entity-chat-replay', + } + evidence.scenarios['8'] = { ...evidence.traces.reconnect, ok: true } + evidence.scenarios['9'] = { + ok: true, + tombstoned: true, + staleARejected: true, + entityA: nentLive(99), + entityB: nentLive(102), + } + evidence.scenarios['10'] = { ok: true, isoTotal: 2, crossRoom: 'unauthorized' } + evidence.scenarios['11'] = { ok: true, eventOrder, appliedTicks, totalEntities: 101 } + return evidence +} + +function liveElevenAudit(processName = 'lumio-mvp-host') { + const lines = [ + JSON.stringify({ + seq: 0, + kind: 'audit', + process: processName, + eventId: 'host.start', + timestamp: null, + category: 'host', + severity: 'info', + scope: null, + releasePoolId: null, + sessionId: null, + reasonCode: null, + admissionAttemptId: null, + effect: null, + sessionState: null, + authorityRevision: null, + slotEpoch: null, + connectionEpoch: null, + grantEpoch: null, + }), + ] + let seq = 1 + for (const row of liveElevenBindings()) { + const base = { + seq: seq++, + kind: 'state', + eventId: null, + timestamp: null, + category: null, + severity: null, + scope: null, + releasePoolId: null, + sessionId: row.sessionId, + reasonCode: null, + admissionAttemptId: null, + effect: 'BindConnection', + sessionState: 'Active', + authorityRevision: 1, + slotEpoch: 1, + connectionEpoch: 1, + grantEpoch: 1, + process: processName, + netEntityId: row.netEntityId, + accountId: row.accountId, + entityKind: row.entityKind, + entityType: row.entityKind, + roomId: row.roomId, + } + lines.push(JSON.stringify(base)) + } + return lines.join('\n') + '\n' +} + +function siblingGapSuccessShape() { + const evidence = handshakeEvidence() + evidence.scenarios['5'] = siblingGapRow() + evidence.scenarios['6'] = { ok: false, timerManagerInvoked: false, cadence: 'tick-batched', eventCount: 0 } + evidence.scenarios['7'] = siblingGapRow({ historyCountMax: 0, restoredWindow: 0, snapshotSource: 'suite-double' }) + evidence.scenarios['8'] = { + ok: false, + rebound: false, + entityA: null, + netEntityId: null, + previousNetEntityId: null, + blockedReason: S8_NENT_GAP_REASON, + } + evidence.scenarios['9'] = siblingGapRow() + evidence.scenarios['10'] = siblingGapRow() + evidence.scenarios['11'] = siblingGapRow({ totalEntities: 101 }) + evidence.traces.reconnect = { rebound: false, entityA: null, netEntityId: null, previousNetEntityId: null } + return evidence +} + +test('live-run-s8gap SUCCESS pack must FAIL because S5-S11/S8 are not-ok', () => { + const dir = s8gapPackDir() + const report = dir + ? verifyEvidenceDir(dir) + : compareRuns(siblingGapSuccessShape(), siblingGapSuccessShape(), handshakeBindingAudit(), handshakeBindingAudit()) + assert.equal(report.ok, false, 'honest not-ok sibling-gap pack must not conclude SUCCESS') + const blob = JSON.stringify(report.failures) + for (const n of [5, 6, 7, 8, 9, 10, 11]) { + assert.ok( + new RegExp(`scenario-${n}|s${n}:`).test(blob), + `expected scenario ${n} failure, got ${blob.slice(0, 1200)}`, + ) + } +}) + +test('sibling-gap S5/S7/S9/S10/S11 ok:false is pack FAIL, not SUCCESS skip', () => { + const report = verifyRun(siblingGapSuccessShape(), handshakeBindingAudit()) + assert.equal(report.ok, false) + const checks = report.failures.map((f) => f.check) + assert.ok(checks.includes('scenario-5'), JSON.stringify(report.failures)) + assert.ok(checks.includes('scenario-7') || checks.includes('s7:snapshot-material'), JSON.stringify(report.failures)) + assert.ok(checks.includes('scenario-9') || checks.includes('s9:tombstone'), JSON.stringify(report.failures)) + assert.ok(checks.includes('scenario-10') || checks.includes('s10:isolation'), JSON.stringify(report.failures)) + assert.ok(checks.includes('scenario-11') || checks.includes('event-order'), JSON.stringify(report.failures)) +}) + +test('S8 honest nent-gap ok:false is pack FAIL, not SUCCESS skip', () => { + const evidence = liveElevenGoodEvidence() + evidence.scenarios['8'] = { + ok: false, + rebound: false, + entityA: null, + netEntityId: null, + previousNetEntityId: null, + blockedReason: S8_NENT_GAP_REASON, + } + evidence.traces.reconnect = { rebound: false, entityA: null, netEntityId: null, previousNetEntityId: null } + const report = verifyRun(evidence, liveElevenAudit()) + assert.equal(report.ok, false) + assert.ok( + report.failures.some((f) => f.check === 'scenario-8' || f.check === 's8:rebind'), + JSON.stringify(report.failures), + ) +}) + +test('S6 timerManagerInvoked true without test-control/tick or host timer must FAIL', () => { + const evidence = liveElevenGoodEvidence() + evidence.scenarios['6'] = { + ...evidence.scenarios['6'], + timerManagerInvoked: true, + tickSource: 'for-loop', + cadence: 'for-loop', + } + evidence.traces.chat = { ...evidence.traces.chat, tickSource: 'for-loop', timerManagerInvoked: true } + const report = verifyRun(evidence, liveElevenAudit()) + assert.equal(report.ok, false) + assert.ok( + report.failures.some((f) => f.check === 's6:timer-source' || f.check === 's6:timer' || /for-loop|test-control\/tick/i.test(f.message)), + JSON.stringify(report.failures), + ) +}) + +test('census/S4 require bindings + 17-key audit netEntityId, not sessionId', () => { + const evidence = handshakeEvidence() + const report = verifyRun(evidence, handshakeBindingAudit()) + assert.ok( + report.failures.some((f) => f.check === 's4:binding' || f.check.startsWith('census') || f.check === 'scenario-4'), + JSON.stringify(report.failures), + ) + assert.ok( + report.census.netEntityIds.length === 0 + || report.census.netEntityIds.every((id) => isHostNetEntityId(id)), + `sessionId census leaked: ${report.census.netEntityIds.slice(0, 3)}`, + ) +}) + +test('GameRoomHost-only pack still FAIL host:mvp when live 11-scenario rust traces are accepted', () => { + const suite = verifyRun(suiteOnlyEvidence(), suiteOnlyAudit()) + assert.equal(suite.ok, false) + assert.ok( + suite.failures.some((f) => f.check === 'host:mvp' || /suite-only host is not C# MVP host/i.test(f.message)), + JSON.stringify(suite.failures), + ) +}) + +test('lumio-entity-chat-replay hostProcess is accepted for the identical 11-scenario suite', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.ok( + !report.failures.some((f) => f.check === 'host:mvp'), + JSON.stringify(report.failures), + ) +}) + +test('rust S5 ok:true with real query traces is not GameRoomHost suite-double', () => { + const evidence = liveElevenGoodEvidence('lumio-entity-chat-replay') + assert.equal(evidence.scenarios['5'].ok, true) + const report = verifyRun(evidence, liveElevenAudit('lumio-entity-chat-replay')) + assert.ok( + !report.failures.some((f) => f.check === 'scenario-5:suite-double' || f.check === 's5:suite-double'), + JSON.stringify(report.failures), + ) +}) + +test('live 11-scenario traces on mvp-host must PASS when all 11 are ok', () => { + const evidence = liveElevenGoodEvidence('lumio-mvp-host') + const report = verifyRun(evidence, liveElevenAudit('lumio-mvp-host')) + assert.equal(report.ok, true, JSON.stringify(report.failures)) + assert.equal(report.census.total, 101) + assert.ok(report.census.netEntityIds.every((id) => isHostNetEntityId(id))) +})